diff --git a/CLAUDE.md b/CLAUDE.md index 82492953..6dc6d708 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa ## Never crash People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, - `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate - (which denies it instead), have no safe API: two functions in + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in + `crates/threedx-client` (which deny it instead), have no safe API: the macOS-only + `crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time + and calls through its function pointers), two functions in `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that records crashes inside native code such as graphics drivers) and the Windows-only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3233e5cc..a077bb74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,9 +110,10 @@ The [README](README.md#architecture) has the layer table. The short version: `cargo xtask assets` enforces this. 4. **Never crash.** People trust a CAD app with their work. - Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data. - There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events) - and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler - denies it. + There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events), + `apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and + `crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the + compiler denies it. - An unsupported case returns an error ("not supported yet: …"); it never panics. - Every call into truck goes through `solidcraft_kernel::guard`. - Every crash fix comes with a regression test. diff --git a/Cargo.lock b/Cargo.lock index cea02e9f..f17283e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4069,6 +4069,7 @@ dependencies = [ "ron", "rustls", "serde_json", + "solidcraft-3dx-client", "solidcraft-engine", "solidcraft-space-mouse", "solidcraft-ui-egui", @@ -4077,6 +4078,10 @@ dependencies = [ "winit", ] +[[package]] +name = "solidcraft-3dx-client" +version = "0.15.0" + [[package]] name = "solidcraft-cli" version = "0.15.0" diff --git a/Cargo.toml b/Cargo.toml index 30d7c7df..e0dd10fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ solidcraft-ui-egui = { path = "crates/ui-egui" } solidcraft-space-mouse = { path = "crates/space-mouse" } solidcraft-mcp = { path = "crates/mcp" } solidcraft-derive = { path = "crates/derive" } +solidcraft-3dx-client = { path = "crates/threedx-client" } # foundation thiserror = "2" diff --git a/README.md b/README.md index f30ec815..884cac20 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ unaccepted artwork Open; run `cargo xtask icons` to check the collection. Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`. -On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis +On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation @@ -257,6 +257,13 @@ lays them out: translation and rotation in separate reports (3Dconnexion's wired in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits. Each axis is scaled from the range the descriptor declares. +On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the +driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled). +The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft +as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows. + +SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode. + In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index df9e36ed..859dba6c 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -41,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere. keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } -[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] +[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies] quick-xml = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] @@ -65,6 +65,11 @@ windows-sys = { version = "0.61", features = [ ] } [target.'cfg(target_os = "macos")'.dependencies] +# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs). +solidcraft-3dx-client = { workspace = true } +# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare +# driver does not shut the app out. +hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] } # Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd. objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] } diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index ea8b26c9..bf10c345 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -25,7 +25,7 @@ mod native_crash; mod native_frame; #[cfg(target_os = "macos")] mod open_documents; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] mod space_mouse; mod startup; @@ -38,7 +38,7 @@ struct App { starting: Option, /// Frames begun; the second one means the first was drawn (startup is over). frames: u32, - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] space_mouse: space_mouse::SpaceMouse, } @@ -68,7 +68,7 @@ impl eframe::App for App { self.solve.open_path_async(&f); } self.solve.logic(ctx); - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] if !self.solve.file_picker_busy() { self.space_mouse.tick(&mut self.solve, ctx); } @@ -78,7 +78,7 @@ impl eframe::App for App { } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { // Before the app's own hook, which adds the control channel's events. - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw); self.solve.raw_input_hook(raw); } @@ -655,14 +655,14 @@ fn main() -> eframe::Result { if !notes.is_empty() { app.set_status(notes.join(" "), true); } - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx); solidcraft_engine::crash::phase("first frame"); Ok(Box::new(App { solve: app, starting, frames: 0, - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] space_mouse, })) }), diff --git a/apps/solidcraft/src/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index a6e30195..b524c4a8 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -1,4 +1,4 @@ -//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else +//! 3D mouse navigation on Windows and macOS (through hidapi) and Linux (through spacenavd when it runs, else //! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones. //! The device reader never touches the document or UI; it publishes the latest axes, and the egui //! thread applies them once per frame. @@ -8,11 +8,12 @@ mod hidraw; #[cfg(target_os = "linux")] mod spacenavd; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use egui::Context; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use hidapi::{DeviceInfo, HidApi}; use quick_xml::Reader; use quick_xml::events::Event; @@ -21,6 +22,14 @@ use solidcraft_ui_egui::SolveApp; use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame}; const STALE_AFTER: Duration = Duration::from_millis(150); +/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading +/// the HID device would fail or report it twice. +static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false); +/// Whether this app registered with the 3Dconnexion driver (macOS). The driver then has the device +/// whether or not it has said so yet, so a failure to open it by HID is no news. +#[cfg(any(target_os = "windows", target_os = "macos"))] +static DRIVER_REGISTERED: AtomicBool = AtomicBool::new(false); + const RETRY_AFTER: Duration = Duration::from_secs(2); const MAX_PROFILE_BYTES: u64 = 1024 * 1024; /// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the @@ -188,7 +197,7 @@ impl Reading { /// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever /// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice. -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] fn is_3d_mouse(info: &DeviceInfo) -> bool { info.usage_page() == 0x01 && info.usage() == 0x08 } @@ -210,6 +219,9 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst /// Publishes one input report from the device to the egui thread. fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8]) { + if DRIVER_CONNECTED.load(Ordering::Relaxed) { + return; + } if let Ok(mut reading) = shared.lock() && decode_report(report, layout, &mut reading, Instant::now()) { @@ -234,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex) { ctx.request_repaint(); } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] fn reader(ctx: Context, shared: Arc>) { let Ok(mut api) = HidApi::new() else { log::warn!("3D mouse: HID initialization failed"); @@ -244,7 +256,16 @@ fn reader(ctx: Context, shared: Arc>) { if let Err(error) = api.refresh_devices() { log::warn!("3D mouse: device scan failed: {error}"); } else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| { - let device = info.open_device(&api).ok()?; + let device = info + .open_device(&api) + .inspect_err(|error| { + // On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device. + // The driver having the device is the reason it cannot be opened, not a problem. + if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) { + log::warn!("3D mouse: cannot open the device: {error}"); + } + }) + .ok()?; let layout = device_layout(&device, info.vendor_id())?; Some((device, layout)) }) { @@ -270,7 +291,7 @@ fn reader(ctx: Context, shared: Arc>) { /// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator, /// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone /// rather than guessed at. -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option { let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE]; let read = device.get_report_descriptor(&mut descriptor); @@ -335,6 +356,34 @@ fn reader(ctx: Context, shared: Arc>) { } } +/// macOS: the 3Dconnexion driver gives the device to its own clients only, so SolidCraft registers +/// as one. Without the driver (or with its helper not running) the HID reader above takes over. +#[cfg(target_os = "macos")] +fn start_driver_client(ctx: &Context, shared: &Arc>) { + use solidcraft_3dx_client::Event; + let (ctx, shared) = (ctx.clone(), Arc::clone(shared)); + let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event { + Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed), + Event::DeviceRemoved => { + DRIVER_CONNECTED.store(false, Ordering::Relaxed); + reset(&ctx, &shared); + } + Event::Axes { translation, rotation } => { + if let Ok(mut reading) = shared.lock() { + let now = Instant::now(); + reading.axes = Axes { translation, rotation }; + reading.at = [now; 6]; + ctx.request_repaint(); + } + } + Event::Buttons(_) => {} + }); + match started { + Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed), + Err(error) => log::info!("3D mouse: no driver client: {error}"), + } +} + pub struct SpaceMouse { shared: Arc>, last_frame: Instant, @@ -354,6 +403,8 @@ impl SpaceMouse { if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) { log::warn!("3D mouse: reader could not start: {error}"); } + #[cfg(target_os = "macos")] + start_driver_client(ctx, &shared); Self { shared, last_frame: now, diff --git a/crates/threedx-client/Cargo.toml b/crates/threedx-client/Cargo.toml new file mode 100644 index 00000000..4bcfecc1 --- /dev/null +++ b/crates/threedx-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "solidcraft-3dx-client" +description = "SolidCraft: a client of the 3Dconnexion driver on macOS (3DxWare), loaded at run time" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# The workspace lints, except unsafe_code: denied, not forbidden, so that src/macos.rs can allow +# it: loading a framework and calling through its function pointers has no safe API. Keep the rest +# in sync with [workspace.lints]. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +neg_cmp_op_on_partial_ord = "allow" diff --git a/crates/threedx-client/examples/dump.rs b/crates/threedx-client/examples/dump.rs new file mode 100644 index 00000000..172e4c6b --- /dev/null +++ b/crates/threedx-client/examples/dump.rs @@ -0,0 +1,11 @@ +//! Prints what the 3Dconnexion driver reports: `cargo run -p solidcraft-3dx-client --example dump`. +//! The driver sends nothing to a program that is not in an app bundle, so only `DeviceAdded` shows here; +//! use SolidCraft's own `.app` to see the cap move. + +fn main() { + if let Err(error) = solidcraft_3dx_client::start("SolidCraft dump", |event| println!("{event:?}")) { + eprintln!("{error}"); + return; + } + std::thread::sleep(std::time::Duration::from_secs(60)); +} diff --git a/crates/threedx-client/src/lib.rs b/crates/threedx-client/src/lib.rs new file mode 100644 index 00000000..77ef94f7 --- /dev/null +++ b/crates/threedx-client/src/lib.rs @@ -0,0 +1,81 @@ +//! A client of the 3Dconnexion driver (3DxWare) on macOS. +//! +//! The driver's helper app opens the device exclusively while it runs, so reading the HID device +//! directly fails then. The driver offers its own client API instead, in +//! `3DconnexionClient.framework`: an app registers with the driver and gets the cap's six axes +//! (after the user's 3DxWare settings) and the buttons as messages on a run loop. +//! +//! The framework is loaded with `dlopen` when [`start`] is called, so nothing links against it and +//! the app runs without the driver installed ([`Error::NotInstalled`]). The framework is never +//! bundled or redistributed; its declarations here are our own. On other platforms [`start`] +//! returns [`Error::Unsupported`]. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt; + +#[cfg(target_os = "macos")] +mod macos; + +/// What the driver reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Event { + /// A 3D mouse was plugged in or the driver connected to one. + DeviceAdded, + /// The 3D mouse was unplugged or the driver went away. + DeviceRemoved, + /// The cap moved: push left/right, forward/back, lift/press, then tilt forward/back, + /// tilt left/right and twist, each about -350..350 as the HID device reports them. + Axes { translation: [i16; 3], rotation: [i16; 3] }, + /// The buttons that are down, one bit each. + Buttons(u32), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// Not macOS. + Unsupported, + /// The driver's framework is not installed (or not loadable). + NotInstalled, + /// The framework lacks a function this client needs (another driver version). + MissingSymbol(&'static str), + /// The driver refused the handlers (its `OSErr`). + Handlers(i16), + /// The driver refused the client registration. + Register, + /// [`start`] was called before. + AlreadyStarted, + /// The client's thread could not be started. + Thread(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Unsupported => write!(f, "the 3Dconnexion driver client only exists on macOS"), + Error::NotInstalled => write!(f, "the 3Dconnexion driver is not installed"), + Error::MissingSymbol(name) => write!(f, "the 3Dconnexion driver lacks {name}"), + Error::Handlers(code) => write!(f, "the 3Dconnexion driver refused the handlers (error {code})"), + Error::Register => write!(f, "the 3Dconnexion driver refused the client"), + Error::AlreadyStarted => write!(f, "the 3Dconnexion client is already started"), + Error::Thread(reason) => write!(f, "the 3Dconnexion client thread did not start: {reason}"), + } + } +} + +impl std::error::Error for Error {} + +/// Connects to the driver and calls `sink` (on the client's own thread) for everything it reports. +/// `name` is how the driver lists this app. Only one client per process; the connection lasts +/// until the process ends. +pub fn start(name: &str, sink: impl Fn(Event) + Send + Sync + 'static) -> Result<(), Error> { + #[cfg(target_os = "macos")] + { + macos::start(name, Box::new(sink)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (name, sink); + Err(Error::Unsupported) + } +} diff --git a/crates/threedx-client/src/macos.rs b/crates/threedx-client/src/macos.rs new file mode 100644 index 00000000..d12c7d66 --- /dev/null +++ b/crates/threedx-client/src/macos.rs @@ -0,0 +1,220 @@ +//! The driver's client API, called through function pointers from `dlopen`. +//! +//! No safe API exists for this, hence the `unsafe_code` allowance for the whole module. The +//! unsafe parts are small: the C declarations, the lookups and the two callbacks, which read the +//! message the driver hands them. + +#![allow(unsafe_code)] + +use std::ffi::{CString, c_char, c_int, c_void}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU16, Ordering}; + +use crate::{Error, Event}; + +const FRAMEWORK: &str = "/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient"; +const RTLD_NOW: c_int = 2; + +/// `'****'`: any application signature. +const WILDCARD_SIGNATURE: u32 = 0x2A2A_2A2A; +/// The driver gives this client the cap, instead of sharing it with other clients. +const MODE_TAKE_OVER: u16 = 1; +/// Every axis and every button. +const MASK_ALL: u32 = 0x3FFF; +/// `'3dSR'`: the message that carries a device state. +const MSG_DEVICE_STATE: u32 = 0x3364_5352; +const CMD_BUTTONS: u16 = 2; +const CMD_AXES: u16 = 3; + +/// The device state the driver passes, read field by field from these byte offsets (the struct is +/// packed to two bytes) so that no reference to driver memory is ever formed. +const STATE_BYTES: usize = 48; +const OFFSET_CLIENT: usize = 2; +const OFFSET_COMMAND: usize = 4; +const OFFSET_AXES: usize = 30; +const OFFSET_BUTTONS: usize = 44; + +unsafe extern "C" { + fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + static kCFRunLoopDefaultMode: *const c_void; + fn CFRunLoopRunInMode(mode: *const c_void, seconds: f64, return_after_source_handled: u8) -> i32; +} + +type MessageHandler = extern "C" fn(u32, u32, *mut c_void); +type DeviceHandler = extern "C" fn(u32); +type InstallHandlers = unsafe extern "C" fn(Option, Option, Option) -> i16; +type RegisterClient = unsafe extern "C" fn(u32, *const u8, u16, u32) -> u16; +type SetMask = unsafe extern "C" fn(u16, u32); + +type Sink = Box; + +static SINK: OnceLock = OnceLock::new(); +/// The id the driver gave this client: it broadcasts every message to every client. +static CLIENT: AtomicU16 = AtomicU16::new(0); + +fn symbol(handle: *mut c_void, name: &'static str) -> Result<*mut c_void, Error> { + let c_name = CString::new(name).map_err(|_| Error::MissingSymbol(name))?; + // SAFETY: `handle` came from a successful dlopen and `c_name` is a NUL-terminated string. + let found = unsafe { dlsym(handle, c_name.as_ptr()) }; + if found.is_null() { Err(Error::MissingSymbol(name)) } else { Ok(found) } +} + +fn emit(event: Event) { + if let Some(sink) = SINK.get() { + // A panic must not unwind into the driver's C code. + let _ = catch_unwind(AssertUnwindSafe(|| sink(event))); + } +} + +extern "C" fn on_added(_connection: u32) { + emit(Event::DeviceAdded); +} + +extern "C" fn on_removed(_connection: u32) { + emit(Event::DeviceRemoved); +} + +extern "C" fn on_message(_connection: u32, kind: u32, argument: *mut c_void) { + if kind != MSG_DEVICE_STATE || argument.is_null() { + return; + } + // SAFETY: for this message type the driver passes a device state, which is `STATE_BYTES` long + // and valid for the duration of the call. + let state = unsafe { std::slice::from_raw_parts(argument.cast::().cast_const(), STATE_BYTES) }; + if let Some(event) = decode(state, CLIENT.load(Ordering::Relaxed)) { + emit(event); + } +} + +fn u16_at(state: &[u8], offset: usize) -> Option { + Some(u16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?)) +} + +fn i16_at(state: &[u8], offset: usize) -> Option { + Some(i16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?)) +} + +/// The event a device state stands for, if it is meant for `client`. +fn decode(state: &[u8], client: u16) -> Option { + if u16_at(state, OFFSET_CLIENT)? != client { + return None; + } + match u16_at(state, OFFSET_COMMAND)? { + CMD_AXES => { + let axis = |i: usize| i16_at(state, OFFSET_AXES + 2 * i); + Some(Event::Axes { translation: [axis(0)?, axis(1)?, axis(2)?], rotation: [axis(3)?, axis(4)?, axis(5)?] }) + } + CMD_BUTTONS => Some(Event::Buttons(u32::from_ne_bytes(state.get(OFFSET_BUTTONS..OFFSET_BUTTONS + 4)?.try_into().ok()?))), + _ => None, + } +} + +/// Loads the framework, installs the handlers and registers the client. Runs on the client thread, +/// whose run loop the driver then sends its messages to. +fn connect(name: &str) -> Result<(), Error> { + let path = CString::new(FRAMEWORK).map_err(|_| Error::NotInstalled)?; + // SAFETY: `path` is a NUL-terminated string. + let handle = unsafe { dlopen(path.as_ptr(), RTLD_NOW) }; + if handle.is_null() { + return Err(Error::NotInstalled); + } + let install = symbol(handle, "InstallConnexionHandlers")?; + let register = symbol(handle, "RegisterConnexionClient")?; + // SAFETY: the framework declares these functions with exactly these signatures. + let (install, register) = + unsafe { (std::mem::transmute::<*mut c_void, InstallHandlers>(install), std::mem::transmute::<*mut c_void, RegisterClient>(register)) }; + // SAFETY: the handlers are `extern "C"` functions that live for the whole process. + let code = unsafe { install(Some(on_message), Some(on_added), Some(on_removed)) }; + if code != 0 { + return Err(Error::Handlers(code)); + } + // The name is a Pascal string: a length byte, then the characters. + let mut pascal = vec![0_u8]; + pascal.extend(name.bytes().take(255)); + pascal[0] = u8::try_from(pascal.len() - 1).unwrap_or(0); + pascal.push(0); + // SAFETY: `pascal` outlives the call, which copies it. + let id = unsafe { register(WILDCARD_SIGNATURE, pascal.as_ptr(), MODE_TAKE_OVER, MASK_ALL) }; + if id == 0 { + return Err(Error::Register); + } + CLIENT.store(id, Ordering::Relaxed); + // Newer drivers want the masks again once the client has an id. Both are optional. + for (name, mask) in [("SetConnexionClientMask", MASK_ALL), ("SetConnexionClientButtonMask", u32::MAX)] { + if let Ok(set) = symbol(handle, name) { + // SAFETY: the framework declares these functions with this signature. + let set = unsafe { std::mem::transmute::<*mut c_void, SetMask>(set) }; + // SAFETY: `id` is the id the driver gave this client. + unsafe { set(id, mask) }; + } + } + Ok(()) +} + +pub fn start(name: &str, sink: Sink) -> Result<(), Error> { + SINK.set(sink).map_err(|_| Error::AlreadyStarted)?; + let name = name.to_owned(); + let (ready, started) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("3dx-client".into()) + .spawn(move || { + let result = connect(&name); + let connected = result.is_ok(); + let _ = ready.send(result); + if !connected { + return; + } + // The driver's messages arrive on this thread's run loop. Waking up now and then keeps + // the loop alive in case it had nothing to wait for yet. + loop { + // SAFETY: the mode is CoreFoundation's own constant. + unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, 0) }; + } + }) + .map_err(|error| Error::Thread(error.to_string()))?; + started.recv().map_err(|error| Error::Thread(error.to_string()))? +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A device state as the driver sent it for a light tilt forward (captured from a SpaceMouse + /// Wireless): command 3 (axes) with the fourth axis, tilt forward/back, at -3. + fn tilt_state(client: u16) -> [u8; STATE_BYTES] { + let mut state = [0_u8; STATE_BYTES]; + state[OFFSET_CLIENT..OFFSET_CLIENT + 2].copy_from_slice(&client.to_ne_bytes()); + state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_AXES.to_ne_bytes()); + state[OFFSET_AXES + 6..OFFSET_AXES + 8].copy_from_slice(&(-3_i16).to_ne_bytes()); + state + } + + #[test] + fn an_axes_state_for_this_client_becomes_axes() { + assert_eq!(decode(&tilt_state(4106), 4106), Some(Event::Axes { translation: [0; 3], rotation: [-3, 0, 0] })); + } + + #[test] + fn a_state_for_another_client_is_ignored() { + assert_eq!(decode(&tilt_state(4106), 4107), None); + } + + #[test] + fn a_button_state_carries_the_mask() { + let mut state = tilt_state(7); + state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_BUTTONS.to_ne_bytes()); + state[OFFSET_BUTTONS..OFFSET_BUTTONS + 4].copy_from_slice(&5_u32.to_ne_bytes()); + assert_eq!(decode(&state, 7), Some(Event::Buttons(5))); + } + + #[test] + fn a_short_message_is_ignored() { + assert_eq!(decode(&[0; 10], 0), None); + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 7bc55bf9..78a83223 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -40,6 +40,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("space-mouse", Class::Layer(0)), // the derive macros (proc-macro, no workspace dependencies) ("derive", Class::Standalone), + // the 3Dconnexion driver's client API on macOS (no workspace dependencies) + ("3dx-client", Class::Standalone), ("sketch", Class::Layer(1)), ("kernel", Class::Layer(1)), ("render", Class::Layer(1)),