Merge pull request #304 from drgnchan/fix/ch9329-macos-drag

fix(hid): add macOS drag compatibility for OTG and CH9329
This commit is contained in:
SilentWind
2026-09-10 23:02:29 +08:00
committed by GitHub
18 changed files with 625 additions and 33 deletions

View File

@@ -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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。

View File

@@ -231,6 +231,9 @@ pub struct HidConfig {
#[serde(default)]
pub ch9329_hybrid_mouse: bool,
#[serde(default)]
#[serde(alias = "ch9329_macos_drag")]
pub mouse_macos_drag: bool,
#[serde(default)]
pub ch9329_descriptor: Ch9329DescriptorConfig,
pub mouse_absolute: bool,
}
@@ -248,6 +251,7 @@ impl Default for HidConfig {
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
ch9329_hybrid_mouse: false,
mouse_macos_drag: false,
ch9329_descriptor: Ch9329DescriptorConfig::default(),
mouse_absolute: true,
}
@@ -273,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(),
@@ -308,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);

View File

@@ -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,
},
@@ -27,6 +30,8 @@ pub enum HidBackendType {
baud_rate: u32,
#[serde(default)]
hybrid_mouse: bool,
#[serde(default)]
macos_drag: bool,
},
#[default]
None,
@@ -35,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",

View File

@@ -238,6 +238,8 @@ pub struct Ch9329Backend {
last_abs_y: Arc<AtomicU16>,
relative_mouse_active: Arc<AtomicBool>,
hybrid_mouse: bool,
macos_drag: bool,
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
runtime: Arc<Ch9329RuntimeState>,
}
@@ -251,6 +253,15 @@ impl Ch9329Backend {
}
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
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<Self> {
Ok(Self {
port_path: port_path.to_string(),
baud_rate,
@@ -266,6 +277,8 @@ impl Ch9329Backend {
last_abs_y: Arc::new(AtomicU16::new(0)),
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()),
})
}
@@ -978,11 +991,12 @@ 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
@@ -1272,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);
@@ -1361,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);
@@ -1666,13 +1706,67 @@ 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_REL_DATA, vec![0x01, 0x00, 0x00, 0x00, 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();

View File

@@ -44,21 +44,27 @@ impl HidBackendFactory {
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
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,
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::Bluetooth { config } => {
#[cfg(target_os = "linux")]
@@ -84,7 +90,7 @@ impl HidBackendFactory {
}
#[cfg(unix)]
async fn create_otg_backend(&self) -> Result<Arc<dyn HidBackend>> {
async fn create_otg_backend(&self, macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
let otg_service = self
.otg_service
.as_ref()
@@ -96,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<Arc<dyn HidBackend>> {
async fn create_otg_backend(&self, _macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
Err(AppError::Config(
"OTG HID is only available on Linux".to_string(),
))

256
src/hid/macos_drag.rs Normal file
View File

@@ -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<MouseReport>) {
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<MouseReport>, 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
}]
);
}
}

View File

@@ -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<Option<JoinHandle<()>>>,
backend_available: Arc<AtomicBool>,
reset_requested: Arc<AtomicBool>,
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,
&current,
"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<MouseEvent>, 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<AtomicBool>,
reset_done: Arc<tokio::sync::Notify>,

View File

@@ -80,6 +80,8 @@ pub struct OtgBackend {
keyboard_leds_enabled: bool,
keyboard_state: Mutex<KeyboardReport>,
mouse_buttons: AtomicU8,
macos_drag: bool,
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
led_state: Arc<parking_lot::RwLock<LedState>>,
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
@@ -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> {
Self::with_macos_drag(paths, false)
}
pub fn with_macos_drag(paths: HidDevicePaths, macos_drag: bool) -> Result<Self> {
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();

View File

@@ -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,11 +409,14 @@ async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
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.mouse_macos_drag,
},
config::HidBackend::None => HidBackendType::None,
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {

View File

@@ -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;
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,11 +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.mouse_macos_drag,
},
HidBackend::None => HidBackendType::None,
HidBackend::Bluetooth => HidBackendType::Bluetooth {

View File

@@ -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));
}

View File

@@ -409,6 +409,8 @@ pub struct HidConfigUpdate {
pub ch9329_port: Option<String>,
pub ch9329_baudrate: Option<u32>,
pub ch9329_hybrid_mouse: Option<bool>,
#[serde(alias = "ch9329_macos_drag")]
pub mouse_macos_drag: Option<bool>,
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
pub otg_udc: Option<String>,
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
@@ -465,6 +467,9 @@ impl HidConfigUpdate {
if let Some(enabled) = self.ch9329_hybrid_mouse {
config.ch9329_hybrid_mouse = 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);
}

View File

@@ -284,6 +284,7 @@ mod tests {
ch9329_port: None,
ch9329_baudrate: None,
ch9329_hybrid_mouse: None,
mouse_macos_drag: None,
ch9329_descriptor: None,
otg_udc: None,
otg_descriptor: None,

View File

@@ -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 {

View File

@@ -818,6 +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',
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...',

View File

@@ -817,6 +817,9 @@ export default {
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送',
mouseMacosDrag: 'macOS 拖拽兼容模式',
mouseMacosDragRequiresBoth: '请同时启用相对鼠标和绝对鼠标接口后保存。',
mouseMacosDragDesc: '实验性缓解绝对鼠标模式下的拖拽中断,适用于 OTG / CH9329。OTG 需同时启用两种鼠标接口。移动速度和落点可能受 macOS 设置影响;本版本尚未进行 Mac 实机验证。',
ch9329Descriptor: 'CH9329 USB 设备描述符',
ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改',
ch9329DescriptorLoading: '正在读取 CH9329 描述符...',

View File

@@ -71,6 +71,7 @@ export interface HidConfig {
ch9329_port: string;
ch9329_baudrate: number;
ch9329_hybrid_mouse?: boolean;
mouse_macos_drag?: boolean;
ch9329_descriptor?: Ch9329DescriptorConfig;
mouse_absolute: boolean;
}
@@ -570,6 +571,7 @@ export interface HidConfigUpdate {
ch9329_port?: string;
ch9329_baudrate?: number;
ch9329_hybrid_mouse?: boolean;
mouse_macos_drag?: boolean;
ch9329_descriptor?: Ch9329DescriptorConfigUpdate;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;

View File

@@ -692,6 +692,7 @@ const config = ref({
} as OtgHidFunctions,
hid_otg_keyboard_leds: false,
hid_ch9329_hybrid_mouse: false,
hid_mouse_macos_drag: false,
msd_enabled: false,
msd_dir: '',
msd_flash_inquiry_string: 'One-KVM Virtual Flash',
@@ -1176,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
)
@@ -1454,7 +1457,12 @@ async function saveConfig() {
return
}
const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329'
? { ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse } : {}
? {
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
} : {}
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,
@@ -1524,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'].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],
})
}
@@ -1566,6 +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_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,6 +3234,14 @@ watch(isWindows, () => {
</template>
<!-- OTG Descriptor Settings -->
<div v-if="['otg', 'ch9329'].includes(config.hid_backend)" class="flex items-center justify-between gap-4 rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.mouseMacosDrag') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.mouseMacosDragDesc') }}</p>
<p v-if="config.hid_backend === 'otg' && config.hid_mouse_macos_drag && (!effectiveOtgFunctions.mouse_relative || !effectiveOtgFunctions.mouse_absolute)" class="text-xs text-warning">{{ t('settings.mouseMacosDragRequiresBoth') }}</p>
</div>
<Switch v-model="config.hid_mouse_macos_drag" />
</div>
<template v-if="config.hid_backend === 'otg'">
<Separator class="my-4" />
<div class="space-y-4">