Macos space mouse support (#1815)

Thanks @NecroKote.
This commit is contained in:
Mikalai Kukhta authored and GitHub committed 2026-10-17 18:15:15 -04:00
1 parent 11a196e6f8
commit 180e62c981
13 files changed
+425 -19

No files matched your search

+20
View File
@@ -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"
+11
View File
@@ -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));
}
+81
View File
@@ -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)
}
}
+220
View File
@@ -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<MessageHandler>, Option<DeviceHandler>, Option<DeviceHandler>) -> i16;
type RegisterClient = unsafe extern "C" fn(u32, *const u8, u16, u32) -> u16;
type SetMask = unsafe extern "C" fn(u16, u32);
type Sink = Box<dyn Fn(Event) + Send + Sync>;
static SINK: OnceLock<Sink> = 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::<u8>().cast_const(), STATE_BYTES) };
if let Some(event) = decode(state, CLIENT.load(Ordering::Relaxed)) {
emit(event);
}
}
fn u16_at(state: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?))
}
fn i16_at(state: &[u8], offset: usize) -> Option<i16> {
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<Event> {
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);
}
}