mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
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:
24
docs/macos-drag-compatibility.md
Normal file
24
docs/macos-drag-compatibility.md
Normal 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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。
|
||||||
@@ -231,6 +231,9 @@ pub struct HidConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ch9329_hybrid_mouse: bool,
|
pub ch9329_hybrid_mouse: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "ch9329_macos_drag")]
|
||||||
|
pub mouse_macos_drag: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub ch9329_descriptor: Ch9329DescriptorConfig,
|
pub ch9329_descriptor: Ch9329DescriptorConfig,
|
||||||
pub mouse_absolute: bool,
|
pub mouse_absolute: bool,
|
||||||
}
|
}
|
||||||
@@ -248,6 +251,7 @@ impl Default for HidConfig {
|
|||||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||||
ch9329_baudrate: 9600,
|
ch9329_baudrate: 9600,
|
||||||
ch9329_hybrid_mouse: false,
|
ch9329_hybrid_mouse: false,
|
||||||
|
mouse_macos_drag: false,
|
||||||
ch9329_descriptor: Ch9329DescriptorConfig::default(),
|
ch9329_descriptor: Ch9329DescriptorConfig::default(),
|
||||||
mouse_absolute: true,
|
mouse_absolute: true,
|
||||||
}
|
}
|
||||||
@@ -273,6 +277,11 @@ impl HidConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let functions = self.effective_otg_functions();
|
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() {
|
if functions.is_empty() {
|
||||||
return Err(crate::error::AppError::BadRequest(
|
return Err(crate::error::AppError::BadRequest(
|
||||||
"OTG HID functions cannot be empty".to_string(),
|
"OTG HID functions cannot be empty".to_string(),
|
||||||
@@ -308,6 +317,22 @@ impl HidConfig {
|
|||||||
mod bluetooth_tests {
|
mod bluetooth_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[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() {
|
fn old_configs_keep_bluetooth_disabled_and_get_defaults() {
|
||||||
let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||||
assert_eq!(config.backend, HidBackend::Otg);
|
assert_eq!(config.backend, HidBackend::Otg);
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ fn default_ch9329_baud_rate() -> u32 {
|
|||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub enum HidBackendType {
|
pub enum HidBackendType {
|
||||||
Otg,
|
Otg {
|
||||||
|
#[serde(default)]
|
||||||
|
macos_drag: bool,
|
||||||
|
},
|
||||||
Bluetooth {
|
Bluetooth {
|
||||||
config: crate::config::BluetoothHidConfig,
|
config: crate::config::BluetoothHidConfig,
|
||||||
},
|
},
|
||||||
@@ -27,6 +30,8 @@ pub enum HidBackendType {
|
|||||||
baud_rate: u32,
|
baud_rate: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
hybrid_mouse: bool,
|
hybrid_mouse: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
macos_drag: bool,
|
||||||
},
|
},
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
@@ -35,7 +40,7 @@ pub enum HidBackendType {
|
|||||||
impl HidBackendType {
|
impl HidBackendType {
|
||||||
pub fn name_str(&self) -> &str {
|
pub fn name_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Otg => "otg",
|
Self::Otg { .. } => "otg",
|
||||||
Self::Bluetooth { .. } => "bluetooth",
|
Self::Bluetooth { .. } => "bluetooth",
|
||||||
Self::Ch9329 { .. } => "ch9329",
|
Self::Ch9329 { .. } => "ch9329",
|
||||||
Self::None => "none",
|
Self::None => "none",
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ pub struct Ch9329Backend {
|
|||||||
last_abs_y: Arc<AtomicU16>,
|
last_abs_y: Arc<AtomicU16>,
|
||||||
relative_mouse_active: Arc<AtomicBool>,
|
relative_mouse_active: Arc<AtomicBool>,
|
||||||
hybrid_mouse: bool,
|
hybrid_mouse: bool,
|
||||||
|
macos_drag: bool,
|
||||||
|
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||||
runtime: Arc<Ch9329RuntimeState>,
|
runtime: Arc<Ch9329RuntimeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +253,15 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
|
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 {
|
Ok(Self {
|
||||||
port_path: port_path.to_string(),
|
port_path: port_path.to_string(),
|
||||||
baud_rate,
|
baud_rate,
|
||||||
@@ -266,6 +277,8 @@ impl Ch9329Backend {
|
|||||||
last_abs_y: Arc::new(AtomicU16::new(0)),
|
last_abs_y: Arc::new(AtomicU16::new(0)),
|
||||||
relative_mouse_active: Arc::new(AtomicBool::new(false)),
|
relative_mouse_active: Arc::new(AtomicBool::new(false)),
|
||||||
hybrid_mouse,
|
hybrid_mouse,
|
||||||
|
macos_drag,
|
||||||
|
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
|
||||||
runtime: Arc::new(Ch9329RuntimeState::new()),
|
runtime: Arc::new(Ch9329RuntimeState::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -978,11 +991,12 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_send_button_wheel_relative(&self) -> bool {
|
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 {
|
fn absolute_move_buttons(&self, buttons: u8) -> u8 {
|
||||||
if self.hybrid_mouse {
|
if self.hybrid_mouse && !self.macos_drag {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
buttons
|
buttons
|
||||||
@@ -1272,6 +1286,31 @@ impl HidBackend for Ch9329Backend {
|
|||||||
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
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 {
|
match event.event_type {
|
||||||
MouseEventType::Move => {
|
MouseEventType::Move => {
|
||||||
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
||||||
@@ -1361,6 +1400,7 @@ impl HidBackend for Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.mouse_buttons.store(0, Ordering::Relaxed);
|
self.mouse_buttons.store(0, Ordering::Relaxed);
|
||||||
|
self.macos_drag_state.lock().reset();
|
||||||
self.last_abs_x.store(0, Ordering::Relaxed);
|
self.last_abs_x.store(0, Ordering::Relaxed);
|
||||||
self.last_abs_y.store(0, Ordering::Relaxed);
|
self.last_abs_y.store(0, Ordering::Relaxed);
|
||||||
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
||||||
@@ -1666,13 +1706,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap();
|
||||||
|
|
||||||
assert!(backend.should_send_button_wheel_relative());
|
assert!(backend.should_send_button_wheel_relative());
|
||||||
assert_eq!(backend.absolute_move_buttons(0x07), 0);
|
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]
|
#[test]
|
||||||
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
|
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
|
||||||
let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap();
|
let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap();
|
||||||
|
|||||||
@@ -44,21 +44,27 @@ impl HidBackendFactory {
|
|||||||
|
|
||||||
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
||||||
match backend_type {
|
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 {
|
HidBackendType::Ch9329 {
|
||||||
port,
|
port,
|
||||||
baud_rate,
|
baud_rate,
|
||||||
hybrid_mouse,
|
hybrid_mouse,
|
||||||
|
macos_drag,
|
||||||
} => {
|
} => {
|
||||||
info!(
|
info!(
|
||||||
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}",
|
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}, macos_drag={}",
|
||||||
port, baud_rate, hybrid_mouse
|
port, baud_rate, hybrid_mouse, macos_drag
|
||||||
);
|
);
|
||||||
Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options(
|
Ok(Some(Arc::new(
|
||||||
|
ch9329::Ch9329Backend::with_compatibility_options(
|
||||||
port,
|
port,
|
||||||
*baud_rate,
|
*baud_rate,
|
||||||
*hybrid_mouse,
|
*hybrid_mouse,
|
||||||
)?)))
|
*macos_drag,
|
||||||
|
)?,
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
HidBackendType::Bluetooth { config } => {
|
HidBackendType::Bluetooth { config } => {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -84,7 +90,7 @@ impl HidBackendFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[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
|
let otg_service = self
|
||||||
.otg_service
|
.otg_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -96,11 +102,13 @@ impl HidBackendFactory {
|
|||||||
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
|
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
|
||||||
|
|
||||||
info!("Creating OTG HID backend from device paths");
|
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))]
|
#[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(
|
Err(AppError::Config(
|
||||||
"OTG HID is only available on Linux".to_string(),
|
"OTG HID is only available on Linux".to_string(),
|
||||||
))
|
))
|
||||||
|
|||||||
256
src/hid/macos_drag.rs
Normal file
256
src/hid/macos_drag.rs
Normal 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
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod consumer;
|
|||||||
pub mod datachannel;
|
pub mod datachannel;
|
||||||
mod factory;
|
mod factory;
|
||||||
pub mod keyboard;
|
pub mod keyboard;
|
||||||
|
mod macos_drag;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod otg;
|
pub mod otg;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -135,6 +136,7 @@ pub struct HidController {
|
|||||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
backend_available: Arc<AtomicBool>,
|
backend_available: Arc<AtomicBool>,
|
||||||
reset_requested: Arc<AtomicBool>,
|
reset_requested: Arc<AtomicBool>,
|
||||||
|
screen_resolution: parking_lot::RwLock<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidController {
|
impl HidController {
|
||||||
@@ -157,6 +159,7 @@ impl HidController {
|
|||||||
runtime_worker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
backend_available: Arc::new(AtomicBool::new(false)),
|
||||||
reset_requested: 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),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
backend_available: Arc::new(AtomicBool::new(false)),
|
||||||
reset_requested: 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.sync_runtime_state_from_backend().await;
|
||||||
|
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
@@ -245,7 +254,8 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +269,7 @@ impl HidController {
|
|||||||
|
|
||||||
let current = self.runtime_state.read().await.clone();
|
let current = self.runtime_state.read().await.clone();
|
||||||
let rebuilding_state = HidRuntimeState::with_error(
|
let rebuilding_state = HidRuntimeState::with_error(
|
||||||
&HidBackendType::Otg,
|
&backend_type,
|
||||||
¤t,
|
¤t,
|
||||||
"OTG gadget is rebuilding",
|
"OTG gadget is rebuilding",
|
||||||
"rebuilding",
|
"rebuilding",
|
||||||
@@ -337,6 +347,24 @@ impl HidController {
|
|||||||
self.backend_type.read().await.clone()
|
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 {
|
pub async fn snapshot(&self) -> HidRuntimeState {
|
||||||
self.runtime_state.read().await.clone()
|
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) {
|
if matches!(new_backend_type, HidBackendType::None) {
|
||||||
*self.backend_type.write().await = 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)]
|
#[cfg(test)]
|
||||||
mod queue_tests {
|
mod queue_tests {
|
||||||
use super::*;
|
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 {
|
struct TestBackend {
|
||||||
pressed: Arc<AtomicBool>,
|
pressed: Arc<AtomicBool>,
|
||||||
reset_done: Arc<tokio::sync::Notify>,
|
reset_done: Arc<tokio::sync::Notify>,
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ pub struct OtgBackend {
|
|||||||
keyboard_leds_enabled: bool,
|
keyboard_leds_enabled: bool,
|
||||||
keyboard_state: Mutex<KeyboardReport>,
|
keyboard_state: Mutex<KeyboardReport>,
|
||||||
mouse_buttons: AtomicU8,
|
mouse_buttons: AtomicU8,
|
||||||
|
macos_drag: bool,
|
||||||
|
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||||
led_state: Arc<parking_lot::RwLock<LedState>>,
|
led_state: Arc<parking_lot::RwLock<LedState>>,
|
||||||
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
||||||
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
||||||
@@ -99,6 +101,15 @@ const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
|||||||
impl OtgBackend {
|
impl OtgBackend {
|
||||||
/// Gadget must already exist; paths come from `OtgService`.
|
/// Gadget must already exist; paths come from `OtgService`.
|
||||||
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
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(());
|
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
keyboard_path: paths.keyboard,
|
keyboard_path: paths.keyboard,
|
||||||
@@ -112,6 +123,8 @@ impl OtgBackend {
|
|||||||
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
||||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||||
mouse_buttons: AtomicU8::new(0),
|
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())),
|
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
|
||||||
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
||||||
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
|
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<()> {
|
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
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 {
|
match event.event_type {
|
||||||
MouseEventType::Move => {
|
MouseEventType::Move => {
|
||||||
let dx = event.x.clamp(-127, 127) as i8;
|
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.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_relative(0, 0, 0, 0)?;
|
||||||
self.send_mouse_report_absolute(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);
|
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]
|
#[tokio::test]
|
||||||
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
||||||
let mut file = tempfile::tempfile().unwrap();
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ impl RuntimeBuilder {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
hid.set_bond_store(config_store.hid_bonds());
|
hid.set_bond_store(config_store.hid_bonds());
|
||||||
hid.set_event_bus(events.clone()).await;
|
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 {
|
if let Err(error) = hid.init().await {
|
||||||
tracing::warn!("Failed to initialize HID backend: {}", error);
|
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 {
|
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
||||||
match config.hid.backend {
|
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 {
|
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||||
port: config.hid.ch9329_port.clone(),
|
port: config.hid.ch9329_port.clone(),
|
||||||
baud_rate: config.hid.ch9329_baudrate,
|
baud_rate: config.hid.ch9329_baudrate,
|
||||||
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
||||||
|
macos_drag: config.hid.mouse_macos_drag,
|
||||||
},
|
},
|
||||||
config::HidBackend::None => HidBackendType::None,
|
config::HidBackend::None => HidBackendType::None,
|
||||||
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||||
|
|||||||
@@ -175,14 +175,15 @@ impl UsbCoordinator {
|
|||||||
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
|
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
|
||||||
let keyboard_leds_changed =
|
let keyboard_leds_changed =
|
||||||
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||||
let ch9329_runtime_changed =
|
let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
|
||||||
old_config.ch9329_hybrid_mouse != new_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
|
if old_config.backend == new_config.backend
|
||||||
&& old_config.ch9329_port == new_config.ch9329_port
|
&& old_config.ch9329_port == new_config.ch9329_port
|
||||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||||
&& old_config.bluetooth == new_config.bluetooth
|
&& old_config.bluetooth == new_config.bluetooth
|
||||||
&& !ch9329_runtime_changed
|
&& !mouse_compatibility_changed
|
||||||
&& old_config.otg_udc == new_config.otg_udc
|
&& old_config.otg_udc == new_config.otg_udc
|
||||||
&& !descriptor_changed
|
&& !descriptor_changed
|
||||||
&& !hid_functions_changed
|
&& !hid_functions_changed
|
||||||
@@ -299,7 +300,7 @@ impl UsbCoordinator {
|
|||||||
|
|
||||||
if hid_config.backend == HidBackend::Otg && (options.force || old_enabled != new_enabled) {
|
if hid_config.backend == HidBackend::Otg && (options.force || old_enabled != new_enabled) {
|
||||||
self.hid
|
self.hid
|
||||||
.reload(HidBackendType::Otg)
|
.reload(hid_backend_type(hid_config))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| AppError::Config(format!("OTG HID reload failed: {error}")))?;
|
.map_err(|error| AppError::Config(format!("OTG HID reload failed: {error}")))?;
|
||||||
}
|
}
|
||||||
@@ -309,11 +310,14 @@ impl UsbCoordinator {
|
|||||||
|
|
||||||
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
||||||
match config.backend {
|
match config.backend {
|
||||||
HidBackend::Otg => HidBackendType::Otg,
|
HidBackend::Otg => HidBackendType::Otg {
|
||||||
|
macos_drag: config.mouse_macos_drag,
|
||||||
|
},
|
||||||
HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||||
port: config.ch9329_port.clone(),
|
port: config.ch9329_port.clone(),
|
||||||
baud_rate: config.ch9329_baudrate,
|
baud_rate: config.ch9329_baudrate,
|
||||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||||
|
macos_drag: config.mouse_macos_drag,
|
||||||
},
|
},
|
||||||
HidBackend::None => HidBackendType::None,
|
HidBackend::None => HidBackendType::None,
|
||||||
HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ impl AppState {
|
|||||||
|
|
||||||
pub async fn publish_device_info(&self) {
|
pub async fn publish_device_info(&self) {
|
||||||
let device_info = self.get_device_info().await;
|
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));
|
let _ = self.device_info_tx.send(Some(device_info));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ pub struct HidConfigUpdate {
|
|||||||
pub ch9329_port: Option<String>,
|
pub ch9329_port: Option<String>,
|
||||||
pub ch9329_baudrate: Option<u32>,
|
pub ch9329_baudrate: Option<u32>,
|
||||||
pub ch9329_hybrid_mouse: Option<bool>,
|
pub ch9329_hybrid_mouse: Option<bool>,
|
||||||
|
#[serde(alias = "ch9329_macos_drag")]
|
||||||
|
pub mouse_macos_drag: Option<bool>,
|
||||||
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||||
@@ -465,6 +467,9 @@ impl HidConfigUpdate {
|
|||||||
if let Some(enabled) = self.ch9329_hybrid_mouse {
|
if let Some(enabled) = self.ch9329_hybrid_mouse {
|
||||||
config.ch9329_hybrid_mouse = enabled;
|
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 {
|
if let Some(ref desc) = self.ch9329_descriptor {
|
||||||
desc.apply_to(&mut config.ch9329_descriptor);
|
desc.apply_to(&mut config.ch9329_descriptor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ mod tests {
|
|||||||
ch9329_port: None,
|
ch9329_port: None,
|
||||||
ch9329_baudrate: None,
|
ch9329_baudrate: None,
|
||||||
ch9329_hybrid_mouse: None,
|
ch9329_hybrid_mouse: None,
|
||||||
|
mouse_macos_drag: None,
|
||||||
ch9329_descriptor: None,
|
ch9329_descriptor: None,
|
||||||
otg_udc: None,
|
otg_udc: None,
|
||||||
otg_descriptor: None,
|
otg_descriptor: None,
|
||||||
|
|||||||
@@ -270,10 +270,8 @@ pub async fn msd_disk_mode_put(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let hid_is_otg = matches!(
|
let hid_backend_type = state.hid.backend_type().await;
|
||||||
state.hid.backend_type().await,
|
let hid_is_otg = matches!(hid_backend_type, crate::hid::HidBackendType::Otg { .. });
|
||||||
crate::hid::HidBackendType::Otg
|
|
||||||
);
|
|
||||||
|
|
||||||
if hid_is_otg {
|
if hid_is_otg {
|
||||||
state
|
state
|
||||||
@@ -294,7 +292,7 @@ pub async fn msd_disk_mode_put(
|
|||||||
let hid_reload_result = if hid_is_otg {
|
let hid_reload_result = if hid_is_otg {
|
||||||
state
|
state
|
||||||
.hid
|
.hid
|
||||||
.reload(crate::hid::HidBackendType::Otg)
|
.reload(hid_backend_type)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {e}")))
|
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {e}")))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -818,6 +818,9 @@ export default {
|
|||||||
ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip',
|
ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip',
|
||||||
ch9329HybridMouse: 'Linux Absolute Mouse Compatibility',
|
ch9329HybridMouse: 'Linux Absolute Mouse Compatibility',
|
||||||
ch9329HybridMouseDesc: 'Keep absolute movement on absolute packets, but send buttons and wheel through relative packets',
|
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',
|
ch9329Descriptor: 'CH9329 USB Device Descriptor',
|
||||||
ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing',
|
ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing',
|
||||||
ch9329DescriptorLoading: 'Reading CH9329 descriptor...',
|
ch9329DescriptorLoading: 'Reading CH9329 descriptor...',
|
||||||
|
|||||||
@@ -817,6 +817,9 @@ export default {
|
|||||||
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
|
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
|
||||||
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
|
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
|
||||||
ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送',
|
ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送',
|
||||||
|
mouseMacosDrag: 'macOS 拖拽兼容模式',
|
||||||
|
mouseMacosDragRequiresBoth: '请同时启用相对鼠标和绝对鼠标接口后保存。',
|
||||||
|
mouseMacosDragDesc: '实验性缓解绝对鼠标模式下的拖拽中断,适用于 OTG / CH9329。OTG 需同时启用两种鼠标接口。移动速度和落点可能受 macOS 设置影响;本版本尚未进行 Mac 实机验证。',
|
||||||
ch9329Descriptor: 'CH9329 USB 设备描述符',
|
ch9329Descriptor: 'CH9329 USB 设备描述符',
|
||||||
ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改',
|
ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改',
|
||||||
ch9329DescriptorLoading: '正在读取 CH9329 描述符...',
|
ch9329DescriptorLoading: '正在读取 CH9329 描述符...',
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface HidConfig {
|
|||||||
ch9329_port: string;
|
ch9329_port: string;
|
||||||
ch9329_baudrate: number;
|
ch9329_baudrate: number;
|
||||||
ch9329_hybrid_mouse?: boolean;
|
ch9329_hybrid_mouse?: boolean;
|
||||||
|
mouse_macos_drag?: boolean;
|
||||||
ch9329_descriptor?: Ch9329DescriptorConfig;
|
ch9329_descriptor?: Ch9329DescriptorConfig;
|
||||||
mouse_absolute: boolean;
|
mouse_absolute: boolean;
|
||||||
}
|
}
|
||||||
@@ -570,6 +571,7 @@ export interface HidConfigUpdate {
|
|||||||
ch9329_port?: string;
|
ch9329_port?: string;
|
||||||
ch9329_baudrate?: number;
|
ch9329_baudrate?: number;
|
||||||
ch9329_hybrid_mouse?: boolean;
|
ch9329_hybrid_mouse?: boolean;
|
||||||
|
mouse_macos_drag?: boolean;
|
||||||
ch9329_descriptor?: Ch9329DescriptorConfigUpdate;
|
ch9329_descriptor?: Ch9329DescriptorConfigUpdate;
|
||||||
otg_udc?: string;
|
otg_udc?: string;
|
||||||
otg_descriptor?: OtgDescriptorConfigUpdate;
|
otg_descriptor?: OtgDescriptorConfigUpdate;
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ const config = ref({
|
|||||||
} as OtgHidFunctions,
|
} as OtgHidFunctions,
|
||||||
hid_otg_keyboard_leds: false,
|
hid_otg_keyboard_leds: false,
|
||||||
hid_ch9329_hybrid_mouse: false,
|
hid_ch9329_hybrid_mouse: false,
|
||||||
|
hid_mouse_macos_drag: false,
|
||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_dir: '',
|
msd_dir: '',
|
||||||
msd_flash_inquiry_string: 'One-KVM Virtual Flash',
|
msd_flash_inquiry_string: 'One-KVM Virtual Flash',
|
||||||
@@ -1176,6 +1177,8 @@ const isCh9329DescriptorDirty = computed(() => {
|
|||||||
|
|
||||||
const isHidSettingsValid = computed(() =>
|
const isHidSettingsValid = computed(() =>
|
||||||
isHidFunctionSelectionValid.value
|
isHidFunctionSelectionValid.value
|
||||||
|
&& !(config.value.hid_backend === 'otg' && config.value.hid_mouse_macos_drag
|
||||||
|
&& (!effectiveOtgFunctions.value.mouse_relative || !effectiveOtgFunctions.value.mouse_absolute))
|
||||||
&& isCh9329DescriptorValid.value
|
&& isCh9329DescriptorValid.value
|
||||||
&& areMsdInquiryStringsValid.value
|
&& areMsdInquiryStringsValid.value
|
||||||
)
|
)
|
||||||
@@ -1454,7 +1457,12 @@ async function saveConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329'
|
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) {
|
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
||||||
hidUpdate.ch9329_descriptor = {
|
hidUpdate.ch9329_descriptor = {
|
||||||
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
||||||
@@ -1524,7 +1532,7 @@ async function saveConfig() {
|
|||||||
const hidFeatureBaseline = ref('')
|
const hidFeatureBaseline = ref('')
|
||||||
function hidFeatureSnapshot() {
|
function hidFeatureSnapshot() {
|
||||||
return JSON.stringify({
|
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],
|
descriptor: [otgVendorIdHex.value, otgProductIdHex.value, otgManufacturer.value, otgProduct.value, otgSerialNumber.value],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1566,6 +1574,7 @@ async function loadConfig() {
|
|||||||
} as OtgHidFunctions,
|
} as OtgHidFunctions,
|
||||||
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
|
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
|
||||||
hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? 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_enabled: msd.enabled || false,
|
||||||
msd_dir: msd.msd_dir || '',
|
msd_dir: msd.msd_dir || '',
|
||||||
msd_flash_inquiry_string: msd.flash_inquiry_string || 'One-KVM Virtual Flash',
|
msd_flash_inquiry_string: msd.flash_inquiry_string || 'One-KVM Virtual Flash',
|
||||||
@@ -3225,6 +3234,14 @@ watch(isWindows, () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OTG Descriptor Settings -->
|
<!-- 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'">
|
<template v-if="config.hid_backend === 'otg'">
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user