From c27d3a6703e2a996c0a6689c60899668b1ecb065 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Tue, 5 May 2026 00:52:16 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E6=94=B9=E8=BF=9Batx=20usb=20?= =?UTF-8?q?=E7=BB=A7=E7=94=B5=E5=99=A8=E9=80=82=E9=85=8D=EF=BC=9B=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20webrtc=20=E6=97=A0=E6=B3=95=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=97=AE=E9=A2=98=EF=BC=9B=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/atx/executor.rs | 116 ++++++- src/atx/mod.rs | 35 +- src/atx/types.rs | 2 +- src/audio/controller.rs | 3 +- src/events/mod.rs | 2 +- src/events/types.rs | 28 +- src/stream/mjpeg.rs | 108 +++++-- src/video/streamer.rs | 3 +- src/web/handlers/config/stream.rs | 2 +- src/web/handlers/config/types.rs | 24 ++ src/web/handlers/mod.rs | 17 +- src/webrtc/universal_session.rs | 109 +++---- src/webrtc/video_track.rs | 18 +- src/webrtc/webrtc_streamer.rs | 3 +- web/package-lock.json | 10 +- web/src/App.vue | 2 +- web/src/components/VirtualKeyboard.vue | 291 ++++++----------- web/src/composables/useConsoleEvents.ts | 18 +- web/src/composables/useWebRTC.ts | 326 ++++++++++--------- web/src/composables/useWebSocket.ts | 9 +- web/src/i18n/en-US.ts | 95 ++++-- web/src/i18n/zh-CN.ts | 89 ++++-- web/src/lib/debugLog.ts | 15 + web/src/lib/streamSignal.ts | 11 + web/src/types/websocket.ts | 8 + web/src/views/ConsoleView.vue | 405 +++++++++++++++++++++--- web/src/views/SettingsView.vue | 348 +++++++++++++------- 27 files changed, 1388 insertions(+), 709 deletions(-) create mode 100644 web/src/lib/debugLog.ts create mode 100644 web/src/lib/streamSignal.ts diff --git a/src/atx/executor.rs b/src/atx/executor.rs index f2e4c41d..5cd276d7 100644 --- a/src/atx/executor.rs +++ b/src/atx/executor.rs @@ -7,6 +7,7 @@ use gpio_cdev::{Chip, LineHandle, LineRequestFlags}; use serialport::SerialPort; use std::fs::{File, OpenOptions}; use std::io::Write; +use std::os::fd::AsRawFd; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -18,6 +19,10 @@ use crate::error::{AppError, Result}; pub type SharedSerialHandle = Arc>>; +const USB_RELAY_MAX_CHANNEL: u8 = 8; +const USB_RELAY_REPORT_LEN: usize = 9; +const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806; // _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x06, 9) + /// Timing constants for ATX operations pub mod timing { use std::time::Duration; @@ -129,12 +134,23 @@ impl AtxKeyExecutor { } } AtxDriverType::UsbRelay => { + if self.config.pin == 0 { + return Err(AppError::Config( + "USB relay channel must be 1-based (>= 1)".to_string(), + )); + } if self.config.pin > u8::MAX as u32 { return Err(AppError::Config(format!( "USB relay channel must be <= {}", u8::MAX ))); } + if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 { + return Err(AppError::Config(format!( + "USB HID relay channel must be <= {}", + USB_RELAY_MAX_CHANNEL + ))); + } } AtxDriverType::Gpio | AtxDriverType::None => {} } @@ -292,26 +308,64 @@ impl AtxKeyExecutor { u8::MAX )) })?; + if channel == 0 { + return Err(AppError::Config( + "USB relay channel must be 1-based (>= 1)".to_string(), + )); + } + if channel > USB_RELAY_MAX_CHANNEL { + return Err(AppError::Config(format!( + "USB HID relay channel must be <= {}", + USB_RELAY_MAX_CHANNEL + ))); + } - // Standard HID relay command format - let cmd = if on { - [0x00, channel + 1, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00] - } else { - [0x00, channel + 1, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x00] - }; + let cmd = Self::build_usb_relay_command(channel, on); let mut guard = self.usb_relay_handle.lock().unwrap(); let device = guard .as_mut() .ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?; - device - .write_all(&cmd) - .map_err(|e| AppError::Internal(format!("USB relay write failed: {}", e)))?; + if let Err(feature_err) = Self::send_usb_relay_feature_report(device, &cmd) { + debug!( + "USB relay feature report failed ({}), falling back to hidraw write", + feature_err + ); + device.write_all(&cmd).map_err(|write_err| { + AppError::Internal(format!( + "USB relay feature report failed: {}; raw write failed: {}", + feature_err, write_err + )) + })?; + device + .flush() + .map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?; + } Ok(()) } + fn build_usb_relay_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] { + let mut cmd = [0x00; USB_RELAY_REPORT_LEN]; + cmd[1] = if on { 0xFF } else { 0xFD }; + cmd[2] = channel; + cmd + } + + fn send_usb_relay_feature_report( + device: &File, + report: &[u8; USB_RELAY_REPORT_LEN], + ) -> std::io::Result<()> { + // Linux hidraw feature reports include the report ID as the first byte. + let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9, report.as_ptr()) }; + if rc < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + /// Pulse Serial relay async fn pulse_serial(&self, duration: Duration) -> Result<()> { info!( @@ -367,6 +421,8 @@ impl AtxKeyExecutor { port.write_all(&cmd) .map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?; + port.flush() + .map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?; Ok(()) } @@ -453,7 +509,7 @@ mod tests { let config = AtxKeyConfig { driver: AtxDriverType::UsbRelay, device: "/dev/hidraw0".to_string(), - pin: 0, + pin: 1, active_level: ActiveLevel::High, // Ignored for USB relay baud_rate: 9600, }; @@ -481,6 +537,18 @@ mod tests { assert_eq!(timing::RESET_PRESS.as_millis(), 500); } + #[test] + fn test_usb_relay_command_format() { + assert_eq!( + AtxKeyExecutor::build_usb_relay_command(1, true), + [0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ); + assert_eq!( + AtxKeyExecutor::build_usb_relay_command(1, false), + [0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ); + } + #[tokio::test] async fn test_executor_init_rejects_serial_channel_zero() { let config = AtxKeyConfig { @@ -495,6 +563,34 @@ mod tests { assert!(matches!(err, AppError::Config(_))); } + #[tokio::test] + async fn test_executor_init_rejects_usb_relay_channel_zero() { + let config = AtxKeyConfig { + driver: AtxDriverType::UsbRelay, + device: "/dev/hidraw0".to_string(), + pin: 0, + active_level: ActiveLevel::High, + baud_rate: 9600, + }; + let mut executor = AtxKeyExecutor::new(config); + let err = executor.init().await.unwrap_err(); + assert!(matches!(err, AppError::Config(_))); + } + + #[tokio::test] + async fn test_executor_init_rejects_usb_relay_channel_overflow() { + let config = AtxKeyConfig { + driver: AtxDriverType::UsbRelay, + device: "/dev/hidraw0".to_string(), + pin: USB_RELAY_MAX_CHANNEL as u32 + 1, + active_level: ActiveLevel::High, + baud_rate: 9600, + }; + let mut executor = AtxKeyExecutor::new(config); + let err = executor.init().await.unwrap_err(); + assert!(matches!(err, AppError::Config(_))); + } + #[tokio::test] async fn test_executor_init_rejects_serial_channel_overflow() { let config = AtxKeyConfig { diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 0a6b1be4..95083259 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -15,6 +15,7 @@ //! //! - **GPIO**: Uses Linux GPIO character device (/dev/gpiochipX) for direct hardware control //! - **USB Relay**: Uses HID USB relay modules for isolated switching +//! - **Serial Relay**: Uses LCUS-style serial relay modules //! //! # Example //! @@ -59,9 +60,25 @@ pub use types::{ }; pub use wol::send_wol; +fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { + let upper = uevent.to_ascii_uppercase(); + upper.contains("000016C0:000005DF") + || upper.contains("16C0:05DF") + || upper.contains("PRODUCT=16C0/5DF") + || upper.contains("USBRELAY") + || upper.contains("USB RELAY") +} + +fn is_usb_relay_hidraw(name: &str) -> bool { + let uevent_path = format!("/sys/class/hidraw/{}/device/uevent", name); + std::fs::read_to_string(uevent_path) + .map(|uevent| hidraw_uevent_is_usb_relay(&uevent)) + .unwrap_or(false) +} + /// Discover available ATX devices on the system /// -/// Scans for GPIO chips and USB HID relay devices in a single pass. +/// Scans for GPIO chips, LCUS USB HID relay devices, and serial relay ports. pub fn discover_devices() -> AtxDevices { let mut devices = AtxDevices::default(); @@ -72,7 +89,7 @@ pub fn discover_devices() -> AtxDevices { let name_str = name.to_string_lossy(); if name_str.starts_with("gpiochip") { devices.gpio_chips.push(format!("/dev/{}", name_str)); - } else if name_str.starts_with("hidraw") { + } else if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) { devices.usb_relays.push(format!("/dev/{}", name_str)); } else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") { devices.serial_ports.push(format!("/dev/{}", name_str)); @@ -96,6 +113,20 @@ mod tests { let _devices = discover_devices(); } + #[test] + fn test_hidraw_uevent_detects_usb_relay_id() { + assert!(hidraw_uevent_is_usb_relay( + "HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n" + )); + } + + #[test] + fn test_hidraw_uevent_rejects_unrelated_hid() { + assert!(!hidraw_uevent_is_usb_relay( + "HID_ID=0003:0000046D:0000C534\nHID_NAME=Logitech USB Receiver\n" + )); + } + #[test] fn test_module_exports() { // Verify all public exports are accessible diff --git a/src/atx/types.rs b/src/atx/types.rs index 25dade85..fdf38d58 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -61,7 +61,7 @@ pub struct AtxKeyConfig { pub device: String, /// Pin or channel number: /// - For GPIO: GPIO pin number - /// - For USB Relay: relay channel (0-based) + /// - For USB Relay: relay channel (1-based) /// - For Serial Relay (LCUS): relay channel (1-based) pub pin: u32, /// Active level (only applicable to GPIO, ignored for USB Relay) diff --git a/src/audio/controller.rs b/src/audio/controller.rs index 1c570985..5a8680e9 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -17,7 +17,7 @@ use super::encoder::{OpusConfig, OpusFrame}; use super::monitor::AudioHealthMonitor; use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig}; use crate::error::{AppError, Result}; -use crate::events::{EventBus, SystemEvent}; +use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; const AUDIO_RECOVERY_RETRY_DELAY: Duration = Duration::from_secs(1); @@ -165,6 +165,7 @@ impl AudioController { ) { if let Some(ref bus) = *event_bus.read().await { bus.publish(SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Audio, device: device.to_string(), reason: reason.to_string(), }); diff --git a/src/events/mod.rs b/src/events/mod.rs index d1c63b22..fce3c612 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS; pub use types::{ AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, - SystemEvent, TtydDeviceInfo, VideoDeviceInfo, + StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, }; use tokio::sync::broadcast; diff --git a/src/events/types.rs b/src/events/types.rs index b90f7ded..18ff0815 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -79,6 +79,14 @@ pub struct ClientStats { pub connected_secs: u64, } +/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamDeviceLostKind { + Video, + Audio, +} + /// JSON: `{"event": "", "data": { ... }}`. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "event", content = "data")] @@ -119,7 +127,11 @@ pub enum SystemEvent { }, #[serde(rename = "stream.device_lost")] - StreamDeviceLost { device: String, reason: String }, + StreamDeviceLost { + kind: StreamDeviceLostKind, + device: String, + reason: String, + }, #[serde(rename = "stream.reconnecting")] StreamReconnecting { device: String, attempt: u32 }, @@ -255,6 +267,19 @@ mod tests { assert_eq!(event.event_name(), "stream.state_changed"); } + #[test] + fn stream_device_lost_json_snake_case_kind() { + let event = SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Audio, + device: "hw:0,0".to_string(), + reason: "test".to_string(), + }; + let v = serde_json::to_value(&event).unwrap(); + let data = v.get("data").unwrap(); + assert_eq!(data.get("kind").and_then(|x| x.as_str()), Some("audio")); + assert_eq!(data.get("device").and_then(|x| x.as_str()), Some("hw:0,0")); + } + #[test] fn exact_topics_covers_all_variants() { use std::collections::HashSet; @@ -283,6 +308,7 @@ mod tests { fps: 0, }, SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Video, device: String::new(), reason: String::new(), }, diff --git a/src/stream/mjpeg.rs b/src/stream/mjpeg.rs index 684c66ee..2a721ea6 100644 --- a/src/stream/mjpeg.rs +++ b/src/stream/mjpeg.rs @@ -8,6 +8,9 @@ use std::time::{Duration, Instant}; use tokio::sync::broadcast; use tracing::{debug, info, warn}; +/// Generation token paired with `client_id` so [`unregister_client`] ignores stale drops. +pub type ClientGeneration = u64; + use crate::video::encoder::traits::{Encoder, EncoderConfig}; use crate::video::encoder::JpegEncoder; use crate::video::format::PixelFormat; @@ -18,6 +21,7 @@ pub type ClientId = String; #[derive(Debug, Clone)] pub struct ClientSession { pub id: ClientId, + pub generation: ClientGeneration, pub connected_at: Instant, pub last_activity: Instant, pub frames_sent: u64, @@ -25,10 +29,11 @@ pub struct ClientSession { } impl ClientSession { - pub fn new(id: ClientId) -> Self { + pub fn new(id: ClientId, generation: ClientGeneration) -> Self { let now = Instant::now(); Self { id, + generation, connected_at: now, last_activity: now, frames_sent: 0, @@ -45,7 +50,6 @@ impl ClientSession { pub struct FpsCalculator { frame_times: VecDeque, window: Duration, - count_in_window: usize, } impl FpsCalculator { @@ -53,28 +57,26 @@ impl FpsCalculator { Self { frame_times: VecDeque::with_capacity(120), window: Duration::from_secs(1), - count_in_window: 0, } } pub fn record_frame(&mut self) { let now = Instant::now(); self.frame_times.push_back(now); - - let cutoff = now - self.window; - while let Some(&oldest) = self.frame_times.front() { - if oldest < cutoff { - self.frame_times.pop_front(); - } else { - break; - } - } - - self.count_in_window = self.frame_times.len(); + self.prune(now); } - pub fn current_fps(&self) -> u32 { - self.count_in_window as u32 + /// Rolling-window FPS sample count (~1s). + pub fn current_fps(&mut self) -> u32 { + self.prune(Instant::now()); + self.frame_times.len() as u32 + } + + fn prune(&mut self, now: Instant) { + let cutoff = now - self.window; + while matches!(self.frame_times.front(), Some(&t) if t < cutoff) { + self.frame_times.pop_front(); + } } } @@ -101,6 +103,7 @@ pub struct MjpegStreamHandler { online: AtomicBool, sequence: AtomicU64, clients: ParkingRwLock>, + next_generation: AtomicU64, auto_pause_config: ParkingRwLock, last_frame_ts: ParkingRwLock>, dropped_same_frames: AtomicU64, @@ -122,6 +125,7 @@ impl MjpegStreamHandler { online: AtomicBool::new(false), sequence: AtomicU64::new(0), clients: ParkingRwLock::new(HashMap::new()), + next_generation: AtomicU64::new(1), jpeg_encoder: ParkingMutex::new(None), auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()), last_frame_ts: ParkingRwLock::new(None), @@ -292,18 +296,26 @@ impl MjpegStreamHandler { self.clients.read().len() as u64 } - pub fn register_client(&self, client_id: ClientId) { - let session = ClientSession::new(client_id.clone()); + /// Connects `client_id`; return value must be passed to [`unregister_client`]. + pub fn register_client(&self, client_id: ClientId) -> ClientGeneration { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let session = ClientSession::new(client_id.clone(), generation); self.clients.write().insert(client_id.clone(), session); info!( "Client {} connected (total: {})", client_id, self.client_count() ); + generation } - pub fn unregister_client(&self, client_id: &str) { - if let Some(session) = self.clients.write().remove(client_id) { + pub fn unregister_client(&self, client_id: &str, expected_generation: ClientGeneration) { + let mut clients = self.clients.write(); + match clients.get(client_id) { + Some(session) if session.generation == expected_generation => {} + _ => return, + } + if let Some(session) = clients.remove(client_id) { let duration = session.connected_elapsed(); let duration_secs = duration.as_secs_f32(); let avg_fps = if duration_secs > 0.1 { @@ -327,9 +339,12 @@ impl MjpegStreamHandler { } pub fn get_clients_stat(&self) -> HashMap { + // write() because `current_fps()` mutates the underlying VecDeque + // to prune stale samples. Held for ~microseconds, called once per + // second by the stats broadcaster. self.clients - .read() - .iter() + .write() + .iter_mut() .map(|(id, session)| { ( id.clone(), @@ -379,13 +394,18 @@ impl MjpegStreamHandler { pub struct ClientGuard { client_id: ClientId, + generation: ClientGeneration, handler: Arc, } impl ClientGuard { pub fn new(client_id: ClientId, handler: Arc) -> Self { - handler.register_client(client_id.clone()); - Self { client_id, handler } + let generation = handler.register_client(client_id.clone()); + Self { + client_id, + generation, + handler, + } } pub fn id(&self) -> &ClientId { @@ -395,7 +415,8 @@ impl ClientGuard { impl Drop for ClientGuard { fn drop(&mut self) { - self.handler.unregister_client(&self.client_id); + self.handler + .unregister_client(&self.client_id, self.generation); } } @@ -525,6 +546,41 @@ mod tests { calc.record_frame(); calc.record_frame(); - assert!(calc.frame_times.len() == 3); + assert_eq!(calc.current_fps(), 3); + assert_eq!(calc.frame_times.len(), 3); + } + + #[test] + fn test_fps_calculator_decays_without_new_frames() { + let mut calc = FpsCalculator::new(); + calc.window = Duration::from_millis(50); + + calc.record_frame(); + calc.record_frame(); + assert_eq!(calc.current_fps(), 2); + + std::thread::sleep(Duration::from_millis(80)); + + assert_eq!(calc.current_fps(), 0); + assert!(calc.frame_times.is_empty()); + } + + #[test] + fn test_client_guard_generation_isolation() { + let handler = Arc::new(MjpegStreamHandler::new()); + let id = "shared-id".to_string(); + + let stale = ClientGuard::new(id.clone(), handler.clone()); + let stale_gen = stale.generation; + + let fresh = ClientGuard::new(id.clone(), handler.clone()); + assert_ne!(stale_gen, fresh.generation); + assert_eq!(handler.client_count(), 1); + + drop(stale); + assert_eq!(handler.client_count(), 1); + + drop(fresh); + assert_eq!(handler.client_count(), 0); } } diff --git a/src/video/streamer.rs b/src/video/streamer.rs index 29783c72..364069d5 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -20,7 +20,7 @@ use super::format::{PixelFormat, Resolution}; use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use super::is_csi_hdmi_bridge; use crate::error::{AppError, Result}; -use crate::events::{EventBus, SystemEvent}; +use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; use crate::stream::MjpegStreamHandler; use crate::utils::LogThrottler; use crate::video::capture_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE}; @@ -1417,6 +1417,7 @@ impl Streamer { // Publish device lost event self.publish_event(SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Video, device: device.clone(), reason: reason.clone(), }) diff --git a/src/web/handlers/config/stream.rs b/src/web/handlers/config/stream.rs index 5b43f006..c4aac191 100644 --- a/src/web/handlers/config/stream.rs +++ b/src/web/handlers/config/stream.rs @@ -34,7 +34,7 @@ pub async fn update_stream_config( &state, &old_stream_config, &new_stream_config, - ConfigApplyOptions::forced(), + ConfigApplyOptions::default(), ) .await?; diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 76565fe9..640a50ac 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -509,6 +509,12 @@ impl AtxConfigUpdate { } crate::atx::AtxDriverType::UsbRelay => { if let Some(pin) = key.pin { + if pin == 0 { + return Err(AppError::BadRequest(format!( + "{} USB relay channel must be 1-based (>= 1)", + name + ))); + } if pin > u8::MAX as u32 { return Err(AppError::BadRequest(format!( "{} USB relay channel must be <= {}", @@ -516,6 +522,12 @@ impl AtxConfigUpdate { u8::MAX ))); } + if pin > 8 { + return Err(AppError::BadRequest(format!( + "{} USB HID relay channel must be <= 8", + name + ))); + } } } crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} @@ -551,6 +563,12 @@ impl AtxConfigUpdate { } } crate::atx::AtxDriverType::UsbRelay => { + if key.pin == 0 { + return Err(AppError::BadRequest(format!( + "{} USB relay channel must be 1-based (>= 1)", + name + ))); + } if key.pin > u8::MAX as u32 { return Err(AppError::BadRequest(format!( "{} USB relay channel must be <= {}", @@ -558,6 +576,12 @@ impl AtxConfigUpdate { u8::MAX ))); } + if key.pin > 8 { + return Err(AppError::BadRequest(format!( + "{} USB HID relay channel must be <= 8", + name + ))); + } } crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} } diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 2a57dd21..41621b8c 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -1493,12 +1493,8 @@ pub async fn mjpeg_stream( handler.clone(), )); - // Use bounded channel (capacity=1) to implement backpressure - // This ensures record_frame_sent() is only called when the previous frame - // has been successfully consumed by the HTTP client let (tx, mut rx) = tokio::sync::mpsc::channel::(1); - // Spawn background task to send frames to channel let guard_clone = guard.clone(); let handler_clone = handler.clone(); tokio::spawn(async move { @@ -1593,20 +1589,19 @@ pub async fn mjpeg_stream( } } - // Guard is automatically dropped here }); - // Create stream that receives from channel - // Record FPS after yield - this is closer to actual TCP send than tx.send() + // Create stream that receives from channel and forwards to the HTTP + // body. Record FPS *before* yield so the final frame of a session + // still gets counted (after-yield code in async_stream! only runs + // when the consumer polls again, which never happens for the last + // frame of a closing connection). let handler_for_stream = handler.clone(); let guard_for_stream = guard.clone(); let body_stream = async_stream::stream! { - // Consume from channel - this drives the backpressure while let Some(data) = rx.recv().await { - yield Ok::(data); - // Record FPS after yield - data has been handed to Axum/hyper - // This is closer to actual TCP send than recording at tx.send() handler_for_stream.record_frame_sent(guard_for_stream.id()); + yield Ok::(data); } }; diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 2cf41c57..f3112ba0 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -12,6 +12,7 @@ use webrtc::data_channel::data_channel_message::DataChannelMessage; use webrtc::data_channel::RTCDataChannel; use webrtc::ice::mdns::MulticastDnsMode; use webrtc::ice_transport::ice_candidate::RTCIceCandidate; +use webrtc::ice_transport::ice_connection_state::RTCIceConnectionState; use webrtc::ice_transport::ice_server::RTCIceServer; use webrtc::interceptor::registry::Registry; use webrtc::peer_connection::configuration::RTCConfiguration; @@ -30,14 +31,12 @@ use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer}; use super::video_track::{UniversalVideoTrack, UniversalVideoTrackConfig, VideoCodec}; use crate::audio::OpusFrame; use crate::error::{AppError, Result}; -use crate::events::{EventBus, SystemEvent}; use crate::hid::datachannel::{parse_hid_message, HidChannelEvent}; use crate::hid::HidController; use crate::video::types::{ BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType, }; use std::sync::atomic::AtomicBool; -use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState; const MIME_TYPE_H265: &str = "video/H265"; @@ -140,7 +139,6 @@ pub struct UniversalSession { state_rx: watch::Receiver, ice_candidates: Arc>>, hid_controller: Option>, - event_bus: Option>, video_receiver_handle: Mutex>>, audio_receiver_handle: Mutex>>, fps: u32, @@ -150,7 +148,7 @@ impl UniversalSession { pub async fn new( config: UniversalSessionConfig, session_id: String, - event_bus: Option>, + _event_bus: Option>, ) -> Result { info!( "Creating {} session: {} @ {}x{} (audio={})", @@ -338,7 +336,6 @@ impl UniversalSession { state_rx, ice_candidates: Arc::new(Mutex::new(vec![])), hid_controller: None, - event_bus, video_receiver_handle: Mutex::new(None), audio_receiver_handle: Mutex::new(None), fps: config.fps, @@ -353,8 +350,6 @@ impl UniversalSession { let state = self.state.clone(); let session_id = self.session_id.clone(); let codec = self.codec; - let event_bus = self.event_bus.clone(); - self.pc .on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { let state = state.clone(); @@ -372,42 +367,49 @@ impl UniversalSession { }; info!("{} session {} state: {}", codec, session_id, new_state); + if matches!( + (*state.borrow(), new_state), + ( + ConnectionState::Connected, + ConnectionState::New | ConnectionState::Connecting + ) + ) { + return; + } let _ = state.send(new_state); }) })); + let state_for_ice = self.state.clone(); let session_id_ice = self.session_id.clone(); self.pc - .on_ice_connection_state_change(Box::new(move |state| { + .on_ice_connection_state_change(Box::new(move |ice_state| { + let state = state_for_ice.clone(); let session_id = session_id_ice.clone(); Box::pin(async move { - info!("[ICE] Session {} connection state: {:?}", session_id, state); - }) - })); + info!( + "[ICE] Session {} connection state: {:?}", + session_id, ice_state + ); - let session_id_gather = self.session_id.clone(); - let event_bus_gather = event_bus.clone(); - self.pc - .on_ice_gathering_state_change(Box::new(move |state| { - let session_id = session_id_gather.clone(); - let event_bus = event_bus_gather.clone(); - Box::pin(async move { - if matches!(state, RTCIceGathererState::Complete) { - if let Some(bus) = event_bus.as_ref() { - bus.publish(SystemEvent::WebRTCIceComplete { session_id }); + let new_state = match ice_state { + RTCIceConnectionState::Connected | RTCIceConnectionState::Completed => { + ConnectionState::Connected } - } + RTCIceConnectionState::Disconnected => ConnectionState::Disconnected, + RTCIceConnectionState::Failed => ConnectionState::Failed, + RTCIceConnectionState::Closed => ConnectionState::Closed, + _ => return, + }; + + let _ = state.send(new_state); }) })); let ice_candidates = self.ice_candidates.clone(); - let session_id_candidate = self.session_id.clone(); - let event_bus_candidate = event_bus.clone(); self.pc .on_ice_candidate(Box::new(move |candidate: Option| { let ice_candidates = ice_candidates.clone(); - let session_id = session_id_candidate.clone(); - let event_bus = event_bus_candidate.clone(); Box::pin(async move { if let Some(c) = candidate { @@ -430,14 +432,6 @@ impl UniversalSession { let mut candidates = ice_candidates.lock().await; candidates.push(candidate.clone()); drop(candidates); - - if let Some(bus) = event_bus.as_ref() { - bus.publish(SystemEvent::WebRTCIceCandidate { - session_id, - candidate: serde_json::to_value(&candidate) - .unwrap_or(serde_json::Value::Null), - }); - } } }) })); @@ -660,9 +654,21 @@ impl UniversalSession { .await; let _ = send_in_flight; - if send_result.is_ok() { - frames_sent += 1; - last_sequence = Some(encoded_frame.sequence); + match send_result { + Ok(()) => { + frames_sent += 1; + last_sequence = Some(encoded_frame.sequence); + } + Err(e) => { + warn!( + "Session {} failed to write video frame: sequence={}, keyframe={}, bytes={}, error={}", + session_id, + encoded_frame.sequence, + encoded_frame.is_keyframe, + encoded_frame.data.len(), + e + ); + } } } } @@ -810,25 +816,14 @@ impl UniversalSession { } } - let mut gather_complete = self.pc.gathering_complete_promise().await; - self.pc .set_local_description(answer.clone()) .await .map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?; - const ICE_GATHER_TIMEOUT: Duration = Duration::from_millis(2500); - if tokio::time::timeout(ICE_GATHER_TIMEOUT, gather_complete.recv()) - .await - .is_err() - { - debug!( - "ICE gathering timeout after {:?} for session {}", - ICE_GATHER_TIMEOUT, self.session_id - ); - } - + tokio::time::sleep(Duration::from_millis(500)).await; let candidates = self.ice_candidates.lock().await.clone(); + Ok(SdpAnswer::with_candidates(answer.sdp, candidates)) } @@ -842,10 +837,16 @@ impl UniversalSession { username_fragment: candidate.username_fragment, }; - self.pc - .add_ice_candidate(init) - .await - .map_err(|e| AppError::VideoError(format!("Failed to add ICE candidate: {}", e)))?; + if let Err(e) = self.pc.add_ice_candidate(init).await { + warn!( + "[ICE] Session {} failed to add remote candidate: {}", + self.session_id, e + ); + return Err(AppError::VideoError(format!( + "Failed to add ICE candidate: {}", + e + ))); + } Ok(()) } diff --git a/src/webrtc/video_track.rs b/src/webrtc/video_track.rs index 30a7abb0..cab70a54 100644 --- a/src/webrtc/video_track.rs +++ b/src/webrtc/video_track.rs @@ -14,7 +14,7 @@ use webrtc::track::track_local::{TrackLocal, TrackLocalWriter}; // rtp `HevcPayloader` mishandles AP+IDR and NAL 20 (`IDR_N_LP`). use super::h265_payloader::H265Payloader; -use crate::error::Result; +use crate::error::{AppError, Result}; use crate::video::types::Resolution; const RTP_MTU: usize = 1200; @@ -250,6 +250,10 @@ impl UniversalVideoTrack { TrackType::Sample(track) => { if let Err(e) = track.write_sample(&sample).await { debug!("H264 write_sample failed: {}", e); + return Err(AppError::WebRtcError(format!( + "H264 write_sample failed: {}", + e + ))); } } TrackType::Rtp(_) => { @@ -276,6 +280,10 @@ impl UniversalVideoTrack { TrackType::Sample(track) => { if let Err(e) = track.write_sample(&sample).await { debug!("VP8 write_sample failed: {}", e); + return Err(AppError::WebRtcError(format!( + "VP8 write_sample failed: {}", + e + ))); } } TrackType::Rtp(_) => { @@ -298,6 +306,10 @@ impl UniversalVideoTrack { TrackType::Sample(track) => { if let Err(e) = track.write_sample(&sample).await { debug!("VP9 write_sample failed: {}", e); + return Err(AppError::WebRtcError(format!( + "VP9 write_sample failed: {}", + e + ))); } } TrackType::Rtp(_) => { @@ -366,6 +378,10 @@ impl UniversalVideoTrack { if let Err(e) = rtp_track.write_rtp(&packet).await { trace!("H265 write_rtp failed: {}", e); + return Err(AppError::WebRtcError(format!( + "H265 write_rtp failed: {}", + e + ))); } } diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index c69beed1..b1a51832 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -9,7 +9,7 @@ use tracing::{debug, info, trace, warn}; use crate::audio::{AudioController, OpusFrame}; use crate::error::{AppError, Result}; -use crate::events::{EventBus, SystemEvent}; +use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; use crate::hid::HidController; use crate::video::device::{ enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint, @@ -352,6 +352,7 @@ impl WebRtcStreamer { ); streamer .publish_stream_event(SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Video, device: original_device.clone(), reason: reason.clone(), }) diff --git a/web/package-lock.json b/web/package-lock.json index 1e51035f..f932a6ea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1368,7 +1368,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1783,7 +1782,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2450,7 +2448,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2498,7 +2495,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2791,8 +2787,7 @@ "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2846,7 +2841,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2912,7 +2906,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2994,7 +2987,6 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", diff --git a/web/src/App.vue b/web/src/App.vue index 7604f8c8..1268b2f0 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -72,12 +72,12 @@ watch( ('windows') const mainKeyboard = ref(null) -const controlKeyboard = ref(null) -const arrowsKeyboard = ref(null) const pressedModifiers = ref(0) const keysDown = ref([]) @@ -81,47 +79,49 @@ const position = ref({ x: 100, y: 100 }) const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) -const getBottomRow = () => osBottomRows[selectedOs.value].join(' ') +const getBottomRow = () => + [...osBottomRows[selectedOs.value], 'ArrowLeft', 'ArrowDown', 'ArrowRight'].join(' ') const keyboardLayout = { main: { default: [ 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', - 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12', - 'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace', - 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash', + 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', + 'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp', + 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown', 'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter', - 'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight', - 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight', + 'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp', + 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight', ], shift: [ 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', - 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12', - '(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace', - 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)', + 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', + '(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp', + 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown', 'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter', - 'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight', - 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight', - ], - }, - control: { - default: [ - 'PrintScreen ScrollLock Pause', - 'Insert Home PageUp', - 'Delete End PageDown', - ], - }, - arrows: { - default: [ - 'ArrowUp', - 'ArrowLeft ArrowDown ArrowRight', + 'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp', + 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight', ], }, } const compactMainLayout = { - default: keyboardLayout.main.default.slice(2), - shift: keyboardLayout.main.shift.slice(2), + default: [ + 'Escape Insert Delete Home End PageUp PageDown PrintScreen', + 'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace', + 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash', + 'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter', + 'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp', + 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight', + ], + shift: [ + 'Escape Insert Delete Home End PageUp PageDown PrintScreen', + '(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace', + 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)', + 'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter', + 'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp', + 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight', + ], } const isCompactLayout = ref(false) @@ -286,8 +286,6 @@ function updateKeyboardLayout() { ], } mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value }) - controlKeyboard.value?.setOptions({ display: keyDisplayMap.value }) - arrowsKeyboard.value?.setOptions({ display: keyDisplayMap.value }) updateKeyboardButtonTheme() } @@ -431,8 +429,6 @@ function updateKeyboardButtonTheme() { ] mainKeyboard.value?.setOptions({ buttonTheme }) - controlKeyboard.value?.setOptions({ buttonTheme }) - arrowsKeyboard.value?.setOptions({ buttonTheme }) } watch([layoutName, () => props.capsLock], ([name]) => { @@ -447,10 +443,8 @@ function initKeyboards() { const id = keyboardId.value const mainEl = document.querySelector(`#${id}-main`) - const controlEl = document.querySelector(`#${id}-control`) - const arrowsEl = document.querySelector(`#${id}-arrows`) - if (!mainEl || !controlEl || !arrowsEl) { + if (!mainEl) { console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id) setTimeout(initKeyboards, 50) return @@ -476,45 +470,13 @@ function initKeyboards() { stopMouseUpPropagation: true, }) - controlKeyboard.value = new Keyboard(controlEl, { - layout: keyboardLayout.control, - layoutName: 'default', - display: keyDisplayMap.value, - theme: 'hg-theme-default hg-layout-default vkb-keyboard', - onKeyPress: onKeyDown, - onKeyReleased: onKeyUp, - disableButtonHold: true, - preventMouseDownDefault: true, - preventMouseUpDefault: true, - stopMouseDownPropagation: true, - stopMouseUpPropagation: true, - }) - - arrowsKeyboard.value = new Keyboard(arrowsEl, { - layout: keyboardLayout.arrows, - layoutName: 'default', - display: keyDisplayMap.value, - theme: 'hg-theme-default hg-layout-default vkb-keyboard', - onKeyPress: onKeyDown, - onKeyReleased: onKeyUp, - disableButtonHold: true, - preventMouseDownDefault: true, - preventMouseUpDefault: true, - stopMouseDownPropagation: true, - stopMouseUpPropagation: true, - }) - updateKeyboardLayout() console.log('[VirtualKeyboard] Keyboards initialized:', id) } function destroyKeyboards() { mainKeyboard.value?.destroy() - controlKeyboard.value?.destroy() - arrowsKeyboard.value?.destroy() mainKeyboard.value = null - controlKeyboard.value = null - arrowsKeyboard.value = null } function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null { @@ -682,10 +644,6 @@ onUnmounted(() => {
-
-
-
-
@@ -818,10 +776,9 @@ onUnmounted(() => { min-width: 90px; } -/* Right Shift - wider */ .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] { - flex-grow: 2.75; - min-width: 110px; + flex-grow: 1.75; + min-width: 70px; } /* Bottom row modifiers */ @@ -852,21 +809,33 @@ onUnmounted(() => { min-width: 55px; } -/* Control keyboard */ -.kb-control-container .hg-button { - min-width: 54px !important; - justify-content: center; +.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"], +.vkb .simple-keyboard .hg-button[data-skbtn="ScrollLock"], +.vkb .simple-keyboard .hg-button[data-skbtn="Pause"], +.vkb .simple-keyboard .hg-button[data-skbtn="Insert"], +.vkb .simple-keyboard .hg-button[data-skbtn="Delete"], +.vkb .simple-keyboard .hg-button[data-skbtn="Home"], +.vkb .simple-keyboard .hg-button[data-skbtn="End"], +.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"], +.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"] { + font-size: 11px; + flex-grow: 0.95; } -/* Arrow buttons */ -.kb-arrows-container .hg-button { - min-width: 44px !important; - width: 44px !important; - justify-content: center; +.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"], +.vkb .simple-keyboard .hg-button[data-skbtn="Insert"], +.vkb .simple-keyboard .hg-button[data-skbtn="Delete"], +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"], +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] { + margin-left: 6px; } -.kb-arrows-container .hg-row { - justify-content: center; +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"], +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowDown"], +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"], +.vkb .simple-keyboard .hg-button[data-skbtn="ArrowRight"] { + font-size: 14px; + flex-grow: 1; } /* Dark mode - must be after simple-keyboard CSS import */ @@ -1127,31 +1096,7 @@ html.dark .hg-theme-default .hg-button.down-key, flex: 1; } -.vkb-side { - display: flex; - flex-direction: column; - gap: 8px; - align-items: flex-end; -} - -.kb-control-container, -.kb-arrows-container { - display: inline-block; -} - /* Responsive */ -@media (max-width: 900px) { - .vkb-keyboards { - flex-direction: column; - } - - .vkb-side { - flex-direction: row; - justify-content: center; - gap: 16px; - } -} - @media (max-width: 640px) { .vkb-body { padding: 4px; @@ -1159,11 +1104,11 @@ html.dark .hg-theme-default .hg-button.down-key, } .vkb .simple-keyboard .hg-button { - height: 28px; - font-size: 10px; - padding: 0 3px; + height: 30px; + font-size: 11px; + padding: 0 2px; margin: 0 1px 2px 0; - min-width: 24px; + min-width: 0; } .vkb .simple-keyboard .hg-button.combination-key { @@ -1172,35 +1117,15 @@ html.dark .hg-theme-default .hg-button.down-key, padding: 0 6px; } - .vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] { - min-width: 60px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="Tab"] { - min-width: 52px; - } - + .vkb .simple-keyboard .hg-button[data-skbtn="Backspace"], + .vkb .simple-keyboard .hg-button[data-skbtn="Tab"], .vkb .simple-keyboard .hg-button[data-skbtn="Backslash"], - .vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] { - min-width: 52px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] { - min-width: 60px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="Enter"] { - min-width: 70px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] { - min-width: 70px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] { - min-width: 80px; - } - + .vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"], + .vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"], + .vkb .simple-keyboard .hg-button[data-skbtn="Enter"], + .vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"], + .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"], + .vkb .simple-keyboard .hg-button[data-skbtn="Space"], .vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"], .vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"], @@ -1208,20 +1133,29 @@ html.dark .hg-theme-default .hg-button.down-key, .vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"], .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] { - min-width: 46px; + min-width: 0; } - .vkb .simple-keyboard .hg-button[data-skbtn="Space"] { - min-width: 140px; + .vkb .simple-keyboard .hg-button[data-skbtn="Insert"], + .vkb .simple-keyboard .hg-button[data-skbtn="Delete"], + .vkb .simple-keyboard .hg-button[data-skbtn="Home"], + .vkb .simple-keyboard .hg-button[data-skbtn="End"], + .vkb .simple-keyboard .hg-button[data-skbtn="PageUp"], + .vkb .simple-keyboard .hg-button[data-skbtn="PageDown"], + .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] { + font-size: 11px; + flex-grow: 1; } - .kb-control-container .hg-button { - min-width: 44px !important; + .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"], + .vkb .simple-keyboard .hg-button[data-skbtn="Insert"], + .vkb .simple-keyboard .hg-button[data-skbtn="Delete"], + .vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] { + margin-left: 0; } - .kb-arrows-container .hg-button { - min-width: 36px !important; - width: 36px !important; + .vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"] { + margin-left: 4px; } .vkb-media-btn { @@ -1233,55 +1167,27 @@ html.dark .hg-theme-default .hg-button.down-key, @media (max-width: 400px) { .vkb .simple-keyboard .hg-button { - height: 26px; - font-size: 9px; - padding: 0 2px; + height: 28px; + font-size: 10px; + padding: 0 1px; margin: 0 1px 2px 0; - min-width: 20px; border-radius: 4px; } - .vkb .simple-keyboard .hg-button[data-skbtn="Space"] { - min-width: 100px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="Backspace"], - .vkb .simple-keyboard .hg-button[data-skbtn="Tab"], - .vkb .simple-keyboard .hg-button[data-skbtn="Backslash"], - .vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"], - .vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] { - min-width: 44px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="Enter"], - .vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"], - .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] { - min-width: 50px; - } - - .vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"], - .vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"], - .vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"], - .vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"], - .vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"], - .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"], - .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] { - min-width: 34px; - } - .vkb .simple-keyboard .hg-button.combination-key { font-size: 8px; height: 22px; padding: 0 4px; } - .kb-control-container .hg-button { - min-width: 34px !important; - } - - .kb-arrows-container .hg-button { - min-width: 30px !important; - width: 30px !important; + .vkb .simple-keyboard .hg-button[data-skbtn="Insert"], + .vkb .simple-keyboard .hg-button[data-skbtn="Delete"], + .vkb .simple-keyboard .hg-button[data-skbtn="Home"], + .vkb .simple-keyboard .hg-button[data-skbtn="End"], + .vkb .simple-keyboard .hg-button[data-skbtn="PageUp"], + .vkb .simple-keyboard .hg-button[data-skbtn="PageDown"], + .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] { + font-size: 10px; } .vkb-media-btn { @@ -1325,15 +1231,6 @@ html.dark .hg-theme-default .hg-button.down-key, font-size: 10px; } -.vkb--floating :deep(.kb-control-container .hg-button) { - min-width: 52px !important; -} - -.vkb--floating :deep(.kb-arrows-container .hg-button) { - min-width: 42px !important; - width: 42px !important; -} - /* Animation */ .keyboard-fade-enter-active, .keyboard-fade-leave-active { diff --git a/web/src/composables/useConsoleEvents.ts b/web/src/composables/useConsoleEvents.ts index 3c770111..cadcb664 100644 --- a/web/src/composables/useConsoleEvents.ts +++ b/web/src/composables/useConsoleEvents.ts @@ -1,7 +1,9 @@ import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import { useSystemStore } from '@/stores/system' +import type { StreamDeviceLostEventData } from '@/types/websocket' import { useWebSocket } from '@/composables/useWebSocket' +import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal' export interface ConsoleEventHandlers { onStreamConfigChanging?: (data: { reason?: string }) => void @@ -14,12 +16,10 @@ export interface ConsoleEventHandlers { onStreamStateChanged?: (data: { state: string device?: string | null - /** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */ reason?: string | null - /** Optional countdown (ms) until the next backend self-recovery attempt. */ next_retry_ms?: number | null }) => void - onStreamDeviceLost?: (data: { device: string; reason: string }) => void + onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void onStreamReconnecting?: (data: { device: string; attempt: number }) => void onStreamRecovered?: (data: { device: string }) => void onDeviceInfo?: (data: any) => void @@ -31,12 +31,16 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { const { on, off, connect } = useWebSocket() const noop = () => {} - function handleStreamDeviceLost(data: { device: string; reason: string }) { - if (systemStore.stream) { + function handleStreamDeviceLost(data: StreamDeviceLostEventData) { + const audioLost = isAudioStreamDeviceLostPayload(data) + if (systemStore.stream && !audioLost) { systemStore.stream.online = false } - toast.error(t('console.deviceLost'), { - description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }), + toast.error(t(audioLost ? 'audio.deviceLost' : 'console.deviceLost'), { + description: t(audioLost ? 'audio.deviceLostDesc' : 'console.deviceLostDesc', { + device: data.device, + reason: data.reason, + }), duration: 5000, }) handlers.onStreamDeviceLost?.(data) diff --git a/web/src/composables/useWebRTC.ts b/web/src/composables/useWebRTC.ts index dba26b76..3f4c9c66 100644 --- a/web/src/composables/useWebRTC.ts +++ b/web/src/composables/useWebRTC.ts @@ -1,6 +1,3 @@ -// WebRTC composable for H264 video streaming -// Provides low-latency video via WebRTC with DataChannel for HID - import { ref, onUnmounted, computed, type Ref } from 'vue' import { webrtcApi, type IceCandidate } from '@/api' import { @@ -9,7 +6,7 @@ import { encodeKeyboardEvent, encodeMouseEvent, } from '@/types/hid' -import { useWebSocket } from '@/composables/useWebSocket' +import { videoDebugLog } from '@/lib/debugLog' export type { HidKeyboardEvent, HidMouseEvent } @@ -28,7 +25,6 @@ export type WebRTCConnectStage = | 'disconnected' | 'failed' -// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown' export interface WebRTCStats { @@ -42,26 +38,14 @@ export interface WebRTCStats { framesPerSecond: number jitter: number roundTripTime: number - // ICE connection info localCandidateType: IceCandidateType remoteCandidateType: IceCandidateType - transportProtocol: string // 'udp' | 'tcp' - isRelay: boolean // true if using TURN relay + transportProtocol: string + isRelay: boolean } -// Cached ICE servers from backend API let cachedIceServers: RTCIceServer[] | null = null -interface WebRTCIceCandidateEvent { - session_id: string - candidate: IceCandidate -} - -interface WebRTCIceCompleteEvent { - session_id: string -} - -// Fetch ICE servers from backend API async function fetchIceServers(): Promise { try { const response = await webrtcApi.getIceServers() @@ -84,7 +68,6 @@ async function fetchIceServers(): Promise { console.warn('[WebRTC] Failed to fetch ICE servers from API, using fallback:', err) } - // Fallback: for local connections, use no ICE servers (host candidates only) const isLocalConnection = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || @@ -107,20 +90,16 @@ async function fetchIceServers(): Promise { let peerConnection: RTCPeerConnection | null = null let dataChannel: RTCDataChannel | null = null let sessionId: string | null = null +const sessionIdRef = ref(null) let statsInterval: number | null = null -let isConnecting = false // Lock to prevent concurrent connect calls +let isConnecting = false let connectInFlight: Promise | null = null -let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set -let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates -let pendingRemoteIceComplete = new Set() // Session IDs waiting for end-of-candidates -let seenRemoteCandidates = new Set() // Deduplicate server ICE candidates -let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating +let pendingIceCandidates: RTCIceCandidate[] = [] +let seenRemoteCandidates = new Set() +let cachedMediaStream: MediaStream | null = null let allowMdnsHostCandidates = false -let wsHandlersRegistered = false -const { on: wsOn } = useWebSocket() - const state = ref('disconnected') const videoTrack = ref(null) const audioTrack = ref(null) @@ -144,10 +123,44 @@ const error = ref(null) const dataChannelReady = ref(false) const connectStage = ref('idle') +function setConnectStage(stage: WebRTCConnectStage, details?: unknown) { + connectStage.value = stage + videoDebugLog(`WebRTC stage -> ${stage}`, details) +} + +function getIceCandidatePoolSize(): number { + if (typeof window === 'undefined') return 0 + const icePoolParam = new URLSearchParams(window.location.search).get('ice_pool') + if (icePoolParam === null) return 0 + return Math.max(0, Number.parseInt(icePoolParam, 10) || 0) +} + +function summarizeIceCandidate(candidate: RTCIceCandidate | IceCandidate | RTCIceCandidateInit | null) { + if (!candidate) return null + const candidateLine = candidate.candidate ?? '' + const parts = candidateLine.trim().split(/\s+/) + const typIndex = parts.indexOf('typ') + const raddrIndex = parts.indexOf('raddr') + const rportIndex = parts.indexOf('rport') + + return { + type: typIndex >= 0 ? parts[typIndex + 1] : 'unknown', + protocol: parts[2] ?? '', + address: parts[4] ?? '', + port: parts[5] ?? '', + relatedAddress: raddrIndex >= 0 ? parts[raddrIndex + 1] : undefined, + relatedPort: rportIndex >= 0 ? parts[rportIndex + 1] : undefined, + sdpMid: candidate.sdpMid ?? undefined, + sdpMLineIndex: candidate.sdpMLineIndex ?? undefined, + usernameFragment: candidate.usernameFragment ?? undefined, + raw: candidateLine, + } +} + function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection { const config: RTCConfiguration = { iceServers, - iceCandidatePoolSize: 10, + iceCandidatePoolSize: getIceCandidatePoolSize(), } const pc = new RTCPeerConnection(config) @@ -159,38 +172,35 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection { break case 'connected': state.value = 'connected' - connectStage.value = 'connected' + setConnectStage('connected') error.value = null startStatsCollection() break case 'disconnected': case 'closed': state.value = 'disconnected' - connectStage.value = 'disconnected' + setConnectStage('disconnected') stopStatsCollection() break case 'failed': state.value = 'failed' - connectStage.value = 'failed' + setConnectStage('failed') error.value = 'Connection failed' stopStatsCollection() break } } - // Handle ICE connection state - pc.oniceconnectionstatechange = () => { - // ICE state changes handled silently - } - - // Handle ICE candidates pc.onicecandidate = async (event) => { - if (!event.candidate) return - if (shouldSkipLocalCandidate(event.candidate)) return + if (!event.candidate) { + return + } + if (shouldSkipLocalCandidate(event.candidate)) { + return + } const currentSessionId = sessionId if (currentSessionId && pc.connectionState !== 'closed') { - // Session ready, send immediately try { await webrtcApi.addIceCandidate(currentSessionId, { candidate: event.candidate.candidate, @@ -198,11 +208,14 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection { sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined, usernameFragment: event.candidate.usernameFragment ?? undefined, }) - } catch { - // ICE candidate send failures are non-fatal + } catch (err) { + videoDebugLog('Failed to send local ICE candidate', { + sessionId: currentSessionId, + candidate: summarizeIceCandidate(event.candidate), + error: err, + }) } } else if (!currentSessionId) { - // Queue candidate until sessionId is set pendingIceCandidates.push(event.candidate) } } @@ -235,7 +248,13 @@ function setupDataChannel(channel: RTCDataChannel) { dataChannelReady.value = false } - channel.onerror = () => { + channel.onerror = (event) => { + videoDebugLog('WebRTC data channel error', { + label: channel.label, + readyState: channel.readyState, + event, + sessionId, + }) } channel.onmessage = () => { @@ -251,59 +270,18 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel { return channel } -function registerWebSocketHandlers() { - if (wsHandlersRegistered) return - wsHandlersRegistered = true - wsOn('webrtc.ice_candidate', handleRemoteIceCandidate) - wsOn('webrtc.ice_complete', handleRemoteIceComplete) -} - function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean { if (allowMdnsHostCandidates) return false const value = candidate.candidate || '' return value.includes(' typ host') && value.includes('.local') } -async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) { - if (!data || !data.candidate) return - - // Queue until session is ready and remote description is set - if (!sessionId) { - pendingRemoteCandidates.push(data) - return +async function addRemoteIceCandidate(candidate: IceCandidate): Promise { + if (!peerConnection) return false + if (!candidate.candidate) return false + if (seenRemoteCandidates.has(candidate.candidate)) { + return false } - if (data.session_id !== sessionId) return - if (!peerConnection || !peerConnection.remoteDescription) { - pendingRemoteCandidates.push(data) - return - } - - await addRemoteIceCandidate(data.candidate) -} - -async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) { - if (!data || !data.session_id) return - - if (!sessionId) { - pendingRemoteIceComplete.add(data.session_id) - return - } - if (data.session_id !== sessionId) return - if (!peerConnection || !peerConnection.remoteDescription) { - pendingRemoteIceComplete.add(data.session_id) - return - } - - try { - await peerConnection.addIceCandidate(null) - } catch { - } -} - -async function addRemoteIceCandidate(candidate: IceCandidate) { - if (!peerConnection) return - if (!candidate.candidate) return - if (seenRemoteCandidates.has(candidate.candidate)) return seenRemoteCandidates.add(candidate.candidate) const iceCandidate: RTCIceCandidateInit = { @@ -315,33 +293,17 @@ async function addRemoteIceCandidate(candidate: IceCandidate) { try { await peerConnection.addIceCandidate(iceCandidate) - } catch { - // ICE candidate add failures are non-fatal + return true + } catch (err) { + videoDebugLog('Failed to apply remote ICE candidate', { + sessionId, + candidate: summarizeIceCandidate(candidate), + error: err, + }) + return false } } -async function flushPendingRemoteIce() { - if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return - - const queued = pendingRemoteCandidates - pendingRemoteCandidates = [] - - for (const event of queued) { - if (event.session_id === sessionId) { - await addRemoteIceCandidate(event.candidate) - } - } - - if (pendingRemoteIceComplete.has(sessionId)) { - pendingRemoteIceComplete.delete(sessionId) - try { - await peerConnection.addIceCandidate(null) - } catch { - } - } -} - -// Start collecting stats function startStatsCollection() { if (statsInterval) return @@ -369,7 +331,6 @@ function startStatsCollection() { (stat.state === 'succeeded' && stat.selected === true) || (stat.state === 'in-progress' && !foundActivePair) - // Also check if this pair has actual data transfer (more reliable indicator) const hasData = (stat.bytesReceived > 0 || stat.bytesSent > 0) if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) { @@ -382,7 +343,6 @@ function startStatsCollection() { } } - // Update video stats if (stat.type === 'inbound-rtp' && stat.kind === 'video') { stats.value.bytesReceived = stat.bytesReceived || 0 stats.value.packetsReceived = stat.packetsReceived || 0 @@ -396,7 +356,6 @@ function startStatsCollection() { } }) - // Update ICE connection info from selected pair const localCandidate = selectedPairLocalId ? candidates[selectedPairLocalId] : undefined const remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined @@ -410,12 +369,10 @@ function startStatsCollection() { stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay' } catch { - // Stats collection errors are non-fatal } }, 1000) } -// Stop collecting stats function stopStatsCollection() { if (statsInterval) { clearInterval(statsInterval) @@ -423,37 +380,42 @@ function stopStatsCollection() { } } -// Send queued ICE candidates after sessionId is set async function flushPendingIceCandidates() { if (!sessionId || pendingIceCandidates.length === 0) return + const currentSessionId = sessionId const candidates = [...pendingIceCandidates] pendingIceCandidates = [] - for (const candidate of candidates) { - if (shouldSkipLocalCandidate(candidate)) continue + const sendTasks = candidates.map(async (candidate) => { + if (shouldSkipLocalCandidate(candidate)) { + return + } try { - await webrtcApi.addIceCandidate(sessionId, { + await webrtcApi.addIceCandidate(currentSessionId, { candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpMLineIndex ?? undefined, usernameFragment: candidate.usernameFragment ?? undefined, }) - } catch { - // ICE candidate send failures are non-fatal + } catch (err) { + videoDebugLog('Failed to send queued local ICE candidate', { + sessionId: currentSessionId, + candidate: summarizeIceCandidate(candidate), + error: err, + }) } - } + }) + + await Promise.allSettled(sendTasks) } -// Connect to WebRTC server async function connect(): Promise { if (connectInFlight) { return connectInFlight } connectInFlight = (async () => { - registerWebSocketHandlers() - if (isConnecting) { return state.value === 'connected' } @@ -468,67 +430,85 @@ async function connect(): Promise { await disconnect() } - // Clear pending ICE candidates from previous attempt pendingIceCandidates = [] + seenRemoteCandidates.clear() try { state.value = 'connecting' error.value = null - connectStage.value = 'fetching_ice_servers' + setConnectStage('fetching_ice_servers') - // Fetch ICE servers from backend API const iceServers = await fetchIceServers() - connectStage.value = 'creating_peer_connection' + setConnectStage('creating_peer_connection', { iceServerCount: iceServers.length }) - // Create peer connection with fetched ICE servers peerConnection = createPeerConnection(iceServers) - connectStage.value = 'creating_data_channel' + setConnectStage('creating_data_channel') createDataChannel(peerConnection) peerConnection.addTransceiver('video', { direction: 'recvonly' }) peerConnection.addTransceiver('audio', { direction: 'recvonly' }) - connectStage.value = 'creating_offer' + setConnectStage('creating_offer') const offer = await peerConnection.createOffer() await peerConnection.setLocalDescription(offer) - connectStage.value = 'waiting_server_answer' + setConnectStage('waiting_server_answer') // Do not pass client_id here: each connect creates a fresh session. const response = await webrtcApi.offer(offer.sdp!) sessionId = response.session_id - - // Send any ICE candidates that were queued while waiting for sessionId - await flushPendingIceCandidates() + sessionIdRef.value = response.session_id const answer: RTCSessionDescriptionInit = { type: 'answer', sdp: response.sdp, } - connectStage.value = 'setting_remote_description' + setConnectStage('setting_remote_description', { sessionId }) await peerConnection.setRemoteDescription(answer) - // Flush any pending server ICE candidates once remote description is set - connectStage.value = 'applying_ice_candidates' - await flushPendingRemoteIce() - - // Add any ICE candidates from the response - if (response.ice_candidates && response.ice_candidates.length > 0) { - for (const candidateObj of response.ice_candidates) { - await addRemoteIceCandidate(candidateObj) + setConnectStage('applying_ice_candidates', { + sessionId, + answerCandidates: response.ice_candidates?.length ?? 0, + }) + let appliedAnswerCandidates = 0 + for (const candidate of response.ice_candidates ?? []) { + if (await addRemoteIceCandidate(candidate)) { + appliedAnswerCandidates += 1 } } + try { + await peerConnection.addIceCandidate(null) + } catch (err) { + videoDebugLog('Failed to apply remote ICE end-of-candidates from answer response', { + sessionId, + appliedAnswerCandidates, + error: err, + }) + } + + void flushPendingIceCandidates() - // Wait for connection to establish (5s for LAN, sufficient for most scenarios) const connectionTimeout = 5000 + const iceConnectedTimeout = 12000 const pollInterval = 100 let waited = 0 - connectStage.value = 'waiting_connection' + setConnectStage('waiting_connection', { + sessionId, + connectionTimeout, + iceConnectedTimeout, + pollInterval, + }) - while (waited < connectionTimeout && peerConnection) { + while (peerConnection) { const pcState = peerConnection.connectionState + const iceState = peerConnection.iceConnectionState + const timeoutForState = iceState === 'connected' || iceState === 'completed' + ? iceConnectedTimeout + : connectionTimeout + if (waited >= timeoutForState) break + if (pcState === 'connected') { - connectStage.value = 'connected' + setConnectStage('connected', { sessionId, waited }) isConnecting = false return true } @@ -539,10 +519,25 @@ async function connect(): Promise { waited += pollInterval } + videoDebugLog('WebRTC connect timed out waiting for ICE/DTLS', { + sessionId, + waited, + connectionState: peerConnection?.connectionState, + iceConnectionState: peerConnection?.iceConnectionState, + iceGatheringState: peerConnection?.iceGatheringState, + signalingState: peerConnection?.signalingState, + }) throw new Error('Connection timeout waiting for ICE negotiation') } catch (err) { state.value = 'failed' - connectStage.value = 'failed' + setConnectStage('failed', { + sessionId, + error: err, + connectionState: peerConnection?.connectionState, + iceConnectionState: peerConnection?.iceConnectionState, + iceGatheringState: peerConnection?.iceGatheringState, + signalingState: peerConnection?.signalingState, + }) error.value = err instanceof Error ? err.message : 'Connection failed' isConnecting = false await disconnect() @@ -557,17 +552,15 @@ async function connect(): Promise { } } -// Disconnect from WebRTC server async function disconnect() { stopStatsCollection() // Clear state FIRST to prevent ICE candidates from being sent const oldSessionId = sessionId sessionId = null + sessionIdRef.value = null isConnecting = false pendingIceCandidates = [] - pendingRemoteCandidates = [] - pendingRemoteIceComplete.clear() seenRemoteCandidates.clear() if (dataChannel) { @@ -584,18 +577,21 @@ async function disconnect() { if (oldSessionId) { try { await webrtcApi.close(oldSessionId) - } catch { + } catch (err) { + videoDebugLog('Failed to close backend WebRTC session', { + sessionId: oldSessionId, + error: err, + }) } } videoTrack.value = null audioTrack.value = null - cachedMediaStream = null // Clear cached stream on disconnect + cachedMediaStream = null state.value = 'disconnected' - connectStage.value = 'disconnected' + setConnectStage('disconnected', { previousSessionId: oldSessionId }) error.value = null - // Reset stats stats.value = { bytesReceived: 0, packetsReceived: 0, @@ -614,7 +610,6 @@ async function disconnect() { } } -// Send keyboard event via DataChannel (binary format) function sendKeyboard(event: HidKeyboardEvent): boolean { if (!dataChannel || dataChannel.readyState !== 'open') { return false @@ -629,7 +624,6 @@ function sendKeyboard(event: HidKeyboardEvent): boolean { } } -// Send mouse event via DataChannel (binary format) function sendMouse(event: HidMouseEvent): boolean { if (!dataChannel || dataChannel.readyState !== 'open') { return false @@ -695,7 +689,7 @@ export function useWebRTC() { error, dataChannelReady, connectStage, - sessionId: computed(() => sessionId), + sessionId: sessionIdRef, connect, disconnect, diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts index 48221076..14647e2d 100644 --- a/web/src/composables/useWebSocket.ts +++ b/web/src/composables/useWebSocket.ts @@ -28,17 +28,16 @@ function arraysEqual(a: string[], b: string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]) } -function syncSubscriptions() { +function syncSubscriptions(force = false) { const topics = getSubscribedTopics() - if (arraysEqual(topics, subscribedTopics)) { + if (!force && arraysEqual(topics, subscribedTopics)) { return } - subscribedTopics = topics - if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { subscribe(topics) + subscribedTopics = topics } } @@ -59,7 +58,7 @@ function connect() { networkErrorMessage.value = null reconnectAttempts.value = 0 - syncSubscriptions() + syncSubscriptions(true) } wsInstance.onmessage = (e) => { diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index ae15e774..95502fee 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -314,6 +314,10 @@ export default { title: 'Video device offline', detail: 'Capture card is not responding, attempting to re-detect…', }, + audioDeviceLost: { + title: 'Audio device offline', + detail: 'Reconnecting the audio capture device…', + }, deviceBusy: { title: 'Video channel busy', detail: 'Applying a new configuration or another component is using the device, please wait…', @@ -335,6 +339,8 @@ export default { device_lost: 'Video node disappeared, waiting for the driver to recover', config_changing: 'Applying new configuration', mode_switching: 'Switching video mode', + audio_device_lost: 'Audio capture is unavailable; recovery in progress', + audio_reconnecting: 'Retrying audio device connection', uvc_usb_error: 'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.', uvc_capture_stall: '', @@ -352,6 +358,10 @@ export default { webrtcPhaseSetRemote: 'Applying remote description...', webrtcPhaseApplyIce: 'Applying ICE candidates...', webrtcPhaseNegotiating: 'Negotiating secure connection...', + mjpegPhaseWebsocket: 'Connecting control channel...', + mjpegPhaseStream: 'Requesting video stream...', + mjpegPhaseFirstFrame: 'Waiting for first frame...', + stepProgress: 'Step {current}/{total}', pointerLocked: 'Pointer Locked', pointerLockedDesc: 'Press Escape to release the pointer', pointerLockFailed: 'Failed to lock pointer', @@ -476,10 +486,11 @@ export default { }, settings: { title: 'Settings', + sidebarSubtitle: 'Manage device, network and extensions', basic: 'Basic', general: 'General', appearance: 'Appearance', - account: 'User', + account: 'Account', access: 'Access', video: 'Video', encoder: 'Encoder', @@ -496,6 +507,19 @@ export default { configured: 'Configured', security: 'Security', about: 'About', + appearanceSubtitle: 'Customize interface appearance and language', + accountSubtitle: 'Manage credentials and session policy', + networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate', + videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers', + hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors', + msdSubtitle: 'Manage Mass Storage Device image directory', + atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN', + environmentSubtitle: 'System runtime environment and USB device maintenance', + aboutSubtitle: 'Online upgrade, version info and hardware overview', + extTtydSubtitle: 'Open a host Shell terminal in the browser', + extRustdeskSubtitle: 'Remote graphical access via RustDesk', + extRtspSubtitle: 'Provide an RTSP video stream for external clients', + extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', aboutDesc: 'Open and Lightweight IP-KVM Solution', deviceInfo: 'Device Info', deviceInfoDesc: 'Host system information', @@ -512,8 +536,8 @@ export default { changePassword: 'Change Password', currentPassword: 'Current Password', newPassword: 'New Password', - usernameDesc: 'Change your login username', - passwordDesc: 'Change your login password', + usernameDesc: 'Change the console login username', + passwordDesc: 'Change the console login password', version: 'Version', buildInfo: 'Build Info', detectDevices: 'Detect Devices', @@ -542,20 +566,25 @@ export default { addBindAddress: 'Add address', bindAddressListEmpty: 'Add at least one IP address.', httpsEnabled: 'Enable HTTPS', - httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)', + httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.', portConfig: 'Port & Protocol', - portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle', + portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle', httpPortReserved: 'HTTP port (reserved)', httpsPortReserved: 'HTTPS port (reserved)', + portActive: 'Active', + portReserved: 'Reserved', + portReservedHint: 'The reserved port is applied only after switching protocol; you can preconfigure it now.', previewUrl: 'Access URL preview', + copyUrl: 'Copy access URL', + openInBrowser: 'Open in browser', listenAddress: 'Listen Address', - listenAddressDesc: 'Configure which network interfaces the web server listens on', + listenAddressDesc: 'Choose which network interfaces the web server binds to', bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces', - bindModeLocalDesc: '127.0.0.1 — Allow local access only', - bindModeCustomDesc: 'Specify a list of IP addresses', - effectiveAddresses: 'Listen address preview', + bindModeLocalDesc: '127.0.0.1 — Loopback only (local access)', + bindModeCustomDesc: 'Bind to a specific list of IP addresses', + effectiveAddresses: 'Effective listen addresses', sslCertificate: 'SSL Certificate', - sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required', + sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one. A service restart is required to apply.', sslCertCustom: 'Custom Certificate', sslCertSelfSigned: 'Self-Signed', sslCertActive: 'Custom certificate is active', @@ -568,6 +597,8 @@ export default { sslCertSaved: 'Certificate saved, restart to apply', sslCertCleared: 'Reverted to self-signed certificate, restart to apply', restartRequired: 'Restart Required', + restartRequiredHint: 'The service will restart automatically to apply the new configuration.', + unsavedChangesHint: 'Click Save to apply changes', restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.', restarting: 'Restarting...', autoRestarting: 'Restarting automatically', @@ -600,10 +631,10 @@ export default { updateMsgInstalling: 'Replacing binary', updateMsgRestarting: 'Restarting service', auth: 'Access', - authSettings: 'Access Settings', - authSettingsDesc: 'Single-user access and session behavior', - allowMultipleSessions: 'Allow multiple web sessions', - allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.', + authSettings: 'Session Policy', + authSettingsDesc: 'Configure single-user login and concurrent session behavior', + allowMultipleSessions: 'Allow concurrent web sessions', + allowMultipleSessionsDesc: 'When disabled, a new login automatically signs out the previous session', userManagement: 'User Management', userManagementDesc: 'Manage user accounts and permissions', addUser: 'Add User', @@ -665,10 +696,10 @@ export default { atxWolInterface: 'Network Interface', atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3', atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing', - themeDesc: 'Choose your preferred color scheme', - languageDesc: 'Select your preferred language', - videoSettings: 'Video Settings', - videoSettingsDesc: 'Configure video capture device', + themeDesc: 'Choose the interface color scheme', + languageDesc: 'Choose the interface display language', + videoSettings: 'Video Capture', + videoSettingsDesc: 'Configure capture device format, resolution and frame rate', videoDevice: 'Video Device', selectDevice: 'Select device...', videoFormat: 'Video Format', @@ -676,13 +707,13 @@ export default { driver: 'Driver', resolution: 'Resolution', frameRate: 'Frame Rate', - encoderBackend: 'Encoder Backend', - encoderBackendDesc: 'Select encoder backend for WebRTC streaming', + encoderBackend: 'Video Encoder', + encoderBackendDesc: 'Select the encoder backend used for WebRTC streaming', backend: 'Backend', autoRecommended: 'Auto (Recommended)', software: 'Software', - supportedFormats: 'Supported Formats', - encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.', + supportedFormats: 'Supported Codecs', + encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.', hidSettings: 'HID Settings', hidSettingsDesc: 'Configure keyboard and mouse control', hidBackend: 'HID Backend', @@ -824,20 +855,20 @@ export default { resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?', resetAction: 'Reset Device', }, - webrtcSettings: 'WebRTC Settings', - webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', - publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production', + webrtcSettings: 'WebRTC Signaling', + webrtcSettingsDesc: 'Configure STUN/TURN servers to assist NAT traversal', + publicIceServersHint: 'Leave empty to use Google\u2019s public STUN servers; TURN must be self-hosted', stunServer: 'STUN Server', stunServerPlaceholder: 'stun:stun.l.google.com:19302', - stunServerHint: 'Custom STUN server (leave empty to use Google public server)', + stunServerHint: 'Leave empty to use Google\u2019s public STUN servers', turnServer: 'TURN Server', turnServerPlaceholder: 'turn:turn.example.com:3478', - turnServerHint: 'Custom TURN relay server (required for production)', + turnServerHint: 'TURN relay server. Strongly recommended for public deployments or strict NAT environments.', turnUsername: 'TURN Username', turnPassword: 'TURN Password', - turnPasswordConfigured: 'Password already configured. Leave empty to keep current password.', - turnCredentialsHint: 'Credentials for TURN server authentication', - iceConfigNote: 'Note: Changes require reconnecting the WebRTC session to take effect.', + turnPasswordConfigured: 'A password is already saved. Leave empty to keep the current password.', + turnCredentialsHint: 'Credentials used for TURN server authentication', + iceConfigNote: 'Changes apply to the next WebRTC session', }, virtualKeyboard: { title: 'Virtual Keyboard', @@ -872,6 +903,10 @@ export default { format: 'Format', resolution: 'Resolution', fps: 'FPS', + fpsTarget: 'Target FPS', + fpsActual: 'Actual FPS', + fpsStaticHint: 'Frame rate drops automatically while the image is static', + paused: 'Paused', clients: 'Clients', backend: 'Backend', mouse: 'Mouse', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 7ce246e2..7ada80a3 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -313,6 +313,10 @@ export default { title: '视频设备已断开', detail: '采集卡离线,正在尝试重新识别…', }, + audioDeviceLost: { + title: '音频设备已断开', + detail: '正在尝试重新连接音频采集设备…', + }, deviceBusy: { title: '视频通道忙', detail: '正在切换配置或被其他组件占用,请稍候…', @@ -334,6 +338,8 @@ export default { device_lost: '视频节点丢失,等待驱动恢复', config_changing: '正在应用新配置', mode_switching: '正在切换视频模式', + audio_device_lost: '音频采集不可用,正在自动恢复', + audio_reconnecting: '正在重试连接音频设备', uvc_usb_error: '可尝试更换 USB 口或线、避免 HUB、或重新插拔设备;也可在 设置 → 环境 → USB 设备 中复位。', uvc_capture_stall: '', @@ -351,6 +357,10 @@ export default { webrtcPhaseSetRemote: '正在应用远端会话描述...', webrtcPhaseApplyIce: '正在应用 ICE 候选...', webrtcPhaseNegotiating: '正在协商安全连接...', + mjpegPhaseWebsocket: '正在连接控制通道...', + mjpegPhaseStream: '正在请求视频流...', + mjpegPhaseFirstFrame: '正在等待首帧...', + stepProgress: '第 {current}/{total} 步', pointerLocked: '鼠标已锁定', pointerLockedDesc: '按 Escape 键释放鼠标', pointerLockFailed: '鼠标锁定失败', @@ -475,10 +485,11 @@ export default { }, settings: { title: '系统设置', + sidebarSubtitle: '管理设备、网络与扩展', basic: '基础', general: '通用', appearance: '外观', - account: '用户', + account: '账户', access: '访问', video: '视频', encoder: '编码器', @@ -495,6 +506,19 @@ export default { configured: '已配置', security: '安全', about: '关于', + appearanceSubtitle: '自定义界面外观与显示语言', + accountSubtitle: '管理登录凭据与会话策略', + networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书', + videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器', + hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符', + msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录', + atxSubtitle: '配置远程电源控制硬件与网络唤醒', + environmentSubtitle: '系统级运行环境与 USB 设备维护', + aboutSubtitle: '在线升级、版本信息与设备硬件概览', + extTtydSubtitle: '在浏览器中打开本机 Shell 终端', + extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问', + extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流', + extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', aboutDesc: '开放轻量的 IP-KVM 解决方案', deviceInfo: '设备信息', deviceInfoDesc: '主机系统信息', @@ -511,8 +535,8 @@ export default { changePassword: '修改密码', currentPassword: '当前密码', newPassword: '新密码', - usernameDesc: '修改登录用户名', - passwordDesc: '修改登录密码', + usernameDesc: '修改控制台登录用户名', + passwordDesc: '修改控制台登录密码', version: '版本', buildInfo: '构建信息', detectDevices: '探测设备', @@ -541,20 +565,25 @@ export default { addBindAddress: '添加地址', bindAddressListEmpty: '请至少填写一个 IP 地址。', httpsEnabled: '启用 HTTPS', - httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)', + httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书', portConfig: '端口与协议', - portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口', + portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口', httpPortReserved: 'HTTP 端口(备用)', httpsPortReserved: 'HTTPS 端口(备用)', + portActive: '当前生效', + portReserved: '备用', + portReservedHint: '备用端口仅在切换协议后生效,可提前配置', previewUrl: '访问地址预览', + copyUrl: '复制访问地址', + openInBrowser: '在浏览器中打开', listenAddress: '监听地址', listenAddressDesc: '配置 Web 服务监听哪些网络接口', bindModeAllDesc: '0.0.0.0 — 监听所有网络接口', bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问', bindModeCustomDesc: '指定一组 IP 地址', - effectiveAddresses: '监听地址预览', + effectiveAddresses: '生效监听地址', sslCertificate: 'SSL 证书', - sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效', + sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,保存后需重启服务生效', sslCertCustom: '自定义证书', sslCertSelfSigned: '自签名证书', sslCertActive: '自定义证书已启用', @@ -567,6 +596,8 @@ export default { sslCertSaved: '证书已保存,重启后生效', sslCertCleared: '已恢复自签名证书,重启后生效', restartRequired: '需要重启', + restartRequiredHint: '保存后将自动重启服务以应用新配置', + unsavedChangesHint: '点击右侧按钮保存当前配置', restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。', restarting: '正在重启...', autoRestarting: '正在自动重启', @@ -599,10 +630,10 @@ export default { updateMsgInstalling: '替换程序中', updateMsgRestarting: '服务重启中', auth: '访问控制', - authSettings: '访问设置', - authSettingsDesc: '单用户访问与会话策略', - allowMultipleSessions: '允许多个 Web 会话', - allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。', + authSettings: '会话策略', + authSettingsDesc: '配置单用户登录与并发会话规则', + allowMultipleSessions: '允许多个 Web 会话并存', + allowMultipleSessionsDesc: '关闭后,新登录将自动踢出旧会话', userManagement: '用户管理', userManagementDesc: '管理用户账号和权限', addUser: '添加用户', @@ -664,10 +695,10 @@ export default { atxWolInterface: '网络接口', atxWolInterfacePlaceholder: '例如: eth0, enp0s3', atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由', - themeDesc: '选择您喜欢的颜色方案', - languageDesc: '选择您的首选语言', - videoSettings: '视频设置', - videoSettingsDesc: '配置视频采集设备', + themeDesc: '选择界面颜色方案', + languageDesc: '选择界面显示语言', + videoSettings: '视频采集', + videoSettingsDesc: '配置视频采集设备的格式、分辨率与帧率', videoDevice: '视频设备', selectDevice: '选择设备...', videoFormat: '视频格式', @@ -675,13 +706,13 @@ export default { driver: '驱动', resolution: '分辨率', frameRate: '帧率', - encoderBackend: '编码器后端', - encoderBackendDesc: '选择 WebRTC 流的编码器后端', + encoderBackend: '视频编码器', + encoderBackendDesc: '选择 WebRTC 输出使用的视频编码器后端', backend: '后端', autoRecommended: '自动(推荐)', software: '软件', - supportedFormats: '支持的格式', - encoderHint: '硬件编码器性能更好,CPU 占用更低。软件编码器兼容性更好,但需要更多 CPU 资源。', + supportedFormats: '支持的编码格式', + encoderHint: '硬件编码器延迟低、CPU 占用小;软件编码器兼容性更广但占用资源更多。', hidSettings: 'HID 设置', hidSettingsDesc: '配置键盘和鼠标控制', hidBackend: 'HID 后端', @@ -823,20 +854,20 @@ export default { resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?', resetAction: '确认复位', }, - webrtcSettings: 'WebRTC 设置', - webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', - publicIceServersHint: '留空将使用 Google 公共 STUN 服务器,TURN 服务器需自行配置', + webrtcSettings: 'WebRTC 信令', + webrtcSettingsDesc: '配置 STUN/TURN 服务器以辅助 NAT 穿透', + publicIceServersHint: '留空将使用 Google 公共 STUN 服务器;TURN 服务器需自行部署', stunServer: 'STUN 服务器', stunServerPlaceholder: 'stun:stun.l.google.com:19302', - stunServerHint: '自定义 STUN 服务器(留空则使用 Google 公共服务器)', + stunServerHint: '留空将使用 Google 公共 STUN 服务器', turnServer: 'TURN 服务器', turnServerPlaceholder: 'turn:turn.example.com:3478', - turnServerHint: '自定义 TURN 中继服务器(生产环境必须配置)', + turnServerHint: 'TURN 中继服务器;公网部署或严格 NAT 环境强烈建议配置', turnUsername: 'TURN 用户名', turnPassword: 'TURN 密码', - turnPasswordConfigured: '密码已配置。留空则保持当前密码。', - turnCredentialsHint: '用于 TURN 服务器认证的凭据', - iceConfigNote: '注意:更改后需要重新连接 WebRTC 会话才能生效。', + turnPasswordConfigured: '密码已保存。留空则保持当前密码不变。', + turnCredentialsHint: '用于 TURN 服务器身份验证的凭据', + iceConfigNote: '更改后将在下一次 WebRTC 会话建立时生效', }, virtualKeyboard: { title: '虚拟键盘', @@ -871,6 +902,10 @@ export default { format: '格式', resolution: '分辨率', fps: '帧率', + fpsTarget: '目标帧率', + fpsActual: '实际帧率', + fpsStaticHint: '画面静止时会自动降帧', + paused: '已暂停', clients: '客户端', backend: '后端', mouse: '鼠标', diff --git a/web/src/lib/debugLog.ts b/web/src/lib/debugLog.ts new file mode 100644 index 00000000..fdc8a61e --- /dev/null +++ b/web/src/lib/debugLog.ts @@ -0,0 +1,15 @@ +export function isDebugLogEnabled(): boolean { + if (typeof window === 'undefined') return false + return new URLSearchParams(window.location.search).get('log') === 'debug' +} + +export function videoDebugLog(message: string, details?: unknown): void { + if (!isDebugLogEnabled()) return + + const timestamp = new Date().toISOString() + if (details === undefined) { + console.log(`[VideoDebug ${timestamp}] ${message}`) + } else { + console.log(`[VideoDebug ${timestamp}] ${message}`, details) + } +} diff --git a/web/src/lib/streamSignal.ts b/web/src/lib/streamSignal.ts new file mode 100644 index 00000000..b7a78251 --- /dev/null +++ b/web/src/lib/streamSignal.ts @@ -0,0 +1,11 @@ +import type { StreamDeviceLostEventData } from '@/types/websocket' + +const AUDIO_STATE_REASONS = new Set(['audio_device_lost', 'audio_reconnecting']) + +export function isAudioDeviceLostStateReason(reason: string | null | undefined): boolean { + return typeof reason === 'string' && AUDIO_STATE_REASONS.has(reason) +} + +export function isAudioStreamDeviceLostPayload(data: StreamDeviceLostEventData): boolean { + return data.kind === 'audio' +} diff --git a/web/src/types/websocket.ts b/web/src/types/websocket.ts index 18e2905b..7ec6f692 100644 --- a/web/src/types/websocket.ts +++ b/web/src/types/websocket.ts @@ -35,6 +35,14 @@ export function buildWsUrl(path: string): string { /** Default reconnect delay in milliseconds */ export const WS_RECONNECT_DELAY = 3000 +export type StreamDeviceLostKind = 'video' | 'audio' + +export interface StreamDeviceLostEventData { + kind: StreamDeviceLostKind + device: string + reason: string +} + /** WebSocket ready states */ export const WS_STATE = { CONNECTING: WebSocket.CONNECTING, diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index cecf8714..81dc0734 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -16,8 +16,11 @@ import { CanonicalKey, HidBackend } from '@/types/generated' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' import { toast } from 'vue-sonner' -import { generateUUID } from '@/lib/utils' +import { cn, generateUUID } from '@/lib/utils' import { formatFpsValue } from '@/lib/fps' +import { videoDebugLog } from '@/lib/debugLog' +import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal' +import type { StreamDeviceLostEventData } from '@/types/websocket' import type { VideoMode } from '@/components/VideoConfigPopover.vue' import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue' @@ -127,7 +130,6 @@ const mousePosition = ref({ x: 0, y: 0 }) const lastMousePosition = ref({ x: 0, y: 0 }) const isPointerLocked = ref(false) -/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */ const localCrosshairPos = ref<{ x: number; y: number } | null>(null) const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16 @@ -180,10 +182,6 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro if (webrtc.isConnecting.value) return 'connecting' if (webrtc.isConnected.value) return 'connected' } - // MJPEG: check if frames have actually arrived (frontend-side detection) - // This is more reliable than relying on stream.online from backend, - // which can be stale due to the debounce delay in device_info broadcaster. - // Also handles browsers that don't fire img.onload for multipart MJPEG streams. if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected' if (systemStore.stream?.online) return 'connected' return 'disconnected' @@ -201,10 +199,18 @@ function getResolutionShortName(width: number, height: number): string { return `${height}p` } +const isMjpegPaused = computed(() => { + if (videoMode.value !== 'mjpeg') return false + const stream = systemStore.stream + if (!stream) return false + return stream.online === false +}) + const videoQuickInfo = computed(() => { const stream = systemStore.stream if (!stream?.resolution) return '' const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1]) + if (isMjpegPaused.value) return `${resShort} ${t('statusCard.paused')}` return `${resShort} ${formatFpsValue(backendFps.value)}fps` }) @@ -212,20 +218,36 @@ const videoDetails = computed(() => { const stream = systemStore.stream if (!stream) return [] const receivedFps = backendFps.value + const paused = isMjpegPaused.value const inputFmt = stream.format || 'MJPEG' const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)` const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}` - const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}` - const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined + const targetFpsValue = formatFpsValue(stream.targetFps ?? 0) + const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps) + const actualStatus: StatusDetail['status'] = paused + ? undefined + : receivedFps > 5 ? 'ok' + : receivedFps > 0 ? 'warning' + : 'error' - return [ + const details: StatusDetail[] = [ { label: t('statusCard.device'), value: stream.device || '-' }, { label: t('statusCard.format'), value: formatDisplay }, { label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' }, - { label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus }, + { label: t('statusCard.fpsTarget'), value: targetFpsValue }, + { label: t('statusCard.fpsActual'), value: actualFpsValue, status: actualStatus }, ] + + if (videoMode.value === 'mjpeg' && !paused && receivedFps > 0 && receivedFps < (stream.targetFps ?? 0)) { + details.push({ + label: '', + value: t('statusCard.fpsStaticHint'), + }) + } + + return details }) const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { @@ -418,7 +440,7 @@ const audioDetails = computed(() => { return [ { label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') }, { label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) }, - { label: t('statusCard.streaming'), value: audio.streaming ? t('statusCard.yes') : t('statusCard.no'), status: audio.streaming ? 'ok' : undefined }, + { label: t('statusCard.streaming'), value: audio.streaming ? t('common.yes') : t('common.no'), status: audio.streaming ? 'ok' : undefined }, ] }) @@ -484,9 +506,48 @@ const msdDetails = computed(() => { return details }) +const WEBRTC_PROGRESS_STAGES = [ + 'fetching_ice_servers', + 'creating_peer_connection', + 'creating_data_channel', + 'creating_offer', + 'waiting_server_answer', + 'setting_remote_description', + 'applying_ice_candidates', + 'waiting_connection', +] as const + +const MJPEG_PROGRESS_STAGES = [ + 'connecting_websocket', + 'requesting_stream', + 'waiting_first_frame', +] as const + +type MjpegProgressStage = (typeof MJPEG_PROGRESS_STAGES)[number] + +const mjpegConnectStage = computed(() => { + if (videoMode.value !== 'mjpeg') return null + if (videoRestarting.value) return null + + if (!wsConnected.value || wsNetworkError.value) return 'connecting_websocket' + if (mjpegTimestamp.value === 0) return 'requesting_stream' + if (!mjpegFrameReceived.value) return 'waiting_first_frame' + return null +}) + const webrtcLoadingMessage = computed(() => { if (videoMode.value === 'mjpeg') { - return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting') + if (videoRestarting.value) return t('console.videoRestarting') + switch (mjpegConnectStage.value) { + case 'connecting_websocket': + return t('console.mjpegPhaseWebsocket') + case 'requesting_stream': + return t('console.mjpegPhaseStream') + case 'waiting_first_frame': + return t('console.mjpegPhaseFirstFrame') + default: + return t('console.connecting') + } } switch (webrtc.connectStage.value) { @@ -515,6 +576,44 @@ const webrtcLoadingMessage = computed(() => { } }) +const connectProgress = computed<{ current: number; total: number } | null>(() => { + if (videoMode.value === 'mjpeg') { + const stage = mjpegConnectStage.value + if (!stage) return null + return { + current: MJPEG_PROGRESS_STAGES.indexOf(stage) + 1, + total: MJPEG_PROGRESS_STAGES.length, + } + } + + const stage = webrtc.connectStage.value + const idx = WEBRTC_PROGRESS_STAGES.indexOf(stage as (typeof WEBRTC_PROGRESS_STAGES)[number]) + if (idx < 0) return null + + return { + current: idx + 1, + total: WEBRTC_PROGRESS_STAGES.length, + } +}) + +const videoContainerStyle = computed(() => { + if (!videoAspectRatio.value) { + return { + width: '100%', + height: '100%', + maxWidth: '100%', + maxHeight: '100%', + minHeight: '120px', + } + } + return { + aspectRatio: videoAspectRatio.value, + maxWidth: '100%', + maxHeight: '100%', + minHeight: '120px', + } +}) + const showMsdStatusCard = computed(() => { return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329') }) @@ -603,7 +702,6 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise }) } -/** For WebRTC watch: skip auto-reconnect when these hold. */ function shouldSuppressAutoReconnect(): boolean { return videoMode.value === 'mjpeg' || !isConsoleActive.value @@ -613,6 +711,14 @@ function shouldSuppressAutoReconnect(): boolean { } function markWebRTCFailure(reason: string, description?: string) { + videoDebugLog('Marking WebRTC failure', { + reason, + description, + videoMode: videoMode.value, + webrtcState: webrtc.state.value, + webrtcStage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) pendingWebRTCReadyGate = false videoError.value = true videoErrorMessage.value = reason @@ -627,25 +733,45 @@ function markWebRTCFailure(reason: string, description?: string) { async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise { if (!pendingWebRTCReadyGate) return + videoDebugLog('Waiting for WebRTC backend ready gate', { reason, timeoutMs }) const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs) if (!ready) { console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`) } + videoDebugLog('WebRTC backend ready gate completed', { reason, ready }) pendingWebRTCReadyGate = false } async function connectWebRTCSerial(reason: string): Promise { if (webrtcConnectTask) { + videoDebugLog('Reusing serialized WebRTC connect task', { + reason, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) return webrtcConnectTask } + videoDebugLog('Starting serialized WebRTC connect task', { + reason, + videoMode: videoMode.value, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) webrtcConnectTask = (async () => { await waitForWebRTCReadyGate(reason) return webrtc.connect() })() try { - return await webrtcConnectTask + const result = await webrtcConnectTask + videoDebugLog('Serialized WebRTC connect task finished', { + reason, + result, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) + return result } finally { webrtcConnectTask = null } @@ -740,7 +866,11 @@ function handleVideoError() { }, delay) } -function handleStreamDeviceLost(data: { device: string; reason: string }) { +function handleStreamDeviceLost(data: StreamDeviceLostEventData) { + videoDebugLog('Stream device lost event', data) + if (isAudioStreamDeviceLostPayload(data)) { + return + } videoError.value = true videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason }) @@ -750,6 +880,12 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) { } function scheduleWebRTCRecovery() { + videoDebugLog('Scheduling WebRTC recovery check', { + attempts: webrtcRecoveryAttempts, + videoMode: videoMode.value, + videoError: videoError.value, + sessionId: webrtc.sessionId.value, + }) if (webrtcRecoveryTimerId !== null) { clearTimeout(webrtcRecoveryTimerId) webrtcRecoveryTimerId = null @@ -798,6 +934,10 @@ function scheduleWebRTCRecovery() { } function cancelWebRTCRecovery() { + videoDebugLog('Cancelling WebRTC recovery', { + attempts: webrtcRecoveryAttempts, + hadTimer: webrtcRecoveryTimerId !== null, + }) if (webrtcRecoveryTimerId !== null) { clearTimeout(webrtcRecoveryTimerId) webrtcRecoveryTimerId = null @@ -806,6 +946,7 @@ function cancelWebRTCRecovery() { } function handleStreamRecovered(_data: { device: string }) { + videoDebugLog('Stream recovered event', _data) cancelWebRTCRecovery() videoError.value = false @@ -836,6 +977,13 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin } function handleStreamConfigChanging(_data: any) { + videoDebugLog('Stream config changing event', { + data: _data, + videoMode: videoMode.value, + webrtcState: webrtc.state.value, + webrtcStage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) if (retryTimeoutId !== null) { clearTimeout(retryTimeoutId) retryTimeoutId = null @@ -856,6 +1004,13 @@ function handleStreamConfigChanging(_data: any) { } async function handleStreamConfigApplied(_data: any) { + videoDebugLog('Stream config applied event', { + data: _data, + videoMode: videoMode.value, + isModeSwitching: isModeSwitching.value, + webrtcState: webrtc.state.value, + webrtcStage: webrtc.connectStage.value, + }) consecutiveErrors = 0 gracePeriodTimeoutId = window.setTimeout(() => { @@ -881,11 +1036,23 @@ async function handleStreamConfigApplied(_data: any) { function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) { console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`) + videoDebugLog('WebRTC backend ready event', { + ...data, + pendingWebRTCReadyGate, + activeTransitionId: videoSession.activeTransitionId.value, + expectedTransitionId: videoSession.expectedTransitionId.value, + }) pendingWebRTCReadyGate = false videoSession.onWebRTCReady(data) } function handleStreamModeReady(data: { transition_id: string; mode: string }) { + videoDebugLog('Stream mode ready event', { + data, + videoMode: videoMode.value, + localSwitching: videoSession.localSwitching.value, + backendSwitching: videoSession.backendSwitching.value, + }) videoSession.onModeReady(data) if (data.mode === 'mjpeg') { pendingWebRTCReadyGate = false @@ -894,6 +1061,12 @@ function handleStreamModeReady(data: { transition_id: string; mode: string }) { } function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) { + videoDebugLog('Stream mode switching event', { + data, + videoMode: videoMode.value, + localSwitching: videoSession.localSwitching.value, + backendSwitching: videoSession.backendSwitching.value, + }) if (!isModeSwitching.value) { videoRestarting.value = true videoLoading.value = true @@ -904,6 +1077,13 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin } function handleStreamStateChanged(data: any) { + videoDebugLog('Stream state changed event', { + data, + videoMode: videoMode.value, + previousSignalState: streamSignalState.value, + webrtcState: webrtc.state.value, + webrtcStage: webrtc.connectStage.value, + }) const state = typeof data?.state === 'string' ? data.state : '' const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0 @@ -950,7 +1130,11 @@ function handleStreamStateChanged(data: any) { captureFrameOverlay().catch(() => {}) } } else if (state === 'device_lost' && videoMode.value !== 'mjpeg') { - if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) { + if ( + !isAudioDeviceLostStateReason(reason) + && webrtcRecoveryTimerId === null + && webrtcRecoveryAttempts === 0 + ) { scheduleWebRTCRecovery() } } else if (state === 'streaming') { @@ -1014,6 +1198,14 @@ const signalOverlayInfo = computed(() => { tone: 'info' as const, } case 'device_lost': + if (isAudioDeviceLostStateReason(reason)) { + return { + title: t('console.signal.audioDeviceLost.title'), + detail: t('console.signal.audioDeviceLost.detail'), + hint, + tone: 'error' as const, + } + } return { title: t('console.signal.deviceLost.title'), detail: t('console.signal.deviceLost.detail'), @@ -1076,6 +1268,10 @@ function normalizeServerMode(mode: string | undefined): VideoMode | null { async function restoreInitialMode(serverMode: VideoMode) { if (initialModeRestoreDone || initialModeRestoreInProgress) return initialModeRestoreInProgress = true + videoDebugLog('Restoring initial video mode from backend', { + serverMode, + currentMode: videoMode.value, + }) try { initialDeviceInfoReceived = true @@ -1097,6 +1293,14 @@ async function restoreInitialMode(serverMode: VideoMode) { } function handleDeviceInfo(data: any) { + videoDebugLog('Device info event received', { + streamMode: data.video?.stream_mode, + configChanging: data.video?.config_changing, + currentVideoMode: videoMode.value, + initialDeviceInfoReceived, + initialModeRestoreDone, + initialModeRestoreInProgress, + }) const prevAudioStreaming = systemStore.audio?.streaming ?? false const prevAudioDevice = systemStore.audio?.device ?? null systemStore.updateFromDeviceInfo(data) @@ -1114,7 +1318,6 @@ function handleDeviceInfo(data: any) { }) } - // This prevents false-positive mode changes during config switching if (data.video?.config_changing) { return } @@ -1139,10 +1342,14 @@ function handleDeviceInfo(data: any) { } function handleStreamModeChanged(data: { mode: string; previous_mode: string }) { + videoDebugLog('Stream mode changed event', { + data, + currentVideoMode: videoMode.value, + localSwitching: isModeSwitching.value, + }) const newMode = normalizeServerMode(data.mode) if (!newMode) return - // Ignore this during a local mode switch because it was triggered by our own request if (isModeSwitching.value) { console.log('[StreamModeChanged] Mode switch in progress, ignoring event') return @@ -1161,6 +1368,11 @@ function reloadPage() { } function refreshVideo() { + videoDebugLog('Refreshing MJPEG video', { + videoMode: videoMode.value, + previousTimestamp: mjpegTimestamp.value, + streamSignalState: streamSignalState.value, + }) backendFps.value = 0 videoError.value = false videoErrorMessage.value = '' @@ -1178,12 +1390,6 @@ function refreshVideo() { }, 1500) } -// MJPEG URL with cache-busting timestamp (reactive) -// Only return valid URL when in MJPEG mode and the backend reports a -// healthy stream. When the backend goes offline (no_signal / device_lost -// / device_busy) we deliberately return an empty string so the `` -// tag has no `src` and the 4-state overlay fully owns the video area — -// no more fake placeholder JPEG peeking through. const mjpegTimestamp = ref(0) const mjpegUrl = computed(() => { if (videoMode.value !== 'mjpeg') { @@ -1199,6 +1405,12 @@ const mjpegUrl = computed(() => { }) async function connectWebRTCOnly(codec: VideoMode = 'h264') { + videoDebugLog('Connecting WebRTC without mode switch', { + codec, + currentMode: videoMode.value, + webrtcState: webrtc.state.value, + sessionId: webrtc.sessionId.value, + }) if (retryTimeoutId !== null) { clearTimeout(retryTimeoutId) retryTimeoutId = null @@ -1222,9 +1434,13 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') { try { const success = await connectWebRTCSerial('connectWebRTCOnly') + videoDebugLog('WebRTC-only connect result', { + codec, + success, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) if (success) { - // Force video rebind even when the track already exists - // This fixes missing video after returning to the page await rebindWebRTCVideo() videoLoading.value = false @@ -1239,6 +1455,12 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') { } async function rebindWebRTCVideo() { + videoDebugLog('Rebinding WebRTC video element', { + hasVideoElement: Boolean(webrtcVideoRef.value), + hasVideoTrack: Boolean(webrtc.videoTrack.value), + hasAudioTrack: Boolean(webrtc.audioTrack.value), + sessionId: webrtc.sessionId.value, + }) if (!webrtcVideoRef.value) return webrtcVideoRef.value.srcObject = null @@ -1259,6 +1481,12 @@ async function rebindWebRTCVideo() { } async function switchToWebRTC(codec: VideoMode = 'h264') { + videoDebugLog('Switching to WebRTC mode', { + codec, + currentMode: videoMode.value, + webrtcState: webrtc.state.value, + sessionId: webrtc.sessionId.value, + }) if (retryTimeoutId !== null) { clearTimeout(retryTimeoutId) retryTimeoutId = null @@ -1281,18 +1509,27 @@ async function switchToWebRTC(codec: VideoMode = 'h264') { pendingWebRTCReadyGate = true try { - // Disconnect first so ICE candidates are not sent to stale sessions during backend codec switch. if (webrtc.isConnected.value || webrtc.sessionId.value) { await webrtc.disconnect() } const modeResp = await streamApi.setMode(codec) + videoDebugLog('Backend setMode response for WebRTC', { + codec, + response: modeResp, + }) if (modeResp.transition_id) { videoSession.registerTransition(modeResp.transition_id) const [mode, webrtcReady] = await Promise.all([ videoSession.waitForModeReady(modeResp.transition_id, 5000), videoSession.waitForWebRTCReady(modeResp.transition_id, 3000), ]) + videoDebugLog('Backend WebRTC mode transition wait finished', { + codec, + transitionId: modeResp.transition_id, + mode, + webrtcReady, + }) if (mode && mode !== codec && mode !== 'webrtc') { console.warn(`[WebRTC] Backend mode_ready returned '${mode}', expected '${codec}', falling back`) @@ -1310,12 +1547,26 @@ async function switchToWebRTC(codec: VideoMode = 'h264') { const RETRY_DELAYS = [200, 800] let success = false for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) { + videoDebugLog('WebRTC connect attempt for mode switch', { + codec, + attempt: attempt + 1, + maxAttempts: MAX_ATTEMPTS, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) if (attempt > 0) { const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1] console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`) await new Promise(resolve => setTimeout(resolve, delay)) } success = await connectWebRTCSerial('switchToWebRTC') + videoDebugLog('WebRTC connect attempt finished for mode switch', { + codec, + attempt: attempt + 1, + success, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + }) } if (success) { await rebindWebRTCVideo() @@ -1332,6 +1583,11 @@ async function switchToWebRTC(codec: VideoMode = 'h264') { } async function switchToMJPEG() { + videoDebugLog('Switching to MJPEG mode', { + currentMode: videoMode.value, + webrtcState: webrtc.state.value, + sessionId: webrtc.sessionId.value, + }) videoLoading.value = true videoError.value = false videoErrorMessage.value = '' @@ -1339,6 +1595,7 @@ async function switchToMJPEG() { try { const modeResp = await streamApi.setMode('mjpeg') + videoDebugLog('Backend setMode response for MJPEG', modeResp) if (modeResp.transition_id) { videoSession.registerTransition(modeResp.transition_id) const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000) @@ -1364,6 +1621,12 @@ async function switchToMJPEG() { } function syncToServerMode(mode: VideoMode) { + videoDebugLog('Syncing frontend video mode to backend mode', { + mode, + currentMode: videoMode.value, + localSwitching: videoSession.localSwitching.value, + backendSwitching: videoSession.backendSwitching.value, + }) if (videoSession.localSwitching.value || videoSession.backendSwitching.value) return if (mode === videoMode.value) return @@ -1378,6 +1641,12 @@ function syncToServerMode(mode: VideoMode) { } async function handleVideoModeChange(mode: VideoMode) { + videoDebugLog('User requested video mode change', { + requestedMode: mode, + currentMode: videoMode.value, + webrtcState: webrtc.state.value, + sessionId: webrtc.sessionId.value, + }) if (mode === videoMode.value) return if (!videoSession.tryStartLocalSwitch()) { console.log('[VideoMode] Switch throttled or in progress, ignoring') @@ -1410,12 +1679,26 @@ async function handleVideoModeChange(mode: VideoMode) { } watch(() => webrtc.videoTrack.value, async (track) => { + videoDebugLog('WebRTC video track ref changed', { + hasTrack: Boolean(track), + trackId: track?.id, + readyState: track?.readyState, + muted: track?.muted, + videoMode: videoMode.value, + }) if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') { await rebindWebRTCVideo() } }) watch(() => webrtc.audioTrack.value, async (track) => { + videoDebugLog('WebRTC audio track ref changed', { + hasTrack: Boolean(track), + trackId: track?.id, + readyState: track?.readyState, + muted: track?.muted, + videoMode: videoMode.value, + }) if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') { const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null if (currentStream && currentStream.getAudioTracks().length === 0) { @@ -1429,6 +1712,19 @@ watch(webrtcVideoRef, (el) => { }, { immediate: true }) watch(webrtc.stats, (stats) => { + if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) { + videoDebugLog('WebRTC video stats updated with active frames', { + fps: stats.framesPerSecond, + frameWidth: stats.frameWidth, + frameHeight: stats.frameHeight, + packetsReceived: stats.packetsReceived, + packetsLost: stats.packetsLost, + localCandidateType: stats.localCandidateType, + remoteCandidateType: stats.remoteCandidateType, + transportProtocol: stats.transportProtocol, + isRelay: stats.isRelay, + }) + } if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) { backendFps.value = Math.round(stats.framesPerSecond) systemStore.setStreamOnline(true) @@ -1442,14 +1738,21 @@ let webrtcReconnectTimeout: ReturnType | null = null let webrtcReconnectFailures = 0 watch(() => webrtc.state.value, (newState, oldState) => { console.log('[WebRTC] State changed:', oldState, '->', newState) + videoDebugLog('WebRTC state watcher observed change', { + oldState, + newState, + stage: webrtc.connectStage.value, + sessionId: webrtc.sessionId.value, + videoMode: videoMode.value, + videoLoading: videoLoading.value, + suppressAutoReconnect: shouldSuppressAutoReconnect(), + }) if (webrtcReconnectTimeout) { clearTimeout(webrtcReconnectTimeout) webrtcReconnectTimeout = null } - // Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting` - // never blocks clearing the loading overlay when ICE becomes connected. if (videoMode.value !== 'mjpeg') { if (newState === 'connected') { systemStore.setStreamOnline(true) @@ -1467,6 +1770,10 @@ watch(() => webrtc.state.value, (newState, oldState) => { } if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') { + videoDebugLog('Scheduling WebRTC auto reconnect after disconnect', { + failures: webrtcReconnectFailures, + sessionId: webrtc.sessionId.value, + }) webrtcReconnectTimeout = setTimeout(async () => { if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') { try { @@ -2112,7 +2419,6 @@ async function activateConsoleView() { isConsoleActive.value = true registerInteractionListeners() - // REST snapshot: returning from Settings (or other routes) may have missed WS device_info void systemStore.fetchAllStates() void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {}) @@ -2405,14 +2711,8 @@ onUnmounted(() => {
{
-

- {{ webrtcLoadingMessage }} +

+ {{ webrtcLoadingMessage }} + + {{ connectProgress.current }}/{{ connectProgress.total }} +

+
+ +

{{ t('console.pleaseWait') }}

diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index f3484ceb..f5c9ab0b 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -44,7 +44,7 @@ import { getVideoFormatState } from '@/lib/video-format-support' import AppLayout from '@/components/AppLayout.vue' import LanguageToggleButton from '@/components/LanguageToggleButton.vue' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' @@ -99,6 +99,7 @@ import { Radio, Globe, Loader2, + AlertTriangle, } from 'lucide-vue-next' const { t, te } = useI18n() @@ -163,6 +164,33 @@ const navGroups = computed(() => [ } ]) +const sectionMeta = computed(() => { + const fallback = { icon: Info, title: t('settings.title'), description: '' } + for (const group of navGroups.value) { + for (const item of group.items) { + if (item.id === activeSection.value) { + const subtitleKey = `settings.${sectionSubtitleKey(item.id)}` + return { + icon: item.icon, + title: item.label, + description: te(subtitleKey) ? t(subtitleKey) : '', + } + } + } + } + return fallback +}) + +function sectionSubtitleKey(id: string): string { + switch (id) { + case 'ext-ttyd': return 'extTtydSubtitle' + case 'ext-rustdesk': return 'extRustdeskSubtitle' + case 'ext-rtsp': return 'extRtspSubtitle' + case 'ext-remote-access': return 'extRemoteAccessSubtitle' + default: return `${id}Subtitle` + } +} + function selectSection(id: string) { activeSection.value = id mobileMenuOpen.value = false @@ -327,6 +355,23 @@ const previewAccessUrl = computed(() => { return `${scheme}://${host}:${port}` }) +const previewUrlCopied = ref(false) +let previewUrlCopiedTimer: ReturnType | null = null + +async function copyPreviewUrl() { + const ok = await clipboardCopy(previewAccessUrl.value) + if (!ok) return + previewUrlCopied.value = true + if (previewUrlCopiedTimer) clearTimeout(previewUrlCopiedTimer) + previewUrlCopiedTimer = setTimeout(() => { + previewUrlCopied.value = false + }, 1500) +} + +function openPreviewUrl() { + window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer') +} + interface DeviceConfig { video: Array<{ path: string @@ -792,14 +837,14 @@ const atxConfig = ref({ power: { driver: 'none' as AtxDriverType, device: '', - pin: 0, + pin: 1, active_level: 'high' as ActiveLevel, baud_rate: 9600, }, reset: { driver: 'none' as AtxDriverType, device: '', - pin: 0, + pin: 1, active_level: 'high' as ActiveLevel, baud_rate: 9600, }, @@ -1038,9 +1083,18 @@ async function saveConfig() { saved.value = false try { - // Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH - if (activeSection.value === 'video') { + const turnUrl = config.value.turn_server.trim() + await configStore.updateStream({ + encoder: config.value.encoder_backend as any, + stun_server: config.value.stun_server.trim(), + turn_server: turnUrl, + turn_username: config.value.turn_username.trim(), + turn_password: + turnUrl === '' + ? '' + : config.value.turn_password || undefined, + }) await configStore.updateVideo({ device: config.value.video_device || undefined, format: config.value.video_format || undefined, @@ -1048,16 +1102,8 @@ async function saveConfig() { height: config.value.video_height, fps: toConfigFps(config.value.video_fps), }) - await configStore.updateStream({ - encoder: config.value.encoder_backend as any, - stun_server: config.value.stun_server || undefined, - turn_server: config.value.turn_server || undefined, - turn_username: config.value.turn_username || undefined, - turn_password: config.value.turn_password || undefined, - }) } - // HID config (includes MSD enable — same gadget; must not race with updateHid) if (activeSection.value === 'hid') { if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) { return @@ -1327,6 +1373,7 @@ async function loadAtxConfig() { wol_interface: config.wol_interface || '', } clearAtxSerialDeviceConflicts() + normalizeAtxRelayChannels() syncSharedAtxSerialBaudRate() } catch (e) { console.error('Failed to load ATX config:', e) @@ -1345,6 +1392,7 @@ async function saveAtxConfig() { loading.value = true saved.value = false try { + normalizeAtxRelayChannels() syncSharedAtxSerialBaudRate() await configStore.updateAtx({ enabled: atxConfig.value.enabled, @@ -1421,6 +1469,14 @@ function syncSharedAtxSerialBaudRate() { atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate } +function normalizeAtxRelayChannels() { + for (const key of [atxConfig.value.power, atxConfig.value.reset]) { + if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) { + key.pin = 1 + } + } +} + watch( () => [config.value.hid_backend, config.value.hid_serial_device], () => { @@ -1428,6 +1484,13 @@ watch( }, ) +watch( + () => [atxConfig.value.power.driver, atxConfig.value.reset.driver], + () => { + normalizeAtxRelayChannels() + }, +) + watch( () => [ atxConfig.value.power.driver, @@ -2056,7 +2119,7 @@ watch(() => route.query.tab, (tab) => {
-
+
-

{{ t('settings.title') }}

+
+ +

{{ sectionMeta.title }}

+