diff --git a/package-lock.json b/package-lock.json index 2985c3a..0fa8180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,11 @@ "": { "name": "openmouse", "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.11.1" + }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "typescript": "^5.8.3", "vite": "^6.3.5" } @@ -804,6 +808,233 @@ "win32" ] }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index 48f5916..56f048c 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,17 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "test": "node --test src/egg-we-protocol.test.ts", - "preview": "vite preview" + "preview": "vite preview", + "tauri": "tauri", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "typescript": "^5.8.3", "vite": "^6.3.5" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.1" } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..e5febfa --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "app" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.6.3" } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +tauri = { version = "2.11.3" } +tauri-plugin-log = "2" +# Native HID I/O for the desktop build. Requires hidapi >= 2.6 for +# `HidDevice::get_report_descriptor` (used by src/hid/descriptor.rs to +# reconstruct the same collections/report tree WebHID exposes to the +# frontend). Update: hidapi links libusb-based hidraw on Linux, which needs +# the udev dev headers (`libudev-dev` on Debian/Ubuntu) available at build +# time — see README for the desktop build prerequisites. +hidapi = "2.6" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..c135d7f --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..77e7d23 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0f7976f Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..98fda06 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..f35d84f Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..1823bb2 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..dc2b22c Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..0ed3984 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..60bf0ea Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..c8ca0ad Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..8756459 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..2c8023c Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..2c5e603 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..17d142c Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..a2993ad Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..d1756ce Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/hid/commands.rs b/src-tauri/src/hid/commands.rs new file mode 100644 index 0000000..3cea599 --- /dev/null +++ b/src-tauri/src/hid/commands.rs @@ -0,0 +1,264 @@ +//! Tauri commands backing `hardware/native.ts`. +//! +//! Command names and payload shapes here are the wire contract with the +//! frontend — every `invoke("hid_...")` call in `native.ts` has a matching +//! `#[tauri::command]` below with the same argument names (Tauri maps JS +//! camelCase args to Rust snake_case params automatically). +//! +//! NOTE: written without a Rust toolchain available to compile/test in this +//! environment. Run `cargo check` before relying on this — see the README +//! section this PR adds for the desktop build prerequisites (in particular +//! `hidapi`'s system dependencies and the `get_report_descriptor` version +//! floor noted in `Cargo.toml`). + +use std::ffi::CString; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +use super::descriptor::{self, CollectionInfo}; +use super::state::{HidManager, OpenDevice}; + +/// Fallback buffer size for a feature report whose length we couldn't +/// determine from the parsed descriptor (e.g. `get_report_descriptor` +/// unsupported on this platform/device). Generous relative to every report +/// size used by the drivers in this repo (all comfortably under 32 bytes). +const FEATURE_REPORT_FALLBACK_LEN: usize = 64; +/// Buffer size for the background input-report reader. HID reports on these +/// devices top out well under this; oversized reads are simply truncated to +/// what the device actually sent. +const INPUT_REPORT_BUFFER_LEN: usize = 64; +/// How long each blocking read waits before looping back to check the stop +/// flag. Bounds how long `hid_close` can block joining the reader thread. +const READ_TIMEOUT_MS: i32 = 250; + +#[derive(Deserialize, Default, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct HidFilterArg { + pub vendor_id: Option, + pub product_id: Option, + pub usage_page: Option, + pub usage: Option, +} + +fn matches_filters(info: &hidapi::DeviceInfo, filters: &[HidFilterArg]) -> bool { + if filters.is_empty() { + return true; + } + filters.iter().any(|f| { + f.vendor_id.map_or(true, |v| v == info.vendor_id()) + && f.product_id.map_or(true, |v| v == info.product_id()) + && f.usage_page.map_or(true, |v| v == info.usage_page()) + && f.usage.map_or(true, |v| v == info.usage()) + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HidDeviceSummary { + pub path: String, + pub vendor_id: u16, + pub product_id: u16, + pub product_name: String, +} + +/// Backs both `hid.getDevices()` (filters: []) and `hid.requestDevice()` +/// (filters: caller-supplied) on the native transport — see the doc comment +/// on `NativeHidTransport.requestDevice` in `native.ts` for why native mode +/// collapses those two into one enumeration call (no OS permission prompt +/// step to gate on, unlike WebHID). +#[tauri::command] +pub fn hid_list_devices( + manager: State, + filters: Vec, +) -> Result, String> { + manager.with_api(|api| { + Ok(api + .device_list() + .filter(|info| matches_filters(info, &filters)) + .map(|info| HidDeviceSummary { + path: info.path().to_string_lossy().into_owned(), + vendor_id: info.vendor_id(), + product_id: info.product_id(), + product_name: info.product_string().unwrap_or("HID device").to_string(), + }) + .collect()) + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HidOpenResult { + pub collections: Vec, +} + +#[tauri::command] +pub fn hid_open(manager: State, path: String) -> Result { + let mut open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + if let Some(existing) = open.get(&path) { + return Ok(HidOpenResult { collections: existing.collections.clone() }); + } + + let device = manager.with_api(|api| { + let path_c = CString::new(path.clone()).map_err(|e| e.to_string())?; + api.open_path(&path_c).map_err(|e| e.to_string()) + })?; + + let mut descriptor_buf = [0u8; 4096]; + let collections = match device.get_report_descriptor(&mut descriptor_buf) { + Ok(len) => descriptor::parse(&descriptor_buf[..len]), + // Descriptor readback isn't universally supported (older hidapi, + // some platform/device combos). Drivers that only rely on fixed + // report IDs still function without it — only `.collections`-based + // usage-page sniffing degrades, gracefully, to "not found". + Err(_) => Vec::new(), + }; + + device.set_blocking_mode(true).map_err(|e| e.to_string())?; + + open.insert( + path, + OpenDevice { + device, + collections: collections.clone(), + reader: None, + stop: Arc::new(AtomicBool::new(false)), + }, + ); + + Ok(HidOpenResult { collections }) +} + +#[tauri::command] +pub fn hid_close(manager: State, path: String) -> Result<(), String> { + let mut open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + if let Some(entry) = open.remove(&path) { + entry.stop.store(true, Ordering::SeqCst); + if let Some(handle) = entry.reader { + // Bounded by READ_TIMEOUT_MS — the reader thread checks the stop + // flag once per read timeout. + let _ = handle.join(); + } + // `entry.device` (and the reader thread's own separate handle, + // already dropped when the thread exited above) close on drop here. + } + Ok(()) +} + +#[tauri::command] +pub fn hid_send_report( + manager: State, + path: String, + report_id: u8, + data: Vec, +) -> Result<(), String> { + let open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + let entry = open.get(&path).ok_or("device not open")?; + let mut buf = Vec::with_capacity(data.len() + 1); + buf.push(report_id); + buf.extend_from_slice(&data); + entry.device.write(&buf).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn hid_send_feature_report( + manager: State, + path: String, + report_id: u8, + data: Vec, +) -> Result<(), String> { + let open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + let entry = open.get(&path).ok_or("device not open")?; + let mut buf = Vec::with_capacity(data.len() + 1); + buf.push(report_id); + buf.extend_from_slice(&data); + entry.device.send_feature_report(&buf).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn hid_receive_feature_report( + manager: State, + path: String, + report_id: u8, +) -> Result, String> { + let open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + let entry = open.get(&path).ok_or("device not open")?; + let len = descriptor::feature_report_length(&entry.collections, report_id as u32) + .unwrap_or(FEATURE_REPORT_FALLBACK_LEN) + .max(1); + // hidapi convention: buf[0] is the report id (set before the call), the + // OS fills buf[1..] with the report body and returns the total count + // including that leading id byte. + let mut buf = vec![0u8; len + 1]; + buf[0] = report_id; + let read = entry.device.get_feature_report(&mut buf).map_err(|e| e.to_string())?; + Ok(buf[1..read.max(1)].to_vec()) +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct InputReportPayload { + path: String, + report_id: u32, + data: Vec, +} + +/// Idempotent: safe to call every time `NativeHidDevice.open()` runs. Spawns +/// a dedicated blocking-read thread for `path` on first call and no-ops on +/// subsequent ones. Reports are pushed to the frontend as `hid://input-report` +/// events (see the single `listen()` subscription in `native.ts`, which +/// demuxes by `path` back to the right `NativeHidDevice` instance) rather +/// than returned from this command, since Rust can't call back into a +/// pending `invoke()` — there's no synchronous equivalent of WebHID's +/// `"inputreport"` DOM event to piggyback on. +#[tauri::command] +pub fn hid_watch_input_reports( + app: AppHandle, + manager: State, + path: String, +) -> Result<(), String> { + let mut open = manager.open.lock().map_err(|_| "hid state lock poisoned".to_string())?; + let entry = open.get_mut(&path).ok_or("device not open")?; + if entry.reader.is_some() { + return Ok(()); + } + + // A second handle to the same path, dedicated to the blocking read loop, + // so command calls (write / feature reports) on `entry.device` never + // block behind it. hidapi opens HID paths non-exclusively on every + // platform this repo targets (Windows/macOS/Linux), which is what makes + // this safe — verify against the pinned hidapi version if you see + // "device busy" style errors here. + let reader_device = manager.with_api(|api| { + let path_c = CString::new(path.clone()).map_err(|e| e.to_string())?; + api.open_path(&path_c).map_err(|e| e.to_string()) + })?; + reader_device.set_blocking_mode(false).map_err(|e| e.to_string())?; + + let stop = entry.stop.clone(); + let emit_path = path.clone(); + let handle = std::thread::spawn(move || { + let mut buf = [0u8; INPUT_REPORT_BUFFER_LEN]; + while !stop.load(Ordering::SeqCst) { + match reader_device.read_timeout(&mut buf, READ_TIMEOUT_MS) { + Ok(0) => continue, // timeout, nothing to report + Ok(len) => { + let report_id = buf[0] as u32; + let data = buf[1..len].to_vec(); + let _ = app.emit( + "hid://input-report", + InputReportPayload { path: emit_path.clone(), report_id, data }, + ); + } + Err(_) => break, // device unplugged or errored — stop quietly + } + } + }); + + entry.reader = Some(handle); + Ok(()) +} diff --git a/src-tauri/src/hid/descriptor.rs b/src-tauri/src/hid/descriptor.rs new file mode 100644 index 0000000..b900af0 --- /dev/null +++ b/src-tauri/src/hid/descriptor.rs @@ -0,0 +1,217 @@ +//! Minimal HID Report Descriptor parser. +//! +//! `hidapi`'s enumeration only exposes a device's *top-level* usage_page/usage +//! (one hidapi "device" per top-level collection — the same granularity +//! WebHID uses). It does not parse the descriptor into the input/output/ +//! feature report tree the frontend drivers inspect via `HIDDevice.collections` +//! (see `mouse-types.ts` and the `isSupported()` / `describeHidDevice()` +//! helpers in each `*-hid.ts` file). This module rebuilds that tree from the +//! raw descriptor bytes (`HidDevice::get_report_descriptor`) so native mode +//! can hand the frontend the same shape a browser would, letting +//! `hardware/native.ts` satisfy the `HIDDevice` interface without the +//! per-driver support-detection logic needing to change. +//! +//! Only short items are handled — long items (prefix 0xFE) are vanishingly +//! rare in practice for HID mice and are skipped rather than interpreted. +//! Reference: USB HID spec v1.11 §6.2.2. +//! +//! NOTE: written without a Rust toolchain available to compile/test it in +//! this environment — review it against a real descriptor dump (see +//! `hid_open`'s doc comment) before shipping. + +use serde::Serialize; + +#[derive(Serialize, Clone, Debug, Default)] +pub struct ReportItem { + #[serde(rename = "reportSize")] + pub report_size: u32, + #[serde(rename = "reportCount")] + pub report_count: u32, +} + +#[derive(Serialize, Clone, Debug, Default)] +pub struct ReportInfo { + #[serde(rename = "reportId")] + pub report_id: u32, + pub items: Vec, +} + +#[derive(Serialize, Clone, Debug, Default)] +pub struct CollectionInfo { + #[serde(rename = "usagePage")] + pub usage_page: u32, + pub usage: u32, + #[serde(rename = "inputReports")] + pub input_reports: Vec, + #[serde(rename = "outputReports")] + pub output_reports: Vec, + #[serde(rename = "featureReports")] + pub feature_reports: Vec, + pub children: Vec, +} + +#[derive(Clone, Default)] +struct GlobalState { + usage_page: u32, + report_size: u32, + report_count: u32, + report_id: u32, +} + +enum ReportKind { + Input, + Output, + Feature, +} + +fn push_report(list: &mut Vec, report_id: u32, item: ReportItem) { + if let Some(existing) = list.iter_mut().find(|r| r.report_id == report_id) { + existing.items.push(item); + } else { + list.push(ReportInfo { report_id, items: vec![item] }); + } +} + +/// Parse a raw HID report descriptor into the same tree shape as +/// `HIDDevice.collections`. Malformed input degrades gracefully — parsing +/// stops at the first byte it can't interpret and returns whatever +/// collections were already closed, rather than panicking. +pub fn parse(descriptor: &[u8]) -> Vec { + let mut root: Vec = Vec::new(); + // Stack of currently-open collections, outermost first. + let mut stack: Vec = Vec::new(); + let mut global = GlobalState::default(); + let mut global_stack: Vec = Vec::new(); + // Local Usage state: only the first Usage before the next Main item + // matters here, since it's the one a following Collection() attaches to. + let mut pending_usage: Option = None; + + let mut i = 0usize; + while i < descriptor.len() { + let prefix = descriptor[i]; + + if prefix == 0xFE { + // Long item: 0xFE, data-size byte, tag byte, then data-size bytes. + if i + 1 >= descriptor.len() { + break; + } + let data_size = descriptor[i + 1] as usize; + i += 3 + data_size; + continue; + } + + let size_code = prefix & 0b0000_0011; + let item_type = (prefix >> 2) & 0b0000_0011; + let tag = (prefix >> 4) & 0b0000_1111; + let data_len = match size_code { + 0 => 0, + 1 => 1, + 2 => 2, + _ => 4, + }; + if i + 1 + data_len > descriptor.len() { + break; + } + let data = &descriptor[i + 1..i + 1 + data_len]; + let value: u32 = data.iter().rev().fold(0u32, |acc, &b| (acc << 8) | b as u32); + i += 1 + data_len; + + match item_type { + // Global item. + 1 => match tag { + 0 => global.usage_page = value, + 7 => global.report_size = value, + 8 => global.report_id = value, + 9 => global.report_count = value, + 10 => global_stack.push(global.clone()), + 11 => { + if let Some(saved) = global_stack.pop() { + global = saved; + } + } + _ => {} + }, + // Local item. + 2 => { + if tag == 0 && pending_usage.is_none() { + pending_usage = Some(value); + } + } + // Main item. + 0 => { + match tag { + // Collection + 10 => stack.push(CollectionInfo { + usage_page: global.usage_page, + usage: pending_usage.unwrap_or(0), + ..Default::default() + }), + // End Collection + 12 => { + if let Some(finished) = stack.pop() { + match stack.last_mut() { + Some(parent) => parent.children.push(finished), + None => root.push(finished), + } + } + } + // Input / Output / Feature + 8 | 9 | 11 => { + let kind = match tag { + 8 => ReportKind::Input, + 9 => ReportKind::Output, + _ => ReportKind::Feature, + }; + if let Some(current) = stack.last_mut() { + let item = ReportItem { + report_size: global.report_size, + report_count: global.report_count, + }; + let list = match kind { + ReportKind::Input => &mut current.input_reports, + ReportKind::Output => &mut current.output_reports, + ReportKind::Feature => &mut current.feature_reports, + }; + push_report(list, global.report_id, item); + } + } + _ => {} + } + // All Main items clear local state (HID spec §6.2.2.8). + pending_usage = None; + } + _ => {} + } + } + + // Flush any collections left open by a truncated/malformed descriptor + // instead of silently dropping their reports. + while let Some(finished) = stack.pop() { + match stack.last_mut() { + Some(parent) => parent.children.push(finished), + None => root.push(finished), + } + } + + root +} + +/// Byte length of a report by id, searched recursively across the whole +/// collection tree. Mirrors `reportPayloadLength()` in `egg-we-hid.ts`. Used +/// to size buffers for `hid_receive_feature_report` without the JS caller +/// needing to know the descriptor. +pub fn feature_report_length(collections: &[CollectionInfo], report_id: u32) -> Option { + fn visit(collections: &[CollectionInfo], report_id: u32) -> Option { + for collection in collections { + if let Some(report) = collection.feature_reports.iter().find(|r| r.report_id == report_id) { + let bits: u32 = report.items.iter().map(|i| i.report_size * i.report_count).sum(); + return Some(((bits + 7) / 8) as usize); + } + if let Some(found) = visit(&collection.children, report_id) { + return Some(found); + } + } + None + } + visit(collections, report_id) +} diff --git a/src-tauri/src/hid/mod.rs b/src-tauri/src/hid/mod.rs new file mode 100644 index 0000000..e950891 --- /dev/null +++ b/src-tauri/src/hid/mod.rs @@ -0,0 +1,10 @@ +//! Native HID Abstraction Layer backend: the Rust half of the HAL described +//! in `src/hardware/`. Talks to real hardware via `hidapi`; exposes it to +//! the frontend as Tauri commands matched 1:1 with the `invoke()` calls in +//! `hardware/native.ts`. + +pub mod commands; +pub mod descriptor; +pub mod state; + +pub use state::HidManager; diff --git a/src-tauri/src/hid/state.rs b/src-tauri/src/hid/state.rs new file mode 100644 index 0000000..9581311 --- /dev/null +++ b/src-tauri/src/hid/state.rs @@ -0,0 +1,48 @@ +//! App-managed state for native HID access. +//! +//! One `HidManager` lives in Tauri's state container (see `lib.rs`). It owns +//! the shared `HidApi` enumeration handle and a registry of currently-open +//! devices keyed by hidapi's device `path` — the same string the frontend +//! uses as a device identity (see `hardware/native.ts`'s `NativeHidDevice`). + +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use hidapi::{HidApi, HidDevice}; + +use super::descriptor::CollectionInfo; + +pub struct OpenDevice { + /// Handle used by command calls (write / feature reports). The + /// background reader thread (if any) uses its own separate handle to + /// the same path — see `commands::hid_watch_input_reports` — so this one + /// stays free for synchronous request/response calls without racing the + /// blocking read loop. + pub device: HidDevice, + pub collections: Vec, + pub reader: Option>, + pub stop: Arc, +} + +#[derive(Default)] +pub struct HidManager { + api: Mutex>, + pub open: Mutex>, +} + +impl HidManager { + /// Runs `f` with a live, freshly-refreshed `HidApi`, initializing it on + /// first use. Held behind a mutex because `HidApi` is not `Sync`. + pub fn with_api(&self, f: impl FnOnce(&mut HidApi) -> Result) -> Result { + let mut guard = self.api.lock().map_err(|_| "hid api lock poisoned".to_string())?; + if guard.is_none() { + *guard = Some(HidApi::new().map_err(|e| e.to_string())?); + } + let api = guard.as_mut().expect("just initialized above"); + // Pick up devices plugged in since the last call (hot-plug). + api.refresh_devices().map_err(|e| e.to_string())?; + f(api) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..70ff7ff --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,30 @@ +mod hid; + +use hid::HidManager; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(HidManager::default()) + .invoke_handler(tauri::generate_handler![ + hid::commands::hid_list_devices, + hid::commands::hid_open, + hid::commands::hid_close, + hid::commands::hid_send_report, + hid::commands::hid_send_feature_report, + hid::commands::hid_receive_feature_report, + hid::commands::hid_watch_input_reports, + ]) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..ad5fe83 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..97b3f3a --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "openmouse", + "version": "0.1.0", + "identifier": "app.openmouse.desktop", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [ + { + "title": "OpenMouse", + "url": "/control-app.html", + "width": 1080, + "height": 720, + "minWidth": 860, + "minHeight": 560, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "android": { + "debugApplicationIdSuffix": ".debug" + } + } +} diff --git a/src/control.ts b/src/control.ts index 226a4eb..986e1a4 100644 --- a/src/control.ts +++ b/src/control.ts @@ -1,4 +1,5 @@ import "./control.css"; +import { hid } from "./hardware/bridge"; import { EGG_BUTTON_MAPPINGS, EGG_BUTTON_NAMES, @@ -513,8 +514,8 @@ function renderControl(): void { }); populateInterfaceSettings(); applyInterfacePreferences(); - navigator.hid?.addEventListener("connect", handleHidConnect); - navigator.hid?.addEventListener("disconnect", handleHidDisconnect); + hid.addEventListener("connect", handleHidConnect); + hid.addEventListener("disconnect", handleHidDisconnect); void reconnectAuthorizedDevice(); } @@ -981,7 +982,7 @@ function listLogicalDevices(devices?: HIDDevice[]): HIDDevice[] { async function renderDeviceSidebar(devices?: HIDDevice[]): Promise { const list = document.querySelector("#sidebar-device-list"); if (!list) return; - const all = devices ?? await navigator.hid?.getDevices() ?? []; + const all = devices ?? await hid.getDevices(); const supportedDevices = listLogicalDevices(all); if (supportedDevices.length === 0) { list.innerHTML = `
No device connectedChoose a supported device
`; @@ -1006,7 +1007,7 @@ async function renderDeviceSidebar(devices?: HIDDevice[]): Promise { async function selectAuthorizedDevice(index: number): Promise { if (settingInProgress || refreshInProgress) return; - const devices = listLogicalDevices(await navigator.hid?.getDevices() ?? []); + const devices = listLogicalDevices(await hid.getDevices()); const device = devices[index]; if (!device || device === activeDevice) return; const client = createSupportedClient(device); @@ -1111,7 +1112,7 @@ function handleHidConnect(event: HIDConnectionEvent): void { if (isEggWeClient(client)) { void (async () => { - const all = await navigator.hid?.getDevices() ?? []; + const all = await hid.getDevices(); await renderDeviceSidebar(all); const result = await eggWeResolveConnect(event.device, activeEggWeClient, activeDevice, all); if (result.action === "ignore") return; @@ -1159,7 +1160,7 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { } showDisconnectedState(); void (async () => { - const devices = (await navigator.hid?.getDevices() ?? []) + const devices = (await hid.getDevices()) .filter((device) => device !== event.device); const logical = listLogicalDevices(devices); const replacement = logical @@ -1177,8 +1178,7 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { } async function requestSupportedClient(): Promise { - if (!navigator.hid) throw new Error("WebHID is unavailable. Use Chrome or Edge on desktop."); - const devices = await navigator.hid.requestDevice({ + const devices = await hid.requestDevice({ filters: SUPPORTED_HID_FILTERS, }); if (devices.length === 0) return null; @@ -1298,7 +1298,7 @@ async function reconnectAuthorizedDevice(): Promise { if (delay) await waitForHidChange(delay); if (hasActiveClient()) return; - const devices = await navigator.hid?.getDevices() ?? []; + const devices = await hid.getDevices(); const clients = listLogicalDevices(devices) .map((device) => ({ client: createSupportedClient(device), score: clientSupportScore(device) })) .filter((entry): entry is { client: SupportedClient; score: number } => entry.client !== null) @@ -1339,13 +1339,7 @@ function setConnectionButtons(disabled: boolean, label: string): void { }); } -function wait(milliseconds: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); -} - function waitForHidChange(milliseconds: number): Promise { - const hid = navigator.hid; - if (!hid) return wait(milliseconds); return new Promise((resolve) => { const finish = (): void => { window.clearTimeout(timer); @@ -1840,8 +1834,8 @@ async function sendStatusToDiscord(): Promise { window.addEventListener("beforeunload", () => { if (refreshTimer !== null) window.clearInterval(refreshTimer); - navigator.hid?.removeEventListener("connect", handleHidConnect); - navigator.hid?.removeEventListener("disconnect", handleHidDisconnect); + hid.removeEventListener("connect", handleHidConnect); + hid.removeEventListener("disconnect", handleHidDisconnect); void activeClient?.close(); void activePulsarClient?.close(); void activeEggClient?.close(); diff --git a/src/egg-we-control.ts b/src/egg-we-control.ts index 9b147c7..ee34fd4 100644 --- a/src/egg-we-control.ts +++ b/src/egg-we-control.ts @@ -4,6 +4,7 @@ * Keeps multi-collection HID discovery, peer bind, and connect coalescing out * of control.ts so other brand PRs do not touch WE-specific paths. */ +import { hid } from "./hardware/bridge"; import { EggWeHidClient } from "./egg-we-hid"; export type { EggWeHidClient }; @@ -47,9 +48,7 @@ export function eggWeFromAuthorized(devices: readonly HIDDevice[]): EggWeHidClie export async function eggWeAuthorizedPool( selected: readonly HIDDevice[], ): Promise { - const granted = typeof navigator !== "undefined" && navigator.hid - ? await navigator.hid.getDevices() - : []; + const granted = await hid.getDevices(); return [...selected, ...granted].filter( (device, index, list) => list.indexOf(device) === index, ); @@ -59,10 +58,7 @@ export async function eggWePrepare( client: EggWeHidClient, devices?: readonly HIDDevice[], ): Promise { - const all = devices - ?? (typeof navigator !== "undefined" && navigator.hid - ? await navigator.hid.getDevices() - : []); + const all = devices ?? await hid.getDevices(); client.bindPeers(all); } diff --git a/src/egg-we-hid.ts b/src/egg-we-hid.ts index c702412..27eee47 100644 --- a/src/egg-we-hid.ts +++ b/src/egg-we-hid.ts @@ -1,4 +1,5 @@ import type { MouseStatus } from "./mouse-types"; +import { hid } from "./hardware/bridge"; import { WE_CMD_GET_POWER, WE_CMD_READ_EEPROM, @@ -171,10 +172,7 @@ export class EggWeHidClient { async open(): Promise { if (!this.channelsResolved) { - const authorized = typeof navigator !== "undefined" && navigator.hid - ? await navigator.hid.getDevices() - : []; - this.bindPeers(authorized); + this.bindPeers(await hid.getDevices()); } const channels = this.resolvedChannels(); if (!channels) { diff --git a/src/hardware/bridge.ts b/src/hardware/bridge.ts new file mode 100644 index 0000000..f1d1c03 --- /dev/null +++ b/src/hardware/bridge.ts @@ -0,0 +1,22 @@ +import type { HidTransport, HidTransportKind } from "./types"; +import { webHidTransport } from "./webHID"; +import { nativeHidTransport } from "./native"; + +/** + * True when running inside the Tauri desktop shell. `__TAURI_INTERNALS__` is + * injected by Tauri's webview preload script and is not present in a regular + * browser tab, which is what makes this safe to branch on. + */ +export function isDesktopApp(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export const hidTransportKind: HidTransportKind = isDesktopApp() ? "native" : "web"; + +/** + * The single entry point for HID access in this app. Every file that used to + * call `navigator.hid` directly should import this instead — the objects it + * hands back satisfy the same `HIDDevice`/`HID` shapes either way, so no + * other driver or UI code needs to know which transport is active. + */ +export const hid: HidTransport = hidTransportKind === "native" ? nativeHidTransport : webHidTransport; diff --git a/src/hardware/native.ts b/src/hardware/native.ts new file mode 100644 index 0000000..90fc6d6 --- /dev/null +++ b/src/hardware/native.ts @@ -0,0 +1,176 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +import type { + HidCollectionInfo, + HidFilter, + HidTransport, + HidTransportDevice, +} from "./types"; + +/** + * Desktop transport. Mirrors `navigator.hid` by proxying to Rust commands + * registered in `src-tauri/src/hid/commands.rs`, which talk to real hardware + * through the `hidapi` crate. See that module for the wire format. + * + * Every device this hands back implements the same `HIDDevice` shape a real + * WebHID device does (open/close/sendReport/receiveFeatureReport/collections/ + * "inputreport" events), so driver code in egg-we-hid.ts, pulsar-hid.ts, + * logitech-hidpp.ts, wlmouse-hid.ts, and egg-op1-hid.ts needs no changes to + * run against it. + */ + +interface NativeDeviceSummary { + path: string; + vendorId: number; + productId: number; + productName: string; +} + +interface NativeOpenResult { + collections: HidCollectionInfo[]; +} + +interface NativeInputReportPayload { + path: string; + reportId: number; + data: number[]; +} + +function toByteArray(data: BufferSource): number[] { + const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + return Array.from(view); +} + +/** path -> live device instance, so incoming Tauri events can be demuxed to the right one. */ +const openDevices = new Map(); + +let inputReportListenerReady: Promise | null = null; + +function ensureInputReportListener(): Promise { + if (!inputReportListenerReady) { + inputReportListenerReady = listen("hid://input-report", (event) => { + const device = openDevices.get(event.payload.path); + if (!device) return; + const bytes = new Uint8Array(event.payload.data); + const reportEvent = new CustomEvent("inputreport", { + detail: { + device, + reportId: event.payload.reportId, + data: new DataView(bytes.buffer), + }, + }) as unknown as HIDInputReportEvent; + device.dispatchEvent(reportEvent); + }).then(() => undefined); + } + return inputReportListenerReady; +} + +class NativeHidDevice extends EventTarget implements HidTransportDevice { + opened = false; + collections: readonly HidCollectionInfo[] = []; + + constructor( + readonly path: string, + readonly vendorId: number, + readonly productId: number, + readonly productName: string, + ) { + super(); + } + + // Overload so this satisfies both EventTarget's base signature (required + // because we `extends EventTarget`) and HIDDevice's narrower "inputreport" + // signature (required because we `implements HidTransportDevice`). + addEventListener(type: "inputreport", listener: (event: HIDInputReportEvent) => void): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature only, see overloads above + addEventListener(type: string, listener: any, options?: any): void { + super.addEventListener(type, listener, options); + } + + removeEventListener(type: "inputreport", listener: (event: HIDInputReportEvent) => void): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature only, see overloads above + removeEventListener(type: string, listener: any, options?: any): void { + super.removeEventListener(type, listener, options); + } + + async open(): Promise { + if (this.opened) return; + await ensureInputReportListener(); + const result = await invoke("hid_open", { path: this.path }); + this.collections = result.collections; + this.opened = true; + openDevices.set(this.path, this); + await invoke("hid_watch_input_reports", { path: this.path }); + } + + async close(): Promise { + if (!this.opened) return; + openDevices.delete(this.path); + this.opened = false; + await invoke("hid_close", { path: this.path }); + } + + async sendReport(reportId: number, data: BufferSource): Promise { + await invoke("hid_send_report", { path: this.path, reportId, data: toByteArray(data) }); + } + + async sendFeatureReport(reportId: number, data: BufferSource): Promise { + await invoke("hid_send_feature_report", { path: this.path, reportId, data: toByteArray(data) }); + } + + async receiveFeatureReport(reportId: number): Promise { + const bytes = await invoke("hid_receive_feature_report", { + path: this.path, + reportId, + }); + return new DataView(new Uint8Array(bytes).buffer); + } +} + +class NativeHidTransport extends EventTarget implements HidTransport { + private async listSummaries(filters: HidFilter[]): Promise { + return invoke("hid_list_devices", { filters }); + } + + private toDevices(summaries: NativeDeviceSummary[]): HidTransportDevice[] { + return summaries.map((summary) => { + const existing = openDevices.get(summary.path); + if (existing) return existing; + return new NativeHidDevice(summary.path, summary.vendorId, summary.productId, summary.productName); + }); + } + + async getDevices(): Promise { + return this.toDevices(await this.listSummaries([])); + } + + /** + * WebHID's requestDevice() shows a browser permission picker. Native HID + * access has no such per-device consent step (the OS may still show its + * own prompt the first time we open a device, e.g. macOS Input Monitoring) + * — so this just returns everything currently matching `filters`. + */ + async requestDevice(options: { filters: HidFilter[] }): Promise { + return this.toDevices(await this.listSummaries(options.filters)); + } + + // Native HID has no OS-level connect/disconnect events wired up yet. + // Polling via getDevices() covers the current UI's needs; if hot-plug + // notifications become necessary, add a Rust-side watcher that emits + // "hid://connect" / "hid://disconnect" and forward them here. + addEventListener(): void {} + removeEventListener(): void {} +} + +export const nativeHidTransport: HidTransport = new NativeHidTransport(); diff --git a/src/hardware/types.ts b/src/hardware/types.ts new file mode 100644 index 0000000..22a3b6c --- /dev/null +++ b/src/hardware/types.ts @@ -0,0 +1,30 @@ +/** + * Transport-agnostic HID types. + * + * We deliberately do NOT invent a parallel type hierarchy. `webhid.d.ts` + * already declares the exact shape every driver in this repo is written + * against (HIDDevice, HIDCollectionInfo, HIDReportInfo, ...). Those are + * ambient/global interfaces, so any object that structurally matches them + * — whether it wraps a real `navigator.hid` device or proxies to the Rust + * side over Tauri IPC — satisfies driver code unmodified. + * + * This file just gives those ambient shapes module-local names so the + * rest of `hardware/` can import them like normal types. + */ + +export type HidTransportDevice = HIDDevice; +export type HidCollectionInfo = HIDCollectionInfo; +export type HidReportInfo = HIDReportInfo; +export type HidReportItem = HIDReportItem; +export type HidInputReportEvent = HIDInputReportEvent; +export type HidConnectionEvent = HIDConnectionEvent; +export type HidFilter = HIDDeviceFilter; + +/** + * Transport-agnostic stand-in for `navigator.hid`. `bridge.ts` picks one + * implementation at module load and every call site in the app imports + * `hid` from `bridge.ts` instead of touching `navigator.hid` directly. + */ +export type HidTransport = HID; + +export type HidTransportKind = "web" | "native"; diff --git a/src/hardware/webHID.ts b/src/hardware/webHID.ts new file mode 100644 index 0000000..0d4d6a0 --- /dev/null +++ b/src/hardware/webHID.ts @@ -0,0 +1,74 @@ +import type { HidFilter, HidTransport, HidTransportDevice } from "./types"; + +/** + * Thin wrapper around `navigator.hid`. + * + * This is the only file in the app that should reference `navigator.hid` + * directly (aside from the ambient type declarations in `webhid.d.ts`). + * Everything else imports `hid` from `bridge.ts`. + * + * Chrome/Edge desktop are the only environments where `navigator.hid` + * exists at all — it's unimplemented in Firefox/Safari and blocked in most + * embedded webviews (including Tauri's own webview, which is exactly why + * `native.ts` exists). Calls here throw a clear, consistent error instead + * of leaving callers to chase `undefined` through optional chains. + */ +class WebHidTransport extends EventTarget implements HidTransport { + private get native(): HID | undefined { + return typeof navigator !== "undefined" ? navigator.hid : undefined; + } + + private require(): HID { + const hid = this.native; + if (!hid) { + throw new Error( + "WebHID is unavailable in this browser. Use Chrome or Edge on desktop, " + + "or run the OpenMouse desktop app.", + ); + } + return hid; + } + + async getDevices(): Promise { + return (await this.native?.getDevices()) ?? []; + } + + async requestDevice(options: { filters: HidFilter[] }): Promise { + return this.require().requestDevice(options); + } + + // Overload so this satisfies both EventTarget's base signature (required + // because we `extends EventTarget`) and HID's narrower "connect"/ + // "disconnect" signature (required because we `implements HidTransport`). + addEventListener( + type: "connect" | "disconnect", + listener: (event: HIDConnectionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature only, see overloads above + addEventListener(type: string, listener: any, options?: any): void { + this.native?.addEventListener(type as "connect" | "disconnect", listener, options); + } + + removeEventListener( + type: "connect" | "disconnect", + listener: (event: HIDConnectionEvent) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature only, see overloads above + removeEventListener(type: string, listener: any, options?: any): void { + this.native?.removeEventListener(type as "connect" | "disconnect", listener, options); + } +} + +export const webHidTransport: HidTransport = new WebHidTransport(); diff --git a/src/logitech-hidpp.ts b/src/logitech-hidpp.ts index 1c967c5..ecb66ab 100644 --- a/src/logitech-hidpp.ts +++ b/src/logitech-hidpp.ts @@ -1,4 +1,5 @@ import type { MouseStatus } from "./mouse-types"; +import { hid } from "./hardware/bridge"; const LOGITECH_VENDOR_ID = 0x046d; const LOGITECH_RECEIVER_PRODUCT_ID = 0xc54d; @@ -111,11 +112,7 @@ export class LogitechHidppClient { } static async requestReceiver(): Promise { - if (!navigator.hid) { - throw new Error("WebHID is unavailable. Use Chrome or Edge on desktop."); - } - - const devices = await navigator.hid.requestDevice({ + const devices = await hid.requestDevice({ filters: [{ vendorId: LOGITECH_VENDOR_ID, productId: LOGITECH_RECEIVER_PRODUCT_ID, @@ -128,11 +125,7 @@ export class LogitechHidppClient { } static async reconnectAuthorizedReceiver(): Promise { - if (!navigator.hid) { - return null; - } - - const devices = await navigator.hid.getDevices(); + const devices = await hid.getDevices(); const device = devices.find((candidate) => this.isSupported(candidate)); return device ? new LogitechHidppClient(device) : null; } diff --git a/src/wlmouse-hid.ts b/src/wlmouse-hid.ts index f8da6d5..02c96f8 100644 --- a/src/wlmouse-hid.ts +++ b/src/wlmouse-hid.ts @@ -1,4 +1,5 @@ import type { MouseStatus } from "./mouse-types"; +import { hid } from "./hardware/bridge"; import { VENDOR_ID, WLMOUSE_MAX_POLLING_HZ, WLMOUSE_PRODUCTS } from "./vendors"; export const WLMOUSE_VENDOR_ID = VENDOR_ID.wlmouse; @@ -138,7 +139,7 @@ export class WLMouseHidClient { async startNotifications(onChange: () => void): Promise { if (this.notifier) return true; - const devices = await navigator.hid?.getDevices() ?? []; + const devices = await hid.getDevices(); const sibling = devices.find((candidate) => candidate !== this.device && candidate.vendorId === this.device.vendorId diff --git a/vite.config.ts b/vite.config.ts index 98bea69..b889168 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,13 +9,39 @@ const packageVersion = JSON.parse( ) as { version: string }; const buildChannel = process.env.OPENMOUSE_BUILD_CHANNEL ?? "insiders"; +// Tauri-specific settings below only take effect when `tauri dev`/`tauri build` +// invoke Vite; a plain `npm run dev`/`npm run build` for the web target ignores +// them (TAURI_ENV_* is only set inside the Tauri CLI's child process). +const isTauriBuild = !!process.env.TAURI_ENV_PLATFORM; + export default defineConfig({ plugins: [sites()], define: { __APP_VERSION__: JSON.stringify(packageVersion.version), __BUILD_CHANNEL__: JSON.stringify(buildChannel), }, + // Don't let Vite clear the terminal on rebuild — we want to see Rust + // compiler errors from `tauri dev` scroll by, not have them wiped. + clearScreen: false, + server: { + port: 5173, + // Tauri's tauri.conf.json build.devUrl is a fixed http://localhost:5173. + // Fail instead of silently moving to 5174+ if the port is taken, so the + // desktop shell doesn't load against nothing. + strictPort: true, + watch: { + // The Rust toolchain owns src-tauri/target; don't let Vite's watcher + // (or `tauri dev`'s own rebuild-on-change) fight over it and loop. + ignored: ["**/src-tauri/**"], + }, + }, build: { + // Recommended Tauri + Vite settings: target the webview's actual engine + // instead of a generic browserslist, and only keep sourcemaps / skip + // minification for Tauri's own debug builds. + target: isTauriBuild ? ["es2021", "chrome100", "safari13"] : undefined, + minify: isTauriBuild ? !process.env.TAURI_ENV_DEBUG : true, + sourcemap: isTauriBuild ? !!process.env.TAURI_ENV_DEBUG : false, rollupOptions: { input: { main: resolve(__dirname, "index.html"),