1 parent
11a196e6f8
commit
180e62c981
13 files changed
+425
-19
No files matched your search
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user