diff --git a/cmake/find_spacemouse.cmake b/cmake/find_spacemouse.cmake
index 3da5731d4..f5ac77410 100644
--- a/cmake/find_spacemouse.cmake
+++ b/cmake/find_spacemouse.cmake
@@ -25,6 +25,7 @@ message(" - find_spacemouse")
# spnav Linux, through the spacenavd daemon (libspnav)
# hid any platform, directly over USB (hidapi), no 3Dconnexion driver
# auto spnav on Linux when libspnav is found, hid otherwise
+# On macOS the 3DxWare backend is added to whichever of these is chosen.
# A backend whose library is not found downgrades the option to off with a
# warning, rather than failing configure for an opt-in feature.
set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid")
@@ -33,6 +34,7 @@ set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid)
set(QET_SPACEMOUSE_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE)
+set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED FALSE)
if(QET_ENABLE_SPACEMOUSE)
find_package(PkgConfig)
@@ -73,6 +75,16 @@ if(QET_ENABLE_SPACEMOUSE)
endif()
endif()
+ # macOS: with 3DxWare installed the device can only be read through
+ # 3DxWare, so its backend comes too, whichever backend was asked for.
+ # It loads 3DxWare's library at run time and needs nothing to build.
+ if(APPLE)
+ set(QET_SPACEMOUSE_ENABLED TRUE)
+ set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED TRUE)
+ add_definitions(-DQET_SPACEMOUSE_BACKEND_CONNEXION)
+ message("QET_ENABLE_SPACEMOUSE ON (backend: 3DxWare when installed)")
+ endif()
+
if(QET_SPACEMOUSE_ENABLED)
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
else()
diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 98ad12f6b..5721e4111 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -905,6 +905,13 @@ if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
)
endif()
+if(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED)
+ list(APPEND QET_SRC_FILES
+ ${QET_DIR}/sources/spacemouse/connexionbackend.cpp
+ ${QET_DIR}/sources/spacemouse/connexionbackend.h
+ )
+endif()
+
set(TS_FILES
${QET_DIR}/lang/qet_ar.ts
${QET_DIR}/lang/qet_ca.ts
diff --git a/misc/MacQetDeploy_arm64_cmake.sh b/misc/MacQetDeploy_arm64_cmake.sh
index 9bf7ea77e..e548f24a5 100644
--- a/misc/MacQetDeploy_arm64_cmake.sh
+++ b/misc/MacQetDeploy_arm64_cmake.sh
@@ -398,10 +398,12 @@ done
echo "-- Signing main executable..."
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
+ --entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$BUNDLE/Contents/MacOS/$APPNAME"
echo "-- Signing bundle..."
-codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$BUNDLE"
+codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
+ --entitlements "${current_dir}/misc/qelectrotech.entitlements" "$BUNDLE"
echo
echo "Verifying bundle signature..."
@@ -502,8 +504,10 @@ find "$MOUNT_POINT/$BUNDLE/Contents/PlugIns" \( -name "*.dylib" -o -name "*.so"
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$lib"
done
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
+ --entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$MOUNT_POINT/$BUNDLE/Contents/MacOS/$APPNAME"
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
+ --entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$MOUNT_POINT/$BUNDLE"
echo "Verifying bundle signature inside DMG..."
diff --git a/misc/qelectrotech.entitlements b/misc/qelectrotech.entitlements
new file mode 100644
index 000000000..d3576e1c4
--- /dev/null
+++ b/misc/qelectrotech.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/sources/spacemouse/connexionbackend.cpp b/sources/spacemouse/connexionbackend.cpp
new file mode 100644
index 000000000..3d8f55f62
--- /dev/null
+++ b/sources/spacemouse/connexionbackend.cpp
@@ -0,0 +1,252 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "connexionbackend.h"
+
+#include
+#include
+#include
+#include
+
+#include
+
+namespace {
+ //The part of 3DconnexionClient's interface QET uses, with the
+ //values Blender uses (intern/ghost/intern/GHOST_NDOFManagerCocoa.mm).
+ constexpr quint32 MSG_DEVICE_STATE = 0x33645352; // '3dSR'
+ constexpr quint16 CMD_HANDLE_BUTTONS = 2;
+ constexpr quint16 CMD_HANDLE_AXIS = 3;
+ constexpr quint16 MODE_TAKE_OVER = 1;
+ constexpr quint32 MASK_ALL = 0x3fff;
+ constexpr quint32 MASK_ALL_BUTTONS = 0xffffffff;
+ constexpr quint32 SIGNATURE = 0x51456c54; // 'QElT'
+
+ //ConnexionDeviceState is packed to 2 bytes. Offsets of the fields read.
+ constexpr int STATE_CLIENT = 2;
+ constexpr int STATE_COMMAND = 4;
+ constexpr int STATE_AXIS = 30;
+ constexpr int STATE_BUTTONS = 44;
+
+ using MessageHandler = void (*)(quint32, quint32, void *);
+ using DeviceHandler = void (*)(quint32);
+ using SetConnexionHandlers = qint16 (*)(MessageHandler, DeviceHandler, DeviceHandler, bool);
+ using RegisterConnexionClient = quint16 (*)(quint32, const quint8 *, quint16, quint32);
+ using SetConnexionClientButtonMask = void (*)(quint16, quint32);
+ using CleanupConnexionHandlers = void (*)();
+ using UnregisterConnexionClient = void (*)(quint16);
+
+ //3DxWare's callbacks carry no context, so the one client lives here.
+ //The mutex is shared with 3DxWare's thread.
+ QMutex instance_mutex;
+ ConnexionBackend *instance = nullptr;
+
+ void deviceChanged(quint32) {}
+}
+
+const char ConnexionBackend::DEFAULT_LIBRARY[] =
+ "/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
+
+/**
+ @brief ConnexionBackend::ConnexionBackend
+ Load 3DxWare's client library and register with it. Not installed, or
+ installed but not running, leaves isAvailable() false: the ordinary
+ case, never reported as an error.
+ @param parent
+ @param library : the client library; only tests pass another
+*/
+ConnexionBackend::ConnexionBackend(QObject *parent, const QString &library) :
+ SpaceMouseBackend(parent),
+ m_library(library)
+{
+ {
+ QMutexLocker lock(&instance_mutex);
+ if (instance) {
+ return; //3DxWare takes one set of handlers per process
+ }
+ }
+ if (!m_library.load()) {
+ return;
+ }
+
+ const auto set_handlers = reinterpret_cast(
+ m_library.resolve("SetConnexionHandlers"));
+ const auto register_client = reinterpret_cast(
+ m_library.resolve("RegisterConnexionClient"));
+ const auto button_mask = reinterpret_cast(
+ m_library.resolve("SetConnexionClientButtonMask"));
+ m_cleanup = reinterpret_cast(
+ m_library.resolve("CleanupConnexionHandlers"));
+ m_unregister = reinterpret_cast(
+ m_library.resolve("UnregisterConnexionClient"));
+ if (!set_handlers || !register_client || !m_cleanup || !m_unregister) {
+ return;
+ }
+
+ {
+ QMutexLocker lock(&instance_mutex);
+ instance = this;
+ }
+ //Fails while 3DxWare is installed but its driver is not running.
+ if (set_handlers(&ConnexionBackend::onMessage, deviceChanged, deviceChanged, true) != 0) {
+ shutdown();
+ return;
+ }
+ m_handlers_installed = true;
+
+ //3DxWare only sends a client its messages while that application
+ //is in front, and recognises it by its executable's name (a Pascal
+ //string). Take-over mode stops 3DxWare's own actions in QET, so the
+ //view never moves twice.
+ QByteArray name = QFileInfo(QCoreApplication::applicationFilePath())
+ .fileName().toUtf8().left(255);
+ name.prepend(char(name.size()));
+ m_client = register_client(SIGNATURE,
+ reinterpret_cast(name.constData()),
+ MODE_TAKE_OVER, MASK_ALL);
+ if (!m_client) {
+ shutdown();
+ return;
+ }
+ if (button_mask) {
+ button_mask(m_client, MASK_ALL_BUTTONS);
+ }
+}
+
+/**
+ @brief ConnexionBackend::~ConnexionBackend
+*/
+ConnexionBackend::~ConnexionBackend()
+{
+ shutdown();
+}
+
+/**
+ @brief ConnexionBackend::sampleFromAxes
+ 3DxWare reports y up and z away from the user; the raw USB reports, and
+ so every other backend, have y towards the user and z down. Derived from
+ Blender, whose 3DxWare and spacenavd code paths must agree.
+ @param axis : TX, TY, TZ, RX, RY, RZ as 3DxWare sends them
+ @return the same movement in QET's convention
+*/
+SpaceMouseSample ConnexionBackend::sampleFromAxes(const qint16 axis[6])
+{
+ SpaceMouseSample sample;
+ sample.x = axis[0];
+ sample.y = -axis[2];
+ sample.z = -axis[1];
+ sample.rx = axis[3];
+ sample.ry = -axis[5];
+ sample.rz = -axis[4];
+ return sample;
+}
+
+/**
+ @brief ConnexionBackend::newlyPressed
+ @param before : the button bitmask of the previous message
+ @param now : the button bitmask of this one
+ @return the 0-based buttons pressed since \a before, as HidBackend numbers them
+*/
+QList ConnexionBackend::newlyPressed(quint32 before, quint32 now)
+{
+ QList pressed;
+ const quint32 down = now & ~before;
+ for (int bit = 0; bit < 32; ++bit) {
+ if (down & (quint32(1) << bit)) {
+ pressed.append(bit);
+ }
+ }
+ return pressed;
+}
+
+/**
+ @brief ConnexionBackend::onMessage
+ Runs on 3DxWare's thread. Copies what matters out of the message and
+ hands it to the main thread.
+ @param type : the message type
+ @param argument : a ConnexionDeviceState for MSG_DEVICE_STATE
+*/
+void ConnexionBackend::onMessage(quint32, quint32 type, void *argument)
+{
+ if (type != MSG_DEVICE_STATE || !argument) {
+ return;
+ }
+ const char *state = static_cast(argument);
+ quint16 client;
+ quint16 command;
+ qint16 axis[6];
+ quint32 buttons;
+ std::memcpy(&client, state + STATE_CLIENT, sizeof client);
+ std::memcpy(&command, state + STATE_COMMAND, sizeof command);
+ std::memcpy(axis, state + STATE_AXIS, sizeof axis);
+ std::memcpy(&buttons, state + STATE_BUTTONS, sizeof buttons);
+
+ //Queued with the backend as context: if it is deleted before the
+ //main thread gets to it, Qt drops the call.
+ QMutexLocker lock(&instance_mutex);
+ ConnexionBackend *backend = instance;
+ if (!backend) {
+ return;
+ }
+ QMetaObject::invokeMethod(backend, [=]() {
+ backend->handleState(client, command, axis, buttons);
+ }, Qt::QueuedConnection);
+}
+
+/**
+ @brief ConnexionBackend::handleState
+ On the main thread: turn one device state into signals.
+*/
+void ConnexionBackend::handleState(quint16 client, quint16 command,
+ const qint16 axis[6], quint32 buttons)
+{
+ if (client != m_client) {
+ return; //3DxWare sends every state to every client
+ }
+ if (command == CMD_HANDLE_AXIS) {
+ emit motion(sampleFromAxes(axis));
+ } else if (command == CMD_HANDLE_BUTTONS) {
+ //State first: a button can open a dialog whose event loop
+ //delivers the next message before emit returns.
+ const QList pressed = newlyPressed(m_buttons, buttons);
+ m_buttons = buttons;
+ for (int button : pressed) {
+ emit buttonPressed(button);
+ }
+ }
+}
+
+/**
+ @brief ConnexionBackend::shutdown
+ Unregister and stop 3DxWare's callbacks. The library stays loaded, since
+ 3DxWare's thread may still be returning from one.
+*/
+void ConnexionBackend::shutdown()
+{
+ if (m_client && m_unregister) {
+ m_unregister(m_client);
+ }
+ m_client = 0;
+ if (m_handlers_installed && m_cleanup) {
+ m_cleanup();
+ }
+ m_handlers_installed = false;
+
+ QMutexLocker lock(&instance_mutex);
+ if (instance == this) {
+ instance = nullptr;
+ }
+}
diff --git a/sources/spacemouse/connexionbackend.h b/sources/spacemouse/connexionbackend.h
new file mode 100644
index 000000000..20267a921
--- /dev/null
+++ b/sources/spacemouse/connexionbackend.h
@@ -0,0 +1,81 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef CONNEXIONBACKEND_H
+#define CONNEXIONBACKEND_H
+
+#include "spacemousebackend.h"
+
+#include
+
+/**
+ @brief The ConnexionBackend class
+ SpaceMouseBackend that reads the device through 3Dconnexion's own macOS
+ driver, 3DxWare, the way Blender does. Only compiled in on macOS
+ (cmake/find_spacemouse.cmake).
+
+ 3DxWare installs a driver extension that takes the device over, so
+ with 3DxWare installed HidBackend can open the device but receives
+ nothing (discussion #599). This backend asks 3DxWare for the motion
+ instead. SpaceMouseListener tries it first and falls back to HidBackend
+ when 3DxWare is not installed or not running, so the device works
+ either way.
+
+ The client library, 3DconnexionClient.framework, is loaded at run time
+ from where 3DxWare installs it: nothing is linked, bundled or needed to
+ build. The few declarations below are written here, as Blender does,
+ rather than taken from 3Dconnexion's SDK, whose headers may not be
+ redistributed.
+
+ 3DxWare delivers its messages on a thread of its own; they are passed
+ to the main thread before any signal is emitted.
+*/
+class ConnexionBackend : public SpaceMouseBackend
+{
+ Q_OBJECT
+
+ public:
+ static const char DEFAULT_LIBRARY[];
+
+ explicit ConnexionBackend(QObject *parent = nullptr,
+ const QString &library = QString::fromLatin1(DEFAULT_LIBRARY));
+ ~ConnexionBackend() override;
+
+ bool isAvailable() const override { return m_client != 0; }
+
+ /// 3DxWare's six axes, in its own order and signs, as QET's
+ /// raw USB convention (+x right, +y towards the user, +z down).
+ static SpaceMouseSample sampleFromAxes(const qint16 axis[6]);
+ /// The 0-based buttons set in \a now but not in \a before.
+ static QList newlyPressed(quint32 before, quint32 now);
+
+ private:
+ static void onMessage(quint32 connection, quint32 type, void *argument);
+ void handleState(quint16 client, quint16 command,
+ const qint16 axis[6], quint32 buttons);
+ void shutdown();
+
+ QLibrary m_library;
+ quint16 m_client = 0;
+ bool m_handlers_installed = false;
+ quint32 m_buttons = 0;
+
+ void (*m_cleanup)() = nullptr;
+ void (*m_unregister)(quint16) = nullptr;
+};
+
+#endif // CONNEXIONBACKEND_H
diff --git a/sources/spacemouse/spacemouselistener.cpp b/sources/spacemouse/spacemouselistener.cpp
index 2c76d277e..be60f4adf 100644
--- a/sources/spacemouse/spacemouselistener.cpp
+++ b/sources/spacemouse/spacemouselistener.cpp
@@ -25,6 +25,9 @@
#ifdef QET_SPACEMOUSE_BACKEND_HID
# include "hidbackend.h"
#endif
+#ifdef QET_SPACEMOUSE_BACKEND_CONNEXION
+# include "connexionbackend.h"
+#endif
#include "../diagramview.h"
#include "../editor/elementview.h"
@@ -48,10 +51,24 @@ SpaceMouseListener::SpaceMouseListener(QObject *parent) :
QObject(parent),
m_settings(SpaceMouseSettings::load())
{
+#if defined(QET_SPACEMOUSE_BACKEND_CONNEXION)
+ //When 3DxWare is installed and running it has the device to
+ //itself, so ask it first; otherwise read the device directly.
+ auto *connexion = new ConnexionBackend(this);
+ if (connexion->isAvailable()) {
+ m_backend = connexion;
+ } else {
+ delete connexion;
+ }
+#endif
#if defined(QET_SPACEMOUSE_BACKEND_SPNAV)
- m_backend = new SpnavBackend(this);
+ if (!m_backend) {
+ m_backend = new SpnavBackend(this);
+ }
#elif defined(QET_SPACEMOUSE_BACKEND_HID)
- m_backend = new HidBackend(this);
+ if (!m_backend) {
+ m_backend = new HidBackend(this);
+ }
#endif
if (m_backend) {
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index 16ec73fb1..2fec5922f 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -203,6 +203,23 @@ add_test(NAME tst_spacemousehid COMMAND tst_spacemousehid)
target_include_directories(tst_spacemousehid PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_spacemousehid PRIVATE Qt::Test Qt::Core)
+# ConnexionBackend -- the macOS 3DxWare backend. Built on every platform and
+# run against fakeconnexion, a stand-in for 3DxWare's client library that
+# exports the same calls and answers from a thread of its own.
+add_library(fakeconnexion MODULE fakeconnexion.cpp)
+target_link_libraries(fakeconnexion PRIVATE Qt::Core)
+add_executable(
+ tst_spacemouseconnexion
+ tst_spacemouseconnexion.cpp
+ ${QET_DIR}/sources/spacemouse/connexionbackend.cpp
+ ${QET_DIR}/sources/spacemouse/spacemousebackend.h)
+add_dependencies(tst_spacemouseconnexion fakeconnexion)
+add_test(NAME tst_spacemouseconnexion COMMAND tst_spacemouseconnexion)
+target_compile_definitions(tst_spacemouseconnexion PRIVATE
+ FAKE_CONNEXION="$")
+target_include_directories(tst_spacemouseconnexion PRIVATE ${QET_DIR}/sources)
+target_link_libraries(tst_spacemouseconnexion PRIVATE Qt::Test Qt::Core)
+
# CrashHandler::formatInt() -- the async-signal-safe decimal formatter the
# signal handler uses for the "Signal: N" line of a crash dump. Compiles
# crashhandler.cpp and logring.cpp alongside; the handler deliberately
diff --git a/tests/qttest/fakeconnexion.cpp b/tests/qttest/fakeconnexion.cpp
new file mode 100644
index 000000000..29a36ce99
--- /dev/null
+++ b/tests/qttest/fakeconnexion.cpp
@@ -0,0 +1,119 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+/*
+ A stand-in for 3DxWare's 3DconnexionClient.framework, for
+ tst_spacemouseconnexion: it exports the calls ConnexionBackend makes,
+ plus fake*() controls the test uses to script 3DxWare's side. Like the
+ real driver, it delivers messages on a thread of its own.
+*/
+#include
+
+#include
+#include
+#include
+#include
+
+#define FAKE_EXPORT extern "C" Q_DECL_EXPORT
+
+namespace {
+ using MessageHandler = void (*)(uint32_t, uint32_t, void *);
+ using DeviceHandler = void (*)(uint32_t);
+
+ MessageHandler handler = nullptr;
+ int16_t handlers_result = 0;
+ uint16_t client_result = 7;
+ std::string registered_name;
+ uint32_t registered_mode = 0;
+ uint32_t registered_mask = 0;
+ uint32_t registered_buttons = 0;
+ int unregisters = 0;
+ int cleanups = 0;
+}
+
+FAKE_EXPORT int16_t SetConnexionHandlers(MessageHandler message, DeviceHandler, DeviceHandler, bool)
+{
+ if (handlers_result == 0) {
+ handler = message;
+ }
+ return handlers_result;
+}
+
+FAKE_EXPORT void CleanupConnexionHandlers()
+{
+ handler = nullptr;
+ ++cleanups;
+}
+
+FAKE_EXPORT uint16_t RegisterConnexionClient(uint32_t, const uint8_t *name, uint16_t mode, uint32_t mask)
+{
+ registered_name.assign(reinterpret_cast(name) + 1, name[0]);
+ registered_mode = mode;
+ registered_mask = mask;
+ return client_result;
+}
+
+FAKE_EXPORT void SetConnexionClientButtonMask(uint16_t, uint32_t mask)
+{
+ registered_buttons = mask;
+}
+
+FAKE_EXPORT void UnregisterConnexionClient(uint16_t)
+{
+ ++unregisters;
+}
+
+/// Reset, and set what SetConnexionHandlers and RegisterConnexionClient return.
+FAKE_EXPORT void fakeReset(int16_t handlers, uint16_t client)
+{
+ handler = nullptr;
+ handlers_result = handlers;
+ client_result = client;
+ registered_name.clear();
+ registered_mode = registered_mask = registered_buttons = 0;
+ unregisters = cleanups = 0;
+}
+
+/// What the backend registered with: name, mode, mask, button mask, and
+/// how many times it unregistered and cleaned up.
+FAKE_EXPORT const char *fakeRegistration(uint32_t *mode, uint32_t *mask, uint32_t *buttons,
+ int *unregistered, int *cleaned)
+{
+ *mode = registered_mode;
+ *mask = registered_mask;
+ *buttons = registered_buttons;
+ *unregistered = unregisters;
+ *cleaned = cleanups;
+ return registered_name.c_str();
+}
+
+/// Send one ConnexionDeviceState (packed to 2 bytes, 48 bytes long) from
+/// another thread, as 3DxWare does.
+FAKE_EXPORT void fakeSend(uint16_t client, uint16_t command, const int16_t axis[6], uint32_t buttons)
+{
+ unsigned char state[48] = {};
+ std::memcpy(state + 2, &client, 2);
+ std::memcpy(state + 4, &command, 2);
+ std::memcpy(state + 30, axis, 12);
+ std::memcpy(state + 44, &buttons, 4);
+ std::thread driver([&state]() {
+ if (handler) {
+ handler(0, 0x33645352, state); // '3dSR'
+ }
+ });
+ driver.join();
+}
diff --git a/tests/qttest/tst_spacemouseconnexion.cpp b/tests/qttest/tst_spacemouseconnexion.cpp
new file mode 100644
index 000000000..9c5197140
--- /dev/null
+++ b/tests/qttest/tst_spacemouseconnexion.cpp
@@ -0,0 +1,265 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+/*
+ ConnexionBackend, the macOS backend that reads a 3D mouse through
+ 3DxWare. It is tested on every platform against fakeconnexion, which
+ exports the calls 3DxWare's client library does and sends messages from
+ its own thread as 3DxWare does. What only a Mac with 3DxWare can check
+ -- that the real library behaves like the fake -- is not tested here.
+*/
+#include "spacemouse/connexionbackend.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace {
+ constexpr quint16 CLIENT = 7;
+ constexpr quint16 AXIS = 3;
+ constexpr quint16 BUTTONS = 2;
+
+ using Reset = void (*)(qint16, quint16);
+ using Registration = const char *(*)(quint32 *, quint32 *, quint32 *, int *, int *);
+ using Send = void (*)(quint16, quint16, const qint16 *, quint32);
+}
+
+class TstSpaceMouseConnexion : public QObject
+{
+ Q_OBJECT
+
+ private slots:
+ void initTestCase();
+ void init();
+ void noLibrary();
+ void driverNotRunning();
+ void registration();
+ void motion();
+ void otherClients();
+ void buttons();
+ void deletedBeforeDelivery();
+ void oneClientPerProcess();
+ void realLibrary();
+
+ private:
+ QList collect(ConnexionBackend &backend,
+ const std::function &send);
+
+ QLibrary m_fake{QStringLiteral(FAKE_CONNEXION)};
+ Reset m_reset = nullptr;
+ Registration m_registration = nullptr;
+ Send m_send = nullptr;
+};
+
+/*
+ A fake that fails to load fails every test that uses it, in init(),
+ rather than here: realLibrary() does not use it, and CI runs it alone
+ under code-signing settings that may refuse the fake too.
+*/
+void TstSpaceMouseConnexion::initTestCase()
+{
+ if (m_fake.load()) {
+ m_reset = reinterpret_cast(m_fake.resolve("fakeReset"));
+ m_registration = reinterpret_cast(m_fake.resolve("fakeRegistration"));
+ m_send = reinterpret_cast(m_fake.resolve("fakeSend"));
+ }
+}
+
+void TstSpaceMouseConnexion::init()
+{
+ if (qstrcmp(QTest::currentTestFunction(), "realLibrary") == 0) {
+ return;
+ }
+ QVERIFY2(m_reset && m_registration && m_send, qPrintable(m_fake.errorString()));
+ m_reset(0, CLIENT);
+}
+
+QList TstSpaceMouseConnexion::collect(
+ ConnexionBackend &backend, const std::function &send)
+{
+ QList samples;
+ auto c = connect(&backend, &SpaceMouseBackend::motion,
+ [&samples](const SpaceMouseSample &s) { samples.append(s); });
+ send();
+ QCoreApplication::processEvents();
+ disconnect(c);
+ return samples;
+}
+
+/// 3DxWare not installed: the ordinary case.
+void TstSpaceMouseConnexion::noLibrary()
+{
+ ConnexionBackend backend(nullptr, QStringLiteral("/nonexistent/3DconnexionClient"));
+ QVERIFY(!backend.isAvailable());
+}
+
+/// Installed but its driver is not running: SetConnexionHandlers fails, and
+/// nothing is left registered for 3DxWare to call.
+void TstSpaceMouseConnexion::driverNotRunning()
+{
+ m_reset(-36, CLIENT);
+ {
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QVERIFY(!backend.isAvailable());
+ }
+ quint32 mode, mask, buttons;
+ int unregistered, cleaned;
+ m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
+ QCOMPARE(unregistered, 0);
+ QCOMPARE(cleaned, 0);
+
+ //And a registration refused (client 0) leaves nothing either.
+ m_reset(0, 0);
+ {
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QVERIFY(!backend.isAvailable());
+ }
+ m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
+ QCOMPARE(cleaned, 1);
+}
+
+/// Registers by executable name, in take-over mode, for everything, and
+/// undoes it all on destruction.
+void TstSpaceMouseConnexion::registration()
+{
+ quint32 mode, mask, buttons;
+ int unregistered, cleaned;
+ {
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QVERIFY(backend.isAvailable());
+ const QString name = QString::fromUtf8(
+ m_registration(&mode, &mask, &buttons, &unregistered, &cleaned));
+ QCOMPARE(name, QFileInfo(QCoreApplication::applicationFilePath()).fileName());
+ QCOMPARE(mode, 1u);
+ QCOMPARE(mask, 0x3fffu);
+ QCOMPARE(buttons, 0xffffffffu);
+ QCOMPARE(unregistered, 0);
+ }
+ m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
+ QCOMPARE(unregistered, 1);
+ QCOMPARE(cleaned, 1);
+}
+
+/// Motion arrives from 3DxWare's thread and is emitted on this one, in
+/// QET's axis convention.
+void TstSpaceMouseConnexion::motion()
+{
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QVERIFY(backend.isAvailable());
+
+ QThread *emitted_on = nullptr;
+ connect(&backend, &SpaceMouseBackend::motion,
+ [&emitted_on]() { emitted_on = QThread::currentThread(); });
+ const qint16 axis[6] = {10, 20, 30, 40, 50, 60};
+ const QList samples = collect(backend, [&]() {
+ m_send(CLIENT, AXIS, axis, 0);
+ });
+ QCOMPARE(samples.size(), 1);
+ QCOMPARE(emitted_on, QThread::currentThread());
+
+ const SpaceMouseSample &s = samples.first();
+ QCOMPARE(s.x, 10);
+ QCOMPARE(s.y, -30);
+ QCOMPARE(s.z, -20);
+ QCOMPARE(s.rx, 40);
+ QCOMPARE(s.ry, -60);
+ QCOMPARE(s.rz, -50);
+}
+
+/// 3DxWare sends every state to every client; only ours counts.
+void TstSpaceMouseConnexion::otherClients()
+{
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
+ QCOMPARE(collect(backend, [&]() { m_send(CLIENT + 1, AXIS, axis, 0); }).size(), 0);
+}
+
+/// A press is a bit set that was clear; 0-based, as HidBackend numbers them.
+void TstSpaceMouseConnexion::buttons()
+{
+ ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QSignalSpy spy(&backend, &SpaceMouseBackend::buttonPressed);
+ const qint16 still[6] = {};
+
+ m_send(CLIENT, BUTTONS, still, 0b101);
+ QCoreApplication::processEvents();
+ QCOMPARE(spy.size(), 2);
+ QCOMPARE(spy.at(0).at(0).toInt(), 0);
+ QCOMPARE(spy.at(1).at(0).toInt(), 2);
+
+ m_send(CLIENT, BUTTONS, still, 0b111);
+ m_send(CLIENT, BUTTONS, still, 0);
+ m_send(CLIENT, BUTTONS, still, 0b100);
+ QCoreApplication::processEvents();
+ QCOMPARE(spy.size(), 4);
+ QCOMPARE(spy.at(2).at(0).toInt(), 1);
+ QCOMPARE(spy.at(3).at(0).toInt(), 2);
+
+ QCOMPARE(ConnexionBackend::newlyPressed(0, 0x80000000u), QList{31});
+ QVERIFY(ConnexionBackend::newlyPressed(0b11, 0b01).isEmpty());
+}
+
+/// A message queued for a backend deleted before the main thread reads it
+/// is dropped, not delivered to freed memory.
+void TstSpaceMouseConnexion::deletedBeforeDelivery()
+{
+ auto *backend = new ConnexionBackend(nullptr, QStringLiteral(FAKE_CONNEXION));
+ const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
+ m_send(CLIENT, AXIS, axis, 0);
+ delete backend;
+ QCoreApplication::processEvents();
+ //And 3DxWare calling after shutdown finds no one to call.
+ m_send(CLIENT, AXIS, axis, 0);
+ QCoreApplication::processEvents();
+}
+
+/// 3DxWare takes one set of handlers per process.
+void TstSpaceMouseConnexion::oneClientPerProcess()
+{
+ ConnexionBackend first(nullptr, QStringLiteral(FAKE_CONNEXION));
+ ConnexionBackend second(nullptr, QStringLiteral(FAKE_CONNEXION));
+ QVERIFY(first.isAvailable());
+ QVERIFY(!second.isAvailable());
+
+ const qint16 axis[6] = {5, 0, 0, 0, 0, 0};
+ QCOMPARE(collect(first, [&]() { m_send(CLIENT, AXIS, axis, 0); }).size(), 1);
+}
+
+/// On a Mac with 3DxWare installed: this process can load 3DxWare's real
+/// library. Under the hardened runtime that takes the entitlement in
+/// misc/qelectrotech.entitlements, so signing this test with and without it
+/// checks the entitlement is what makes the difference. Whether 3DxWare's
+/// driver then answers depends on the machine, so it is not checked.
+void TstSpaceMouseConnexion::realLibrary()
+{
+ QLibrary library(QString::fromLatin1(ConnexionBackend::DEFAULT_LIBRARY));
+ if (!QFileInfo::exists(library.fileName())) {
+ QSKIP("3DxWare is not installed");
+ }
+ QVERIFY2(library.load(), qPrintable(library.errorString()));
+ QVERIFY(library.resolve("SetConnexionHandlers"));
+
+ ConnexionBackend backend;
+ qInfo("3DxWare's driver %s", backend.isAvailable() ? "answered" : "did not answer");
+}
+
+QTEST_GUILESS_MAIN(TstSpaceMouseConnexion)
+#include "tst_spacemouseconnexion.moc"