fix: 优化 WebRTC 建连流程、修复平台信息、修复虚拟键盘键值映射

- WebRTC:默认 mDNS 调整为 QueryOnly,Answer 阶段改为等待 ICE gathering complete(2.5s 超时),提升首次建连成功率与候选完整性
- WebRTC:前端建连流程增加阶段化状态与串行保护(connectInFlight/ready gate),优化配置变更后的重连时机与失败处理,减少竞态和无效重试
- Device:平台信息补充 `/proc/device-tree/model` 回退并统一展示为“处理器/平台”
- HID:键盘输入链路统一为 HID usage + modifier bitmask,修复虚拟键盘/宏/粘贴键值映射错误
This commit is contained in:
mofeng-git
2026-02-20 13:34:49 +08:00
parent 5f03971579
commit ce622e4492
16 changed files with 667 additions and 390 deletions

View File

@@ -9,7 +9,7 @@
//!
//! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
//! - Byte 2: Key code (USB HID usage code or JS keyCode)
//! - Byte 2: Key code (USB HID usage code)
//! - Byte 3: Modifiers bitmask
//! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift
@@ -119,7 +119,7 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type,
key,
modifiers,
is_usb_hid: false, // WebRTC datachannel sends JS keycodes
is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages
}))
}
@@ -245,6 +245,7 @@ mod tests {
assert_eq!(kb.key, 0x04);
assert!(kb.modifiers.left_ctrl);
assert!(!kb.modifiers.left_shift);
assert!(kb.is_usb_hid);
}
_ => panic!("Expected keyboard event"),
}
@@ -280,7 +281,7 @@ mod tests {
right_alt: false,
right_meta: false,
},
is_usb_hid: false,
is_usb_hid: true,
};
let encoded = encode_keyboard_event(&event);

View File

@@ -183,31 +183,59 @@ fn get_hostname() -> String {
.unwrap_or_else(|_| "unknown".to_string())
}
/// Get CPU model name from /proc/cpuinfo
/// Get CPU model name from /proc/cpuinfo, fallback to device-tree model
fn get_cpu_model() -> String {
std::fs::read_to_string("/proc/cpuinfo")
let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok();
if let Some(model) = cpuinfo
.as_deref()
.and_then(parse_cpu_model_from_cpuinfo_content)
{
return model;
}
if let Some(model) = read_device_tree_model() {
return model;
}
if let Some(content) = cpuinfo.as_deref() {
let cores = content
.lines()
.filter(|line| line.starts_with("processor"))
.count();
if cores > 0 {
return format!("{} {}C", std::env::consts::ARCH, cores);
}
}
std::env::consts::ARCH.to_string()
}
fn parse_cpu_model_from_cpuinfo_content(content: &str) -> Option<String> {
content
.lines()
.find(|line| line.starts_with("model name") || line.starts_with("Model"))
.and_then(|line| line.split(':').nth(1))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn read_device_tree_model() -> Option<String> {
std::fs::read("/proc/device-tree/model")
.ok()
.and_then(|content| {
// Try to get model name
let model = content
.lines()
.find(|line| line.starts_with("model name") || line.starts_with("Model"))
.and_then(|line| line.split(':').nth(1))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
.and_then(|bytes| parse_device_tree_model_bytes(&bytes))
}
if model.is_some() {
return model;
}
fn parse_device_tree_model_bytes(bytes: &[u8]) -> Option<String> {
let model = String::from_utf8_lossy(bytes)
.trim_matches(|c: char| c == '\0' || c.is_whitespace())
.to_string();
// Fallback: show arch and core count
let cores = content
.lines()
.filter(|line| line.starts_with("processor"))
.count();
Some(format!("{} {}C", std::env::consts::ARCH, cores))
})
.unwrap_or_else(|| std::env::consts::ARCH.to_string())
if model.is_empty() {
None
} else {
Some(model)
}
}
/// CPU usage state for calculating usage between samples
@@ -389,6 +417,38 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
addresses
}
#[cfg(test)]
mod tests {
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};
#[test]
fn parse_cpu_model_from_model_name_field() {
let input = "processor\t: 0\nmodel name\t: Intel(R) Xeon(R)\n";
assert_eq!(
parse_cpu_model_from_cpuinfo_content(input),
Some("Intel(R) Xeon(R)".to_string())
);
}
#[test]
fn parse_cpu_model_from_model_field() {
let input = "processor\t: 0\nModel\t\t: Raspberry Pi 4 Model B Rev 1.4\n";
assert_eq!(
parse_cpu_model_from_cpuinfo_content(input),
Some("Raspberry Pi 4 Model B Rev 1.4".to_string())
);
}
#[test]
fn parse_device_tree_model_trimmed() {
let input = b"Onething OEC Box\0\n";
assert_eq!(
parse_device_tree_model_bytes(input),
Some("Onething OEC Box".to_string())
);
}
}
// ============================================================================
// Authentication
// ============================================================================
@@ -2053,10 +2113,11 @@ pub async fn webrtc_offer(
));
}
// Create session if client_id not provided
// Backward compatibility: `client_id` is treated as an existing session_id hint.
// New clients should not pass it; each offer creates a fresh session.
let webrtc = state.stream_manager.webrtc_streamer();
let session_id = if let Some(client_id) = &req.client_id {
// Check if session exists
// Reuse only when it matches an active session ID.
if webrtc.get_session(client_id).await.is_some() {
client_id.clone()
} else {
@@ -2411,15 +2472,13 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
let mut checks = Vec::new();
let build_response = |
checks: Vec<OtgSelfCheckItem>,
selected_udc: Option<String>,
bound_udc: Option<String>,
udc_state: Option<String>,
udc_speed: Option<String>,
available_udcs: Vec<String>,
other_gadgets: Vec<String>,
| {
let build_response = |checks: Vec<OtgSelfCheckItem>,
selected_udc: Option<String>,
bound_udc: Option<String>,
udc_state: Option<String>,
udc_speed: Option<String>,
available_udcs: Vec<String>,
other_gadgets: Vec<String>| {
let error_count = checks
.iter()
.filter(|item| item.level == OtgSelfCheckLevel::Error)
@@ -2528,7 +2587,9 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
OtgSelfCheckLevel::Info
},
"Check configured UDC validity",
Some("You can set hid_otg_udc in settings to avoid ambiguity in multi-controller setups"),
Some(
"You can set hid_otg_udc in settings to avoid ambiguity in multi-controller setups",
),
Some("/sys/class/udc"),
);
}
@@ -2854,7 +2915,6 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
);
}
}
}
if !other_gadgets.is_empty() {

View File

@@ -18,7 +18,9 @@ pub fn mdns_mode_from_env() -> Option<MulticastDnsMode> {
}
pub fn mdns_mode() -> MulticastDnsMode {
mdns_mode_from_env().unwrap_or(MulticastDnsMode::QueryAndGather)
// Default to QueryOnly to avoid gathering .local host candidates by default.
// This is generally more stable for LAN first-connection while preserving mDNS queries.
mdns_mode_from_env().unwrap_or(MulticastDnsMode::QueryOnly)
}
pub fn mdns_mode_label(mode: MulticastDnsMode) -> &'static str {

View File

@@ -317,14 +317,26 @@ impl PeerConnection {
.await
.map_err(|e| AppError::VideoError(format!("Failed to create answer: {}", e)))?;
// Wait for ICE gathering complete (or timeout) after setting local description.
// This improves first-connection robustness by returning a fuller initial candidate set.
let mut gather_complete = self.pc.gathering_complete_promise().await;
// Set local description
self.pc
.set_local_description(answer.clone())
.await
.map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?;
// Wait a bit for ICE candidates to gather
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
const ICE_GATHER_TIMEOUT: tokio::time::Duration = tokio::time::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
);
}
// Get gathered ICE candidates
let candidates = self.ice_candidates.lock().await.clone();

View File

@@ -833,13 +833,24 @@ 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)))?;
// Wait for ICE candidates
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Wait for ICE gathering complete (or timeout) to return a fuller initial candidate set.
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
);
}
let candidates = self.ice_candidates.lock().await.clone();
Ok(SdpAnswer::with_candidates(answer.sdp, candidates))