From 5f039715798a8acf6f035353797a0565a45e4f3e Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 20 Feb 2026 09:44:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=20HID=20OTG=20?= =?UTF-8?q?=E8=87=AA=E6=A3=80=E6=8E=A5=E5=8F=A3=E4=B8=8E=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E7=8E=AF=E5=A2=83=E8=AF=8A=E6=96=AD=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9C=A8=E7=BA=BF=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E7=8A=B6=E6=80=81=E6=96=87=E6=A1=88=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96=E5=8F=8A=E9=87=8D=E5=90=AF=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/handlers/mod.rs | 757 +++++++++++++++++++++++++++++++++ src/web/routes.rs | 1 + web/src/api/index.ts | 22 + web/src/i18n/en-US.ts | 86 ++++ web/src/i18n/zh-CN.ts | 86 ++++ web/src/views/SettingsView.vue | 377 +++++++++++++++- 6 files changed, 1318 insertions(+), 11 deletions(-) diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index ce837f88..c88fab92 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -2242,6 +2242,763 @@ pub struct HidStatus { pub screen_resolution: Option<(u32, u32)>, } +#[derive(Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OtgSelfCheckLevel { + Info, + Warn, + Error, +} + +#[derive(Serialize)] +pub struct OtgSelfCheckItem { + pub id: &'static str, + pub ok: bool, + pub level: OtgSelfCheckLevel, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Serialize)] +pub struct OtgSelfCheckResponse { + pub overall_ok: bool, + pub error_count: usize, + pub warning_count: usize, + pub hid_backend: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_udc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bound_udc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub udc_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub udc_speed: Option, + pub available_udcs: Vec, + pub other_gadgets: Vec, + pub checks: Vec, +} + +fn push_otg_check( + checks: &mut Vec, + id: &'static str, + ok: bool, + level: OtgSelfCheckLevel, + message: impl Into, + hint: Option>, + path: Option>, +) { + checks.push(OtgSelfCheckItem { + id, + ok, + level, + message: message.into(), + hint: hint.map(|v| v.into()), + path: path.map(|v| v.into()), + }); +} + +fn list_dir_names(path: &std::path::Path) -> Vec { + let mut names = std::fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>(); + names.sort(); + names +} + +fn read_trimmed(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) +} + +fn proc_modules_has(module_name: &str) -> bool { + std::fs::read_to_string("/proc/modules") + .ok() + .map(|content| { + content + .lines() + .filter_map(|line| line.split_whitespace().next()) + .any(|name| name == module_name) + }) + .unwrap_or(false) +} + +fn modules_metadata_has(module_name: &str) -> bool { + let kernel_release = match read_trimmed(std::path::Path::new("/proc/sys/kernel/osrelease")) { + Some(value) if !value.is_empty() => value, + _ => return false, + }; + + let module_dir = std::path::Path::new("/lib/modules").join(kernel_release); + let candidates = ["modules.builtin", "modules.builtin.modinfo", "modules.dep"]; + + candidates.iter().any(|filename| { + let path = module_dir.join(filename); + std::fs::read_to_string(path) + .ok() + .map(|content| { + let module_token = format!("/{module_name}.ko"); + content.lines().any(|line| { + line.contains(&module_token) + || line.contains(module_name) + || line.contains(&module_name.replace('_', "-")) + }) + }) + .unwrap_or(false) + }) +} + +fn kernel_config_option_enabled(option_name: &str) -> bool { + let kernel_release = match read_trimmed(std::path::Path::new("/proc/sys/kernel/osrelease")) { + Some(value) if !value.is_empty() => value, + _ => return false, + }; + + let config_paths = [ + std::path::PathBuf::from(format!("/boot/config-{kernel_release}")), + std::path::PathBuf::from("/boot/config"), + std::path::PathBuf::from(format!("/lib/modules/{kernel_release}/build/.config")), + ]; + + config_paths.iter().any(|path| { + std::fs::read_to_string(path) + .ok() + .map(|content| { + let enabled_y = format!("{option_name}=y"); + let enabled_m = format!("{option_name}=m"); + content + .lines() + .any(|line| line == enabled_y || line == enabled_m) + }) + .unwrap_or(false) + }) +} + +fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool { + let sys_module = std::path::Path::new("/sys/module/libcomposite").exists(); + if sys_module { + return true; + } + + if proc_modules_has("libcomposite") { + return true; + } + + if modules_metadata_has("libcomposite") { + return true; + } + + if kernel_config_option_enabled("CONFIG_USB_LIBCOMPOSITE") + || kernel_config_option_enabled("CONFIG_USB_CONFIGFS") + { + return true; + } + + // Fallback: if usb_gadget path exists, libcomposite may be built-in and already active. + gadget_root.exists() +} + +/// OTG self-check status for troubleshooting USB gadget issues +pub async fn hid_otg_self_check(State(state): State>) -> Json { + let config = state.config.get(); + let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg); + let mut checks = Vec::new(); + + let build_response = | + checks: Vec, + selected_udc: Option, + bound_udc: Option, + udc_state: Option, + udc_speed: Option, + available_udcs: Vec, + other_gadgets: Vec, + | { + let error_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Error) + .count(); + let warning_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Warn) + .count(); + + Json(OtgSelfCheckResponse { + overall_ok: error_count == 0, + error_count, + warning_count, + hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + selected_udc, + bound_udc, + udc_state, + udc_speed, + available_udcs, + other_gadgets, + checks, + }) + }; + + let udc_root = std::path::Path::new("/sys/class/udc"); + let available_udcs = list_dir_names(udc_root); + let selected_udc = config + .hid + .otg_udc + .clone() + .filter(|udc| !udc.trim().is_empty()) + .or_else(|| available_udcs.first().cloned()); + let mut udc_stage_ok = true; + if !udc_root.exists() { + udc_stage_ok = false; + push_otg_check( + &mut checks, + "udc_dir_exists", + false, + OtgSelfCheckLevel::Error, + "Check /sys/class/udc existence", + Some("Ensure UDC/OTG kernel drivers are enabled"), + Some("/sys/class/udc"), + ); + } else if available_udcs.is_empty() { + udc_stage_ok = false; + push_otg_check( + &mut checks, + "udc_has_entries", + false, + OtgSelfCheckLevel::Error, + "Check available UDC entries", + Some("Ensure OTG controller is enabled in device tree"), + Some("/sys/class/udc"), + ); + } else { + push_otg_check( + &mut checks, + "udc_has_entries", + true, + OtgSelfCheckLevel::Info, + "Check available UDC entries", + None::, + Some("/sys/class/udc"), + ); + } + + let mut configured_udc_ok = true; + if let Some(config_udc) = config + .hid + .otg_udc + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + if available_udcs.iter().any(|item| item == &config_udc) { + push_otg_check( + &mut checks, + "configured_udc_valid", + true, + OtgSelfCheckLevel::Info, + "Check configured UDC validity", + None::, + Some("/sys/class/udc"), + ); + } else { + configured_udc_ok = false; + push_otg_check( + &mut checks, + "configured_udc_valid", + false, + OtgSelfCheckLevel::Error, + "Check configured UDC validity", + Some("Please reselect UDC in HID OTG settings"), + Some("/sys/class/udc"), + ); + } + } else { + push_otg_check( + &mut checks, + "configured_udc_valid", + !available_udcs.is_empty(), + if available_udcs.is_empty() { + OtgSelfCheckLevel::Warn + } else { + OtgSelfCheckLevel::Info + }, + "Check configured UDC validity", + Some("You can set hid_otg_udc in settings to avoid ambiguity in multi-controller setups"), + Some("/sys/class/udc"), + ); + } + + if !udc_stage_ok || !configured_udc_ok { + return build_response( + checks, + selected_udc, + None, + None, + None, + available_udcs, + vec![], + ); + } + + let gadget_root = std::path::Path::new("/sys/kernel/config/usb_gadget"); + let configfs_mounted = std::fs::read_to_string("/proc/mounts") + .ok() + .map(|mounts| { + mounts.lines().any(|line| { + let mut parts = line.split_whitespace(); + let _src = parts.next(); + let mount_point = parts.next(); + let fs_type = parts.next(); + mount_point == Some("/sys/kernel/config") && fs_type == Some("configfs") + }) + }) + .unwrap_or(false); + + let mut gadget_config_ok = true; + + if configfs_mounted { + push_otg_check( + &mut checks, + "configfs_mounted", + true, + OtgSelfCheckLevel::Info, + "Check configfs mount status", + None::, + Some("/sys/kernel/config"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "configfs_mounted", + false, + OtgSelfCheckLevel::Error, + "Check configfs mount status", + Some("Try: mount -t configfs none /sys/kernel/config"), + Some("/sys/kernel/config"), + ); + } + + if gadget_root.exists() { + push_otg_check( + &mut checks, + "usb_gadget_dir_exists", + true, + OtgSelfCheckLevel::Info, + "Check /sys/kernel/config/usb_gadget access", + None::, + Some("/sys/kernel/config/usb_gadget"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "usb_gadget_dir_exists", + false, + OtgSelfCheckLevel::Error, + "Check /sys/kernel/config/usb_gadget access", + Some("Ensure configfs and USB gadget support are enabled"), + Some("/sys/kernel/config/usb_gadget"), + ); + } + + let libcomposite_available = detect_libcomposite_available(gadget_root); + if libcomposite_available { + push_otg_check( + &mut checks, + "libcomposite_loaded", + true, + OtgSelfCheckLevel::Info, + "Check libcomposite module status", + None::, + Some("/sys/module/libcomposite"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "libcomposite_loaded", + false, + OtgSelfCheckLevel::Error, + "Check libcomposite module status", + Some("Try: modprobe libcomposite"), + Some("/sys/module/libcomposite"), + ); + } + + if !gadget_config_ok { + return build_response( + checks, + selected_udc, + None, + None, + None, + available_udcs, + vec![], + ); + } + + let gadget_names = list_dir_names(gadget_root); + let one_kvm_path = gadget_root.join("one-kvm"); + let one_kvm_exists = one_kvm_path.exists(); + if one_kvm_exists { + push_otg_check( + &mut checks, + "one_kvm_gadget_exists", + true, + OtgSelfCheckLevel::Info, + "Check one-kvm gadget presence", + None::, + Some(one_kvm_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "one_kvm_gadget_exists", + false, + if hid_backend_is_otg { + OtgSelfCheckLevel::Error + } else { + OtgSelfCheckLevel::Warn + }, + "Check one-kvm gadget presence", + Some("Enable OTG HID or MSD to let one-kvm gadget be created automatically"), + Some(one_kvm_path.display().to_string()), + ); + } + + let other_gadgets = gadget_names + .iter() + .filter(|name| name.as_str() != "one-kvm") + .cloned() + .collect::>(); + if other_gadgets.is_empty() { + push_otg_check( + &mut checks, + "other_gadgets", + true, + OtgSelfCheckLevel::Info, + "Check for other gadget services", + None::, + Some("/sys/kernel/config/usb_gadget"), + ); + } else { + push_otg_check( + &mut checks, + "other_gadgets", + false, + OtgSelfCheckLevel::Warn, + "Check for other gadget services", + Some("Potential UDC contention with one-kvm; check other OTG services"), + Some("/sys/kernel/config/usb_gadget"), + ); + } + + let mut bound_udc = None; + + if one_kvm_exists { + let one_kvm_udc_path = one_kvm_path.join("UDC"); + let current_udc = read_trimmed(&one_kvm_udc_path).unwrap_or_default(); + if current_udc.is_empty() { + push_otg_check( + &mut checks, + "one_kvm_bound_udc", + false, + OtgSelfCheckLevel::Warn, + "Check one-kvm UDC binding", + Some("Ensure HID/MSD is enabled and initialized successfully"), + Some(one_kvm_udc_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "one_kvm_bound_udc", + true, + OtgSelfCheckLevel::Info, + "Check one-kvm UDC binding", + None::, + Some(one_kvm_udc_path.display().to_string()), + ); + bound_udc = Some(current_udc); + } + + let functions_path = one_kvm_path.join("functions"); + let function_names = list_dir_names(&functions_path) + .into_iter() + .filter(|name| name.contains(".usb")) + .collect::>(); + let hid_functions = function_names + .iter() + .filter(|name| name.starts_with("hid.usb")) + .cloned() + .collect::>(); + if hid_functions.is_empty() { + push_otg_check( + &mut checks, + "hid_functions_present", + false, + if hid_backend_is_otg { + OtgSelfCheckLevel::Error + } else { + OtgSelfCheckLevel::Warn + }, + "Check HID function creation", + Some("Check OTG HID config and enable at least one HID function"), + Some(functions_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "hid_functions_present", + true, + OtgSelfCheckLevel::Info, + "Check HID function creation", + None::, + Some(functions_path.display().to_string()), + ); + } + + let config_path = one_kvm_path.join("configs/c.1"); + if !config_path.exists() { + push_otg_check( + &mut checks, + "config_c1_exists", + false, + OtgSelfCheckLevel::Error, + "Check configs/c.1 structure", + Some("Gadget structure is incomplete; try restarting One-KVM"), + Some(config_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "config_c1_exists", + true, + OtgSelfCheckLevel::Info, + "Check configs/c.1 structure", + None::, + Some(config_path.display().to_string()), + ); + + let linked_functions = list_dir_names(&config_path) + .into_iter() + .filter(|name| name.contains(".usb")) + .collect::>(); + let missing_links = function_names + .iter() + .filter(|func| !linked_functions.iter().any(|link| link == *func)) + .cloned() + .collect::>(); + + if missing_links.is_empty() { + push_otg_check( + &mut checks, + "function_links_ok", + true, + OtgSelfCheckLevel::Info, + "Check function links in configs/c.1", + None::, + Some(config_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "function_links_ok", + false, + OtgSelfCheckLevel::Warn, + "Check function links in configs/c.1", + Some("Reinitialize OTG (toggle HID backend once or restart service)"), + Some(config_path.display().to_string()), + ); + } + } + + let missing_hid_devices = hid_functions + .iter() + .filter_map(|name| { + let index = name.strip_prefix("hid.usb")?.parse::().ok()?; + let dev_path = std::path::PathBuf::from(format!("/dev/hidg{}", index)); + if dev_path.exists() { + None + } else { + Some(dev_path.display().to_string()) + } + }) + .collect::>(); + + if !hid_functions.is_empty() { + if missing_hid_devices.is_empty() { + push_otg_check( + &mut checks, + "hid_device_nodes", + true, + OtgSelfCheckLevel::Info, + "Check /dev/hidg* device nodes", + None::, + Some("/dev/hidg*"), + ); + } else { + push_otg_check( + &mut checks, + "hid_device_nodes", + false, + OtgSelfCheckLevel::Warn, + "Check /dev/hidg* device nodes", + Some("Ensure gadget is bound and check kernel logs"), + Some("/dev/hidg*"), + ); + } + } + + } + + if !other_gadgets.is_empty() { + let check_udc = bound_udc.clone().or_else(|| selected_udc.clone()); + if let Some(target_udc) = check_udc { + let conflicting_gadgets = other_gadgets + .iter() + .filter_map(|name| { + let udc_file = gadget_root.join(name).join("UDC"); + let udc = read_trimmed(&udc_file)?; + if udc == target_udc { + Some(name.clone()) + } else { + None + } + }) + .collect::>(); + + if conflicting_gadgets.is_empty() { + push_otg_check( + &mut checks, + "udc_conflict", + true, + OtgSelfCheckLevel::Info, + "Check UDC binding conflicts", + None::, + Some("/sys/kernel/config/usb_gadget/*/UDC"), + ); + } else { + push_otg_check( + &mut checks, + "udc_conflict", + false, + OtgSelfCheckLevel::Error, + "Check UDC binding conflicts", + Some("Stop other OTG services or switch one-kvm to an idle UDC"), + Some("/sys/kernel/config/usb_gadget/*/UDC"), + ); + } + } + } + + let active_udc = bound_udc.clone().or_else(|| selected_udc.clone()); + let mut udc_state = None; + let mut udc_speed = None; + + if let Some(udc) = active_udc.clone() { + let state_path = udc_root.join(&udc).join("state"); + match read_trimmed(&state_path) { + Some(state_name) if state_name.eq_ignore_ascii_case("configured") => { + udc_state = Some(state_name.clone()); + push_otg_check( + &mut checks, + "udc_state", + true, + OtgSelfCheckLevel::Info, + "Check UDC connection state", + None::, + Some(state_path.display().to_string()), + ); + } + Some(state_name) => { + udc_state = Some(state_name.clone()); + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure target host is connected and has recognized the USB device"), + Some(state_path.display().to_string()), + ); + } + None => { + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure UDC name is valid and check kernel permissions"), + Some(state_path.display().to_string()), + ); + } + } + + let speed_path = udc_root.join(&udc).join("current_speed"); + if let Some(speed) = read_trimmed(&speed_path) { + udc_speed = Some(speed.clone()); + let is_unknown = speed.eq_ignore_ascii_case("unknown"); + push_otg_check( + &mut checks, + "udc_speed", + !is_unknown, + if is_unknown { + OtgSelfCheckLevel::Warn + } else { + OtgSelfCheckLevel::Info + }, + "Check UDC current link speed", + if is_unknown { + Some("Device may not be fully enumerated; try reconnecting USB".to_string()) + } else { + None + }, + Some(speed_path.display().to_string()), + ); + } + } else { + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure UDC is available and one-kvm gadget is bound first"), + Some("/sys/class/udc"), + ); + } + + let error_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Error) + .count(); + let warning_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Warn) + .count(); + + Json(OtgSelfCheckResponse { + overall_ok: error_count == 0, + error_count, + warning_count, + hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + selected_udc, + bound_udc, + udc_state, + udc_speed, + available_udcs, + other_gadgets, + checks, + }) +} + /// Get HID status pub async fn hid_status(State(state): State>) -> Json { let info = state.hid.info().await; diff --git a/src/web/routes.rs b/src/web/routes.rs index 864b3b56..06489a2a 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -60,6 +60,7 @@ pub fn create_router(state: Arc) -> Router { .route("/webrtc/close", post(handlers::webrtc_close_session)) // HID endpoints .route("/hid/status", get(handlers::hid_status)) + .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/hid/reset", post(handlers::hid_reset)) // WebSocket HID endpoint (for MJPEG mode) .route("/ws/hid", any(ws_hid_handler)) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a2375451..31453e4e 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -303,6 +303,28 @@ export const hidApi = { screen_resolution: [number, number] | null }>('/hid/status'), + otgSelfCheck: () => + request<{ + overall_ok: boolean + error_count: number + warning_count: number + hid_backend: string + selected_udc: string | null + bound_udc: string | null + udc_state: string | null + udc_speed: string | null + available_udcs: string[] + other_gadgets: string[] + checks: Array<{ + id: string + ok: boolean + level: 'info' | 'warn' | 'error' + message: string + hint?: string + path?: string + }> + }>('/hid/otg/self-check'), + keyboard: async (type: 'down' | 'up', key: number, modifiers?: { ctrl?: boolean shift?: boolean diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 34f3712a..dde176d2 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -440,6 +440,7 @@ export default { hid: 'HID', msd: 'MSD', atx: 'ATX', + environment: 'Environment', network: 'Network', users: 'Users', hardware: 'Hardware', @@ -521,6 +522,11 @@ export default { updatePhaseRestarting: 'Restarting', updatePhaseSuccess: 'Success', updatePhaseFailed: 'Failed', + updateMsgChecking: 'Checking for updates', + updateMsgDownloading: 'Downloading binary', + updateMsgVerifying: 'Verifying (SHA256)', + updateMsgInstalling: 'Replacing binary', + updateMsgRestarting: 'Restarting service', // Auth auth: 'Access', authSettings: 'Access Settings', @@ -650,6 +656,86 @@ export default { serialNumber: 'Serial Number', serialNumberAuto: 'Auto-generated', descriptorWarning: 'Changing these settings will reconnect the USB device', + otgSelfCheck: { + title: 'OTG Self-Check', + desc: 'Check UDC, gadget binding, and link status', + run: 'Run Self-Check', + failed: 'Failed to run OTG self-check', + overall: 'Overall Status', + ok: 'Healthy', + hasIssues: 'Issues Found', + summary: 'Issue Summary', + counts: '{errors} errors, {warnings} warnings', + groupCounts: '{ok} passed, {warnings} warnings, {errors} errors', + notRun: 'Not run', + status: { + ok: 'Healthy', + warn: 'Warning', + error: 'Error', + skipped: 'Skipped', + }, + groups: { + udc: 'UDC Basics', + gadgetConfig: 'Gadget Config', + oneKvm: 'one-kvm Gadget', + functions: 'Functions & Nodes', + link: 'Link State', + }, + values: { + missing: 'Missing', + notConfigured: 'Not configured', + mounted: 'Mounted', + unmounted: 'Unmounted', + available: 'Available', + unavailable: 'Unavailable', + exists: 'Exists', + none: 'None', + unbound: 'Unbound', + noConflict: 'No conflict', + conflict: 'Conflict', + unknown: 'Unknown', + normal: 'Normal', + abnormal: 'Abnormal', + }, + selectedUdc: 'Target UDC', + boundUdc: 'Bound UDC', + messages: { + udc_dir_exists: 'UDC directory check', + udc_has_entries: 'UDC check', + configfs_mounted: 'configfs check', + usb_gadget_dir_exists: 'usb_gadget check', + libcomposite_loaded: 'libcomposite check', + one_kvm_gadget_exists: 'one-kvm gadget check', + other_gadgets: 'Other gadget check', + configured_udc_valid: 'Configured UDC check', + one_kvm_bound_udc: 'Bound UDC check', + hid_functions_present: 'HID function check', + config_c1_exists: 'configs/c.1 check', + function_links_ok: 'Function link check', + hid_device_nodes: 'HID node check', + udc_conflict: 'UDC conflict check', + udc_state: 'UDC state check', + udc_speed: 'UDC speed check', + }, + hints: { + udc_dir_exists: 'Ensure UDC/OTG kernel drivers are enabled', + udc_has_entries: 'Ensure OTG controller is enabled in device tree', + configfs_mounted: 'Try: mount -t configfs none /sys/kernel/config', + usb_gadget_dir_exists: 'Ensure configfs and USB gadget support are enabled', + libcomposite_loaded: 'Try: modprobe libcomposite', + one_kvm_gadget_exists: 'Enable OTG HID or MSD to let one-kvm gadget be created automatically', + other_gadgets: 'Potential UDC contention with one-kvm; check other OTG services', + configured_udc_valid: 'Please reselect UDC in HID OTG settings', + one_kvm_bound_udc: 'Ensure HID/MSD is enabled and initialized successfully', + hid_functions_present: 'Check OTG HID config and enable at least one HID function', + config_c1_exists: 'Gadget structure is incomplete; try restarting One-KVM', + function_links_ok: 'Reinitialize OTG (toggle HID backend once or restart service)', + hid_device_nodes: 'Ensure gadget is bound and check kernel logs', + udc_conflict: 'Stop other OTG services or switch one-kvm to an idle UDC', + udc_state: 'Ensure target host is connected and has recognized the USB device', + udc_speed: 'Device may not be fully enumerated; try reconnecting USB', + }, + }, // WebRTC / ICE webrtcSettings: 'WebRTC Settings', webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 6773e873..be87bc94 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -440,6 +440,7 @@ export default { hid: 'HID', msd: 'MSD', atx: 'ATX', + environment: '环境', network: '网络', users: '用户', hardware: '硬件', @@ -521,6 +522,11 @@ export default { updatePhaseRestarting: '重启中', updatePhaseSuccess: '成功', updatePhaseFailed: '失败', + updateMsgChecking: '检查更新中', + updateMsgDownloading: '下载中', + updateMsgVerifying: '校验中(SHA256)', + updateMsgInstalling: '替换程序中', + updateMsgRestarting: '服务重启中', // Auth auth: '访问控制', authSettings: '访问设置', @@ -650,6 +656,86 @@ export default { serialNumber: '序列号', serialNumberAuto: '自动生成', descriptorWarning: '修改这些设置将导致 USB 设备重新连接', + otgSelfCheck: { + title: 'OTG 自检', + desc: '检查 UDC、gadget 绑定和连接状态', + run: '运行自检', + failed: '执行 OTG 自检失败', + overall: '总体状态', + ok: '正常', + hasIssues: '存在问题', + summary: '问题统计', + counts: '错误 {errors},警告 {warnings}', + groupCounts: '通过 {ok},警告 {warnings},错误 {errors}', + notRun: '未执行', + status: { + ok: '正常', + warn: '告警', + error: '异常', + skipped: '跳过', + }, + groups: { + udc: 'UDC 基础', + gadgetConfig: 'gadget 配置', + oneKvm: 'one-kvm gadget', + functions: '功能与设备节点', + link: '连接状态', + }, + values: { + missing: '不存在', + notConfigured: '未配置', + mounted: '已挂载', + unmounted: '未挂载', + available: '可用', + unavailable: '不可用', + exists: '存在', + none: '无', + unbound: '未绑定', + noConflict: '无冲突', + conflict: '冲突', + unknown: '未知', + normal: '正常', + abnormal: '异常', + }, + selectedUdc: '目标 UDC', + boundUdc: '已绑定 UDC', + messages: { + udc_dir_exists: 'UDC 目录检查', + udc_has_entries: 'UDC 检查', + configfs_mounted: 'configfs 检查', + usb_gadget_dir_exists: 'usb_gadget 检查', + libcomposite_loaded: 'libcomposite 检查', + one_kvm_gadget_exists: 'one-kvm gadget 检查', + other_gadgets: '其他 gadget 检查', + configured_udc_valid: '配置 UDC 检查', + one_kvm_bound_udc: 'gadget 绑定 UDC 检查', + hid_functions_present: 'HID 函数检查', + config_c1_exists: 'configs/c.1 检查', + function_links_ok: 'functions 链接检查', + hid_device_nodes: 'HID 设备节点检查', + udc_conflict: 'UDC 冲突检查', + udc_state: 'UDC 状态检查', + udc_speed: 'UDC 速率检查', + }, + hints: { + udc_dir_exists: '请确认内核已启用 UDC/OTG 驱动', + udc_has_entries: '请确认 OTG 控制器已在设备树中启用', + configfs_mounted: '可执行: mount -t configfs none /sys/kernel/config', + usb_gadget_dir_exists: '请确认 configfs 与 USB gadget 支持已启用', + libcomposite_loaded: '可执行: modprobe libcomposite', + one_kvm_gadget_exists: '启用 OTG HID 或 MSD 后会自动创建 one-kvm gadget', + other_gadgets: '可能与 one-kvm 抢占 UDC,请检查是否有其他 OTG 服务', + configured_udc_valid: '请在 HID OTG 设置中重新选择 UDC', + one_kvm_bound_udc: '请确认 HID/MSD 已启用并成功初始化', + hid_functions_present: '请检查 OTG HID 配置是否至少启用了一个 HID 功能', + config_c1_exists: 'gadget 结构不完整,请尝试重启 One-KVM', + function_links_ok: '建议重新初始化 OTG(切换一次 HID 后端或重启服务)', + hid_device_nodes: '请确认 gadget 已绑定并检查内核日志', + udc_conflict: '请停用其他 OTG 服务或切换 one-kvm 到空闲 UDC', + udc_state: '请确认已连接被控机,且被控机已识别 USB 设备', + udc_speed: '设备可能未完成枚举,可尝试重插 USB', + }, + }, // WebRTC / ICE webrtcSettings: 'WebRTC 设置', webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index c28613a2..8aa1c6c3 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth' import { authApi, configApi, + hidApi, streamApi, atxConfigApi, extensionsApi, @@ -63,6 +64,7 @@ import { Check, HardDrive, Power, + Server, Menu, Lock, User, @@ -79,7 +81,7 @@ import { Radio, } from 'lucide-vue-next' -const { t, locale } = useI18n() +const { t, te, locale } = useI18n() const systemStore = useSystemStore() const configStore = useConfigStore() const authStore = useAuthStore() @@ -107,15 +109,16 @@ const navGroups = computed(() => [ { id: 'hid', label: t('settings.hid'), icon: Keyboard, status: config.value.hid_backend.toUpperCase() }, ...(config.value.msd_enabled ? [{ id: 'msd', label: t('settings.msd'), icon: HardDrive }] : []), { id: 'atx', label: t('settings.atx'), icon: Power }, + { id: 'environment', label: t('settings.environment'), icon: Server }, ] }, { title: t('settings.extensions'), items: [ + { id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal }, { id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare }, { id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, - { id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal }, ] }, { @@ -230,6 +233,9 @@ const updateChannel = ref('stable') const updateOverview = ref(null) const updateStatus = ref(null) const updateLoading = ref(false) +const updateSawRestarting = ref(false) +const updateSawRequestFailure = ref(false) +const updateAutoReloadTriggered = ref(false) const updateRunning = computed(() => { const phase = updateStatus.value?.phase return phase === 'checking' @@ -332,6 +338,207 @@ const showLowEndpointHint = computed(() => config.value.hid_backend === 'otg' && isLowEndpointUdc.value ) +type OtgSelfCheckLevel = 'info' | 'warn' | 'error' +type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' + +interface OtgSelfCheckItem { + id: string + ok: boolean + level: OtgSelfCheckLevel + message: string + hint?: string + path?: string +} + +interface OtgSelfCheckResult { + overall_ok: boolean + error_count: number + warning_count: number + hid_backend: string + selected_udc: string | null + bound_udc: string | null + udc_state: string | null + udc_speed: string | null + available_udcs: string[] + other_gadgets: string[] + checks: OtgSelfCheckItem[] +} + +interface OtgCheckGroupDef { + id: string + titleKey: string + checkIds: string[] +} + +interface OtgCheckGroup { + id: string + titleKey: string + status: OtgCheckGroupStatus + okCount: number + warningCount: number + errorCount: number + items: OtgSelfCheckItem[] +} + +const otgSelfCheckLoading = ref(false) +const otgSelfCheckResult = ref(null) +const otgSelfCheckError = ref('') +const otgRunButtonPressed = ref(false) + +const otgCheckGroupDefs: OtgCheckGroupDef[] = [ + { + id: 'udc', + titleKey: 'settings.otgSelfCheck.groups.udc', + checkIds: ['udc_dir_exists', 'udc_has_entries', 'configured_udc_valid'], + }, + { + id: 'gadget_config', + titleKey: 'settings.otgSelfCheck.groups.gadgetConfig', + checkIds: ['configfs_mounted', 'usb_gadget_dir_exists', 'libcomposite_loaded'], + }, + { + id: 'one_kvm', + titleKey: 'settings.otgSelfCheck.groups.oneKvm', + checkIds: ['one_kvm_gadget_exists', 'one_kvm_bound_udc', 'other_gadgets', 'udc_conflict'], + }, + { + id: 'functions', + titleKey: 'settings.otgSelfCheck.groups.functions', + checkIds: ['hid_functions_present', 'config_c1_exists', 'function_links_ok', 'hid_device_nodes'], + }, + { + id: 'link', + titleKey: 'settings.otgSelfCheck.groups.link', + checkIds: ['udc_state', 'udc_speed'], + }, +] + +const otgCheckGroups = computed(() => { + const items = otgSelfCheckResult.value?.checks || [] + return otgCheckGroupDefs.map((group) => { + const groupItems = items.filter(item => group.checkIds.includes(item.id)) + const errorCount = groupItems.filter(item => item.level === 'error').length + const warningCount = groupItems.filter(item => item.level === 'warn').length + const okCount = Math.max(0, groupItems.length - errorCount - warningCount) + let status: OtgCheckGroupStatus = 'skipped' + if (groupItems.length > 0) { + if (errorCount > 0) status = 'error' + else if (warningCount > 0) status = 'warn' + else status = 'ok' + } + + return { + id: group.id, + titleKey: group.titleKey, + status, + okCount, + warningCount, + errorCount, + items: groupItems, + } + }) +}) + +function otgCheckLevelClass(level: OtgSelfCheckLevel): string { + if (level === 'error') return 'bg-red-500' + if (level === 'warn') return 'bg-amber-500' + return 'bg-blue-500' +} + +function otgCheckStatusText(level: OtgSelfCheckLevel): string { + if (level === 'error') return t('common.error') + if (level === 'warn') return t('common.warning') + return t('common.info') +} + +function otgGroupStatusClass(status: OtgCheckGroupStatus): string { + if (status === 'error') return 'bg-red-500' + if (status === 'warn') return 'bg-amber-500' + if (status === 'ok') return 'bg-emerald-500' + return 'bg-muted-foreground/40' +} + +function otgGroupStatusText(status: OtgCheckGroupStatus): string { + return t(`settings.otgSelfCheck.status.${status}`) +} + +function otgGroupSummary(group: OtgCheckGroup): string { + if (group.items.length === 0) { + return t('settings.otgSelfCheck.notRun') + } + return t('settings.otgSelfCheck.groupCounts', { + ok: group.okCount, + warnings: group.warningCount, + errors: group.errorCount, + }) +} + +function otgCheckMessage(item: OtgSelfCheckItem): string { + const key = `settings.otgSelfCheck.messages.${item.id}` + const label = te(key) ? t(key) : item.message + const result = otgSelfCheckResult.value + if (!result) return label + + const value = (name: string) => t(`settings.otgSelfCheck.values.${name}`) + + switch (item.id) { + case 'udc_has_entries': + return `${label}:${result.available_udcs.length ? result.available_udcs.join(', ') : value('missing')}` + case 'configured_udc_valid': + if (!result.selected_udc) return `${label}:${value('notConfigured')}` + return `${label}:${item.ok ? result.selected_udc : `${value('missing')}/${result.selected_udc}`}` + case 'configfs_mounted': + return `${label}:${item.ok ? value('mounted') : value('unmounted')}` + case 'usb_gadget_dir_exists': + return `${label}:${item.ok ? value('available') : value('unavailable')}` + case 'libcomposite_loaded': + return `${label}:${item.ok ? value('available') : value('unavailable')}` + case 'one_kvm_gadget_exists': + return `${label}:${item.ok ? value('exists') : value('missing')}` + case 'other_gadgets': + return `${label}:${result.other_gadgets.length ? result.other_gadgets.join(', ') : value('none')}` + case 'one_kvm_bound_udc': + return `${label}:${result.bound_udc || value('unbound')}` + case 'udc_conflict': + return `${label}:${item.ok ? value('noConflict') : value('conflict')}` + case 'udc_state': + return `${label}:${result.udc_state || value('unknown')}` + case 'udc_speed': + return `${label}:${result.udc_speed || value('unknown')}` + default: + return `${label}:${item.ok ? value('normal') : value('abnormal')}` + } +} + +function otgCheckHint(item: OtgSelfCheckItem): string { + if (!item.hint) return '' + const key = `settings.otgSelfCheck.hints.${item.id}` + return te(key) ? t(key) : item.hint +} + +async function runOtgSelfCheck() { + otgSelfCheckLoading.value = true + otgSelfCheckError.value = '' + try { + otgSelfCheckResult.value = await hidApi.otgSelfCheck() + } catch (e) { + console.error('Failed to run OTG self-check:', e) + otgSelfCheckError.value = t('settings.otgSelfCheck.failed') + } finally { + otgSelfCheckLoading.value = false + } +} + +async function onRunOtgSelfCheckClick() { + if (!otgSelfCheckLoading.value) { + otgRunButtonPressed.value = true + window.setTimeout(() => { + otgRunButtonPressed.value = false + }, 160) + } + await runOtgSelfCheck() +} + function alignHidProfileForLowEndpoint() { if (hidProfileAligned.value) return if (!configLoaded.value || !devicesLoaded.value) return @@ -1148,8 +1355,18 @@ async function loadUpdateOverview() { async function refreshUpdateStatus() { try { updateStatus.value = await updateApi.status() + + if (updateSawRestarting.value && !updateAutoReloadTriggered.value) { + if (updateSawRequestFailure.value || updateStatus.value.phase === 'idle') { + updateAutoReloadTriggered.value = true + window.location.reload() + } + } } catch (e) { console.error('Failed to refresh update status:', e) + if (updateSawRestarting.value) { + updateSawRequestFailure.value = true + } } } @@ -1164,6 +1381,9 @@ function startUpdatePolling() { if (updateStatusTimer !== null) return updateStatusTimer = window.setInterval(async () => { await refreshUpdateStatus() + if (updateStatus.value?.phase === 'restarting') { + updateSawRestarting.value = true + } if (!updateRunning.value) { stopUpdatePolling() await loadUpdateOverview() @@ -1173,6 +1393,9 @@ function startUpdatePolling() { async function startOnlineUpgrade() { try { + updateSawRestarting.value = false + updateSawRequestFailure.value = false + updateAutoReloadTriggered.value = false await updateApi.upgrade({ channel: updateChannel.value }) await refreshUpdateStatus() startUpdatePolling() @@ -1195,6 +1418,25 @@ function updatePhaseText(phase?: string): string { } } +function localizeUpdateMessage(message?: string): string | null { + if (!message) return null + + if (message === 'Checking for updates') return t('settings.updateMsgChecking') + if (message.startsWith('Downloading binary')) { + return message.replace('Downloading binary', t('settings.updateMsgDownloading')) + } + if (message === 'Verifying sha256') return t('settings.updateMsgVerifying') + if (message === 'Replacing binary') return t('settings.updateMsgInstalling') + if (message === 'Restarting service') return t('settings.updateMsgRestarting') + + return message +} + +function updateStatusBadgeText(): string { + return localizeUpdateMessage(updateStatus.value?.message) + || updatePhaseText(updateStatus.value?.phase) +} + async function saveRustdeskConfig() { loading.value = true saved.value = false @@ -1462,11 +1704,17 @@ onMounted(async () => { if (updateRunning.value) { startUpdatePolling() } + + await runOtgSelfCheck() }) watch(updateChannel, async () => { await loadUpdateOverview() }) + +watch(() => config.value.hid_backend, async () => { + await runOtgSelfCheck() +}) + + + + +
+ + +
+ {{ t('settings.otgSelfCheck.title') }} + {{ t('settings.otgSelfCheck.desc') }} +
+ +
+ +

+ {{ otgSelfCheckError }} +

+ + +

+ {{ t('common.loading') }} +

+
+
@@ -2843,9 +3190,21 @@ watch(updateChannel, async () => {
- - {{ t('settings.onlineUpgrade') }} - {{ t('settings.onlineUpgradeDesc') }} + +
+ {{ t('settings.onlineUpgrade') }} + {{ t('settings.onlineUpgradeDesc') }} +
+
@@ -2876,9 +3235,9 @@ watch(updateChannel, async () => { - {{ updateStatus?.message || updatePhaseText(updateStatus?.phase) }} + {{ updateStatusBadgeText() }}
@@ -2905,10 +3264,6 @@ watch(updateChannel, async () => {
-