mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(hid): 支持 CH9329 + OTG 组合模式
CH9329 负责键鼠,OTG 提供虚拟媒体、USB 网络和音频;分别配置设备、报告运行状态,并在 OTG 配置变更和回滚时保留未变化的 CH9329 实例。 补齐双设备校验、串口冲突检查、初始化引导和功能入口,修复组合模式下鼠标兼容选项缺失。 验证:独立功能版本的 591 项 Rust 库测试、前端生产构建、3 项引导逻辑测试及 2 组浏览器回归通过。未进行目标 KVM 硬件验证。
This commit is contained in:
@@ -56,11 +56,37 @@ impl BluetoothHidConfig {
|
||||
pub enum HidBackend {
|
||||
Otg,
|
||||
Ch9329,
|
||||
#[serde(rename = "ch9329_otg")]
|
||||
Ch9329Otg,
|
||||
Bluetooth,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl HidBackend {
|
||||
pub fn uses_ch9329(&self) -> bool {
|
||||
matches!(self, Self::Ch9329 | Self::Ch9329Otg)
|
||||
}
|
||||
|
||||
pub fn uses_otg_hid(&self) -> bool {
|
||||
matches!(self, Self::Otg)
|
||||
}
|
||||
|
||||
pub fn supports_otg_functions(&self) -> bool {
|
||||
matches!(self, Self::Otg | Self::Ch9329Otg)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Otg => "otg",
|
||||
Self::Ch9329 => "ch9329",
|
||||
Self::Ch9329Otg => "ch9329_otg",
|
||||
Self::Bluetooth => "bluetooth",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OtgDescriptorConfig {
|
||||
@@ -141,6 +167,15 @@ pub struct OtgHidFunctions {
|
||||
}
|
||||
|
||||
impl OtgHidFunctions {
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
keyboard: false,
|
||||
mouse_relative: false,
|
||||
mouse_absolute: false,
|
||||
consumer: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full() -> Self {
|
||||
Self {
|
||||
keyboard: true,
|
||||
@@ -264,15 +299,49 @@ impl HidConfig {
|
||||
}
|
||||
|
||||
pub fn effective_otg_keyboard_leds(&self) -> bool {
|
||||
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
|
||||
self.otg_keyboard_leds && self.constrained_otg_functions().keyboard
|
||||
}
|
||||
|
||||
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
|
||||
self.effective_otg_functions()
|
||||
if self.backend.uses_otg_hid() {
|
||||
self.effective_otg_functions()
|
||||
} else {
|
||||
OtgHidFunctions::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_platform(&self) -> crate::error::Result<()> {
|
||||
if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires Linux with a USB device controller".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_composite_selection(&self) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Ch9329Otg {
|
||||
return Ok(());
|
||||
}
|
||||
if self.ch9329_port.trim().is_empty()
|
||||
|| ![9600, 19200, 38400, 57600, 115200].contains(&self.ch9329_baudrate)
|
||||
{
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires a serial port and a supported baudrate".into(),
|
||||
));
|
||||
}
|
||||
if !self.otg_udc.as_ref().is_some_and(|udc| {
|
||||
!udc.trim().is_empty() && !udc.contains('/') && udc != "." && udc != ".."
|
||||
}) {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires an explicit USB device controller".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
if !self.backend.uses_otg_hid() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -293,7 +362,7 @@ impl HidConfig {
|
||||
|
||||
#[inline]
|
||||
pub fn resolved_otg_udc(&self) -> Option<String> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
if !self.backend.supports_otg_functions() {
|
||||
return None;
|
||||
}
|
||||
self.otg_udc
|
||||
@@ -301,6 +370,9 @@ impl HidConfig {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
if !self.backend.uses_otg_hid() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
crate::otg::OtgGadgetManager::find_udc()
|
||||
@@ -316,6 +388,53 @@ impl HidConfig {
|
||||
#[cfg(test)]
|
||||
mod bluetooth_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn composite_mode_preserves_usb_functions_and_saved_otg_profile() {
|
||||
let mut config = crate::config::AppConfig::default();
|
||||
config.hid.backend = HidBackend::Ch9329Otg;
|
||||
config.hid.otg_profile = OtgHidProfile::LegacyKeyboard;
|
||||
config.hid.otg_keyboard_leds = true;
|
||||
config.hid.mouse_macos_drag = true;
|
||||
config.msd.enabled = true;
|
||||
config.otg_network.enabled = true;
|
||||
config.uac.enabled = true;
|
||||
config.enforce_invariants();
|
||||
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
|
||||
assert!(config.hid.constrained_otg_functions().is_empty());
|
||||
assert!(!config.hid.effective_otg_keyboard_leds());
|
||||
assert!(config.hid.validate_otg_functions().is_ok());
|
||||
let saved = serde_json::to_value(&config.hid).unwrap();
|
||||
assert_eq!(saved["backend"], "ch9329_otg");
|
||||
assert_eq!(saved["otg_profile"], "legacy_keyboard");
|
||||
config.hid = serde_json::from_value(saved).unwrap();
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
assert!(config.hid.constrained_otg_functions().keyboard);
|
||||
assert!(config.hid.effective_otg_keyboard_leds());
|
||||
config.hid.backend = HidBackend::Ch9329;
|
||||
config.enforce_invariants();
|
||||
assert!(!config.msd.enabled && !config.otg_network.enabled && !config.uac.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_selection_requires_both_devices_without_auto_udc_fallback() {
|
||||
let mut config = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
assert!(config.resolved_otg_udc().is_none());
|
||||
config.otg_udc = Some("test.udc".into());
|
||||
assert!(config.validate_composite_selection().is_ok());
|
||||
assert_eq!(config.resolved_otg_udc().as_deref(), Some("test.udc"));
|
||||
for udc in ["", "../other", "/sys/class/udc/test", ".."] {
|
||||
config.otg_udc = Some(udc.into());
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
}
|
||||
config.otg_udc = Some("test.udc".into());
|
||||
config.ch9329_port.clear();
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_compatibility_defaults_alias_and_otg_validation() {
|
||||
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||
|
||||
@@ -51,7 +51,7 @@ pub struct AppConfig {
|
||||
|
||||
impl AppConfig {
|
||||
pub fn enforce_invariants(&mut self) {
|
||||
if self.hid.backend != HidBackend::Otg {
|
||||
if !self.hid.backend.supports_otg_functions() {
|
||||
self.msd.enabled = false;
|
||||
self.otg_network.enabled = false;
|
||||
self.uac.enabled = false;
|
||||
|
||||
@@ -37,6 +37,27 @@ pub enum HidBackendType {
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<&crate::config::HidConfig> for HidBackendType {
|
||||
fn from(config: &crate::config::HidConfig) -> Self {
|
||||
use crate::config::HidBackend;
|
||||
match config.backend {
|
||||
HidBackend::Otg => Self::Otg {
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Ch9329 | HidBackend::Ch9329Otg => Self::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Bluetooth => Self::Bluetooth {
|
||||
config: config.bluetooth.clone(),
|
||||
},
|
||||
HidBackend::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HidBackendType {
|
||||
pub fn name_str(&self) -> &str {
|
||||
match self {
|
||||
|
||||
@@ -767,6 +767,60 @@ fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
|
||||
#[cfg(test)]
|
||||
mod queue_tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn composite_otg_updates_and_rollback_preserve_hid_instance() {
|
||||
let mut old = crate::config::AppConfig::default();
|
||||
old.hid.backend = crate::config::HidBackend::Ch9329;
|
||||
old.enforce_invariants();
|
||||
let otg = Arc::new(OtgService::new());
|
||||
let controller = Arc::new(HidController::new(
|
||||
HidBackendType::from(&old.hid),
|
||||
Some(otg.clone()),
|
||||
));
|
||||
let pressed = Arc::new(AtomicBool::new(true));
|
||||
let (runtime, _) = tokio::sync::watch::channel(());
|
||||
let backend: Arc<dyn HidBackend> = Arc::new(TestBackend {
|
||||
pressed: pressed.clone(),
|
||||
reset_done: Arc::new(tokio::sync::Notify::new()),
|
||||
runtime,
|
||||
});
|
||||
*controller.backend.write().await = Some(backend.clone());
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let coordinator = crate::runtime::UsbCoordinator::new(
|
||||
controller.clone(),
|
||||
otg,
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(EventBus::new()),
|
||||
data.path().into(),
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.hid.backend = crate::config::HidBackend::Ch9329Otg;
|
||||
new.hid.otg_udc = Some("test.udc".into());
|
||||
new.hid.otg_descriptor.product = "Advanced USB".into();
|
||||
new.hid.otg_keyboard_leds = true;
|
||||
coordinator.apply_config(&old, &new).await.unwrap();
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
coordinator.apply_config(&new, &old).await.unwrap();
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
assert!(pressed.load(Ordering::Acquire));
|
||||
// Invalid OTG configuration must not touch the healthy input backend.
|
||||
new.uac.enabled = true;
|
||||
new.uac.sample_rate = 8000;
|
||||
assert!(coordinator.apply_config(&old, &new).await.is_err());
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -149,8 +149,11 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool {
|
||||
|
||||
/// OTG self-check status for troubleshooting USB gadget issues
|
||||
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
|
||||
let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled;
|
||||
let hid_backend_is_otg = config.hid.backend.uses_otg_hid();
|
||||
let gadget_expected = hid_backend_is_otg
|
||||
|| config.msd.enabled
|
||||
|| config.otg_network.enabled
|
||||
|| config.uac.enabled;
|
||||
let mut checks = Vec::new();
|
||||
|
||||
let build_response = |checks: Vec<OtgSelfCheckItem>,
|
||||
@@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
overall_ok: error_count == 0,
|
||||
error_count,
|
||||
warning_count,
|
||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
||||
hid_backend: config.hid.backend.as_str().to_string(),
|
||||
selected_udc,
|
||||
bound_udc,
|
||||
udc_state,
|
||||
@@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Warn
|
||||
},
|
||||
"Check one-kvm gadget presence",
|
||||
Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"),
|
||||
Some("Enable OTG HID, MSD, USB Ethernet, or USB audio to create the one-kvm gadget"),
|
||||
Some(one_kvm_path.display().to_string()),
|
||||
);
|
||||
}
|
||||
@@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
false,
|
||||
OtgSelfCheckLevel::Warn,
|
||||
"Check one-kvm UDC binding",
|
||||
Some("Ensure HID/MSD is enabled and initialized successfully"),
|
||||
Some("Ensure an OTG function is enabled and initialized successfully"),
|
||||
Some(one_kvm_udc_path.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
@@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if hid_functions.is_empty() {
|
||||
let hid_functions_ok = hid_backend_is_otg != hid_functions.is_empty();
|
||||
if !hid_functions_ok {
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"hid_functions_present",
|
||||
false,
|
||||
if hid_backend_is_otg {
|
||||
OtgSelfCheckLevel::Error
|
||||
OtgSelfCheckLevel::Error,
|
||||
"Check OTG HID function selection",
|
||||
Some(if hid_backend_is_otg {
|
||||
"Check OTG HID config and enable at least one HID function"
|
||||
} else {
|
||||
OtgSelfCheckLevel::Warn
|
||||
},
|
||||
"Check HID function creation",
|
||||
Some("Check OTG HID config and enable at least one HID function"),
|
||||
"OTG HID must be disabled when input is handled by another backend"
|
||||
}),
|
||||
Some(functions_path.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
@@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
"hid_functions_present",
|
||||
true,
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check HID function creation",
|
||||
"Check OTG HID function selection",
|
||||
None::<String>,
|
||||
Some(functions_path.display().to_string()),
|
||||
);
|
||||
@@ -569,9 +573,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
})
|
||||
.is_some_and(|name| {
|
||||
name == "okvm-br0" || name == config.otg_network.bridge_interface
|
||||
name == "okvm-br0"
|
||||
|| name == config.otg_network.bridge_interface
|
||||
|| (config.otg_network.bridge_interface.is_empty()
|
||||
&& std::path::Path::new("/sys/class/net").join(name).join("bridge").is_dir())
|
||||
&& std::path::Path::new("/sys/class/net")
|
||||
.join(name)
|
||||
.join("bridge")
|
||||
.is_dir())
|
||||
});
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
@@ -843,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
overall_ok: error_count == 0,
|
||||
error_count,
|
||||
warning_count,
|
||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
||||
hid_backend: config.hid.backend.as_str().to_string(),
|
||||
selected_udc,
|
||||
bound_udc,
|
||||
udc_state,
|
||||
|
||||
@@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime;
|
||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
|
||||
use crate::config::{
|
||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
||||
UacConfig,
|
||||
HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
@@ -40,6 +39,17 @@ pub struct OtgNetworkStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct OtgRuntimeStatus {
|
||||
pub enabled: bool,
|
||||
pub health: OtgRuntimeHealth,
|
||||
pub gadget_active: bool,
|
||||
pub udc: Option<String>,
|
||||
pub udc_state: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl HidDevicePaths {
|
||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||
[
|
||||
@@ -93,7 +103,7 @@ impl OtgDesiredState {
|
||||
network.validate()?;
|
||||
uac.validate()?;
|
||||
msd.validate()?;
|
||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||
let hid_functions = if hid.backend.uses_otg_hid() {
|
||||
let functions = hid.constrained_otg_functions();
|
||||
Some(functions)
|
||||
} else {
|
||||
@@ -107,7 +117,13 @@ impl OtgDesiredState {
|
||||
.as_ref()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(OtgGadgetManager::find_udc)
|
||||
.or_else(|| {
|
||||
if hid.backend.uses_ch9329() {
|
||||
None
|
||||
} else {
|
||||
OtgGadgetManager::find_udc()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -249,6 +265,25 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn runtime_status(&self, enabled: bool) -> OtgRuntimeStatus {
|
||||
let state = self.state.read().await;
|
||||
let udc_state = state.configured_udc.as_ref().and_then(|udc| {
|
||||
// Only UDC names, never a path supplied through a configuration file.
|
||||
if udc.contains('/') || udc == "." || udc == ".." {
|
||||
return None;
|
||||
}
|
||||
crate::utils::read_trimmed(&PathBuf::from(format!("/sys/class/udc/{udc}/state")))
|
||||
});
|
||||
OtgRuntimeStatus {
|
||||
enabled,
|
||||
health: state.health,
|
||||
gadget_active: state.gadget_active,
|
||||
udc: state.configured_udc.clone(),
|
||||
udc_state,
|
||||
error: state.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_config(
|
||||
&self,
|
||||
hid: &HidConfig,
|
||||
@@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::HidBackend;
|
||||
|
||||
#[test]
|
||||
fn composite_gadget_supports_every_advanced_function_combination_without_hid() {
|
||||
let hid = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("test.udc".into()),
|
||||
otg_keyboard_leds: true,
|
||||
..Default::default()
|
||||
};
|
||||
for mask in 0..8 {
|
||||
let msd = MsdConfig {
|
||||
enabled: mask & 1 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let network = OtgNetworkConfig {
|
||||
enabled: mask & 2 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let uac = UacConfig {
|
||||
enabled: mask & 4 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let desired = OtgDesiredState::from_config(&hid, &msd, &network, &uac).unwrap();
|
||||
assert!(!desired.hid_enabled());
|
||||
assert!(!desired.keyboard_leds);
|
||||
assert_eq!(desired.msd_enabled, msd.enabled);
|
||||
assert_eq!(desired.network_enabled(), network.enabled);
|
||||
assert_eq!(desired.uac.enabled, uac.enabled);
|
||||
assert_eq!(desired.udc.is_some(), mask != 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_new_and_availability_probe() {
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn capabilities() -> PlatformCapabilities {
|
||||
encoder: FeatureCapability::available([
|
||||
"software", "vaapi", "nvenc", "qsv", "amf", "rkmpp", "v4l2m2m",
|
||||
]),
|
||||
hid: FeatureCapability::available(["otg", "ch9329", "bluetooth", "none"]),
|
||||
hid: FeatureCapability::available(["otg", "ch9329", "ch9329_otg", "bluetooth", "none"]),
|
||||
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
|
||||
msd: FeatureCapability::available(["configfs"]),
|
||||
otg: FeatureCapability::available(["configfs"]),
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::broadcast;
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||
use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
||||
use crate::config::{self, AppConfig, ConfigStore};
|
||||
use crate::config::{AppConfig, ConfigStore};
|
||||
use crate::db::{open_database_pool, DatabasePool};
|
||||
use crate::events::EventBus;
|
||||
use crate::extensions::ExtensionManager;
|
||||
@@ -81,6 +81,7 @@ impl RuntimeBuilder {
|
||||
web_overrides,
|
||||
} = self;
|
||||
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
|
||||
config.hid.validate_platform()?;
|
||||
web_overrides.apply(&mut config);
|
||||
|
||||
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||
@@ -400,26 +401,13 @@ async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to apply OTG config: {}", error);
|
||||
service.mark_degraded(error.to_string()).await;
|
||||
}
|
||||
service
|
||||
}
|
||||
|
||||
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
||||
match config.hid.backend {
|
||||
config::HidBackend::Otg => HidBackendType::Otg {
|
||||
macos_drag: config.hid.mouse_macos_drag,
|
||||
},
|
||||
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.hid.ch9329_port.clone(),
|
||||
baud_rate: config.hid.ch9329_baudrate,
|
||||
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
||||
macos_drag: config.hid.mouse_macos_drag,
|
||||
},
|
||||
config::HidBackend::None => HidBackendType::None,
|
||||
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||
config: config.hid.bluetooth.clone(),
|
||||
},
|
||||
}
|
||||
HidBackendType::from(&config.hid)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -51,24 +51,31 @@ impl UsbCoordinator {
|
||||
}
|
||||
|
||||
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
|
||||
new_config.hid.validate_platform()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
|
||||
&& new_config.hid.backend != HidBackend::Otg;
|
||||
let hid_unchanged = old_config.hid == new_config.hid;
|
||||
let gadget_rebuilt = old_config.msd != new_config.msd
|
||||
|| old_config.otg_network != new_config.otg_network
|
||||
|| old_config.uac != new_config.uac
|
||||
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|
||||
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|
||||
|| old_config.hid.backend != new_config.hid.backend
|
||||
|| old_config.hid.constrained_otg_functions()
|
||||
!= new_config.hid.constrained_otg_functions()
|
||||
|| old_config.hid.effective_otg_keyboard_leds()
|
||||
!= new_config.hid.effective_otg_keyboard_leds();
|
||||
let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid);
|
||||
let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config(
|
||||
&old_config.hid,
|
||||
&old_config.msd,
|
||||
&old_config.otg_network,
|
||||
&old_config.uac,
|
||||
)? != crate::otg::service::OtgDesiredState::from_config(
|
||||
&new_config.hid,
|
||||
&new_config.msd,
|
||||
&new_config.otg_network,
|
||||
&new_config.uac,
|
||||
)?;
|
||||
let restart_uac =
|
||||
old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt);
|
||||
|
||||
if gadget_rebuilt {
|
||||
// This is a no-op for CH9329, including the composite mode.
|
||||
self.hid.prepare_otg_rebuild().await?;
|
||||
}
|
||||
|
||||
if restart_uac {
|
||||
let playback = self.uac_playback.write().await.take();
|
||||
if let Some(playback) = playback {
|
||||
@@ -167,29 +174,11 @@ impl UsbCoordinator {
|
||||
uac_config: &UacConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
new_config.validate_platform()?;
|
||||
new_config.validate_otg_functions()?;
|
||||
new_config.bluetooth.validate()?;
|
||||
|
||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||
let hid_functions_changed =
|
||||
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
|
||||
let keyboard_leds_changed =
|
||||
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||
let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
|
||||
!= new_config.ch9329_hybrid_mouse
|
||||
|| old_config.mouse_macos_drag != new_config.mouse_macos_drag;
|
||||
|
||||
if old_config.backend == new_config.backend
|
||||
&& old_config.ch9329_port == new_config.ch9329_port
|
||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||
&& old_config.bluetooth == new_config.bluetooth
|
||||
&& !mouse_compatibility_changed
|
||||
&& old_config.otg_udc == new_config.otg_udc
|
||||
&& !descriptor_changed
|
||||
&& !hid_functions_changed
|
||||
&& !keyboard_leds_changed
|
||||
&& !options.force
|
||||
{
|
||||
if !hid_requires_reload(old_config, new_config) && !options.force {
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -256,7 +245,7 @@ impl UsbCoordinator {
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
let old_enabled = old_config.enabled;
|
||||
let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg;
|
||||
let new_enabled = new_config.enabled && hid_config.backend.supports_otg_functions();
|
||||
let directory_changed = old_config.msd_dir != new_config.msd_dir;
|
||||
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|
||||
|| old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string;
|
||||
@@ -309,28 +298,72 @@ impl UsbCoordinator {
|
||||
}
|
||||
|
||||
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
||||
match config.backend {
|
||||
HidBackend::Otg => HidBackendType::Otg {
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::None => HidBackendType::None,
|
||||
HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||
config: config.bluetooth.clone(),
|
||||
},
|
||||
}
|
||||
HidBackendType::from(config)
|
||||
}
|
||||
|
||||
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool {
|
||||
old_config.backend == HidBackend::Otg
|
||||
|| new_config.backend == HidBackend::Otg
|
||||
|| old_config.otg_udc != new_config.otg_udc
|
||||
|| old_config.otg_descriptor != new_config.otg_descriptor
|
||||
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|
||||
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
|
||||
fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool {
|
||||
HidBackendType::from(old) != HidBackendType::from(new)
|
||||
|| (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new))
|
||||
}
|
||||
|
||||
fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
|
||||
old.backend.uses_otg_hid() != new.backend.uses_otg_hid()
|
||||
|| old.otg_udc != new.otg_udc
|
||||
|| old.otg_descriptor != new.otg_descriptor
|
||||
|| old.constrained_otg_functions() != new.constrained_otg_functions()
|
||||
|| old.effective_otg_keyboard_leds() != new.effective_otg_keyboard_leds()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() {
|
||||
let old = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("first.udc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut new = old.clone();
|
||||
new.otg_udc = Some("second.udc".into());
|
||||
new.otg_descriptor.product = "Advanced USB".into();
|
||||
new.otg_keyboard_leds = !old.otg_keyboard_leds;
|
||||
new.otg_profile = crate::config::OtgHidProfile::LegacyKeyboard;
|
||||
assert!(!hid_requires_reload(&old, &new));
|
||||
assert!(!hid_requires_reload(&new, &old));
|
||||
|
||||
let input_changes: [fn(&mut HidConfig); 4] = [
|
||||
|hid: &mut HidConfig| hid.ch9329_port.push('1'),
|
||||
|hid: &mut HidConfig| hid.ch9329_baudrate = 115200,
|
||||
|hid: &mut HidConfig| hid.ch9329_hybrid_mouse = !hid.ch9329_hybrid_mouse,
|
||||
|hid: &mut HidConfig| hid.mouse_macos_drag = !hid.mouse_macos_drag,
|
||||
];
|
||||
for change in input_changes {
|
||||
let mut changed = old.clone();
|
||||
change(&mut changed);
|
||||
assert!(hid_requires_reload(&old, &changed));
|
||||
assert!(hid_requires_reload(&changed, &old));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_mode_switch_only_reloads_when_input_driver_changes() {
|
||||
let composite = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("test.udc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
for (backend, reload) in [
|
||||
(HidBackend::Ch9329, false),
|
||||
(HidBackend::Otg, true),
|
||||
(HidBackend::Bluetooth, true),
|
||||
(HidBackend::None, true),
|
||||
] {
|
||||
let mut other = composite.clone();
|
||||
other.backend = backend;
|
||||
assert_eq!(hid_requires_reload(&composite, &other), reload);
|
||||
assert_eq!(hid_requires_reload(&other, &composite), reload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::atx::AtxDriverType;
|
||||
use crate::config::{AtxConfig, HidBackend, HidConfig};
|
||||
use crate::config::{AtxConfig, HidConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -17,12 +17,13 @@ pub async fn update_atx_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AtxConfigUpdate>,
|
||||
) -> Result<Json<AtxConfig>> {
|
||||
let _usb_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let current_config = state.config.get();
|
||||
let old_atx_config = current_config.atx.clone();
|
||||
|
||||
req.validate_with_current(&old_atx_config)?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let mut merged_atx_config = old_atx_config.clone();
|
||||
req.apply_to(&mut merged_atx_config);
|
||||
validate_windows_atx_backends(&merged_atx_config)?;
|
||||
@@ -56,8 +57,8 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||
if hid.backend != HidBackend::Ch9329 {
|
||||
pub(super) fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||
if !hid.backend.uses_ch9329() {
|
||||
return Ok(());
|
||||
}
|
||||
let reserved = hid.ch9329_port.trim();
|
||||
@@ -78,6 +79,7 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::HidBackend;
|
||||
|
||||
#[test]
|
||||
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
|
||||
@@ -90,6 +92,8 @@ mod tests {
|
||||
hid.ch9329_port = "/dev/ttyUSB0".to_string();
|
||||
|
||||
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||
hid.backend = HidBackend::Ch9329Otg;
|
||||
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
|
||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig, UacConfig};
|
||||
use crate::error::Result;
|
||||
use crate::otg::OtgNetworkStatus;
|
||||
use crate::web::state::UsbApiState;
|
||||
@@ -16,6 +16,7 @@ pub struct OtgConfigResponse {
|
||||
pub hid: HidConfig,
|
||||
pub msd: MsdConfig,
|
||||
pub network: OtgNetworkConfig,
|
||||
pub uac: UacConfig,
|
||||
pub status: OtgNetworkStatus,
|
||||
}
|
||||
|
||||
@@ -48,6 +49,10 @@ pub(super) async fn update_otg_config_inner(
|
||||
if let Some(ref update) = request.network {
|
||||
update.apply_to(&mut staged.otg_network);
|
||||
}
|
||||
if let Some(ref uac) = request.uac {
|
||||
uac.validate()?;
|
||||
staged.uac = uac.clone();
|
||||
}
|
||||
|
||||
Ok(requested_ch9329_descriptor)
|
||||
})
|
||||
@@ -57,6 +62,7 @@ pub(super) async fn update_otg_config_inner(
|
||||
hid: staged_config.hid,
|
||||
msd: staged_config.msd,
|
||||
network: staged_config.otg_network,
|
||||
uac: staged_config.uac,
|
||||
status: state.otg.network_status().await,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -427,6 +427,7 @@ pub struct OtgConfigUpdate {
|
||||
pub hid: Option<HidConfigUpdate>,
|
||||
pub msd: Option<MsdConfigUpdate>,
|
||||
pub network: Option<OtgNetworkConfigUpdate>,
|
||||
pub uac: Option<crate::config::UacConfig>,
|
||||
}
|
||||
|
||||
impl HidConfigUpdate {
|
||||
|
||||
@@ -47,6 +47,21 @@ where
|
||||
let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
|
||||
|
||||
staged_config.enforce_invariants();
|
||||
staged_config.hid.validate_platform()?;
|
||||
staged_config.hid.validate_composite_selection()?;
|
||||
#[cfg(unix)]
|
||||
if staged_config.hid.backend == HidBackend::Ch9329Otg
|
||||
&& (old_config.hid.backend != staged_config.hid.backend
|
||||
|| old_config.hid.otg_udc != staged_config.hid.otg_udc)
|
||||
&& !crate::otg::list_udc_devices()
|
||||
.iter()
|
||||
.any(|udc| Some(udc) == staged_config.hid.otg_udc.as_ref())
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Selected USB device controller is missing".into(),
|
||||
));
|
||||
}
|
||||
super::atx::validate_serial_device_conflict(&staged_config.atx, &staged_config.hid)?;
|
||||
staged_config.hid.validate_otg_functions()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -179,7 +194,7 @@ where
|
||||
}
|
||||
|
||||
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
||||
if staged_config.hid.backend == HidBackend::Ch9329 {
|
||||
if staged_config.hid.backend.uses_ch9329() {
|
||||
match state.hid.apply_ch9329_descriptor(descriptor).await {
|
||||
Ok(actual) => {
|
||||
staged_config.hid.ch9329_descriptor = actual.descriptor;
|
||||
@@ -250,7 +265,7 @@ async fn rollback_after_failure(
|
||||
{
|
||||
rollback_errors.push(format!("runtime rollback failed: {error}"));
|
||||
}
|
||||
if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 {
|
||||
if restore_descriptor && old_config.hid.backend.uses_ch9329() {
|
||||
if let Err(error) = state
|
||||
.hid
|
||||
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
|
||||
@@ -276,6 +291,31 @@ mod tests {
|
||||
use crate::config::HidBackend;
|
||||
use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn composite_advanced_features_can_be_submitted_in_one_usb_transaction() {
|
||||
let request: super::super::types::OtgConfigUpdate = serde_json::from_value(serde_json::json!({
|
||||
"hid": { "backend": "ch9329_otg", "ch9329_port": "/dev/ttyUSB1", "otg_udc": "test.udc" },
|
||||
"msd": { "enabled": true },
|
||||
"network": { "enabled": true },
|
||||
"uac": { "enabled": true, "sample_rate": 48000, "channels": 2 }
|
||||
})).unwrap();
|
||||
let mut config = AppConfig::default();
|
||||
stage_hid_config_update(&mut config.hid, request.hid.as_ref().unwrap()).unwrap();
|
||||
request.msd.as_ref().unwrap().apply_to(&mut config.msd);
|
||||
request
|
||||
.network
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.apply_to(&mut config.otg_network);
|
||||
config.uac = request.uac.unwrap();
|
||||
config.enforce_invariants();
|
||||
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
|
||||
assert!(config.hid.constrained_otg_functions().is_empty());
|
||||
assert_eq!(config.hid.ch9329_port, "/dev/ttyUSB1");
|
||||
assert_eq!(config.hid.otg_udc.as_deref(), Some("test.udc"));
|
||||
}
|
||||
|
||||
fn hid_update() -> HidConfigUpdate {
|
||||
HidConfigUpdate {
|
||||
bluetooth_reset_pairing: None,
|
||||
|
||||
@@ -11,6 +11,9 @@ pub struct Ch9329DescriptorQuery {
|
||||
pub struct HidStatus {
|
||||
pub available: bool,
|
||||
pub backend: String,
|
||||
pub configured_backend: String,
|
||||
#[cfg(unix)]
|
||||
pub otg: crate::otg::service::OtgRuntimeStatus,
|
||||
pub initialized: bool,
|
||||
pub online: bool,
|
||||
pub supports_absolute_mouse: bool,
|
||||
@@ -34,9 +37,21 @@ pub async fn hid_otg_self_check(
|
||||
/// Get HID status
|
||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||
let hid = state.hid.snapshot().await;
|
||||
let config = state.config.get();
|
||||
Json(HidStatus {
|
||||
available: hid.available,
|
||||
backend: hid.backend,
|
||||
configured_backend: config.hid.backend.as_str().to_string(),
|
||||
#[cfg(unix)]
|
||||
otg: state
|
||||
.otg_service
|
||||
.runtime_status(
|
||||
config.hid.backend.uses_otg_hid()
|
||||
|| config.msd.enabled
|
||||
|| config.otg_network.enabled
|
||||
|| config.uac.enabled,
|
||||
)
|
||||
.await,
|
||||
initialized: hid.initialized,
|
||||
online: hid.online,
|
||||
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||
|
||||
@@ -69,6 +69,8 @@ impl SetupRequest {
|
||||
if let Some(enabled) = self.hid_otg_keyboard_leds {
|
||||
hid.otg_keyboard_leds = enabled;
|
||||
}
|
||||
hid.validate_platform()?;
|
||||
hid.validate_composite_selection()?;
|
||||
hid.bluetooth.validate()?;
|
||||
hid.validate_otg_functions()?;
|
||||
Ok(hid)
|
||||
@@ -305,6 +307,21 @@ mod tests {
|
||||
assert!(hid.effective_otg_keyboard_leds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn setup_applies_composite_selection_to_both_usb_paths() {
|
||||
let req = request(json!({
|
||||
"hid_backend": "ch9329_otg", "hid_ch9329_port": "/dev/ttyUSB2",
|
||||
"hid_ch9329_baudrate": 115200, "hid_otg_udc": "test.udc"
|
||||
}));
|
||||
let hid = req.hid_config(&HidConfig::default()).unwrap();
|
||||
assert_eq!(hid.backend, HidBackend::Ch9329Otg);
|
||||
assert_eq!(hid.ch9329_port, "/dev/ttyUSB2");
|
||||
assert_eq!(hid.ch9329_baudrate, 115200);
|
||||
assert_eq!(hid.otg_udc.as_deref(), Some("test.udc"));
|
||||
assert!(hid.constrained_otg_functions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_applies_selected_ch9329_port_and_baudrate() {
|
||||
let req = request(json!({
|
||||
|
||||
@@ -164,7 +164,7 @@ export const systemApi = {
|
||||
video_width?: number
|
||||
video_height?: number
|
||||
video_fps?: number
|
||||
hid_backend?: 'otg' | 'ch9329' | 'bluetooth' | 'none'
|
||||
hid_backend?: 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none'
|
||||
hid_bluetooth?: { adapter: string; name: string }
|
||||
hid_ch9329_port?: string
|
||||
hid_ch9329_baudrate?: number
|
||||
@@ -411,6 +411,8 @@ export const hidApi = {
|
||||
request<{
|
||||
available: boolean
|
||||
backend: string
|
||||
configured_backend: string
|
||||
otg?: import('@/lib/hidGuide').OtgDeviceStatus
|
||||
initialized: boolean
|
||||
online: boolean
|
||||
supports_absolute_mouse: boolean
|
||||
|
||||
@@ -58,11 +58,7 @@ const systemStore = useSystemStore()
|
||||
|
||||
const overflowMenuOpen = ref(false)
|
||||
|
||||
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
|
||||
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
|
||||
const showMsd = computed(() => {
|
||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||
})
|
||||
const showMsd = computed(() => !!systemStore.msd?.available)
|
||||
const props = defineProps<{
|
||||
layout?: ConsoleLayout
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MousePointer, Move, Loader2 } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { HidBackend, OtgHidProfile } from '@/types/generated'
|
||||
import { usesCh9329 } from '@/lib/hidGuide'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -66,7 +67,7 @@ watch(showCursor, (newValue, oldValue) => {
|
||||
const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||
const hidSaving = ref(false)
|
||||
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
|
||||
const supportsLinuxCompatibility = computed(() => configStore.hid?.backend === HidBackend.Ch9329)
|
||||
const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend))
|
||||
const supportsMacosCompatibility = computed(() =>
|
||||
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { HidConfig } from '@/types/generated'
|
||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||
import { hidDeviceError, hidDeviceStage, type HidDeviceStatus } from '@/lib/hidGuide'
|
||||
import { hidDeviceError, hidDeviceStage, otgDeviceStage, usesCh9329, supportsOtgFunctions, type HidDeviceStatus } from '@/lib/hidGuide'
|
||||
const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>()
|
||||
const { t } = useI18n()
|
||||
const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error)
|
||||
@@ -13,8 +13,8 @@ const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? nul
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="font-medium">{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}</p>
|
||||
<dl v-if="hid && hid.backend !== 'none'" class="space-y-1 break-words">
|
||||
<div v-if="hid.backend === 'otg'"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}</dd></div>
|
||||
<template v-if="hid.backend === 'ch9329'">
|
||||
<div v-if="supportsOtgFunctions(hid.backend)"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}</dd></div>
|
||||
<template v-if="usesCh9329(hid.backend)">
|
||||
<div><dt class="inline text-muted-foreground">{{ t('hidGuide.device_ch9329') }}: </dt><dd class="inline">{{ hid.ch9329_port || '—' }}</dd></div>
|
||||
<div><dt class="inline text-muted-foreground">{{ t('actionbar.baudrate') }}: </dt><dd class="inline">{{ hid.ch9329_baudrate }}</dd></div>
|
||||
</template>
|
||||
@@ -25,9 +25,13 @@ const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? nul
|
||||
</template>
|
||||
</dl>
|
||||
<p v-if="hid && hid.backend !== 'none'" role="status" :class="!connectionError && stage === 'ready' ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'">
|
||||
{{ t(`hidGuide.${stage}`) }}
|
||||
<span v-if="hid.backend === 'ch9329_otg'">{{ t('hidGuide.keyboardChannel') }}: </span>{{ t(`hidGuide.${stage}`) }}
|
||||
<span v-if="bluetooth?.pairing_seconds"> · {{ bluetooth.pairing_seconds }}s</span>
|
||||
</p>
|
||||
<template v-if="hid?.backend === 'ch9329_otg'">
|
||||
<p role="status" class="text-muted-foreground">{{ t('hidGuide.otgChannel') }}: {{ t(`hidGuide.${otgDeviceStage(status?.otg)}`) }}</p>
|
||||
<p v-if="status?.otg?.error" role="alert" class="text-destructive break-words">{{ status.otg.error }}</p>
|
||||
</template>
|
||||
<p v-if="connectionError" role="alert" class="text-destructive break-words">{{ connectionError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSystemStore } from '@/stores/system'
|
||||
import { request } from '@/api/request'
|
||||
import type { HidConfig, MsdConfig } from '@/types/generated'
|
||||
import { useHidConnection } from '@/composables/useHidConnection'
|
||||
import { deviceRequest, matchesSelection, selectionFrom, type HidSelection } from '@/lib/hidGuide'
|
||||
import { deviceRequest, matchesSelection, selectionFrom, supportsOtgFunctions, type HidSelection } from '@/lib/hidGuide'
|
||||
import HidDriverForm from './HidDriverForm.vue'
|
||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
||||
@@ -127,7 +127,7 @@ async function load() {
|
||||
if (!systemStore.platform) await systemStore.fetchSystemInfo()
|
||||
await refreshHid()
|
||||
draft.value = selectionFrom(store.hid)
|
||||
if (otgSupported.value && store.hid?.backend === 'otg') {
|
||||
if (otgSupported.value && supportsOtgFunctions(store.hid?.backend)) {
|
||||
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
||||
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ onMounted(load)
|
||||
</template>
|
||||
<p v-if="connectionTimedOut && !ready && draft.backend !== 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.connectionTimeout') }}</p>
|
||||
</template>
|
||||
<p v-if="!applied && store.hid?.backend === 'otg' && draft.backend !== 'otg' && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
|
||||
<p v-if="!applied && supportsOtgFunctions(store.hid?.backend) && !supportsOtgFunctions(draft.backend) && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
|
||||
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
|
||||
@@ -9,57 +9,82 @@ import { Cable, RefreshCw } from 'lucide-vue-next'
|
||||
import { configApi, type PlatformCapabilities } from '@/api'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothAdapter } from '@/types/bluetooth'
|
||||
import { selectDevice, validName, type HidSelection, type Driver } from '@/lib/hidGuide'
|
||||
import { selectDevice, validName, usesCh9329, supportsOtgFunctions, type HidSelection, type Driver } from '@/lib/hidGuide'
|
||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||
const props = defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean }>()
|
||||
const props = withDefaults(defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean; deviceOnly?: boolean; idPrefix?: string }>(), { idPrefix: 'hid' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>()
|
||||
const { t } = useI18n()
|
||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||
const options = ref<Array<{ value: string; label: string }>>([])
|
||||
const loading = ref(false), error = ref(''), missing = ref('')
|
||||
type DeviceField = 'serial' | 'udc' | 'bluetooth'
|
||||
type DeviceOption = { value: string; label: string }
|
||||
const options = ref<Record<DeviceField, DeviceOption[]>>({ serial: [], udc: [], bluetooth: [] })
|
||||
const loading = ref(false), error = ref('')
|
||||
let generation = 0
|
||||
const drivers = computed(() => (['otg', 'ch9329', 'bluetooth', 'none'] as Driver[])
|
||||
const drivers = computed(() => (['otg', 'ch9329', 'ch9329_otg', 'bluetooth', 'none'] as Driver[])
|
||||
.filter(driver => props.platform?.hid.backends.includes(driver)))
|
||||
const selected = computed(() => props.modelValue.backend === 'otg' ? props.modelValue.otg_udc : props.modelValue.backend === 'ch9329' ? props.modelValue.ch9329_port : props.modelValue.bluetooth.adapter)
|
||||
function device(value: string) {
|
||||
const fields = computed<DeviceField[]>(() => {
|
||||
if (props.modelValue.backend === 'bluetooth') return ['bluetooth']
|
||||
const result: DeviceField[] = []
|
||||
if (usesCh9329(props.modelValue.backend)) result.push('serial')
|
||||
if (supportsOtgFunctions(props.modelValue.backend)) result.push('udc')
|
||||
return result
|
||||
})
|
||||
function selected(field: DeviceField): string {
|
||||
return field === 'serial' ? props.modelValue.ch9329_port : field === 'udc' ? props.modelValue.otg_udc : props.modelValue.bluetooth.adapter
|
||||
}
|
||||
function deviceLabel(field: DeviceField): string {
|
||||
return options.value[field].find(option => option.value === selected(field))?.label
|
||||
|| selected(field) || t('hidGuide.selectDevice')
|
||||
}
|
||||
function assign(draft: HidSelection, field: DeviceField, value: string) {
|
||||
if (field === 'serial') draft.ch9329_port = value
|
||||
if (field === 'udc') draft.otg_udc = value
|
||||
if (field === 'bluetooth') draft.bluetooth.adapter = value
|
||||
}
|
||||
function device(field: DeviceField, value: string) {
|
||||
if (value === selected(field)) return
|
||||
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
||||
if (draft.backend === 'otg') draft.otg_udc = value
|
||||
if (draft.backend === 'ch9329') draft.ch9329_port = value
|
||||
if (draft.backend === 'bluetooth') draft.bluetooth.adapter = value
|
||||
assign(draft, field, value)
|
||||
emit('update:modelValue', draft)
|
||||
}
|
||||
function fieldLabel(field: DeviceField): string {
|
||||
return field === 'udc' && props.deviceOnly ? t('hidPage.udcLabel') : t(`hidGuide.device_${field === 'serial' ? 'ch9329' : field === 'udc' ? 'otg' : 'bluetooth'}`)
|
||||
}
|
||||
async function refresh() {
|
||||
const own = ++generation
|
||||
options.value = []; error.value = ''; missing.value = ''; loading.value = true
|
||||
options.value = { serial: [], udc: [], bluetooth: [] }; error.value = ''; loading.value = true
|
||||
try {
|
||||
if (!drivers.value.includes(props.modelValue.backend) || props.modelValue.backend === 'none') return
|
||||
let next: typeof options.value
|
||||
const next: Record<DeviceField, DeviceOption[]> = { serial: [], udc: [], bluetooth: [] }
|
||||
if (props.modelValue.backend === 'bluetooth') {
|
||||
next = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
|
||||
next.bluetooth = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
|
||||
} else {
|
||||
const devices = await configApi.listDevices()
|
||||
next = props.modelValue.backend === 'otg' ? devices.udc.map(d => ({ value: d.name, label: d.name })) : devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
|
||||
next.udc = devices.udc.map(d => ({ value: d.name, label: d.name }))
|
||||
next.serial = devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
|
||||
}
|
||||
if (own !== generation) return
|
||||
options.value = next
|
||||
if (selected.value && !next.some(d => d.value === selected.value)) missing.value = t('hidGuide.missingDevice', { device: selected.value })
|
||||
device(selectDevice(selected.value, next.map(d => d.value)))
|
||||
if (!next.length) error.value = t('hidGuide.noDevices')
|
||||
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
||||
for (const field of fields.value) assign(draft, field, selectDevice(selected(field), next[field].map(d => d.value)))
|
||||
if (JSON.stringify(draft) !== JSON.stringify(props.modelValue)) emit('update:modelValue', draft)
|
||||
} catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) }
|
||||
finally { if (own === generation) loading.value = false }
|
||||
}
|
||||
const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value && options.value.some(d => d.value === selected.value)
|
||||
const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value
|
||||
&& fields.value.every(field => options.value[field].some(d => d.value === selected(field)))
|
||||
&& (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name))
|
||||
&& (props.modelValue.backend !== 'ch9329' || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate)))))
|
||||
&& (!usesCh9329(props.modelValue.backend) || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate)))))
|
||||
watch(valid, value => emit('valid', value), { immediate: true })
|
||||
watch(() => [props.modelValue.backend, drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true })
|
||||
watch([() => props.modelValue.backend, () => drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true })
|
||||
onUnmounted(() => generation++)
|
||||
defineExpose({ refresh, loading })
|
||||
</script>
|
||||
<template>
|
||||
<fieldset :disabled="locked" class="space-y-4 min-w-0">
|
||||
<div class="space-y-1 text-sm">
|
||||
<fieldset :disabled="loading || locked" class="space-y-4 min-w-0">
|
||||
<div v-if="!deviceOnly" class="space-y-1 text-sm">
|
||||
<div class="flex min-h-8 items-center justify-between gap-2">
|
||||
<label for="hid-driver">{{ t('hidGuide.driver') }}</label>
|
||||
<label :for="`${idPrefix}-driver`">{{ t('hidGuide.driver') }}</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<HoverCard v-if="modelValue.backend !== 'none'" :open-delay="150" :close-delay="100">
|
||||
<HoverCardTrigger as-child>
|
||||
@@ -77,7 +102,7 @@ onUnmounted(() => generation++)
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="loading"
|
||||
:disabled="loading || locked"
|
||||
:aria-label="t('hidGuide.refreshDevices')"
|
||||
:title="t('hidGuide.refreshDevices')"
|
||||
@click="refresh"
|
||||
@@ -88,9 +113,10 @@ onUnmounted(() => generation++)
|
||||
</div>
|
||||
<Select
|
||||
:model-value="modelValue.backend"
|
||||
:disabled="loading || locked"
|
||||
@update:model-value="value => emit('update:modelValue', { ...modelValue, backend: String(value) as Driver })"
|
||||
>
|
||||
<SelectTrigger id="hid-driver" class="w-full">
|
||||
<SelectTrigger :id="`${idPrefix}-driver`" class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -101,46 +127,48 @@ onUnmounted(() => generation++)
|
||||
</Select>
|
||||
</div>
|
||||
<p v-if="modelValue.backend === 'none'" class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
|
||||
<label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
|
||||
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span>
|
||||
<Select
|
||||
:model-value="selected || EMPTY_SELECT_VALUE"
|
||||
:disabled="loading"
|
||||
@update:model-value="value => device(value === EMPTY_SELECT_VALUE ? '' : String(value))"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="t('hidGuide.selectDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE" disabled>{{ t('hidGuide.selectDevice') }}</SelectItem>
|
||||
<SelectItem v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label v-if="modelValue.backend === 'ch9329'" class="block space-y-1 text-sm">
|
||||
<span>{{ t('actionbar.baudrate') }}</span>
|
||||
<Select
|
||||
:model-value="String(modelValue.ch9329_baudrate)"
|
||||
@update:model-value="value => emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(value) })"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate" :value="String(rate)">
|
||||
{{ rate }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label v-if="modelValue.backend === 'bluetooth'" class="block space-y-1 text-sm">
|
||||
<p v-if="!deviceOnly && modelValue.backend === 'ch9329_otg'" class="text-sm text-muted-foreground">{{ t('hidGuide.compositeHelp') }}</p>
|
||||
<div v-for="field in fields" :key="field" class="grid min-w-0 gap-4"
|
||||
:class="{ 'grid-cols-[minmax(0,1fr)_7rem]': field === 'serial' }">
|
||||
<div class="min-w-0 space-y-1 text-sm">
|
||||
<label :for="`${idPrefix}-device-${field}`">{{ fieldLabel(field) }}</label>
|
||||
<Select :model-value="selected(field) || EMPTY_SELECT_VALUE" :disabled="loading || locked"
|
||||
@update:model-value="value => device(field, value === EMPTY_SELECT_VALUE ? '' : String(value))">
|
||||
<SelectTrigger :id="`${idPrefix}-device-${field}`" class="w-full">
|
||||
<SelectValue :placeholder="t('hidGuide.selectDevice')">{{ deviceLabel(field) }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE" disabled>{{ t('hidGuide.selectDevice') }}</SelectItem>
|
||||
<SelectItem v-if="selected(field) && !options[field].some(d => d.value === selected(field))" :value="selected(field)" disabled>{{ selected(field) }}</SelectItem>
|
||||
<SelectItem v-for="option in options[field]" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!loading && selected(field) && !options[field].some(d => d.value === selected(field))" class="text-warning">{{ t('hidGuide.missingDevice', { device: selected(field) }) }}</p>
|
||||
<p v-if="!loading && !options[field].length" class="text-destructive">{{ fieldLabel(field) }}: {{ t('hidGuide.noDevices') }}</p>
|
||||
</div>
|
||||
<div v-if="field === 'serial'" class="min-w-0 space-y-1 text-sm">
|
||||
<label :for="`${idPrefix}-baudrate`">{{ t('actionbar.baudrate') }}</label>
|
||||
<Select
|
||||
:model-value="String(modelValue.ch9329_baudrate)"
|
||||
:disabled="loading || locked"
|
||||
@update:model-value="value => emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(value) })"
|
||||
>
|
||||
<SelectTrigger :id="`${idPrefix}-baudrate`" class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate" :value="String(rate)">
|
||||
{{ rate }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="modelValue.backend === 'bluetooth'" :for="`${idPrefix}-bluetooth-name`" class="block space-y-1 text-sm">
|
||||
<span>{{ t('bluetoothHid.name') }}</span>
|
||||
<Input :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
|
||||
<Input :id="`${idPrefix}-bluetooth-name`" :disabled="loading || locked" :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
|
||||
<span v-if="!validName(modelValue.bluetooth.name)" class="text-destructive text-xs">{{ t('hidGuide.nameInvalid') }}</span>
|
||||
</label>
|
||||
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,27 @@ const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<figure v-if="backend !== 'none'" class="wiring-diagram rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
|
||||
<svg viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
|
||||
<svg v-if="backend === 'ch9329_otg'" viewBox="0 0 480 190" class="block w-full text-foreground" role="img" :aria-label="t('hidGuide.wiring_ch9329_otg')">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="8" y="51" width="112" height="100" rx="8" />
|
||||
<rect x="174" y="24" width="112" height="35" rx="6" />
|
||||
<rect x="332" y="19" width="140" height="151" rx="8" />
|
||||
</g>
|
||||
<g fill="currentColor" text-anchor="middle" font-size="12">
|
||||
<text x="64" y="80">One-KVM</text>
|
||||
<text x="230" y="46">CH9329</text>
|
||||
<text x="402" y="43">{{ t('hidGuide.kvmSwitch') }}</text>
|
||||
<text x="402" y="84">{{ t('hidGuide.dedicatedHidPort') }}</text>
|
||||
<text x="402" y="143">{{ t('hidGuide.generalUsbPort') }}</text>
|
||||
<text x="146" y="33">UART</text>
|
||||
<text x="229" y="127">OTG USB</text>
|
||||
</g>
|
||||
<g class="connection" fill="none" stroke="currentColor" stroke-linejoin="round">
|
||||
<path d="M120 90h22V42h32m112 0h20v35h26m-8-5 8 5-8 5" />
|
||||
<path d="M120 136h212m-8-5 8 5-8 5" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
|
||||
<!-- A faint ground plane anchors the perspective without shadows or filters. -->
|
||||
<g fill="none" stroke="currentColor" stroke-width=".65" opacity=".07" aria-hidden="true">
|
||||
<path d="m7 87 101-46 75 38-101 46Zm270-5 98-45 79 39-98 46M31 99l101-46m-77 58 101-46M302 94l98-45m-71 58 98-45" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue'
|
||||
import type { hidApi } from '@/api'
|
||||
import { runtimeBackend } from '@/lib/hidGuide'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||
export function useHidConnection(active: Ref<boolean>, backend: Ref<string | undefined>) {
|
||||
@@ -26,8 +27,8 @@ export function useHidConnection(active: Ref<boolean>, backend: Ref<string | und
|
||||
if (hidResult.status === 'rejected') throw hidResult.reason
|
||||
if (btResult.status === 'rejected') throw btResult.reason
|
||||
const hid = hidResult.value, bt = btResult.value
|
||||
status.value = hid.backend === backend.value ? hid : null
|
||||
bluetooth.value = hid.backend === backend.value ? bt : null
|
||||
status.value = hid.backend === runtimeBackend(backend.value) ? hid : null
|
||||
bluetooth.value = hid.backend === runtimeBackend(backend.value) ? bt : null
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
if (own !== generation) return
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
export default {
|
||||
hidGuide: {
|
||||
driver_ch9329_otg: 'CH9329 + OTG composite mode',
|
||||
compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.',
|
||||
wiring_ch9329_otg: 'Connect CH9329 to the dedicated keyboard/mouse port and OTG to a general USB port. Use an operation that switches video, keyboard/mouse and USB together.',
|
||||
keyboardChannel: 'CH9329 input',
|
||||
otgChannel: 'OTG functions',
|
||||
kvmSwitch: 'KVM switch',
|
||||
dedicatedHidPort: 'Keyboard/mouse port',
|
||||
generalUsbPort: 'General USB port',
|
||||
advancedFunctions: 'OTG advanced USB functions',
|
||||
otgInactive: 'Not enabled',
|
||||
otgConfigured: 'USB configured by host',
|
||||
otgWaiting: 'Bound, waiting for host',
|
||||
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
||||
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
||||
"deviceTitle": "Driver and device",
|
||||
@@ -50,7 +62,7 @@ export default {
|
||||
"pairTimeout": "The pairing window ended. You can open it again.",
|
||||
"reopenPairing": "Reopen pairing",
|
||||
"resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.",
|
||||
"disableUsb": "Leaving USB OTG will disable: {functions}.",
|
||||
"disableUsb": "Switching drivers will disable: {functions}.",
|
||||
"msd": "Virtual disk",
|
||||
"network": "USB network",
|
||||
"audio": "USB audio",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
export default {
|
||||
hidGuide: {
|
||||
driver_ch9329_otg: 'CH9329 + OTG 复合模式',
|
||||
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
|
||||
wiring_ch9329_otg: 'CH9329 接 KVM 专用键鼠口,OTG 接通用 USB 口。请使用同时切换视频、键鼠和通用 USB 的操作。',
|
||||
keyboardChannel: 'CH9329 键鼠',
|
||||
otgChannel: 'OTG 高级功能',
|
||||
kvmSwitch: 'KVM 切换器',
|
||||
dedicatedHidPort: '专用键鼠口',
|
||||
generalUsbPort: '通用 USB 口',
|
||||
advancedFunctions: 'OTG 高级 USB 功能',
|
||||
otgInactive: '未启用',
|
||||
otgConfigured: '主机已配置 USB',
|
||||
otgWaiting: '已绑定,等待主机连接',
|
||||
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
||||
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
||||
"deviceTitle": "驱动与设备",
|
||||
@@ -50,7 +62,7 @@ export default {
|
||||
"pairTimeout": "配对窗口已结束,可重新开启。",
|
||||
"reopenPairing": "重新开启配对",
|
||||
"resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。",
|
||||
"disableUsb": "切出 USB OTG 将关闭:{functions}。",
|
||||
"disableUsb": "切换驱动将关闭:{functions}。",
|
||||
"msd": "虚拟磁盘",
|
||||
"network": "USB 网络",
|
||||
"audio": "USB 音频",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { HidConfig, HidConfigUpdate } from '../types/generated'
|
||||
import type { BluetoothStatus } from '../types/bluetooth'
|
||||
export type Driver = 'otg' | 'ch9329' | 'bluetooth' | 'none'
|
||||
export type Driver = 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none'
|
||||
export function usesCh9329(backend?: string): boolean {
|
||||
return backend === 'ch9329' || backend === 'ch9329_otg'
|
||||
}
|
||||
export function supportsOtgFunctions(backend?: string): boolean {
|
||||
return backend === 'otg' || backend === 'ch9329_otg'
|
||||
}
|
||||
export function runtimeBackend(backend?: string): string | undefined {
|
||||
return backend === 'ch9329_otg' ? 'ch9329' : backend
|
||||
}
|
||||
export interface HidSelection {
|
||||
backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number
|
||||
bluetooth: { adapter: string; name: string }
|
||||
@@ -11,7 +20,7 @@ export function selectionFrom(hid?: HidConfig | null): HidSelection {
|
||||
bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } }
|
||||
}
|
||||
export function selectDevice(saved: string, candidates: string[]): string {
|
||||
return candidates.includes(saved) ? saved : candidates.length === 1 ? candidates[0]! : ''
|
||||
return saved || (candidates.length === 1 ? candidates[0]! : '')
|
||||
}
|
||||
export function validName(name: string): boolean {
|
||||
const length = new TextEncoder().encode(name).length
|
||||
@@ -21,6 +30,7 @@ export function deviceRequest(draft: HidSelection): HidConfigUpdate {
|
||||
const backend = draft.backend as HidConfigUpdate['backend']
|
||||
switch (draft.backend) {
|
||||
case 'otg': return { backend, otg_udc: draft.otg_udc }
|
||||
case 'ch9329_otg': return { backend, otg_udc: draft.otg_udc, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
||||
case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
||||
case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true }
|
||||
case 'none': return { backend }
|
||||
@@ -30,6 +40,7 @@ export function matchesSelection(hid: HidConfig, draft: HidSelection): boolean {
|
||||
if (hid.backend !== draft.backend) return false
|
||||
switch (draft.backend) {
|
||||
case 'otg': return hid.otg_udc === draft.otg_udc
|
||||
case 'ch9329_otg': return hid.otg_udc === draft.otg_udc && hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
||||
case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
||||
case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name
|
||||
case 'none': return true
|
||||
@@ -43,7 +54,25 @@ export function bluetoothStage(status: BluetoothStatus | null): string {
|
||||
if (status.pairing_seconds > 0) return 'pairing'
|
||||
return status.initialized ? 'waiting' : 'initializing'
|
||||
}
|
||||
export interface OtgDeviceStatus {
|
||||
enabled: boolean
|
||||
health: 'healthy' | 'applying' | 'degraded'
|
||||
gadget_active: boolean
|
||||
udc: string | null
|
||||
udc_state: string | null
|
||||
error: string | null
|
||||
}
|
||||
export function otgDeviceStage(status?: OtgDeviceStatus | null): string {
|
||||
if (!status) return 'unknown'
|
||||
if (!status.enabled) return 'otgInactive'
|
||||
if (status.health === 'applying') return 'initializing'
|
||||
if (status.health === 'degraded' || status.error) return 'unknown'
|
||||
if (!status.gadget_active) return 'unknown'
|
||||
return status.udc_state === 'configured' ? 'otgConfigured' : 'otgWaiting'
|
||||
}
|
||||
export interface HidDeviceStatus {
|
||||
otg?: OtgDeviceStatus | null
|
||||
configured_backend?: string
|
||||
backend: string; online: boolean; error?: string | null; error_code?: string | null
|
||||
}
|
||||
export function hidDeviceError(status?: HidDeviceStatus | null): string | null {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface BluetoothHidConfig {
|
||||
export enum HidBackend {
|
||||
Otg = "otg",
|
||||
Ch9329 = "ch9329",
|
||||
Ch9329Otg = "ch9329_otg",
|
||||
Bluetooth = "bluetooth",
|
||||
None = "none",
|
||||
}
|
||||
@@ -548,7 +549,17 @@ export interface OtgNetworkStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OtgRuntimeStatus {
|
||||
enabled: boolean;
|
||||
health: OtgRuntimeHealth;
|
||||
gadget_active: boolean;
|
||||
udc?: string;
|
||||
udc_state?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OtgConfigResponse {
|
||||
uac: UacConfig;
|
||||
hid: HidConfig;
|
||||
msd: MsdConfig;
|
||||
network: OtgNetworkConfig;
|
||||
@@ -564,6 +575,7 @@ export interface OtgNetworkConfigUpdate {
|
||||
}
|
||||
|
||||
export interface OtgConfigUpdate {
|
||||
uac?: UacConfig;
|
||||
hid?: HidConfigUpdate;
|
||||
msd?: MsdConfigUpdate;
|
||||
network?: OtgNetworkConfigUpdate;
|
||||
|
||||
@@ -736,7 +736,7 @@ watch(videoLoading, (loading) => {
|
||||
|
||||
|
||||
const showMsdStatusCard = computed(() => {
|
||||
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
|
||||
return !!(systemStore.msd?.available)
|
||||
})
|
||||
|
||||
const hidHoverAlign = computed<'start' | 'end'>(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { usesCh9329, supportsOtgFunctions } from '@/lib/hidGuide'
|
||||
import McpSettingsCard from '@/components/McpSettingsCard.vue'
|
||||
import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
|
||||
import ConsoleLayoutPreview from '@/components/ConsoleLayoutPreview.vue'
|
||||
@@ -1152,7 +1153,7 @@ function clearCh9329DescriptorState() {
|
||||
}
|
||||
|
||||
const isCh9329DescriptorSourceCurrent = computed(() => {
|
||||
if (config.value.hid_backend !== 'ch9329') return false
|
||||
if (!usesCh9329(config.value.hid_backend)) return false
|
||||
const source = ch9329DescriptorSource.value
|
||||
if (!source) return false
|
||||
const current = currentCh9329DescriptorSource()
|
||||
@@ -1160,7 +1161,7 @@ const isCh9329DescriptorSourceCurrent = computed(() => {
|
||||
})
|
||||
|
||||
async function loadCh9329Descriptor() {
|
||||
if (config.value.hid_backend !== 'ch9329') return
|
||||
if (!usesCh9329(config.value.hid_backend)) return
|
||||
const source = currentCh9329DescriptorSource()
|
||||
ch9329DescriptorLoading.value = true
|
||||
ch9329DescriptorLoaded.value = false
|
||||
@@ -1182,14 +1183,14 @@ async function loadCh9329Descriptor() {
|
||||
}
|
||||
|
||||
const isCh9329DescriptorValid = computed(() => {
|
||||
if (config.value.hid_backend !== 'ch9329') return true
|
||||
if (!usesCh9329(config.value.hid_backend)) return true
|
||||
return utf8ByteLength(ch9329Manufacturer.value) <= 23
|
||||
&& utf8ByteLength(ch9329Product.value) <= 23
|
||||
&& utf8ByteLength(ch9329SerialNumber.value) <= 23
|
||||
})
|
||||
|
||||
const canEditCh9329Descriptor = computed(() =>
|
||||
config.value.hid_backend === 'ch9329'
|
||||
usesCh9329(config.value.hid_backend)
|
||||
&& ch9329DescriptorLoaded.value
|
||||
&& isCh9329DescriptorSourceCurrent.value
|
||||
&& !ch9329DescriptorLoading.value
|
||||
@@ -1268,7 +1269,7 @@ const atxDevices = ref<AtxDevices>({
|
||||
})
|
||||
|
||||
const ch9329ReservedSerialDevice = computed(() => {
|
||||
if (config.value.hid_backend !== 'ch9329') return ''
|
||||
if (!usesCh9329(config.value.hid_backend)) return ''
|
||||
return config.value.hid_serial_device.trim()
|
||||
})
|
||||
|
||||
@@ -1492,7 +1493,7 @@ async function saveConfig() {
|
||||
return
|
||||
}
|
||||
const hidUpdate: HidConfigUpdate = {}
|
||||
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
||||
if (usesCh9329(config.value.hid_backend) && isCh9329DescriptorDirty.value) {
|
||||
hidUpdate.ch9329_descriptor = {
|
||||
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
||||
product_id: parseInt(ch9329ProductIdHex.value, 16) || 0xe129,
|
||||
@@ -1501,7 +1502,7 @@ async function saveConfig() {
|
||||
serial_number: ch9329SerialNumber.value || '',
|
||||
}
|
||||
}
|
||||
if (config.value.hid_backend === 'otg') {
|
||||
if (supportsOtgFunctions(config.value.hid_backend)) {
|
||||
hidUpdate.otg_descriptor = {
|
||||
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
|
||||
product_id: parseInt(otgProductIdHex.value, 16) || 0x0104,
|
||||
@@ -1509,12 +1510,14 @@ async function saveConfig() {
|
||||
product: otgProduct.value || 'One-KVM USB Device',
|
||||
serial_number: otgSerialNumber.value || undefined,
|
||||
}
|
||||
hidUpdate.otg_profile = 'custom' as OtgHidProfile
|
||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||
if (config.value.hid_backend === 'otg') {
|
||||
hidUpdate.otg_profile = 'custom' as OtgHidProfile
|
||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||
}
|
||||
}
|
||||
if (configStore.hid?.backend === 'otg') {
|
||||
const otgEnabled = config.value.hid_backend === 'otg'
|
||||
if (supportsOtgFunctions(configStore.hid?.backend)) {
|
||||
const otgEnabled = supportsOtgFunctions(config.value.hid_backend)
|
||||
const response = await configStore.updateOtg({
|
||||
hid: hidUpdate,
|
||||
msd: {
|
||||
@@ -1528,15 +1531,14 @@ async function saveConfig() {
|
||||
driver_mode: config.value.otg_network_driver as any,
|
||||
bridge_interface: config.value.otg_network_interface,
|
||||
},
|
||||
uac: {
|
||||
enabled: otgEnabled && config.value.uac_enabled,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
})
|
||||
otgNetworkStatus.value = response.status
|
||||
|
||||
await uacApi.update({
|
||||
enabled: otgEnabled && config.value.uac_enabled,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
})
|
||||
} else if (configStore.hid?.backend === 'ch9329') {
|
||||
} else if (usesCh9329(configStore.hid?.backend)) {
|
||||
await configStore.updateHid(hidUpdate)
|
||||
}
|
||||
await loadConfig()
|
||||
@@ -1632,11 +1634,11 @@ async function loadConfig() {
|
||||
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
|
||||
}
|
||||
if (hid.ch9329_descriptor) {
|
||||
if (hid.backend !== 'ch9329') {
|
||||
if (!usesCh9329(hid.backend)) {
|
||||
applyCh9329DescriptorForm(hid.ch9329_descriptor, true)
|
||||
}
|
||||
}
|
||||
if (hid.backend === 'ch9329') {
|
||||
if (usesCh9329(hid.backend)) {
|
||||
await loadCh9329Descriptor()
|
||||
} else {
|
||||
clearCh9329DescriptorState()
|
||||
@@ -2773,7 +2775,7 @@ watch(updateChannel, async () => {
|
||||
watch(() => config.value.hid_backend, () => {
|
||||
otgSelfCheckResult.value = null
|
||||
otgSelfCheckError.value = ''
|
||||
if (config.value.hid_backend === 'ch9329') {
|
||||
if (usesCh9329(config.value.hid_backend)) {
|
||||
void loadCh9329Descriptor()
|
||||
} else {
|
||||
clearCh9329DescriptorState()
|
||||
@@ -2783,7 +2785,7 @@ watch(() => config.value.hid_backend, () => {
|
||||
watch(
|
||||
() => [config.value.hid_serial_device, config.value.hid_serial_baudrate],
|
||||
() => {
|
||||
if (config.value.hid_backend === 'ch9329' && !isCh9329DescriptorSourceCurrent.value) {
|
||||
if (usesCh9329(config.value.hid_backend) && !isCh9329DescriptorSourceCurrent.value) {
|
||||
clearCh9329DescriptorState()
|
||||
}
|
||||
},
|
||||
@@ -3170,10 +3172,10 @@ watch(isWindows, () => {
|
||||
<!-- HID Section -->
|
||||
<div v-show="activeSection === 'hid'" class="space-y-4">
|
||||
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
|
||||
<Card v-if="configStore.hid?.backend === 'otg' || configStore.hid?.backend === 'ch9329'">
|
||||
<Card v-if="supportsOtgFunctions(configStore.hid?.backend) || usesCh9329(configStore.hid?.backend)">
|
||||
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<template v-if="config.hid_backend === 'ch9329'">
|
||||
<template v-if="usesCh9329(config.hid_backend)">
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
@@ -3261,7 +3263,7 @@ watch(isWindows, () => {
|
||||
</template>
|
||||
|
||||
<!-- OTG Descriptor Settings -->
|
||||
<template v-if="config.hid_backend === 'otg'">
|
||||
<template v-if="supportsOtgFunctions(config.hid_backend)">
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
@@ -3323,10 +3325,10 @@ watch(isWindows, () => {
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
|
||||
<h4 class="text-sm font-medium">{{ t(config.hid_backend === 'otg' ? 'settings.otgHidProfile' : 'hidGuide.advancedFunctions') }}</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div v-if="config.hid_backend === 'otg'" class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -3364,13 +3366,13 @@ watch(isWindows, () => {
|
||||
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
||||
</div>
|
||||
<p v-if="hasMacosDragInterfaceConflict" class="text-xs text-warning">{{ t('settings.mouseMacosDragRequiresBoth') }}</p>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label>{{ t('settings.uacMic') }}</Label>
|
||||
<Switch v-model="config.uac_enabled" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 p-3">
|
||||
<Label>{{ t('settings.uacMic') }}</Label>
|
||||
<Switch v-model="config.uac_enabled" />
|
||||
</div>
|
||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -3466,7 +3468,7 @@ watch(isWindows, () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-warning">
|
||||
<p v-if="config.hid_backend === 'otg'" class="text-xs text-warning">
|
||||
{{ t('settings.otgProfileWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -5419,7 +5421,7 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
|
||||
<!-- Save Button (sticky) -->
|
||||
<div v-if="activeSection === 'video' || (activeSection === 'hid' && ['otg', 'ch9329'].includes(configStore.hid?.backend ?? ''))" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div v-if="activeSection === 'video' || (activeSection === 'hid' && (supportsOtgFunctions(configStore.hid?.backend) || usesCh9329(configStore.hid?.backend)))" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||
<AlertTriangle class="size-3.5 shrink-0" />
|
||||
@@ -5433,7 +5435,7 @@ watch(isWindows, () => {
|
||||
<AlertTriangle class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.ch9329StringLengthWarning') }}</span>
|
||||
</p>
|
||||
<p v-else-if="activeSection === 'hid' && config.hid_backend === 'ch9329' && ch9329DescriptorLoading" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||
<p v-else-if="activeSection === 'hid' && usesCh9329(config.hid_backend) && ch9329DescriptorLoading" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||
<AlertTriangle class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
|
||||
</p>
|
||||
|
||||
@@ -387,10 +387,11 @@ async function handleSetup() {
|
||||
|
||||
const hid = hidSelection.value
|
||||
setupData.hid_backend = hid.backend
|
||||
if (hid.backend === 'ch9329') {
|
||||
if (hid.backend === 'ch9329' || hid.backend === 'ch9329_otg') {
|
||||
setupData.hid_ch9329_port = hid.ch9329_port
|
||||
setupData.hid_ch9329_baudrate = hid.ch9329_baudrate
|
||||
} else if (hid.backend === 'otg') {
|
||||
}
|
||||
if (hid.backend === 'otg' || hid.backend === 'ch9329_otg') {
|
||||
setupData.hid_otg_udc = hid.otg_udc
|
||||
} else if (hid.backend === 'bluetooth') {
|
||||
setupData.hid_bluetooth = { ...hid.bluetooth }
|
||||
|
||||
Reference in New Issue
Block a user