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"