1 parent
11a196e6f8
commit
180e62c981
13 files changed
+425
-19
No files matched your search
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
|||||||
## Never crash
|
## Never crash
|
||||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
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!`,
|
- **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
|
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||||
(which denies it instead), have no safe API: two functions 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
|
`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
|
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
|
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||||
|
|||||||
+4
-3
@@ -110,9 +110,10 @@ The [README](README.md#architecture) has the layer table. The short version:
|
|||||||
`cargo xtask assets` enforces this.
|
`cargo xtask assets` enforces this.
|
||||||
4. **Never crash.** People trust a CAD app with their work.
|
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.
|
- 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)
|
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
|
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
|
||||||
denies it.
|
`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.
|
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||||
- Every crash fix comes with a regression test.
|
- Every crash fix comes with a regression test.
|
||||||
|
|||||||
Generated
+5
@@ -4069,6 +4069,7 @@ dependencies = [
|
|||||||
"ron",
|
"ron",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"solidcraft-3dx-client",
|
||||||
"solidcraft-engine",
|
"solidcraft-engine",
|
||||||
"solidcraft-space-mouse",
|
"solidcraft-space-mouse",
|
||||||
"solidcraft-ui-egui",
|
"solidcraft-ui-egui",
|
||||||
@@ -4077,6 +4078,10 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "solidcraft-3dx-client"
|
||||||
|
version = "0.15.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solidcraft-cli"
|
name = "solidcraft-cli"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ solidcraft-ui-egui = { path = "crates/ui-egui" }
|
|||||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||||
solidcraft-mcp = { path = "crates/mcp" }
|
solidcraft-mcp = { path = "crates/mcp" }
|
||||||
solidcraft-derive = { path = "crates/derive" }
|
solidcraft-derive = { path = "crates/derive" }
|
||||||
|
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||||
|
|
||||||
# foundation
|
# foundation
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|||||||
@@ -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`.
|
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
|
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.
|
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
|
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.
|
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.
|
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
|
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
# 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"] }
|
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 }
|
quick-xml = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
@@ -65,6 +65,11 @@ windows-sys = { version = "0.61", features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[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.
|
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ mod native_crash;
|
|||||||
mod native_frame;
|
mod native_frame;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod open_documents;
|
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 space_mouse;
|
||||||
mod startup;
|
mod startup;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ struct App {
|
|||||||
starting: Option<std::path::PathBuf>,
|
starting: Option<std::path::PathBuf>,
|
||||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||||
frames: u32,
|
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,
|
space_mouse: space_mouse::SpaceMouse,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ impl eframe::App for App {
|
|||||||
self.solve.open_path_async(&f);
|
self.solve.open_path_async(&f);
|
||||||
}
|
}
|
||||||
self.solve.logic(ctx);
|
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() {
|
if !self.solve.file_picker_busy() {
|
||||||
self.space_mouse.tick(&mut self.solve, ctx);
|
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) {
|
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.
|
// 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.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||||
self.solve.raw_input_hook(raw);
|
self.solve.raw_input_hook(raw);
|
||||||
}
|
}
|
||||||
@@ -655,14 +655,14 @@ fn main() -> eframe::Result {
|
|||||||
if !notes.is_empty() {
|
if !notes.is_empty() {
|
||||||
app.set_status(notes.join(" "), true);
|
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);
|
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||||
solidcraft_engine::crash::phase("first frame");
|
solidcraft_engine::crash::phase("first frame");
|
||||||
Ok(Box::new(App {
|
Ok(Box::new(App {
|
||||||
solve: app,
|
solve: app,
|
||||||
starting,
|
starting,
|
||||||
frames: 0,
|
frames: 0,
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||||
space_mouse,
|
space_mouse,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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.
|
//! 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
|
//! The device reader never touches the document or UI; it publishes the latest axes, and the egui
|
||||||
//! thread applies them once per frame.
|
//! thread applies them once per frame.
|
||||||
@@ -8,11 +8,12 @@ mod hidraw;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod spacenavd;
|
mod spacenavd;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use egui::Context;
|
use egui::Context;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
use hidapi::{DeviceInfo, HidApi};
|
use hidapi::{DeviceInfo, HidApi};
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
use quick_xml::events::Event;
|
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};
|
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||||
|
|
||||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
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 RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||||
/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the
|
/// 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
|
/// 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.
|
/// 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 {
|
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||||
info.usage_page() == 0x01 && info.usage() == 0x08
|
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.
|
/// Publishes one input report from the device to the egui thread.
|
||||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||||
|
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Ok(mut reading) = shared.lock()
|
if let Ok(mut reading) = shared.lock()
|
||||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||||
{
|
{
|
||||||
@@ -234,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
|||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||||
let Ok(mut api) = HidApi::new() else {
|
let Ok(mut api) = HidApi::new() else {
|
||||||
log::warn!("3D mouse: HID initialization failed");
|
log::warn!("3D mouse: HID initialization failed");
|
||||||
@@ -244,7 +256,16 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
|||||||
if let Err(error) = api.refresh_devices() {
|
if let Err(error) = api.refresh_devices() {
|
||||||
log::warn!("3D mouse: device scan failed: {error}");
|
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| {
|
} 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())?;
|
let layout = device_layout(&device, info.vendor_id())?;
|
||||||
Some((device, layout))
|
Some((device, layout))
|
||||||
}) {
|
}) {
|
||||||
@@ -270,7 +291,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
|||||||
/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator,
|
/// 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
|
/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone
|
||||||
/// rather than guessed at.
|
/// 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<Layout> {
|
fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
|
||||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||||
let read = device.get_report_descriptor(&mut descriptor);
|
let read = device.get_report_descriptor(&mut descriptor);
|
||||||
@@ -335,6 +356,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Mutex<Reading>>) {
|
||||||
|
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 {
|
pub struct SpaceMouse {
|
||||||
shared: Arc<Mutex<Reading>>,
|
shared: Arc<Mutex<Reading>>,
|
||||||
last_frame: Instant,
|
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)) {
|
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}");
|
log::warn!("3D mouse: reader could not start: {error}");
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
start_driver_client(ctx, &shared);
|
||||||
Self {
|
Self {
|
||||||
shared,
|
shared,
|
||||||
last_frame: now,
|
last_frame: now,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ pub const TABLE: &[(&str, Class)] = &[
|
|||||||
("space-mouse", Class::Layer(0)),
|
("space-mouse", Class::Layer(0)),
|
||||||
// the derive macros (proc-macro, no workspace dependencies)
|
// the derive macros (proc-macro, no workspace dependencies)
|
||||||
("derive", Class::Standalone),
|
("derive", Class::Standalone),
|
||||||
|
// the 3Dconnexion driver's client API on macOS (no workspace dependencies)
|
||||||
|
("3dx-client", Class::Standalone),
|
||||||
("sketch", Class::Layer(1)),
|
("sketch", Class::Layer(1)),
|
||||||
("kernel", Class::Layer(1)),
|
("kernel", Class::Layer(1)),
|
||||||
("render", Class::Layer(1)),
|
("render", Class::Layer(1)),
|
||||||
|
|||||||
Reference in new issue
Block a user