From 628909a192fc2b0fd8d32da3c711391855a7ccc4 Mon Sep 17 00:00:00 2001 From: raymond Date: Sat, 29 Aug 2026 01:30:18 +0800 Subject: [PATCH 1/2] fix(hid): add CH9329 macOS drag compatibility --- src/config/schema/hid.rs | 3 + src/hid/backend.rs | 2 + src/hid/ch9329.rs | 129 ++++++++++++++++++++++++-- src/hid/factory.rs | 18 ++-- src/main.rs | 1 + src/web/handlers/config/apply.rs | 4 +- src/web/handlers/config/types.rs | 4 + src/web/handlers/config/usb_update.rs | 1 + web/src/i18n/en-US.ts | 2 + web/src/i18n/zh-CN.ts | 2 + web/src/types/generated.ts | 2 + web/src/views/SettingsView.vue | 10 ++ 12 files changed, 164 insertions(+), 14 deletions(-) diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 6c533566..c8607835 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -181,6 +181,8 @@ pub struct HidConfig { #[serde(default)] pub ch9329_hybrid_mouse: bool, #[serde(default)] + pub ch9329_macos_drag: bool, + #[serde(default)] pub ch9329_descriptor: Ch9329DescriptorConfig, pub mouse_absolute: bool, } @@ -197,6 +199,7 @@ impl Default for HidConfig { ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_baudrate: 9600, ch9329_hybrid_mouse: false, + ch9329_macos_drag: false, ch9329_descriptor: Ch9329DescriptorConfig::default(), mouse_absolute: true, } diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 8ffa899c..f32021cb 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -24,6 +24,8 @@ pub enum HidBackendType { baud_rate: u32, #[serde(default)] hybrid_mouse: bool, + #[serde(default)] + macos_drag: bool, }, #[default] None, diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 4a1a2e18..a19db531 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -235,6 +235,7 @@ pub struct Ch9329Backend { last_abs_y: Arc, relative_mouse_active: Arc, hybrid_mouse: bool, + macos_drag: bool, runtime: Arc, } @@ -248,6 +249,15 @@ impl Ch9329Backend { } pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result { + Self::with_compatibility_options(port_path, baud_rate, hybrid_mouse, false) + } + + pub fn with_compatibility_options( + port_path: &str, + baud_rate: u32, + hybrid_mouse: bool, + macos_drag: bool, + ) -> Result { Ok(Self { port_path: port_path.to_string(), baud_rate, @@ -263,6 +273,7 @@ impl Ch9329Backend { last_abs_y: Arc::new(AtomicU16::new(0)), relative_mouse_active: Arc::new(AtomicBool::new(false)), hybrid_mouse, + macos_drag, runtime: Arc::new(Ch9329RuntimeState::new()), }) } @@ -965,17 +976,32 @@ impl Ch9329Backend { } fn should_send_button_wheel_relative(&self) -> bool { - self.hybrid_mouse || self.relative_mouse_active.load(Ordering::Relaxed) + (self.hybrid_mouse && !self.macos_drag) + || self.relative_mouse_active.load(Ordering::Relaxed) } fn absolute_move_buttons(&self, buttons: u8) -> u8 { - if self.hybrid_mouse { + if self.hybrid_mouse && !self.macos_drag { 0 } else { buttons } } + fn absolute_delta_to_relative(current: u16, previous: u16, extent: u32) -> i8 { + let delta = current as i32 - previous as i32; + if delta == 0 { + return 0; + } + + let scaled = delta * extent.max(1) as i32 / CH9329_MOUSE_RESOLUTION as i32; + if scaled == 0 { + delta.signum() as i8 + } else { + scaled.clamp(-127, 127) as i8 + } + } + fn worker_loop( port_path: String, baud_rate: u32, @@ -1267,9 +1293,23 @@ impl HidBackend for Ch9329Backend { self.relative_mouse_active.store(false, Ordering::Relaxed); let x = ((event.x.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; let y = ((event.y.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; - self.last_abs_x.store(x, Ordering::Relaxed); - self.last_abs_y.store(y, Ordering::Relaxed); - self.send_mouse_absolute(self.absolute_move_buttons(buttons), x, y, 0)?; + let previous_x = self.last_abs_x.swap(x, Ordering::Relaxed); + let previous_y = self.last_abs_y.swap(y, Ordering::Relaxed); + + if self.macos_drag && buttons != 0 { + // macOS accepts button edges from CH9329 absolute report ID 2, + // but may terminate a drag when movement continues on that + // report. Keep the absolute button held and move through the + // relative report until the matching absolute button-up. + let (width, height) = *self.screen_resolution.read(); + let dx = Self::absolute_delta_to_relative(x, previous_x, width); + let dy = Self::absolute_delta_to_relative(y, previous_y, height); + if dx != 0 || dy != 0 { + self.send_mouse_relative(buttons, dx, dy, 0)?; + } + } else { + self.send_mouse_absolute(self.absolute_move_buttons(buttons), x, y, 0)?; + } } MouseEventType::Down => { if let Some(button) = event.button { @@ -1650,13 +1690,66 @@ mod tests { } #[test] - fn test_hybrid_mouse_routes_buttons_and_wheel_to_relative_reports() { + fn test_hybrid_mouse_preserves_linux_compatibility_routing() { let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap(); assert!(backend.should_send_button_wheel_relative()); assert_eq!(backend.absolute_move_buttons(0x07), 0); } + #[tokio::test] + async fn test_macos_drag_uses_absolute_edges_and_relative_motion() { + let backend = + Ch9329Backend::with_compatibility_options("/dev/null", DEFAULT_BAUD_RATE, false, true) + .unwrap(); + let (worker_tx, worker_rx) = mpsc::channel(); + *backend.worker_tx.lock() = Some(worker_tx); + backend.set_screen_resolution(1920, 1080); + + backend + .send_mouse(MouseEvent::move_abs(8000, 8000)) + .await + .unwrap(); + backend + .send_mouse(MouseEvent::button_down(crate::hid::MouseButton::Left)) + .await + .unwrap(); + backend + .send_mouse(MouseEvent::move_abs(8064, 8064)) + .await + .unwrap(); + backend + .send_mouse(MouseEvent::button_up(crate::hid::MouseButton::Left)) + .await + .unwrap(); + + let packets: Vec<_> = worker_rx + .try_iter() + .filter_map(|command| match command { + WorkerCommand::Packet { cmd, data } => Some((cmd, data)), + _ => None, + }) + .collect(); + assert_eq!( + packets, + vec![ + ( + cmd::SEND_MS_ABS_DATA, + vec![0x02, 0x00, 0xE8, 0x03, 0xE8, 0x03, 0x00], + ), + ( + cmd::SEND_MS_ABS_DATA, + vec![0x02, 0x01, 0xE8, 0x03, 0xE8, 0x03, 0x00], + ), + (cmd::SEND_MS_REL_DATA, vec![0x01, 0x01, 0x03, 0x02, 0x00]), + ( + cmd::SEND_MS_ABS_DATA, + vec![0x02, 0x00, 0xF0, 0x03, 0xF0, 0x03, 0x00], + ), + ] + ); + } + #[test] fn test_default_mouse_mode_preserves_absolute_report_buttons() { let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap(); @@ -1664,4 +1757,28 @@ mod tests { assert!(!backend.should_send_button_wheel_relative()); assert_eq!(backend.absolute_move_buttons(0x07), 0x07); } + + #[test] + fn test_absolute_delta_to_relative_preserves_small_movements_and_clamps() { + assert_eq!( + Ch9329Backend::absolute_delta_to_relative(1001, 1000, 1920), + 1 + ); + assert_eq!( + Ch9329Backend::absolute_delta_to_relative(999, 1000, 1920), + -1 + ); + assert_eq!( + Ch9329Backend::absolute_delta_to_relative(2000, 1000, 1920), + 127 + ); + assert_eq!( + Ch9329Backend::absolute_delta_to_relative(0, 1000, 1920), + -127 + ); + assert_eq!( + Ch9329Backend::absolute_delta_to_relative(1000, 1000, 1920), + 0 + ); + } } diff --git a/src/hid/factory.rs b/src/hid/factory.rs index f92afbd5..61e8b53d 100644 --- a/src/hid/factory.rs +++ b/src/hid/factory.rs @@ -43,16 +43,20 @@ impl HidBackendFactory { port, baud_rate, hybrid_mouse, + macos_drag, } => { info!( - "Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}", - port, baud_rate, hybrid_mouse + "Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}, macos_drag={}", + port, baud_rate, hybrid_mouse, macos_drag ); - Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options( - port, - *baud_rate, - *hybrid_mouse, - )?))) + Ok(Some(Arc::new( + ch9329::Ch9329Backend::with_compatibility_options( + port, + *baud_rate, + *hybrid_mouse, + *macos_drag, + )?, + ))) } HidBackendType::None => { warn!("HID backend disabled"); diff --git a/src/main.rs b/src/main.rs index e407bb39..fff1b3c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -320,6 +320,7 @@ async fn main() -> anyhow::Result<()> { port: config.hid.ch9329_port.clone(), baud_rate: config.hid.ch9329_baudrate, hybrid_mouse: config.hid.ch9329_hybrid_mouse, + macos_drag: config.hid.ch9329_macos_drag, }, config::HidBackend::None => HidBackendType::None, }; diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index be073e4c..387f5305 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -57,6 +57,7 @@ fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType { port: config.ch9329_port.clone(), baud_rate: config.ch9329_baudrate, hybrid_mouse: config.ch9329_hybrid_mouse, + macos_drag: config.ch9329_macos_drag, }, HidBackend::None => crate::hid::HidBackendType::None, } @@ -206,7 +207,8 @@ pub async fn apply_hid_config( let hid_functions_changed = old_hid_functions != new_hid_functions; let keyboard_leds_changed = old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); - let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; + let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse + || old_config.ch9329_macos_drag != new_config.ch9329_macos_drag; if old_config.backend == new_config.backend && old_config.ch9329_port == new_config.ch9329_port diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 83f0d487..72397899 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -406,6 +406,7 @@ pub struct HidConfigUpdate { pub ch9329_port: Option, pub ch9329_baudrate: Option, pub ch9329_hybrid_mouse: Option, + pub ch9329_macos_drag: Option, pub ch9329_descriptor: Option, pub otg_udc: Option, pub otg_descriptor: Option, @@ -456,6 +457,9 @@ impl HidConfigUpdate { if let Some(enabled) = self.ch9329_hybrid_mouse { config.ch9329_hybrid_mouse = enabled; } + if let Some(enabled) = self.ch9329_macos_drag { + config.ch9329_macos_drag = enabled; + } if let Some(ref desc) = self.ch9329_descriptor { desc.apply_to(&mut config.ch9329_descriptor); } diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index bce96476..d749f9e8 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -157,6 +157,7 @@ mod tests { ch9329_port: None, ch9329_baudrate: None, ch9329_hybrid_mouse: None, + ch9329_macos_drag: None, ch9329_descriptor: None, otg_udc: None, otg_descriptor: None, diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index cf522915..309f6b99 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -713,6 +713,8 @@ export default { ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip', ch9329HybridMouse: 'Linux Absolute Mouse Compatibility', ch9329HybridMouseDesc: 'Keep absolute movement on absolute packets, but send buttons and wheel through relative packets', + ch9329MacosDrag: 'macOS Drag Compatibility', + ch9329MacosDragDesc: 'Use absolute packets for button edges and relative packets for movement while a button is held', ch9329Descriptor: 'CH9329 USB Device Descriptor', ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing', ch9329DescriptorLoading: 'Reading CH9329 descriptor...', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index ebf91aa4..28ba1b1f 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -712,6 +712,8 @@ export default { ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性', ch9329HybridMouse: 'Linux 绝对鼠标兼容模式', ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送', + ch9329MacosDrag: 'macOS 拖拽兼容模式', + ch9329MacosDragDesc: '按钮按下与释放使用绝对鼠标包,按住期间改用相对鼠标包移动', ch9329Descriptor: 'CH9329 USB 设备描述符', ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改', ch9329DescriptorLoading: '正在读取 CH9329 描述符...', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 890ea13d..9756be76 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -63,6 +63,7 @@ export interface HidConfig { ch9329_port: string; ch9329_baudrate: number; ch9329_hybrid_mouse?: boolean; + ch9329_macos_drag?: boolean; ch9329_descriptor?: Ch9329DescriptorConfig; mouse_absolute: boolean; } @@ -546,6 +547,7 @@ export interface HidConfigUpdate { ch9329_port?: string; ch9329_baudrate?: number; ch9329_hybrid_mouse?: boolean; + ch9329_macos_drag?: boolean; ch9329_descriptor?: Ch9329DescriptorConfigUpdate; otg_udc?: string; otg_descriptor?: OtgDescriptorConfigUpdate; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 5bd5644d..29180eb9 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -668,6 +668,7 @@ const config = ref({ } as OtgHidFunctions, hid_otg_keyboard_leds: false, hid_ch9329_hybrid_mouse: false, + hid_ch9329_macos_drag: false, msd_enabled: false, msd_dir: '', msd_flash_inquiry_string: 'One-KVM Virtual Flash', @@ -1434,6 +1435,7 @@ async function saveConfig() { ch9329_port: config.value.hid_serial_device || undefined, ch9329_baudrate: config.value.hid_serial_baudrate, ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse, + ch9329_macos_drag: config.value.hid_ch9329_macos_drag, otg_udc: config.value.hid_otg_udc, } if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) { @@ -1533,6 +1535,7 @@ async function loadConfig() { } as OtgHidFunctions, hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false, hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false, + hid_ch9329_macos_drag: hid.ch9329_macos_drag ?? false, msd_enabled: msd.enabled || false, msd_dir: msd.msd_dir || '', msd_flash_inquiry_string: msd.flash_inquiry_string || 'One-KVM Virtual Flash', @@ -3198,6 +3201,13 @@ watch(isWindows, () => { +
+
+ +

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

+
+ +
From 78af9f67def67da9ec263907d463d315834ad9cc Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Thu, 10 Sep 2026 23:00:05 +0800 Subject: [PATCH 2/2] fix(hid): share macOS drag compatibility across OTG and CH9329 --- docs/macos-drag-compatibility.md | 24 +++ src/config/schema/hid.rs | 26 ++- src/hid/backend.rs | 7 +- src/hid/ch9329.rs | 87 ++++----- src/hid/factory.rs | 12 +- src/hid/macos_drag.rs | 256 ++++++++++++++++++++++++++ src/hid/mod.rs | 64 ++++++- src/hid/otg.rs | 81 ++++++++ src/runtime/builder.rs | 8 +- src/runtime/usb.rs | 15 +- src/state.rs | 5 + src/web/handlers/config/types.rs | 7 +- src/web/handlers/config/usb_update.rs | 2 +- src/web/handlers/msd_api.rs | 8 +- web/src/i18n/en-US.ts | 5 +- web/src/i18n/zh-CN.ts | 5 +- web/src/types/generated.ts | 4 +- web/src/views/SettingsView.vue | 27 +-- 18 files changed, 542 insertions(+), 101 deletions(-) create mode 100644 docs/macos-drag-compatibility.md create mode 100644 src/hid/macos_drag.rs diff --git a/docs/macos-drag-compatibility.md b/docs/macos-drag-compatibility.md new file mode 100644 index 00000000..47f64660 --- /dev/null +++ b/docs/macos-drag-compatibility.md @@ -0,0 +1,24 @@ +# macOS 拖拽兼容模式(实验性) + +适用于 OTG 和 CH9329 后端的绝对鼠标输入。遇到目标 Mac 上拖拽只能移动一小段便停止时,可在设置页的 HID 配置中开启「macOS 拖拽兼容模式」。默认关闭。 + +配置字段为 `hid.mouse_macos_drag`,旧 PR 的 `ch9329_macos_drag` 字段仍可作为读取别名。OTG 必须同时启用相对鼠标和绝对鼠标接口,否则配置保存会被拒绝。CH9329 同时启用 Linux 兼容开关时,此模式优先。 + +## 报告行为 + +- 未按键时仍使用绝对坐标定位。 +- 从绝对输入开始的拖拽:按下使用绝对报告,移动和滚轮使用相对报告,释放时先更新相对按钮状态,再发送绝对按钮释放。 +- 按键报告的通道保持到所有按钮释放,不随拖拽期间的输入模式切换而改变。 +- 从相对输入开始的点击、拖拽继续使用相对按钮报告;此选项不修复原生相对模式的点击兼容问题。 +- 位移从原始 15 位输入坐标换算,累计小数余量,大位移按单包范围分包并保留总量及方向。 +- 运行时使用当前视频采集尺寸,并在 HID 重载后恢复尺寸;视频尚未提供尺寸时使用配置尺寸。 + +## 验证范围与限制 + +本次 OTG 扩展及位移换算修改没有 macOS 实机验证。原 PR 作者对旧 CH9329 实现的实机反馈不能视为本版本已经验证。 + +本地自动化覆盖位移累计/分包、1080p/4K 换算、往返移动、滚轮、输入模式切换、复位、CH9329 命令队列、OTG 临时文件模拟端点及配置校验。临时文件测试仅验证写出的字节,不验证 USB 枚举或目标系统的事件解释。 + +相对位移受 macOS 鼠标速度、加速、显示缩放及多屏布局影响,无法保证与绝对定位逐像素一致。松手报告携带最后的客户端绝对坐标,仍可能出现落点偏移或光标跳位;大位移分包也可能增加低波特率串口延迟。 + +后续实机验证应分别覆盖两个后端:窗口/文件/文本拖拽,快速及慢速移动,滚轮和多按钮组合,1080p/4K 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。 diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index af83cc35..7ecd514d 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -231,7 +231,8 @@ pub struct HidConfig { #[serde(default)] pub ch9329_hybrid_mouse: bool, #[serde(default)] - pub ch9329_macos_drag: bool, + #[serde(alias = "ch9329_macos_drag")] + pub mouse_macos_drag: bool, #[serde(default)] pub ch9329_descriptor: Ch9329DescriptorConfig, pub mouse_absolute: bool, @@ -250,7 +251,7 @@ impl Default for HidConfig { ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_baudrate: 9600, ch9329_hybrid_mouse: false, - ch9329_macos_drag: false, + mouse_macos_drag: false, ch9329_descriptor: Ch9329DescriptorConfig::default(), mouse_absolute: true, } @@ -276,6 +277,11 @@ impl HidConfig { } let functions = self.effective_otg_functions(); + if self.mouse_macos_drag && (!functions.mouse_relative || !functions.mouse_absolute) { + return Err(crate::error::AppError::BadRequest( + "macOS drag compatibility requires both OTG mouse interfaces".to_string(), + )); + } if functions.is_empty() { return Err(crate::error::AppError::BadRequest( "OTG HID functions cannot be empty".to_string(), @@ -311,6 +317,22 @@ impl HidConfig { mod bluetooth_tests { use super::*; #[test] + fn mouse_compatibility_defaults_alias_and_otg_validation() { + let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); + assert!(!defaults.mouse_macos_drag); + let mut config: HidConfig = + serde_json::from_str(r#"{"backend":"otg","ch9329_macos_drag":true}"#).unwrap(); + assert!(config.mouse_macos_drag); + assert!(config.validate_otg_functions().is_ok()); + config.otg_profile = OtgHidProfile::LegacyMouseRelative; + assert!(config.validate_otg_functions().is_err()); + config.backend = HidBackend::Ch9329; + assert!(config.validate_otg_functions().is_ok()); + let saved = serde_json::to_value(&config).unwrap(); + assert_eq!(saved["mouse_macos_drag"], true); + assert!(saved.get("ch9329_macos_drag").is_none()); + } + #[test] fn old_configs_keep_bluetooth_disabled_and_get_defaults() { let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); assert_eq!(config.backend, HidBackend::Otg); diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 12c1d5bd..38604bd2 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -17,7 +17,10 @@ fn default_ch9329_baud_rate() -> u32 { #[serde(tag = "type", rename_all = "lowercase")] #[derive(Default)] pub enum HidBackendType { - Otg, + Otg { + #[serde(default)] + macos_drag: bool, + }, Bluetooth { config: crate::config::BluetoothHidConfig, }, @@ -37,7 +40,7 @@ pub enum HidBackendType { impl HidBackendType { pub fn name_str(&self) -> &str { match self { - Self::Otg => "otg", + Self::Otg { .. } => "otg", Self::Bluetooth { .. } => "bluetooth", Self::Ch9329 { .. } => "ch9329", Self::None => "none", diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index df4ab5a5..b213bcc4 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -239,6 +239,7 @@ pub struct Ch9329Backend { relative_mouse_active: Arc, hybrid_mouse: bool, macos_drag: bool, + macos_drag_state: Mutex, runtime: Arc, } @@ -277,6 +278,7 @@ impl Ch9329Backend { relative_mouse_active: Arc::new(AtomicBool::new(false)), hybrid_mouse, macos_drag, + macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()), runtime: Arc::new(Ch9329RuntimeState::new()), }) } @@ -1001,20 +1003,6 @@ impl Ch9329Backend { } } - fn absolute_delta_to_relative(current: u16, previous: u16, extent: u32) -> i8 { - let delta = current as i32 - previous as i32; - if delta == 0 { - return 0; - } - - let scaled = delta * extent.max(1) as i32 / CH9329_MOUSE_RESOLUTION as i32; - if scaled == 0 { - delta.signum() as i8 - } else { - scaled.clamp(-127, 127) as i8 - } - } - fn worker_loop( port_path: String, baud_rate: u32, @@ -1298,6 +1286,31 @@ impl HidBackend for Ch9329Backend { async fn send_mouse(&self, event: MouseEvent) -> Result<()> { let buttons = self.mouse_buttons.load(Ordering::Relaxed); + if self.macos_drag { + use super::macos_drag::MouseReport; + let mut state = self.macos_drag_state.lock(); + let (buttons, reports) = state.plan(event, buttons, *self.screen_resolution.read()); + self.mouse_buttons.store(buttons, Ordering::Relaxed); + for report in reports { + match report { + MouseReport::Absolute { buttons, x, y } => { + let x = (u32::from(x) * CH9329_MOUSE_RESOLUTION / 32768) as u16; + let y = (u32::from(y) * CH9329_MOUSE_RESOLUTION / 32768) as u16; + self.send_mouse_absolute(buttons, x, y, 0)?; + } + MouseReport::Relative { + buttons, + dx, + dy, + wheel, + } => { + self.send_mouse_relative(buttons, dx, dy, wheel)?; + } + } + } + return Ok(()); + } + match event.event_type { MouseEventType::Move => { self.relative_mouse_active.store(true, Ordering::Relaxed); @@ -1309,23 +1322,9 @@ impl HidBackend for Ch9329Backend { self.relative_mouse_active.store(false, Ordering::Relaxed); let x = ((event.x.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; let y = ((event.y.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; - let previous_x = self.last_abs_x.swap(x, Ordering::Relaxed); - let previous_y = self.last_abs_y.swap(y, Ordering::Relaxed); - - if self.macos_drag && buttons != 0 { - // macOS accepts button edges from CH9329 absolute report ID 2, - // but may terminate a drag when movement continues on that - // report. Keep the absolute button held and move through the - // relative report until the matching absolute button-up. - let (width, height) = *self.screen_resolution.read(); - let dx = Self::absolute_delta_to_relative(x, previous_x, width); - let dy = Self::absolute_delta_to_relative(y, previous_y, height); - if dx != 0 || dy != 0 { - self.send_mouse_relative(buttons, dx, dy, 0)?; - } - } else { - self.send_mouse_absolute(self.absolute_move_buttons(buttons), x, y, 0)?; - } + self.last_abs_x.store(x, Ordering::Relaxed); + self.last_abs_y.store(y, Ordering::Relaxed); + self.send_mouse_absolute(self.absolute_move_buttons(buttons), x, y, 0)?; } MouseEventType::Down => { if let Some(button) = event.button { @@ -1401,6 +1400,7 @@ impl HidBackend for Ch9329Backend { } self.mouse_buttons.store(0, Ordering::Relaxed); + self.macos_drag_state.lock().reset(); self.last_abs_x.store(0, Ordering::Relaxed); self.last_abs_y.store(0, Ordering::Relaxed); self.relative_mouse_active.store(false, Ordering::Relaxed); @@ -1758,6 +1758,7 @@ mod tests { vec![0x02, 0x01, 0xE8, 0x03, 0xE8, 0x03, 0x00], ), (cmd::SEND_MS_REL_DATA, vec![0x01, 0x01, 0x03, 0x02, 0x00]), + (cmd::SEND_MS_REL_DATA, vec![0x01, 0x00, 0x00, 0x00, 0x00]), ( cmd::SEND_MS_ABS_DATA, vec![0x02, 0x00, 0xF0, 0x03, 0xF0, 0x03, 0x00], @@ -1773,28 +1774,4 @@ mod tests { assert!(!backend.should_send_button_wheel_relative()); assert_eq!(backend.absolute_move_buttons(0x07), 0x07); } - - #[test] - fn test_absolute_delta_to_relative_preserves_small_movements_and_clamps() { - assert_eq!( - Ch9329Backend::absolute_delta_to_relative(1001, 1000, 1920), - 1 - ); - assert_eq!( - Ch9329Backend::absolute_delta_to_relative(999, 1000, 1920), - -1 - ); - assert_eq!( - Ch9329Backend::absolute_delta_to_relative(2000, 1000, 1920), - 127 - ); - assert_eq!( - Ch9329Backend::absolute_delta_to_relative(0, 1000, 1920), - -127 - ); - assert_eq!( - Ch9329Backend::absolute_delta_to_relative(1000, 1000, 1920), - 0 - ); - } } diff --git a/src/hid/factory.rs b/src/hid/factory.rs index 3e88b3ac..c43615e6 100644 --- a/src/hid/factory.rs +++ b/src/hid/factory.rs @@ -44,7 +44,9 @@ impl HidBackendFactory { async fn create(&self, backend_type: &HidBackendType) -> Result>> { match backend_type { - HidBackendType::Otg => self.create_otg_backend().await.map(Some), + HidBackendType::Otg { macos_drag } => { + self.create_otg_backend(*macos_drag).await.map(Some) + } HidBackendType::Ch9329 { port, baud_rate, @@ -88,7 +90,7 @@ impl HidBackendFactory { } #[cfg(unix)] - async fn create_otg_backend(&self) -> Result> { + async fn create_otg_backend(&self, macos_drag: bool) -> Result> { let otg_service = self .otg_service .as_ref() @@ -100,11 +102,13 @@ impl HidBackendFactory { .ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?; info!("Creating OTG HID backend from device paths"); - Ok(Arc::new(super::otg::OtgBackend::from_handles(handles)?)) + Ok(Arc::new(super::otg::OtgBackend::with_macos_drag( + handles, macos_drag, + )?)) } #[cfg(not(unix))] - async fn create_otg_backend(&self) -> Result> { + async fn create_otg_backend(&self, _macos_drag: bool) -> Result> { Err(AppError::Config( "OTG HID is only available on Linux".to_string(), )) diff --git a/src/hid/macos_drag.rs b/src/hid/macos_drag.rs new file mode 100644 index 00000000..ce97a1cc --- /dev/null +++ b/src/hid/macos_drag.rs @@ -0,0 +1,256 @@ +//! Experimental absolute-button / relative-drag routing shared by USB backends. +//! Relative counts are subject to host acceleration; they are not screen pixels. + +use super::{MouseEvent, MouseEventType}; + +#[derive(Debug, PartialEq)] +pub(super) enum MouseReport { + Absolute { + buttons: u8, + x: u16, + y: u16, + }, + Relative { + buttons: u8, + dx: i8, + dy: i8, + wheel: i8, + }, +} + +#[derive(Default)] +pub(super) struct MacosDrag { + position: (u16, u16), + remainder: (i64, i64), + extent: (u32, u32), + relative_input: bool, + absolute_drag: bool, +} + +impl MacosDrag { + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn plan( + &mut self, + event: MouseEvent, + buttons: u8, + extent: (u32, u32), + ) -> (u8, Vec) { + let mut reports = Vec::new(); + let mut next_buttons = buttons; + if extent != self.extent || buttons == 0 { + self.remainder = (0, 0); + self.extent = extent; + } + match event.event_type { + MouseEventType::MoveAbs => { + let position = ( + event.x.clamp(0, 32767) as u16, + event.y.clamp(0, 32767) as u16, + ); + self.relative_input = false; + if buttons != 0 { + let dx = + Self::delta(position.0, self.position.0, extent.0, &mut self.remainder.0); + let dy = + Self::delta(position.1, self.position.1, extent.1, &mut self.remainder.1); + Self::motion(&mut reports, buttons, dx, dy); + } else { + reports.push(MouseReport::Absolute { + buttons: 0, + x: position.0, + y: position.1, + }); + } + self.position = position; + } + MouseEventType::Move => { + self.relative_input = true; + Self::motion( + &mut reports, + buttons, + i64::from(event.x.clamp(-127, 127)), + i64::from(event.y.clamp(-127, 127)), + ); + } + MouseEventType::Down | MouseEventType::Up => { + if let Some(button) = event.button { + let down = event.event_type == MouseEventType::Down; + next_buttons = if down { + buttons | button.to_hid_bit() + } else { + buttons & !button.to_hid_bit() + }; + if down && buttons == 0 { + self.absolute_drag = !self.relative_input; + } + // Latch the button route until all buttons are released, even + // if the client changes pointer mode during the drag. + if self.absolute_drag { + if !down { + // Relative drag reports also carry buttons. Clear their + // state before releasing the absolute collection. + reports.push(MouseReport::Relative { + buttons: next_buttons, + dx: 0, + dy: 0, + wheel: 0, + }); + } + reports.push(MouseReport::Absolute { + buttons: next_buttons, + x: self.position.0, + y: self.position.1, + }); + } else { + reports.push(MouseReport::Relative { + buttons: next_buttons, + dx: 0, + dy: 0, + wheel: 0, + }); + } + if next_buttons == 0 { + self.absolute_drag = false; + self.remainder = (0, 0); + } + } + } + MouseEventType::Scroll => { + reports.push(MouseReport::Relative { + buttons, + dx: 0, + dy: 0, + wheel: event.scroll, + }); + } + } + (next_buttons, reports) + } + + fn delta(current: u16, previous: u16, extent: u32, remainder: &mut i64) -> i64 { + // Keep the original 15-bit input precision and carry fractional counts. + let scaled = + (i64::from(current) - i64::from(previous)) * i64::from(extent.max(1)) + *remainder; + *remainder = scaled % 32768; + scaled / 32768 + } + + fn motion(reports: &mut Vec, buttons: u8, dx: i64, dy: i64) { + // Distribute both axes over the same packets to preserve diagonal paths. + let count = (dx.abs().max(dy.abs()) + 126) / 127; + let mut previous = (0, 0); + for index in 1..=count { + let position = (dx * index / count, dy * index / count); + reports.push(MouseReport::Relative { + buttons, + dx: (position.0 - previous.0) as i8, + dy: (position.1 - previous.1) as i8, + wheel: 0, + }); + previous = position; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hid::MouseButton; + + fn drag(steps: &[i32], extent: (u32, u32)) -> (i64, i64) { + let mut state = MacosDrag::default(); + state.plan(MouseEvent::move_abs(8000, 8000), 0, extent); + state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent); + let mut total = (0, 0); + for &x in steps { + for report in state.plan(MouseEvent::move_abs(x, x), 1, extent).1 { + if let MouseReport::Relative { dx, dy, .. } = report { + total.0 += i64::from(dx); + total.1 += i64::from(dy); + } else { + panic!("absolute movement during drag"); + } + } + } + total + } + + #[test] + fn preserves_total_across_event_splitting_and_large_moves() { + let steps: Vec<_> = (8001..=16000).collect(); + assert_eq!(drag(&[16000], (1920, 1080)), (468, 263)); + assert_eq!(drag(&steps, (1920, 1080)), (468, 263)); + assert_eq!(drag(&[16000, 8000], (1920, 1080)), (0, 0)); + assert_eq!(drag(&[16000], (3840, 2160)), (937, 527)); + } + + #[test] + fn scroll_and_mode_switch_keep_absolute_drag_release() { + let mut state = MacosDrag::default(); + let extent = (1920, 1080); + state.plan(MouseEvent::move_abs(8000, 8000), 0, extent); + state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent); + let mut wheel = MouseEvent::move_abs(0, 0); + wheel.event_type = MouseEventType::Scroll; + wheel.scroll = 1; + assert_eq!( + state.plan(wheel, 1, extent).1, + vec![MouseReport::Relative { + buttons: 1, + dx: 0, + dy: 0, + wheel: 1 + }] + ); + state.plan(MouseEvent::move_rel(10, 0), 1, extent); + assert_eq!( + state + .plan(MouseEvent::button_up(MouseButton::Left), 1, extent) + .1, + vec![ + MouseReport::Relative { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0 + }, + MouseReport::Absolute { + buttons: 0, + x: 8000, + y: 8000 + }, + ] + ); + } + + #[test] + fn native_relative_clicks_and_reset() { + let mut state = MacosDrag::default(); + state.plan(MouseEvent::move_rel(1, 1), 0, (1920, 1080)); + assert_eq!( + state + .plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080)) + .1, + vec![MouseReport::Relative { + buttons: 1, + dx: 0, + dy: 0, + wheel: 0 + }] + ); + state.reset(); + assert_eq!( + state + .plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080)) + .1, + vec![MouseReport::Absolute { + buttons: 1, + x: 0, + y: 0 + }] + ); + } +} diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 34e8364d..980159b9 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -9,6 +9,7 @@ pub mod consumer; pub mod datachannel; mod factory; pub mod keyboard; +mod macos_drag; #[cfg(unix)] pub mod otg; #[cfg(unix)] @@ -135,6 +136,7 @@ pub struct HidController { runtime_worker: Mutex>>, backend_available: Arc, reset_requested: Arc, + screen_resolution: parking_lot::RwLock<(u32, u32)>, } impl HidController { @@ -157,6 +159,7 @@ impl HidController { runtime_worker: Mutex::new(None), backend_available: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)), + screen_resolution: parking_lot::RwLock::new((1920, 1080)), } } @@ -179,6 +182,7 @@ impl HidController { runtime_worker: Mutex::new(None), backend_available: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)), + screen_resolution: parking_lot::RwLock::new((1920, 1080)), } } @@ -210,7 +214,12 @@ impl HidController { } }; - *self.backend.write().await = Some(backend); + { + let mut slot = self.backend.write().await; + let (width, height) = *self.screen_resolution.read(); + backend.set_screen_resolution(width, height); + *slot = Some(backend); + } self.sync_runtime_state_from_backend().await; self.start_event_worker().await; @@ -245,7 +254,8 @@ impl HidController { } pub async fn prepare_otg_rebuild(&self) -> Result<()> { - if !matches!(*self.backend_type.read().await, HidBackendType::Otg) { + let backend_type = self.backend_type.read().await.clone(); + if !matches!(backend_type, HidBackendType::Otg { .. }) { return Ok(()); } @@ -259,7 +269,7 @@ impl HidController { let current = self.runtime_state.read().await.clone(); let rebuilding_state = HidRuntimeState::with_error( - &HidBackendType::Otg, + &backend_type, ¤t, "OTG gadget is rebuilding", "rebuilding", @@ -337,6 +347,24 @@ impl HidController { self.backend_type.read().await.clone() } + /// Keep the active capture dimensions across HID reloads and USB rebuilds. + pub async fn set_screen_resolution(&self, width: u32, height: u32) { + if width == 0 || height == 0 || width > 65535 || height > 65535 { + return; + } + { + let mut resolution = self.screen_resolution.write(); + if *resolution == (width, height) { + return; + } + *resolution = (width, height); + } + if let Some(backend) = self.backend.read().await.as_ref() { + let (width, height) = *self.screen_resolution.read(); + backend.set_screen_resolution(width, height); + } + } + pub async fn snapshot(&self) -> HidRuntimeState { self.runtime_state.read().await.clone() } @@ -403,7 +431,14 @@ impl HidController { } }; - *self.backend.write().await = new_backend; + { + let mut slot = self.backend.write().await; + if let Some(backend) = new_backend.as_ref() { + let (width, height) = *self.screen_resolution.read(); + backend.set_screen_resolution(width, height); + } + *slot = new_backend; + } if matches!(new_backend_type, HidBackendType::None) { *self.backend_type.write().await = HidBackendType::None; @@ -687,6 +722,27 @@ fn merge_pending_move(pending: &mut Option, event: MouseEvent) { #[cfg(test)] mod queue_tests { use super::*; + #[tokio::test] + async fn screen_resolution_updates_backend_and_retains_valid_dimensions() { + #[cfg(unix)] + let controller = HidController::new(HidBackendType::None, None); + #[cfg(not(unix))] + let controller = HidController::new(HidBackendType::None); + let backend = Arc::new(ch9329::Ch9329Backend::new("/dev/null").unwrap()); + *controller.backend.write().await = Some(backend.clone()); + controller.set_screen_resolution(3840, 2160).await; + assert_eq!( + backend.runtime_snapshot().screen_resolution, + Some((3840, 2160)) + ); + controller.set_screen_resolution(0, 0).await; + assert_eq!(*controller.screen_resolution.read(), (3840, 2160)); + controller.set_screen_resolution(1280, 720).await; + assert_eq!( + backend.runtime_snapshot().screen_resolution, + Some((1280, 720)) + ); + } struct TestBackend { pressed: Arc, reset_done: Arc, diff --git a/src/hid/otg.rs b/src/hid/otg.rs index 23443ea1..c122e2eb 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -80,6 +80,8 @@ pub struct OtgBackend { keyboard_leds_enabled: bool, keyboard_state: Mutex, mouse_buttons: AtomicU8, + macos_drag: bool, + macos_drag_state: Mutex, led_state: Arc>, screen_resolution: parking_lot::RwLock>, udc_name: Arc>>, @@ -99,6 +101,15 @@ const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500); impl OtgBackend { /// Gadget must already exist; paths come from `OtgService`. pub fn from_handles(paths: HidDevicePaths) -> Result { + Self::with_macos_drag(paths, false) + } + + pub fn with_macos_drag(paths: HidDevicePaths, macos_drag: bool) -> Result { + if macos_drag && (paths.mouse_relative.is_none() || paths.mouse_absolute.is_none()) { + return Err(AppError::Config( + "macOS drag compatibility requires both OTG mouse interfaces".into(), + )); + } let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(()); Ok(Self { keyboard_path: paths.keyboard, @@ -112,6 +123,8 @@ impl OtgBackend { keyboard_leds_enabled: paths.keyboard_leds_enabled, keyboard_state: Mutex::new(KeyboardReport::default()), mouse_buttons: AtomicU8::new(0), + macos_drag, + macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()), led_state: Arc::new(parking_lot::RwLock::new(LedState::default())), screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))), udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)), @@ -852,6 +865,28 @@ impl HidBackend for OtgBackend { async fn send_mouse(&self, event: MouseEvent) -> Result<()> { let buttons = self.mouse_buttons.load(Ordering::Relaxed); + if self.macos_drag { + use super::macos_drag::MouseReport; + let mut state = self.macos_drag_state.lock(); + let extent = self.screen_resolution.read().unwrap_or((1920, 1080)); + let (buttons, reports) = state.plan(event, buttons, extent); + self.mouse_buttons.store(buttons, Ordering::Relaxed); + for report in reports { + match report { + MouseReport::Absolute { buttons, x, y } => { + self.send_mouse_report_absolute(buttons, x, y, 0)? + } + MouseReport::Relative { + buttons, + dx, + dy, + wheel, + } => self.send_mouse_report_relative(buttons, dx, dy, wheel)?, + } + } + return Ok(()); + } + match event.event_type { MouseEventType::Move => { let dx = event.x.clamp(-127, 127) as i8; @@ -896,6 +931,7 @@ impl HidBackend for OtgBackend { } self.mouse_buttons.store(0, Ordering::Relaxed); + self.macos_drag_state.lock().reset(); self.send_mouse_report_relative(0, 0, 0, 0)?; self.send_mouse_report_absolute(0, 0, 0, 0)?; @@ -988,6 +1024,51 @@ mod tests { assert_eq!(kb_report.to_bytes().len(), 8); } + #[tokio::test] + async fn mouse_compatibility_writes_both_endpoints_and_preserves_default() { + use crate::hid::MouseButton; + for enabled in [false, true] { + let relative = tempfile::NamedTempFile::new().unwrap(); + let absolute = tempfile::NamedTempFile::new().unwrap(); + let backend = OtgBackend::with_macos_drag( + HidDevicePaths { + mouse_relative: Some(relative.path().to_path_buf()), + mouse_absolute: Some(absolute.path().to_path_buf()), + ..Default::default() + }, + enabled, + ) + .unwrap(); + for event in [ + MouseEvent::move_abs(8000, 8000), + MouseEvent::button_down(MouseButton::Left), + MouseEvent::move_abs(16000, 16000), + MouseEvent::button_up(MouseButton::Left), + ] { + backend.send_mouse(event).await.unwrap(); + } + let abs = fs::read(absolute.path()).unwrap(); + let rel = fs::read(relative.path()).unwrap(); + let buttons: Vec<_> = abs.chunks_exact(6).map(|packet| packet[0]).collect(); + assert_eq!(buttons, if enabled { vec![0, 1, 0] } else { vec![0, 0] }); + let dx: i32 = rel + .chunks_exact(4) + .map(|packet| i32::from(packet[1] as i8)) + .sum(); + let dy: i32 = rel + .chunks_exact(4) + .map(|packet| i32::from(packet[2] as i8)) + .sum(); + assert_eq!((dx, dy), if enabled { (468, 263) } else { (0, 0) }); + assert_eq!(&rel[rel.len() - 4..], &[0, 0, 0, 0]); + } + } + + #[test] + fn compatibility_requires_both_mouse_endpoints() { + assert!(OtgBackend::with_macos_drag(HidDevicePaths::default(), true).is_err()); + } + #[tokio::test] async fn prepare_rebuild_closes_devices_without_writing_reset_reports() { let mut file = tempfile::tempfile().unwrap(); diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index 5b29affa..1643a347 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -107,6 +107,8 @@ impl RuntimeBuilder { #[cfg(target_os = "linux")] hid.set_bond_store(config_store.hid_bonds()); hid.set_event_bus(events.clone()).await; + hid.set_screen_resolution(video_resolution.width, video_resolution.height) + .await; if let Err(error) = hid.init().await { tracing::warn!("Failed to initialize HID backend: {}", error); } @@ -407,12 +409,14 @@ async fn build_otg(config: &AppConfig) -> Arc { fn hid_backend_type(config: &AppConfig) -> HidBackendType { match config.hid.backend { - config::HidBackend::Otg => HidBackendType::Otg, + 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.ch9329_macos_drag, + macos_drag: config.hid.mouse_macos_drag, }, config::HidBackend::None => HidBackendType::None, config::HidBackend::Bluetooth => HidBackendType::Bluetooth { diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs index f57500eb..3fa7eebe 100644 --- a/src/runtime/usb.rs +++ b/src/runtime/usb.rs @@ -175,14 +175,15 @@ impl UsbCoordinator { 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 ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse - || old_config.ch9329_macos_drag != new_config.ch9329_macos_drag; + 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 - && !ch9329_runtime_changed + && !mouse_compatibility_changed && old_config.otg_udc == new_config.otg_udc && !descriptor_changed && !hid_functions_changed @@ -299,7 +300,7 @@ impl UsbCoordinator { if hid_config.backend == HidBackend::Otg && (options.force || old_enabled != new_enabled) { self.hid - .reload(HidBackendType::Otg) + .reload(hid_backend_type(hid_config)) .await .map_err(|error| AppError::Config(format!("OTG HID reload failed: {error}")))?; } @@ -309,12 +310,14 @@ impl UsbCoordinator { fn hid_backend_type(config: &HidConfig) -> HidBackendType { match config.backend { - HidBackend::Otg => HidBackendType::Otg, + 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.ch9329_macos_drag, + macos_drag: config.mouse_macos_drag, }, HidBackend::None => HidBackendType::None, HidBackend::Bluetooth => HidBackendType::Bluetooth { diff --git a/src/state.rs b/src/state.rs index 1d1e1389..aec21dfe 100644 --- a/src/state.rs +++ b/src/state.rs @@ -218,6 +218,11 @@ impl AppState { pub async fn publish_device_info(&self) { let device_info = self.get_device_info().await; + if let SystemEvent::DeviceInfo { video, .. } = &device_info { + if let Some((width, height)) = video.resolution { + self.hid.set_screen_resolution(width, height).await; + } + } let _ = self.device_info_tx.send(Some(device_info)); } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index ef389ec9..9e843236 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -409,7 +409,8 @@ pub struct HidConfigUpdate { pub ch9329_port: Option, pub ch9329_baudrate: Option, pub ch9329_hybrid_mouse: Option, - pub ch9329_macos_drag: Option, + #[serde(alias = "ch9329_macos_drag")] + pub mouse_macos_drag: Option, pub ch9329_descriptor: Option, pub otg_udc: Option, pub otg_descriptor: Option, @@ -466,8 +467,8 @@ impl HidConfigUpdate { if let Some(enabled) = self.ch9329_hybrid_mouse { config.ch9329_hybrid_mouse = enabled; } - if let Some(enabled) = self.ch9329_macos_drag { - config.ch9329_macos_drag = enabled; + if let Some(enabled) = self.mouse_macos_drag { + config.mouse_macos_drag = enabled; } if let Some(ref desc) = self.ch9329_descriptor { desc.apply_to(&mut config.ch9329_descriptor); diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index d2f4d2c0..6f2d1691 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -284,7 +284,7 @@ mod tests { ch9329_port: None, ch9329_baudrate: None, ch9329_hybrid_mouse: None, - ch9329_macos_drag: None, + mouse_macos_drag: None, ch9329_descriptor: None, otg_udc: None, otg_descriptor: None, diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index a29b8c56..b7261079 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -270,10 +270,8 @@ pub async fn msd_disk_mode_put( })); } - let hid_is_otg = matches!( - state.hid.backend_type().await, - crate::hid::HidBackendType::Otg - ); + let hid_backend_type = state.hid.backend_type().await; + let hid_is_otg = matches!(hid_backend_type, crate::hid::HidBackendType::Otg { .. }); if hid_is_otg { state @@ -294,7 +292,7 @@ pub async fn msd_disk_mode_put( let hid_reload_result = if hid_is_otg { state .hid - .reload(crate::hid::HidBackendType::Otg) + .reload(hid_backend_type) .await .map_err(|e| AppError::Config(format!("OTG HID reload failed: {e}"))) } else { diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 335ad754..7479fb38 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -818,8 +818,9 @@ export default { ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip', ch9329HybridMouse: 'Linux Absolute Mouse Compatibility', ch9329HybridMouseDesc: 'Keep absolute movement on absolute packets, but send buttons and wheel through relative packets', - ch9329MacosDrag: 'macOS Drag Compatibility', - ch9329MacosDragDesc: 'Use absolute packets for button edges and relative packets for movement while a button is held', + mouseMacosDrag: 'macOS Drag Compatibility', + mouseMacosDragRequiresBoth: 'Enable both relative and absolute mouse interfaces before saving.', + mouseMacosDragDesc: 'Experimental workaround for interrupted drags in absolute mouse mode (OTG / CH9329). OTG requires both mouse interfaces. Tracking speed and drop position may vary with macOS settings; this version has not been tested on a Mac.', ch9329Descriptor: 'CH9329 USB Device Descriptor', ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing', ch9329DescriptorLoading: 'Reading CH9329 descriptor...', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index ce2ef60a..442a7e1b 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -817,8 +817,9 @@ export default { ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性', ch9329HybridMouse: 'Linux 绝对鼠标兼容模式', ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送', - ch9329MacosDrag: 'macOS 拖拽兼容模式', - ch9329MacosDragDesc: '按钮按下与释放使用绝对鼠标包,按住期间改用相对鼠标包移动', + mouseMacosDrag: 'macOS 拖拽兼容模式', + mouseMacosDragRequiresBoth: '请同时启用相对鼠标和绝对鼠标接口后保存。', + mouseMacosDragDesc: '实验性缓解绝对鼠标模式下的拖拽中断,适用于 OTG / CH9329。OTG 需同时启用两种鼠标接口。移动速度和落点可能受 macOS 设置影响;本版本尚未进行 Mac 实机验证。', ch9329Descriptor: 'CH9329 USB 设备描述符', ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改', ch9329DescriptorLoading: '正在读取 CH9329 描述符...', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index c8ee290e..aa3387e3 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -71,7 +71,7 @@ export interface HidConfig { ch9329_port: string; ch9329_baudrate: number; ch9329_hybrid_mouse?: boolean; - ch9329_macos_drag?: boolean; + mouse_macos_drag?: boolean; ch9329_descriptor?: Ch9329DescriptorConfig; mouse_absolute: boolean; } @@ -571,7 +571,7 @@ export interface HidConfigUpdate { ch9329_port?: string; ch9329_baudrate?: number; ch9329_hybrid_mouse?: boolean; - ch9329_macos_drag?: boolean; + mouse_macos_drag?: boolean; ch9329_descriptor?: Ch9329DescriptorConfigUpdate; otg_udc?: string; otg_descriptor?: OtgDescriptorConfigUpdate; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 033ad08b..1395c92d 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -692,7 +692,7 @@ const config = ref({ } as OtgHidFunctions, hid_otg_keyboard_leds: false, hid_ch9329_hybrid_mouse: false, - hid_ch9329_macos_drag: false, + hid_mouse_macos_drag: false, msd_enabled: false, msd_dir: '', msd_flash_inquiry_string: 'One-KVM Virtual Flash', @@ -1177,6 +1177,8 @@ const isCh9329DescriptorDirty = computed(() => { const isHidSettingsValid = computed(() => isHidFunctionSelectionValid.value + && !(config.value.hid_backend === 'otg' && config.value.hid_mouse_macos_drag + && (!effectiveOtgFunctions.value.mouse_relative || !effectiveOtgFunctions.value.mouse_absolute)) && isCh9329DescriptorValid.value && areMsdInquiryStringsValid.value ) @@ -1457,8 +1459,10 @@ async function saveConfig() { const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329' ? { ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse, - ch9329_macos_drag: config.value.hid_ch9329_macos_drag, } : {} + if (['otg', 'ch9329'].includes(config.value.hid_backend)) { + hidUpdate.mouse_macos_drag = config.value.hid_mouse_macos_drag + } if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) { hidUpdate.ch9329_descriptor = { vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86, @@ -1528,7 +1532,7 @@ async function saveConfig() { const hidFeatureBaseline = ref('') function hidFeatureSnapshot() { return JSON.stringify({ - fields: Object.fromEntries(Object.entries(config.value).filter(([key]) => key.startsWith('msd_') || key.startsWith('otg_network_') || key.startsWith('uac_') || ['hid_otg_functions', 'hid_otg_keyboard_leds', 'hid_ch9329_hybrid_mouse', 'hid_ch9329_macos_drag'].includes(key))), + fields: Object.fromEntries(Object.entries(config.value).filter(([key]) => key.startsWith('msd_') || key.startsWith('otg_network_') || key.startsWith('uac_') || ['hid_otg_functions', 'hid_otg_keyboard_leds', 'hid_ch9329_hybrid_mouse', 'hid_mouse_macos_drag'].includes(key))), descriptor: [otgVendorIdHex.value, otgProductIdHex.value, otgManufacturer.value, otgProduct.value, otgSerialNumber.value], }) } @@ -1570,7 +1574,7 @@ async function loadConfig() { } as OtgHidFunctions, hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false, hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false, - hid_ch9329_macos_drag: hid.ch9329_macos_drag ?? false, + hid_mouse_macos_drag: hid.mouse_macos_drag ?? false, msd_enabled: msd.enabled || false, msd_dir: msd.msd_dir || '', msd_flash_inquiry_string: msd.flash_inquiry_string || 'One-KVM Virtual Flash', @@ -3225,18 +3229,19 @@ watch(isWindows, () => { -
-
- -

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

-
- -
+
+
+ +

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

+

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

+
+ +