diff --git a/Cargo.toml b/Cargo.toml index d6f0346..d2637b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,9 @@ winresource = "0.1" anyhow = "1.0" axum = { version = "0.8", features = ["ws"] } directories = "6.0" -hidapi = "2.6.6" +# macos-shared-device: macOS otherwise seizes every opened device, which freezes +# the cursor when the opened interface is a mouse's pointer collection. +hidapi = { version = "2.6.6", features = ["macos-shared-device"] } notify-rust = "4.11" png = "0.18" semver = "1.0" diff --git a/native-hid/src/attack-shark-x11.mjs b/native-hid/src/attack-shark-x11.mjs index 8a62956..c8a71da 100644 --- a/native-hid/src/attack-shark-x11.mjs +++ b/native-hid/src/attack-shark-x11.mjs @@ -31,7 +31,7 @@ export function buildX11PollingReport(pollingRateHz) { export function applyX11PollingRate( pollingRateHz, - { infos = devices(), open = (path) => new HID(path) } = {}, + { infos = devices(), open = (path) => new HID(path, { nonExclusive: true }) } = {}, ) { const candidates = x11DeviceInfos(infos); if (candidates.length === 0) return false; diff --git a/native-hid/src/automation.test.mjs b/native-hid/src/automation.test.mjs index 0a9b0ef..8ebd428 100644 --- a/native-hid/src/automation.test.mjs +++ b/native-hid/src/automation.test.mjs @@ -209,3 +209,21 @@ test("manual adapter keeps its existing multi-path behavior without a live ident await device.close(); assert.deepEqual(calls, [["open", endpoint.path], ["open", "manual-second"], "close", "close"]); }); + +test("native opens are non-exclusive so macOS does not seize the device and freeze the cursor", async () => { + const options = []; + class FakeHID { + constructor(path, opts) { options.push(opts); } + getDeviceInfo() { return { ...endpoint }; } + on() {} + removeAllListeners() {} + close() {} + } + const manual = new HidDeviceAdapter([endpoint, { ...endpoint, path: "manual-second" }], null, FakeHID); + await manual.open(); + await manual.close(); + const verified = new HidDeviceAdapter([endpoint], endpoint, FakeHID); + await verified.open(); + await verified.close(); + assert.deepEqual(options, [{ nonExclusive: true }, { nonExclusive: true }, { nonExclusive: true }]); +}); diff --git a/native-hid/src/hid-device-adapter.mjs b/native-hid/src/hid-device-adapter.mjs index 58af888..37693d0 100644 --- a/native-hid/src/hid-device-adapter.mjs +++ b/native-hid/src/hid-device-adapter.mjs @@ -27,6 +27,12 @@ import { HID, devices } from "node-hid"; const REPLAY_ID_MAX = 255; +// macOS seizes a device by default when it is opened, which detaches it from +// the system: a mouse's own pointer collection stops moving the cursor for as +// long as Bridge holds it. Shared opens leave the OS driver in place. The flag +// is ignored on Windows and Linux. +const OPEN_OPTIONS = { nonExclusive: true }; + /** * @param {import("node-hid").Device[]} infos - every node-hid entry that * splits off the same underlying USB/BT interface (see module docs above). @@ -59,7 +65,7 @@ export class HidDeviceAdapter { let device; try { if (this._infos.length !== 1) throw new Error("native live identity requires one endpoint"); - device = new this._Handle(this._infos[0].path); + device = new this._Handle(this._infos[0].path, OPEN_OPTIONS); const live = device.getDeviceInfo(); const fields = ["vendorId", "productId", "path", "serialNumber", "interface"]; if (!live || fields.some((field) => live[field] === undefined || live[field] !== this._identity[field])) throw new Error("native live handle identity is missing or changed"); @@ -80,7 +86,7 @@ export class HidDeviceAdapter { let lastError = null; for (const info of this._infos) { try { - const device = new this._Handle(info.path); + const device = new this._Handle(info.path, OPEN_OPTIONS); device.on("data", this._onData); device.on("error", this._onError); openedDevices.push(device); diff --git a/src/hid.rs b/src/hid.rs index 0e01322..d3697d1 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -1545,6 +1545,15 @@ async fn send_json(socket: &mut impl HidSocket, value: &impl Serialize) -> Resul #[cfg(test)] mod tests { + /// macOS seizes devices opened in exclusive mode, which stops a mouse's + /// pointer collection from moving the cursor while Bridge holds it. + #[cfg(target_os = "macos")] + #[test] + fn macos_opens_devices_without_seizing_them() { + let api = HidApi::new().expect("HID subsystem should initialize"); + assert!(!api.get_open_exclusive()); + } + use super::*; /// Logitech receivers also split their report paths across several HID