Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/macos-drag-compatibility.md
Original file line number Diff line number Diff line change
@@ -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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。
25 changes: 25 additions & 0 deletions src/config/schema/hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand All @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions src/hid/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -27,6 +30,8 @@ pub enum HidBackendType {
baud_rate: u32,
#[serde(default)]
hybrid_mouse: bool,
#[serde(default)]
macos_drag: bool,
},
#[default]
None,
Expand All @@ -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",
Expand Down
100 changes: 97 additions & 3 deletions src/hid/ch9329.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}

Expand All @@ -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,
Expand All @@ -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()),
})
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 19 additions & 11 deletions src/hid/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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()
Expand All @@ -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(),
))
Expand Down
Loading