mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 22:24:13 +02:00
Read the 3D mouse through 3DxWare on macOS when it is installed
On macOS, 3DxWare installs a driver extension that takes the 3D mouse over. With it installed, HidBackend opens the device but receives nothing, so the mouse did nothing in QElectroTech until 3DxWare was uninstalled (discussion #599, PR #1028). Most Mac owners of a 3D mouse have 3DxWare installed. ConnexionBackend asks 3DxWare for the motion instead, through 3DconnexionClient.framework, as Blender does. SpaceMouseListener tries it first. When 3DxWare is not installed, or is installed but its driver is not running, it falls back to HidBackend, so the device works in both setups. Take-over mode stops 3DxWare's own actions in QET, so the view does not move twice. The library is loaded at run time from where 3DxWare installs it: nothing is linked or bundled, and the build needs no SDK. The few declarations are written here, from Blender's GHOST_NDOFManagerCocoa.mm, because 3Dconnexion's SDK headers may not be redistributed. 3DxWare's axes are y up and z away from the user; they are mapped to QET's raw USB convention by comparing Blender's 3DxWare and spacenavd code paths. The release script signs with the hardened runtime, which refuses a library another team signed. misc/qelectrotech.entitlements adds com.apple.security.cs.disable-library-validation (Blender's notarized build carries the same one), and MacQetDeploy_arm64_cmake.sh now passes it to all four signings of the app, including the re-sign inside the DMG. Tested: tst_spacemouseconnexion runs the backend on every platform against fakeconnexion, a stand-in library that answers from its own thread as 3DxWare does: registration, the axis mapping, buttons, other clients' messages, 3DxWare not installed or not running, deletion with a message in flight. Flipping an axis sign or dropping the client check turns it red. realLibrary() loads the real framework when 3DxWare is installed. Linux Qt 6 build with the 3D mouse enabled: all 18 tests pass. Not tested: on a Mac with a real device. The axis signs and whether buttons arrive as a bitmask with current 3DxWare are unverified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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_FILE:fakeconnexion>")
|
||||
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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/*
|
||||
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 <QtGlobal>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#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<const char *>(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();
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/*
|
||||
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 <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QLibrary>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<SpaceMouseSample> collect(ConnexionBackend &backend,
|
||||
const std::function<void()> &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<Reset>(m_fake.resolve("fakeReset"));
|
||||
m_registration = reinterpret_cast<Registration>(m_fake.resolve("fakeRegistration"));
|
||||
m_send = reinterpret_cast<Send>(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<SpaceMouseSample> TstSpaceMouseConnexion::collect(
|
||||
ConnexionBackend &backend, const std::function<void()> &send)
|
||||
{
|
||||
QList<SpaceMouseSample> 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<SpaceMouseSample> 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<int>{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"
|
||||
Reference in New Issue
Block a user