From 15c09dda47e4f4fdfef47a9f1523cda780230870 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 23 Oct 2026 21:38:04 -0500 Subject: [PATCH] Add the USB forwarding settings section and wire it into the stream Adds a "USB forwarding" section to Settings (Windows only) and starts the session manager when a stream begins. Both are off by default; with the setting off nothing is created, no thread starts, and the stream behaves exactly as before. Settings lets the user turn the feature on, see whether usbipd-win is installed and running, share or stop sharing a device, and choose which shared devices are forwarded automatically when a stream starts. Sharing asks Windows for permission only when the user presses the button, and never blocks the window. When the setting is on, the stream starts the manager after the window exists and stops it during teardown, after the input is released and the window is gone. The manager never blocks the stream thread: the device check runs on a pool thread, so quitting a stream cannot freeze behind a slow usbipd. Its status is logged from the existing idle wake and after handled events, once a second at most, with no event pump and no per-frame work. Ctrl+Alt+Shift+U pauses or resumes forwarding; with the setting off the keys pass through to the host as before. The tunnel host is always the host the stream is connected to, in one canonical form, and the pinned server certificate and the client identity come from pairing, never from user input. Co-Authored-By: Claude Sonnet 5.5 --- app/app.pro | 7 +- app/backend/systemproperties.cpp | 6 + app/backend/systemproperties.h | 2 + app/backend/usbforwardingbackend.cpp | 53 ++++ app/backend/usbforwardingbackend.h | 12 + app/backend/usbforwardingenvironment.cpp | 40 +++ app/backend/usbforwardingenvironment.h | 9 + app/backend/usbforwardingmanager.cpp | 56 ++++- app/backend/usbforwardingmanager.h | 5 + app/backend/usbforwardingsessionglue.cpp | 52 ++++ app/backend/usbforwardingsessionglue.h | 55 ++++ app/backend/usbforwardingsessionseams.cpp | 89 +++++++ app/backend/usbforwardingsessionseams.h | 23 ++ app/backend/usbforwardinguilogic.h | 130 ++++++++++ app/gui/SettingsView.qml | 168 +++++++++++++ app/main.cpp | 19 ++ app/streaming/input/input.cpp | 15 ++ app/streaming/input/input.h | 1 + app/streaming/input/keyboard.cpp | 8 + app/streaming/session.cpp | 138 +++++++++- app/streaming/session.h | 17 ++ tests/run_usb_forwarding_tests.ps1 | 2 +- tests/usb_forwarding_elevation/main.cpp | 23 ++ tests/usb_forwarding_environment/main.cpp | 16 ++ tests/usb_forwarding_manager/main.cpp | 50 ++++ tests/usb_forwarding_session_glue/main.cpp | 238 ++++++++++++++++++ .../usb_forwarding_session_glue.pro | 17 ++ tests/usb_forwarding_ui_logic/main.cpp | 162 ++++++++++++ .../usb_forwarding_ui_logic.pro | 7 + 29 files changed, 1408 insertions(+), 12 deletions(-) create mode 100644 app/backend/usbforwardingsessionglue.cpp create mode 100644 app/backend/usbforwardingsessionglue.h create mode 100644 app/backend/usbforwardingsessionseams.cpp create mode 100644 app/backend/usbforwardingsessionseams.h create mode 100644 app/backend/usbforwardinguilogic.h create mode 100644 tests/usb_forwarding_session_glue/main.cpp create mode 100644 tests/usb_forwarding_session_glue/usb_forwarding_session_glue.pro create mode 100644 tests/usb_forwarding_ui_logic/main.cpp create mode 100644 tests/usb_forwarding_ui_logic/usb_forwarding_ui_logic.pro diff --git a/app/app.pro b/app/app.pro index 453957f4c3..f5c5a0d653 100644 --- a/app/app.pro +++ b/app/app.pro @@ -211,6 +211,8 @@ SOURCES += \ backend/usbforwardingbackend.cpp \ backend/usbforwardingelevation.cpp \ backend/usbforwardingmanager.cpp \ + backend/usbforwardingsessionglue.cpp \ + backend/usbforwardingsessionseams.cpp \ wm.cpp HEADERS += \ @@ -258,7 +260,10 @@ HEADERS += \ backend/usbforwardingenvironment.h \ backend/usbforwardingbackend.h \ backend/usbforwardingelevation.h \ - backend/usbforwardingmanager.h + backend/usbforwardingmanager.h \ + backend/usbforwardingsessionglue.h \ + backend/usbforwardingsessionseams.h \ + backend/usbforwardinguilogic.h # Platform-specific renderers and decoders ffmpeg { diff --git a/app/backend/systemproperties.cpp b/app/backend/systemproperties.cpp index 9abf52d8bb..7ac88edd0c 100644 --- a/app/backend/systemproperties.cpp +++ b/app/backend/systemproperties.cpp @@ -62,6 +62,12 @@ SystemProperties::SystemProperties() logFilesAvailable = false; #endif +#ifdef Q_OS_WIN + usbForwardingAvailable = true; +#else + usbForwardingAvailable = false; +#endif + #ifdef Q_OS_DARWIN isDarwin = true; #else diff --git a/app/backend/systemproperties.h b/app/backend/systemproperties.h index f19bbecee6..a3503d58b8 100644 --- a/app/backend/systemproperties.h +++ b/app/backend/systemproperties.h @@ -27,6 +27,7 @@ class SystemProperties : public QObject Q_PROPERTY(bool usesMaterial3Theme MEMBER usesMaterial3Theme CONSTANT) Q_PROPERTY(QString versionString MEMBER versionString CONSTANT) Q_PROPERTY(bool logFilesAvailable MEMBER logFilesAvailable CONSTANT) + Q_PROPERTY(bool usbForwardingAvailable MEMBER usbForwardingAvailable CONSTANT) Q_PROPERTY(bool diagnosticLoggingEnabled READ diagnosticLoggingEnabled NOTIFY diagnosticLoggingEnabledChanged) // Properties queried asynchronously (startAsyncLoad() must be called!) @@ -75,6 +76,7 @@ private slots: bool usesMaterial3Theme; bool isDarwin; bool logFilesAvailable; + bool usbForwardingAvailable; // Properties only set if startAsyncLoad() is called bool hasHardwareAcceleration; diff --git a/app/backend/usbforwardingbackend.cpp b/app/backend/usbforwardingbackend.cpp index fd00100345..1e26753100 100644 --- a/app/backend/usbforwardingbackend.cpp +++ b/app/backend/usbforwardingbackend.cpp @@ -3,7 +3,9 @@ #include "usbforwardingbackend.h" #include "usbforwardingcapability.h" #include "usbforwardingenvironment.h" +#include "usbforwardinguilogic.h" +#include #include #include #include @@ -104,13 +106,39 @@ UsbForwardingBackend::UsbForwardingBackend(QObject *parent) UsbForwardingBackend::~UsbForwardingBackend() { + shutdown(); std::lock_guard lock(m_Link->mutex); m_Link->backend = nullptr; } +void UsbForwardingBackend::shutdown() +{ + const QList probes = findChildren(QString(), Qt::FindDirectChildrenOnly); + for (QProcess *probe : probes) { + probe->disconnect(); + if (probe->state() != QProcess::NotRunning) { + probe->kill(); + probe->waitForFinished(1000); + } + delete probe; + } + if (!probes.isEmpty()) { + m_Busy = false; + } +} + UsbForwardingBackend *UsbForwardingBackend::get() { + // A function-local static is destroyed after the application object is gone, + // so a probe still running at exit is stopped from aboutToQuit instead. static UsbForwardingBackend backend; + static const bool wired = [] { + if (QCoreApplication *app = QCoreApplication::instance()) { + QObject::connect(app, &QCoreApplication::aboutToQuit, &backend, [] { backend.shutdown(); }); + } + return true; + }(); + Q_UNUSED(wired) return &backend; } @@ -195,6 +223,31 @@ QVariantList UsbForwardingBackend::parseUsbipdState(const QByteArray &stateJson, return devices; } +QString UsbForwardingBackend::shareStateText(const QVariantMap &device) const +{ + return UsbForwarding::Ui::deviceShareStateText(UsbForwarding::Ui::deviceShareState(device)); +} + +bool UsbForwardingBackend::canShare(const QVariantMap &device) const +{ + return UsbForwarding::Ui::canShare(device); +} + +bool UsbForwardingBackend::canStopSharing(const QVariantMap &device) const +{ + return UsbForwarding::Ui::canStopSharing(device); +} + +bool UsbForwardingBackend::autoDevicesContain(const QStringList &list, const QString &identity) const +{ + return UsbForwarding::Ui::autoDevicesContain(list, identity); +} + +QStringList UsbForwardingBackend::autoDevicesWith(const QStringList &list, const QString &identity, bool on) const +{ + return UsbForwarding::Ui::autoDevicesWith(list, identity, on); +} + void UsbForwardingBackend::setBusy(bool busy) { if (m_Busy == busy) { diff --git a/app/backend/usbforwardingbackend.h b/app/backend/usbforwardingbackend.h index ba26c30a71..a626ff2850 100644 --- a/app/backend/usbforwardingbackend.h +++ b/app/backend/usbforwardingbackend.h @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -86,9 +88,19 @@ class UsbForwardingBackend : public QObject ~UsbForwardingBackend() override; Q_INVOKABLE void refresh(); + // Kills and deletes a usbipd state probe that is still running. Called on + // application quit and by the destructor. + void shutdown(); Q_INVOKABLE void bind(const QString &busId); Q_INVOKABLE void unbind(const QString &busId, const QString &persistedGuid); + // Helpers for the settings list (see usbforwardinguilogic.h). + Q_INVOKABLE QString shareStateText(const QVariantMap &device) const; + Q_INVOKABLE bool canShare(const QVariantMap &device) const; + Q_INVOKABLE bool canStopSharing(const QVariantMap &device) const; + Q_INVOKABLE bool autoDevicesContain(const QStringList &list, const QString &identity) const; + Q_INVOKABLE QStringList autoDevicesWith(const QStringList &list, const QString &identity, bool on) const; + QVariantList devices() const { return m_Devices; } bool busy() const { return m_Busy; } QString error() const { return m_Error; } diff --git a/app/backend/usbforwardingenvironment.cpp b/app/backend/usbforwardingenvironment.cpp index 6ffef0079b..d4c19bf48b 100644 --- a/app/backend/usbforwardingenvironment.cpp +++ b/app/backend/usbforwardingenvironment.cpp @@ -1,7 +1,9 @@ // Derived from qiin2333/moonlight-qt (GPL-3.0). #include "usbforwardingenvironment.h" +#include "usbforwardinguilogic.h" +#include #include #include #include @@ -246,9 +248,42 @@ UsbForwardingEnvironment::UsbForwardingEnvironment(QObject *parent) { } +UsbForwardingEnvironment::~UsbForwardingEnvironment() +{ + shutdown(); +} + +void UsbForwardingEnvironment::shutdown() +{ + const QList probes = findChildren(QString(), Qt::FindDirectChildrenOnly); + for (QProcess *probe : probes) { + probe->disconnect(); + if (probe->state() != QProcess::NotRunning) { + probe->kill(); + probe->waitForFinished(1000); + } + delete probe; + } + if (m_Checking) { + m_Checking = false; + m_State = CheckFailed; + emit checkingChanged(); + emit stateChanged(); + } +} + UsbForwardingEnvironment* UsbForwardingEnvironment::get() { + // A function-local static is destroyed after the application object is gone, + // so a probe still running at exit is stopped from aboutToQuit instead. static UsbForwardingEnvironment environment; + static const bool wired = [] { + if (QCoreApplication *app = QCoreApplication::instance()) { + QObject::connect(app, &QCoreApplication::aboutToQuit, &environment, [] { environment.shutdown(); }); + } + return true; + }(); + Q_UNUSED(wired) return &environment; } @@ -517,6 +552,11 @@ UsbForwardingEnvironment::State UsbForwardingEnvironment::probeServices() #endif } +QString UsbForwardingEnvironment::statusText() const +{ + return UsbForwarding::Ui::environmentStatusText(m_State, m_Version); +} + QString UsbForwardingEnvironment::readinessError(State state) { switch (state) { diff --git a/app/backend/usbforwardingenvironment.h b/app/backend/usbforwardingenvironment.h index 082a98fb4a..4bbff6a524 100644 --- a/app/backend/usbforwardingenvironment.h +++ b/app/backend/usbforwardingenvironment.h @@ -22,6 +22,8 @@ class UsbForwardingEnvironment : public QObject Q_PROPERTY(State state READ state NOTIFY stateChanged) Q_PROPERTY(QString usbipdVersion READ usbipdVersion NOTIFY stateChanged) Q_PROPERTY(bool checking READ checking NOTIFY checkingChanged) + Q_PROPERTY(bool ready READ ready NOTIFY stateChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) public: enum State @@ -80,10 +82,17 @@ class UsbForwardingEnvironment : public QObject static QString readinessError(State state); Q_INVOKABLE void refresh(); + // Kills and deletes any probe that is still running. Called on application + // quit (and by the destructor), so no QProcess outlives the application. + void shutdown(); + ~UsbForwardingEnvironment() override; State state() const { return m_State; } QString usbipdVersion() const { return m_Version; } bool checking() const { return m_Checking; } + bool ready() const { return m_State == Ready; } + // Plain text for the settings page (see usbforwardinguilogic.h). + QString statusText() const; signals: void stateChanged(); diff --git a/app/backend/usbforwardingmanager.cpp b/app/backend/usbforwardingmanager.cpp index 322ee90f46..afa57c042f 100644 --- a/app/backend/usbforwardingmanager.cpp +++ b/app/backend/usbforwardingmanager.cpp @@ -458,19 +458,42 @@ class Engine final : public QObject }); } + /* Asks which devices usbipd is sharing. The lister can wait on a process + * for seconds, so it runs on a pool thread, never on this event loop: + * stop() must not wait for it. A late answer finds no engine, or an id + * that is no longer expected, and is dropped. */ void reconcile() { - QList shared; - try { - if (m_Seams.listDevices) { - shared = m_Seams.listDevices(); - } - } catch (...) { - shared.clear(); - } if (!m_Running || m_Paused) { - return; // stopped or paused from inside the device lister + return; } + const quint64 id = ++m_ListSeq; + m_ExpectedList = id; + const DeviceLister lister = m_Seams.listDevices; + const std::shared_ptr link = m_Link; + QThreadPool::globalInstance()->start([lister, link, id] { + QList shared; + try { + if (lister) { + shared = lister(); + } + } catch (...) { + shared.clear(); + } + std::lock_guard lock(link->mutex); + if (Engine *engine = link->engine) { + QMetaObject::invokeMethod( + engine, [engine, id, shared] { engine->onListed(id, shared); }, Qt::QueuedConnection); + } + }); + } + + void onListed(quint64 id, const QList &shared) + { + if (!m_Running || m_Paused || id != m_ExpectedList) { + return; // stale: stopped, paused, or a newer listing replaced it + } + m_ExpectedList = 0; /* NotShared rows hold no resources: rebuild them from the current list. */ m_Devs.erase(std::remove_if(m_Devs.begin(), m_Devs.end(), [](const std::unique_ptr &d) { return d->state == DeviceState::NotShared; }), @@ -640,6 +663,7 @@ class Engine final : public QObject void teardown() { m_ExpectedFetch = 0; + m_ExpectedList = 0; m_FetchTimer->stop(); for (auto &dev : m_Devs) { if (dev->retry != nullptr) { @@ -678,6 +702,8 @@ class Engine final : public QObject std::vector> m_Graveyard; quint64 m_FetchSeq = 0; quint64 m_ExpectedFetch = 0; + quint64 m_ListSeq = 0; + quint64 m_ExpectedList = 0; int m_CapFailures = 0; int m_Rechecks = 0; bool m_Started = false; @@ -725,12 +751,23 @@ Snapshot Manager::snapshot() const return m_Shared->snapshot; } +void Manager::abandonListing() noexcept +{ + try { + if (m_Seams.abandonListing) { + m_Seams.abandonListing(); + } + } catch (...) { + } +} + void Manager::teardownLocked() { if (!m_Runtime) { return; } m_Running = false; + abandonListing(); if (m_Runtime->thread.isRunning()) { Engine *engine = m_Runtime->engine; QMetaObject::invokeMethod(engine, [engine] { engine->stopAll(); }, Qt::BlockingQueuedConnection); @@ -788,6 +825,7 @@ void Manager::stop() noexcept QThread *worker = m_WorkerThread.load(); if (worker != nullptr && QThread::currentThread() == worker) { m_Running = false; + abandonListing(); static_cast(m_WorkerEngine.load())->requestStop(); return; } diff --git a/app/backend/usbforwardingmanager.h b/app/backend/usbforwardingmanager.h index 8bd0527669..ac29766530 100644 --- a/app/backend/usbforwardingmanager.h +++ b/app/backend/usbforwardingmanager.h @@ -90,6 +90,10 @@ struct ManagerSeams DeviceLister listDevices; /* Optional: defaults to the real UsbForwarding::Tunnel. */ TunnelFactory createTunnel; + /* Optional. Called by stop() before it joins the worker, from any thread: + * a lister that is waiting on something (a process) must give up and + * return soon. stop() never waits for a lister either way. */ + std::function abandonListing; }; struct ManagerOptions @@ -218,6 +222,7 @@ class Manager final : public QObject struct Runtime; void publishLocal(State state); void teardownLocked(); + void abandonListing() noexcept; ManagerSeams m_Seams; ManagerOptions m_Options; diff --git a/app/backend/usbforwardingsessionglue.cpp b/app/backend/usbforwardingsessionglue.cpp new file mode 100644 index 0000000000..7c8d707062 --- /dev/null +++ b/app/backend/usbforwardingsessionglue.cpp @@ -0,0 +1,52 @@ +#include "usbforwardingsessionglue.h" + +#include + +namespace UsbForwarding { + +SessionPlan planSession(const SessionInputs &inputs) +{ + SessionPlan plan; + if (!inputs.platformSupported) { + plan.kind = PlanKind::NotApplicable; + return plan; + } + if (!inputs.enabled) { + plan.kind = PlanKind::Disabled; + return plan; + } + + plan.kind = PlanKind::Refused; + + /* Both addresses go through the same normaliser, so a legitimate stream is + * never refused for a spelling difference (case, trailing dot, brackets). */ + const QString stream = ManagerConfig::canonicalHostAddress(inputs.streamAddress); + const QString paired = ManagerConfig::canonicalHostAddress(inputs.pairedAddress); + if (stream.isEmpty() || paired.isEmpty()) { + plan.reason = QStringLiteral("the host address is missing or is not a host address"); + return plan; + } + if (stream != paired) { + plan.reason = QStringLiteral("the stream address is not the paired host's address"); + return plan; + } + if (inputs.pinnedServerCertificate.isNull()) { + plan.reason = QStringLiteral("the paired server certificate is missing"); + return plan; + } + if (inputs.clientSsl.localCertificate().isNull() || inputs.clientSsl.privateKey().isNull()) { + plan.reason = QStringLiteral("the client certificate or key is missing"); + return plan; + } + + plan.config.enabled = true; + plan.config.autoDevices = inputs.autoDevices; + plan.config.streamAddress = stream; + plan.config.pairedAddress = paired; + plan.config.pinnedServerCertificate = inputs.pinnedServerCertificate; + plan.config.clientSsl = inputs.clientSsl; + plan.kind = PlanKind::Start; + return plan; +} + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingsessionglue.h b/app/backend/usbforwardingsessionglue.h new file mode 100644 index 0000000000..5cfb1f0dd8 --- /dev/null +++ b/app/backend/usbforwardingsessionglue.h @@ -0,0 +1,55 @@ +#pragma once + +#include "usbforwardingmanager.h" + +#include +#include +#include +#include + +/* + * Decides, from plain values, whether a streaming session should start USB + * forwarding and with which configuration. It knows nothing about Session, + * NvComputer or the preference object, so it can be unit tested alone. + * + * Nothing here opens a socket, starts a thread or writes to a log. The caller + * logs SessionPlan::reason (a short developer-facing line that never holds the + * certificate, the key or an address). + */ +namespace UsbForwarding { + +struct SessionInputs +{ + /* True only on platforms that support the feature (Windows). */ + bool platformSupported = false; + /* StreamingPreferences::usbForwardingEnabled / usbForwardingAutoDevices. */ + bool enabled = false; + QStringList autoDevices; + /* The address the stream is connected to, and the address of the paired + * computer record. Spelling differences are normalised here. */ + QString streamAddress; + QString pairedAddress; + /* NvComputer::serverCert (the pinned certificate). */ + QSslCertificate pinnedServerCertificate; + /* IdentityManager::getSslConfig(): the client certificate and key. */ + QSslConfiguration clientSsl; +}; + +enum class PlanKind +{ + NotApplicable, // wrong platform: do nothing, touch nothing + Disabled, // switched off in the preferences (the default): do nothing + Refused, // switched on, but the inputs are not safe to use; see reason + Start, // config is ready for Manager::start() +}; + +struct SessionPlan +{ + PlanKind kind = PlanKind::NotApplicable; + ManagerConfig config; + QString reason; +}; + +SessionPlan planSession(const SessionInputs &inputs); + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingsessionseams.cpp b/app/backend/usbforwardingsessionseams.cpp new file mode 100644 index 0000000000..337f64eb3f --- /dev/null +++ b/app/backend/usbforwardingsessionseams.cpp @@ -0,0 +1,89 @@ +#include "usbforwardingsessionseams.h" + +#include "nvhttp.h" +#include "usbforwardingbackend.h" +#include "usbforwardingenvironment.h" + +#include +#include +#include +#include +#include + +namespace UsbForwarding { + +#ifdef Q_OS_WIN +/* Runs `usbipd state` on the calling (pool) thread. It gives up as soon as + * `abandon` is set, killing the child, so a stop never waits for it. */ +static QList listSharedDevicesBlocking(const std::atomic &abandon) +{ + const QString exe = UsbForwardingEnvironment::locateUsbipd(); + if (exe.isEmpty() || abandon.load()) { + return {}; + } + QProcess process; + process.setWorkingDirectory(QFileInfo(exe).absolutePath()); + process.start(exe, { QStringLiteral("state") }); + + /* Poll in short steps so an abandon is noticed within about 100 ms. */ + QElapsedTimer clock; + clock.start(); + bool started = false; + while (!abandon.load() && clock.elapsed() < 5000) { + if (process.waitForStarted(100)) { + started = true; + break; + } + if (process.error() == QProcess::FailedToStart) { + return {}; + } + } + bool finished = false; + if (started) { + clock.restart(); + while (!abandon.load() && clock.elapsed() < 8000) { + if (process.waitForFinished(100)) { + finished = true; + break; + } + } + } + if (!finished) { + if (process.state() != QProcess::NotRunning) { + process.kill(); + process.waitForFinished(500); + } + return {}; + } + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + return {}; + } + QString error; + const QVariantList devices = UsbForwardingBackend::parseUsbipdState(process.readAllStandardOutput(), &error); + if (!error.isEmpty()) { + return {}; + } + return sharedDevicesFromBackendList(devices); +} +#endif + +ManagerSeams makeSessionSeams(const NvAddress &address, uint16_t httpsPort, + const QSslCertificate &pinnedServerCertificate, bool useTrueUid) +{ + ManagerSeams seams; + seams.fetchCapability = [address, httpsPort, pinnedServerCertificate, useTrueUid]() { + NvHTTP http(address, httpsPort, pinnedServerCertificate, useTrueUid); + return http.getUsbForwardingCapability(); + }; +#ifdef Q_OS_WIN + /* One flag per session's seams: set by Manager::stop(), never reset. */ + const auto abandon = std::make_shared>(false); + seams.listDevices = [abandon]() { return listSharedDevicesBlocking(*abandon); }; + seams.abandonListing = [abandon]() { abandon->store(true); }; +#else + seams.listDevices = []() { return QList(); }; +#endif + return seams; +} + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingsessionseams.h b/app/backend/usbforwardingsessionseams.h new file mode 100644 index 0000000000..7db1d05388 --- /dev/null +++ b/app/backend/usbforwardingsessionseams.h @@ -0,0 +1,23 @@ +#pragma once + +#include "nvaddress.h" +#include "usbforwardingmanager.h" + +#include + +namespace UsbForwarding { + +/* + * The real seams for a streaming session. Everything is copied in, so the + * callbacks own what they use and never touch the NvComputer after this + * returns. Both callbacks run off the stream thread and may block briefly. + * + * fetchCapability GET /api/v1/usb-forwarding on the paired host, pinned to + * the paired certificate (throws on failure) + * listDevices devices usbipd is sharing right now (empty on any error); + * runs `usbipd state` itself and never uses the UI backend + */ +ManagerSeams makeSessionSeams(const NvAddress &address, uint16_t httpsPort, + const QSslCertificate &pinnedServerCertificate, bool useTrueUid); + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardinguilogic.h b/app/backend/usbforwardinguilogic.h new file mode 100644 index 0000000000..b6d7184241 --- /dev/null +++ b/app/backend/usbforwardinguilogic.h @@ -0,0 +1,130 @@ +#pragma once + +#include "usbforwardingenvironment.h" + +#include +#include +#include +#include + +// Pure helpers behind the USB forwarding settings section. No I/O, no state, +// so they can be unit tested without a window. +namespace UsbForwarding { +namespace Ui { + +enum class DeviceShareState +{ + NotShared, + Shared, + SharedNotPlugged, + Busy, + Unsupported, +}; + +// What the settings list shows for one entry of UsbForwardingBackend::devices(). +inline DeviceShareState deviceShareState(const QVariantMap &device) +{ + const bool bound = device.value(QStringLiteral("isBound")).toBool(); + const bool connected = device.value(QStringLiteral("isConnected")).toBool(); + const bool attached = device.value(QStringLiteral("isAttached")).toBool(); + const bool supported = device.value(QStringLiteral("isSupported")).toBool(); + if (attached) { + return DeviceShareState::Busy; + } + if (bound) { + return connected ? DeviceShareState::Shared : DeviceShareState::SharedNotPlugged; + } + return supported ? DeviceShareState::NotShared : DeviceShareState::Unsupported; +} + +inline QString deviceShareStateText(DeviceShareState state) +{ + switch (state) { + case DeviceShareState::Shared: + return QCoreApplication::translate("UsbForwardingUi", "Shared"); + case DeviceShareState::SharedNotPlugged: + return QCoreApplication::translate("UsbForwardingUi", "Shared (not plugged in)"); + case DeviceShareState::Busy: + return QCoreApplication::translate("UsbForwardingUi", "Busy (in use right now)"); + case DeviceShareState::Unsupported: + return QCoreApplication::translate("UsbForwardingUi", "Cannot be shared"); + case DeviceShareState::NotShared: + default: + return QCoreApplication::translate("UsbForwardingUi", "Not shared"); + } +} + +inline bool canShare(const QVariantMap &device) +{ + return deviceShareState(device) == DeviceShareState::NotShared; +} + +inline bool canStopSharing(const QVariantMap &device) +{ + const DeviceShareState state = deviceShareState(device); + return state == DeviceShareState::Shared || state == DeviceShareState::SharedNotPlugged; +} + +// Plain text for the environment check. Never contains a link or a command. +inline QString environmentStatusText(UsbForwardingEnvironment::State state, const QString &version) +{ + using E = UsbForwardingEnvironment; + switch (state) { + case E::Checking: + return QCoreApplication::translate("UsbForwardingUi", "Checking for usbipd-win..."); + case E::NotInstalled: + return QCoreApplication::translate("UsbForwardingUi", + "usbipd-win was not found. Install usbipd-win yourself to share USB devices; MoonlightVibe does not install it."); + case E::ServiceStopped: + return QCoreApplication::translate("UsbForwardingUi", + "usbipd-win is installed, but its service is not running. Start the usbipd service, then press Refresh."); + case E::DriverStopped: + return QCoreApplication::translate("UsbForwardingUi", + "usbipd-win is installed, but its driver is not running. Restart Windows or start the driver as administrator, then press Refresh."); + case E::CheckFailed: + return QCoreApplication::translate("UsbForwardingUi", + "Could not check usbipd-win. Make sure it is installed correctly, then press Refresh."); + case E::Unsupported: + return QCoreApplication::translate("UsbForwardingUi", "USB forwarding is not supported on this platform."); + case E::Ready: + default: + return version.isEmpty() + ? QCoreApplication::translate("UsbForwardingUi", "usbipd-win is installed and its service is running.") + : QCoreApplication::translate("UsbForwardingUi", "usbipd-win %1 is installed and its service is running.").arg(version); + } +} + +// Whether Ctrl+Alt+Shift+U is active. Only on Windows with USB forwarding turned +// on; in every other case the key combo is not intercepted at all. +inline bool usbForwardingHotkeyEnabled(bool platformIsWindows, bool preferenceOn) +{ + return platformIsWindows && preferenceOn; +} + +// The auto-forward list stores stable device identities ("vid:pid" or +// "vid:pid#serial"). Empty identities are never stored. +inline bool autoDevicesContain(const QStringList &list, const QString &identity) +{ + return !identity.isEmpty() && list.contains(identity); +} + +// Returns the list with identity added (once) or removed (every copy). The +// input order is kept; other entries are never touched. +inline QStringList autoDevicesWith(const QStringList &list, const QString &identity, bool on) +{ + if (identity.isEmpty()) { + return list; + } + QStringList result = list; + if (on) { + if (!result.contains(identity)) { + result.append(identity); + } + } else { + result.removeAll(identity); + } + return result; +} + +} // namespace Ui +} // namespace UsbForwarding diff --git a/app/gui/SettingsView.qml b/app/gui/SettingsView.qml index 923c9ffa92..373a2890b4 100644 --- a/app/gui/SettingsView.qml +++ b/app/gui/SettingsView.qml @@ -7,6 +7,8 @@ import StreamingPreferences 1.0 import ComputerManager 1.0 import SdlGamepadKeyNavigation 1.0 import SystemProperties 1.0 +import UsbForwardingBackend 1.0 +import UsbForwardingEnvironment 1.0 Flickable { id: settingsPage @@ -1071,6 +1073,172 @@ Flickable { } } + GroupBox { + id: usbForwardingGroupBox + width: (parent.width - (parent.leftPadding + parent.rightPadding)) + padding: 12 + visible: SystemProperties.usbForwardingAvailable + title: "" + qsTr("USB forwarding") + "" + font.pointSize: 12 + + Column { + id: usbForwardingColumn + anchors.fill: parent + spacing: 5 + + // The last result of Share / Stop sharing, in plain words + property string usbMessage: "" + // True once a refresh has been requested (so an empty list means something) + property bool usbRefreshed: false + + function refreshUsb() { + UsbForwardingEnvironment.refresh() + UsbForwardingBackend.refresh() + usbRefreshed = true + } + + Component.onCompleted: { + if (SystemProperties.usbForwardingAvailable && StreamingPreferences.usbForwardingEnabled) { + refreshUsb() + } + } + + Connections { + target: UsbForwardingBackend + + function onOperationFinished(success, message) { + usbForwardingColumn.usbMessage = message + } + } + + CheckBox { + id: usbForwardingEnabledCheck + width: parent.width + text: qsTr("Forward USB devices to the host while streaming") + font.pointSize: 12 + checked: StreamingPreferences.usbForwardingEnabled + onCheckedChanged: { + StreamingPreferences.usbForwardingEnabled = checked + if (checked) { + usbForwardingColumn.refreshUsb() + } + } + + ToolTip.delay: 1000 + ToolTip.timeout: 5000 + ToolTip.visible: hovered + ToolTip.text: qsTr("When this is on, the devices you pick below are sent to the host PC every time you start a stream. Nothing is forwarded while this is off.") + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + text: qsTr("The host must be running ApolloVibe with USB forwarding turned on, and it must allow this device (client) to forward USB. The host decides: if it does not allow it, nothing is forwarded and the stream is not affected.") + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + text: qsTr("Forwarded devices appear on the host as if they were plugged in there. While a device is forwarded it is not available on this PC. Press Ctrl+Alt+Shift+U while streaming to pause or resume forwarding.") + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + text: UsbForwardingEnvironment.statusText + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + visible: UsbForwardingBackend.error !== "" + text: UsbForwardingBackend.error + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + visible: usbForwardingColumn.usbMessage !== "" + text: usbForwardingColumn.usbMessage + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + text: qsTr("Sharing or unsharing a device shows a Windows permission prompt (administrator approval). Two devices of the same model without a serial number have the same identity, so choosing one of them chooses both.") + } + + Button { + text: qsTr("Refresh") + enabled: !UsbForwardingBackend.busy && !UsbForwardingEnvironment.checking + onClicked: usbForwardingColumn.refreshUsb() + } + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 10 + visible: usbForwardingColumn.usbRefreshed && !UsbForwardingBackend.busy && + UsbForwardingBackend.error === "" && UsbForwardingBackend.devices.length === 0 + text: qsTr("No USB devices found. Press Refresh after installing usbipd-win or plugging in a device.") + } + + Repeater { + model: UsbForwardingBackend.devices + + Column { + id: usbDevice + width: parent.width + spacing: 3 + + // modelData is one entry of the backend's device list + property var device: modelData + + Label { + width: parent.width + wrapMode: Text.Wrap + font.pointSize: 12 + text: (usbDevice.device.description !== "" ? usbDevice.device.description : qsTr("Unknown USB device")) + + " [" + (usbDevice.device.identity !== "" ? usbDevice.device.identity : qsTr("no identity")) + "] " + + UsbForwardingBackend.shareStateText(usbDevice.device) + } + + Row { + spacing: 5 + + Button { + text: qsTr("Share") + enabled: !UsbForwardingBackend.busy && UsbForwardingBackend.canShare(usbDevice.device) + onClicked: UsbForwardingBackend.bind(usbDevice.device.busId) + } + + Button { + text: qsTr("Stop sharing") + enabled: !UsbForwardingBackend.busy && UsbForwardingBackend.canStopSharing(usbDevice.device) + onClicked: UsbForwardingBackend.unbind(usbDevice.device.busId, usbDevice.device.persistedGuid) + } + } + + CheckBox { + width: parent.width + text: qsTr("Forward automatically when streaming") + font.pointSize: 10 + enabled: usbDevice.device.identity !== "" + checked: UsbForwardingBackend.autoDevicesContain(StreamingPreferences.usbForwardingAutoDevices, usbDevice.device.identity) + onClicked: StreamingPreferences.usbForwardingAutoDevices = + UsbForwardingBackend.autoDevicesWith(StreamingPreferences.usbForwardingAutoDevices, usbDevice.device.identity, checked) + } + } + } + } + } + GroupBox { id: hostSettingsGroupBox width: (parent.width - (parent.leftPadding + parent.rightPadding)) diff --git a/app/main.cpp b/app/main.cpp index 1faa95395d..54cb88dc86 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -53,6 +53,8 @@ #include "backend/autoupdatechecker.h" #include "backend/computermanager.h" #include "backend/systemproperties.h" +#include "backend/usbforwardingbackend.h" +#include "backend/usbforwardingenvironment.h" #include "streaming/session.h" #include "settings/streamingpreferences.h" #include "gui/sdlgamepadkeynavigation.h" @@ -977,6 +979,23 @@ int main(int argc, char *argv[]) return StreamingPreferences::get(qmlEngine); }); + // USB forwarding (the settings section is shown on Windows only). Both are + // process-wide objects that are never deleted, so QML must not own them. + qmlRegisterSingletonType("UsbForwardingBackend", 1, 0, + "UsbForwardingBackend", + [](QQmlEngine*, QJSEngine*) -> QObject* { + QObject* backend = UsbForwardingBackend::get(); + QQmlEngine::setObjectOwnership(backend, QQmlEngine::CppOwnership); + return backend; + }); + qmlRegisterSingletonType("UsbForwardingEnvironment", 1, 0, + "UsbForwardingEnvironment", + [](QQmlEngine*, QJSEngine*) -> QObject* { + QObject* environment = UsbForwardingEnvironment::get(); + QQmlEngine::setObjectOwnership(environment, QQmlEngine::CppOwnership); + return environment; + }); + // Create the identity manager on the main thread IdentityManager::get(); diff --git a/app/streaming/input/input.cpp b/app/streaming/input/input.cpp index c81bd008da..9720fb053c 100644 --- a/app/streaming/input/input.cpp +++ b/app/streaming/input/input.cpp @@ -2,6 +2,7 @@ #include "SDL_compat.h" #include "streaming/session.h" #include "settings/mappingmanager.h" +#include "backend/usbforwardinguilogic.h" #include "path.h" #include "utils.h" @@ -136,6 +137,20 @@ SdlInputHandler::SdlInputHandler(StreamingPreferences& prefs, int streamWidth, i m_SpecialKeyCombos[KeyComboToggleKeyboardGrab].scanCode = SDL_SCANCODE_K; m_SpecialKeyCombos[KeyComboToggleKeyboardGrab].enabled = WMUtils::isRunningDesktopEnvironment(); + // Ctrl+Alt+Shift+U pauses or resumes USB forwarding. It is only active when the + // user turned USB forwarding on (Windows only); otherwise the keys pass through + // to the host exactly as before. + m_SpecialKeyCombos[KeyComboToggleUsbForwarding].keyCombo = KeyComboToggleUsbForwarding; + m_SpecialKeyCombos[KeyComboToggleUsbForwarding].keyCode = SDLK_u; + m_SpecialKeyCombos[KeyComboToggleUsbForwarding].scanCode = SDL_SCANCODE_U; +#ifdef Q_OS_WIN + const bool isWindows = true; +#else + const bool isWindows = false; +#endif + m_SpecialKeyCombos[KeyComboToggleUsbForwarding].enabled = + UsbForwarding::Ui::usbForwardingHotkeyEnabled(isWindows, prefs.usbForwardingEnabled); + m_OldIgnoreDevices = SDL_GetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES); m_OldIgnoreDevicesExcept = SDL_GetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT); diff --git a/app/streaming/input/input.h b/app/streaming/input/input.h index dd2c2d0dd3..dd2f8751ba 100644 --- a/app/streaming/input/input.h +++ b/app/streaming/input/input.h @@ -168,6 +168,7 @@ class SdlInputHandler KeyComboTogglePointerRegionLock, KeyComboQuitAndExit, KeyComboToggleKeyboardGrab, + KeyComboToggleUsbForwarding, KeyComboMax }; diff --git a/app/streaming/input/keyboard.cpp b/app/streaming/input/keyboard.cpp index 29e218da6f..3392029276 100644 --- a/app/streaming/input/keyboard.cpp +++ b/app/streaming/input/keyboard.cpp @@ -177,6 +177,14 @@ void SdlInputHandler::performSpecialKeyCombo(KeyCombo combo) updateKeyboardGrabState(); break; + case KeyComboToggleUsbForwarding: + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "Detected USB forwarding toggle combo"); + + // Pauses or resumes USB forwarding. Does nothing if it is not running. + Session::get()->toggleUsbForwarding(); + break; + default: Q_UNREACHABLE(); } diff --git a/app/streaming/session.cpp b/app/streaming/session.cpp index bed7f5ad75..7998a32f60 100644 --- a/app/streaming/session.cpp +++ b/app/streaming/session.cpp @@ -4,6 +4,10 @@ #include #include "backend/richpresencemanager.h" #include "streaming/audio/capture/microphonecapture.h" +#include "backend/identitymanager.h" +#include "backend/usbforwardingsessionglue.h" +#include "backend/usbforwardingsessionseams.h" +#include #include #include "SDL_compat.h" @@ -588,7 +592,10 @@ Session::Session(NvComputer* computer, NvApp& app, StreamingPreferences *prefere m_AudioSampleCount(0), m_DropAudioEndTime(0), m_MicrophoneCapture(nullptr), - m_MicrophoneEnabled(false) + m_MicrophoneEnabled(false), + m_UsbForwarding(nullptr), + m_UsbForwardingRevision(0), + m_UsbForwardingLastPoll(0) { } @@ -597,6 +604,9 @@ Session::~Session() // NB: This may not get destroyed for a long time! Don't put any non-trivial cleanup here. // Use Session::exec() or DeferredSessionCleanupTask instead. + // exec() already did this on every path; this is only a safety net (a null check). + destroyUsbForwarding(); + SDL_DestroyMutex(m_DecoderLock); } @@ -1660,6 +1670,7 @@ bool Session::startConnectionAsync() } QByteArray hostnameStr = m_Computer->activeAddress.address().toUtf8(); + m_StreamHostAddress = m_Computer->activeAddress.address(); QByteArray siAppVersion = m_Computer->appVersion.toUtf8(); SERVER_INFORMATION hostInfo; @@ -1840,6 +1851,113 @@ void Session::destroyMicrophoneCapture() m_MicrophoneEnabled = false; } +void Session::startUsbForwarding() +{ +#ifdef Q_OS_WIN + if (m_UsbForwarding != nullptr || !m_Preferences->usbForwardingEnabled) { + return; + } + + try { + UsbForwarding::SessionInputs inputs; + inputs.platformSupported = true; + inputs.enabled = true; + inputs.autoDevices = m_Preferences->usbForwardingAutoDevices; + // NB: streamAddress and pairedAddress both come from m_Computer->activeAddress + // (streamAddress as it was when the connection started, pairedAddress as it is + // now). The equality check in planSession() therefore only guards against the + // address changing in between (it fails closed); it is not an independent check. + inputs.streamAddress = m_StreamHostAddress; + + NvAddress address; + uint16_t httpsPort; + bool useTrueUid; + { + QReadLocker locker(&m_Computer->lock); + address = m_Computer->activeAddress; + httpsPort = m_Computer->activeHttpsPort; + useTrueUid = !m_Computer->isNvidiaServerSoftware; + inputs.pinnedServerCertificate = m_Computer->serverCert; + } + inputs.pairedAddress = address.address(); + inputs.clientSsl = IdentityManager::get()->getSslConfig(); + + const UsbForwarding::SessionPlan plan = UsbForwarding::planSession(inputs); + if (plan.kind == UsbForwarding::PlanKind::Refused) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "USB forwarding not started: %s", plan.reason.toUtf8().constData()); + return; + } + if (plan.kind != UsbForwarding::PlanKind::Start) { + return; + } + + m_UsbForwardingRevision = 0; + m_UsbForwardingLastPoll = SDL_GetTicks(); + m_UsbForwarding = new UsbForwarding::Manager( + UsbForwarding::makeSessionSeams(address, httpsPort, inputs.pinnedServerCertificate, useTrueUid)); + if (!m_UsbForwarding->start(plan.config)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "USB forwarding refused its configuration"); + destroyUsbForwarding(); + } + } catch (...) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "USB forwarding could not be started"); + destroyUsbForwarding(); + } +#endif +} + +void Session::destroyUsbForwarding() noexcept +{ + UsbForwarding::Manager* manager = m_UsbForwarding; + m_UsbForwarding = nullptr; + if (manager != nullptr) { + manager->stop(); + delete manager; + } +} + +// Called from the SDL loop when it wakes with nothing to do and after each handled +// event, throttled to once a second, so there is no per-frame work and no Qt event +// pump. It only logs a short fixed status line when the manager's state changed. +void Session::pollUsbForwarding() +{ + if (m_UsbForwarding == nullptr) { + return; + } + + try { + const Uint32 now = SDL_GetTicks(); + if (now - m_UsbForwardingLastPoll < 1000) { + return; + } + m_UsbForwardingLastPoll = now; + + const UsbForwarding::Snapshot snapshot = m_UsbForwarding->snapshot(); + if (snapshot.revision != m_UsbForwardingRevision) { + m_UsbForwardingRevision = snapshot.revision; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "USB forwarding: %s", snapshot.status.toUtf8().constData()); + } + } catch (...) { + } +} + +void Session::toggleUsbForwarding() +{ + // With the feature off there is no manager, so the key combo does nothing. + if (m_UsbForwarding == nullptr) { + return; + } + + try { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "%s", m_UsbForwarding->toggle() ? "USB forwarding paused or resumed" + : "USB forwarding: nothing to pause or resume"); + } catch (...) { + } +} + void Session::exec() { // If the connection failed, clean up and abort the connection. @@ -2030,6 +2148,9 @@ void Session::exec() // (m_UnexpectedTermination is set back to true). m_UnexpectedTermination = false; + // Start USB forwarding. Does nothing unless the user turned it on in Settings. + startUsbForwarding(); + // Start rich presence to indicate we're in game RichPresenceManager presence(*m_Preferences, m_App.name); @@ -2055,6 +2176,7 @@ void Session::exec() // and other problems. if (!SDL_WaitEventTimeout(&event, 1000)) { presence.runCallbacks(); + pollUsbForwarding(); continue; } #else @@ -2071,6 +2193,7 @@ void Session::exec() SDL_Delay(10); #endif presence.runCallbacks(); + pollUsbForwarding(); continue; } #endif @@ -2384,6 +2507,13 @@ void Session::exec() } break; } + + // Also check after handling an event, so the status log cannot stall under + // continuous input. A null check when USB forwarding is off; otherwise one + // tick read and compare (the 1 second throttle is inside). + if (m_UsbForwarding != nullptr) { + pollUsbForwarding(); + } } DispatchDeferredCleanup: @@ -2453,6 +2583,12 @@ void Session::exec() SDL_QuitSubSystem(SDL_INIT_VIDEO); + // Stop USB forwarding only now: the window is gone, input is released and the + // UI can continue, so even a slow stop cannot hold up the visible teardown. + // This label is the only exit from the stream loop, and it runs on this + // thread (the Manager's own thread), which keeps the QObject delete safe. + destroyUsbForwarding(); + // Cleanup can take a while, so dispatch it to a worker thread. // When it is complete, it will release our s_ActiveSessionSemaphore // reference. diff --git a/app/streaming/session.h b/app/streaming/session.h index 81e295ff0e..c4ca682e4e 100644 --- a/app/streaming/session.h +++ b/app/streaming/session.h @@ -13,6 +13,10 @@ class MicrophoneCapture; +namespace UsbForwarding { +class Manager; +} + class SupportedVideoFormatList : public QList { public: @@ -167,6 +171,13 @@ class Session : public QObject void destroyMicrophoneCapture(); + // USB forwarding (Windows only, off by default). All of these do nothing + // when the feature is off or unsupported, and none of them can throw. + void startUsbForwarding(); + void destroyUsbForwarding() noexcept; + void pollUsbForwarding(); + void toggleUsbForwarding(); + void getWindowDimensions(int& x, int& y, int& width, int& height); @@ -287,6 +298,12 @@ class Session : public QObject MicrophoneCapture* m_MicrophoneCapture; bool m_MicrophoneEnabled; + UsbForwarding::Manager* m_UsbForwarding; + quint64 m_UsbForwardingRevision; + Uint32 m_UsbForwardingLastPoll; + // The host string the stream connected to (what LiStartConnection() got) + QString m_StreamHostAddress; + Overlay::OverlayManager m_OverlayManager; static CONNECTION_LISTENER_CALLBACKS k_ConnCallbacks; diff --git a/tests/run_usb_forwarding_tests.ps1 b/tests/run_usb_forwarding_tests.ps1 index 7f2b9d6b88..a272c16b4f 100644 --- a/tests/run_usb_forwarding_tests.ps1 +++ b/tests/run_usb_forwarding_tests.ps1 @@ -9,7 +9,7 @@ param( [string[]]$Tests = @('usb_forwarding_capability', 'usb_forwarding_environment', 'usb_forwarding_backend_list', 'usb_forwarding_tunnel', 'usb_forwarding_tunnel_clientcert', 'usb_forwarding_nvhttp', 'usb_forwarding_manager', 'usb_forwarding_elevation', - 'usb_forwarding_keepalive'), + 'usb_forwarding_keepalive', 'usb_forwarding_session_glue', 'usb_forwarding_ui_logic'), [string]$QtDir = 'C:\Qt\6.10.3\msvc2022_64', [string]$VcVars = 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat', [string]$OpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe' diff --git a/tests/usb_forwarding_elevation/main.cpp b/tests/usb_forwarding_elevation/main.cpp index 18aabf1ef1..e69046b387 100644 --- a/tests/usb_forwarding_elevation/main.cpp +++ b/tests/usb_forwarding_elevation/main.cpp @@ -319,6 +319,29 @@ private slots: QCOMPARE(hooked.refreshes->load(), 0); } + void destroyingTheBackendWithARunningProbeIsQuietAndFast() + { + static QStringList warnings; + warnings.clear(); + const QtMessageHandler previous = qInstallMessageHandler( + [](QtMsgType, const QMessageLogContext &, const QString &message) { warnings.append(message); }); + auto *backend = new UsbForwardingBackend; + auto *probe = new QProcess(backend); // stands in for the `usbipd state` probe + probe->start(QStringLiteral("C:/Windows/System32/PING.EXE"), + { QStringLiteral("-n"), QStringLiteral("60"), QStringLiteral("127.0.0.1") }); + const bool started = probe->waitForStarted(5000); + QElapsedTimer timer; + timer.start(); + delete backend; // the application is exiting mid-probe + const qint64 elapsed = timer.elapsed(); + qInstallMessageHandler(previous); + if (!started) { + QSKIP("could not start PING.EXE in this environment"); + } + QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(QLatin1Char('|')))); + QVERIFY2(elapsed < 3000, qPrintable(QString::number(elapsed))); + } + // ---- the real wait, with the non-elevating "open" verb --------------- void launchAndWaitReadsTheRealExitCode() diff --git a/tests/usb_forwarding_environment/main.cpp b/tests/usb_forwarding_environment/main.cpp index 47167136c9..29c44ff831 100644 --- a/tests/usb_forwarding_environment/main.cpp +++ b/tests/usb_forwarding_environment/main.cpp @@ -292,6 +292,22 @@ private slots: QCOMPARE(env->state(), Env::CheckFailed); } + void applicationQuitStopsARunningProbe() + { + Env *env = Env::get(); + env->probeWith(QStringLiteral("C:/Windows/System32/PING.EXE"), + { QStringLiteral("-n"), QStringLiteral("60"), QStringLiteral("127.0.0.1") }, 60000); + QVERIFY(env->checking()); + QCOMPARE(env->findChildren().size(), 1); + QElapsedTimer timer; + timer.start(); + QMetaObject::invokeMethod(QCoreApplication::instance(), "aboutToQuit"); // a private signal: call it by name + QVERIFY2(timer.elapsed() < 3000, qPrintable(QString::number(timer.elapsed()))); + QVERIFY(!env->checking()); + QCOMPARE(env->findChildren().size(), 0); + QVERIFY(env->state() != Env::Checking); + } + void failingVersionProbeMeansStoppedService() { Env *env = Env::get(); diff --git a/tests/usb_forwarding_manager/main.cpp b/tests/usb_forwarding_manager/main.cpp index e1a3718bc0..304c164f5c 100644 --- a/tests/usb_forwarding_manager/main.cpp +++ b/tests/usb_forwarding_manager/main.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -1156,6 +1158,54 @@ private slots: QCOMPARE(manager.snapshot().forwardingCount(), 0); manager.stop(); } + + // A device check that is stuck (usbipd hanging) must never hold up stop(), + // and its answer, when it finally comes, must be dropped. + void slowDeviceListNeverDelaysStopAndLateAnswerIsDropped() + { + for (int round = 0; round < 100; ++round) { + auto env = std::make_shared(); + std::mutex gateMutex; + std::condition_variable gate; + bool release = false; + std::atomic entered{false}; + std::atomic finishedLate{false}; + std::atomic abandoned{false}; + ManagerSeams seams = fakeSeams(env); + seams.listDevices = [&] { + entered = true; + { + std::unique_lock lock(gateMutex); + // "Blocks for several seconds" unless the test lets it go. + gate.wait_for(lock, std::chrono::seconds(5), [&] { return release; }); + } + finishedLate = true; + return QList{{QStringLiteral("054c:0ce6"), "1-1"}}; + }; + seams.abandonListing = [&] { abandoned = true; }; + Manager manager(seams, fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return entered.load(); }, 5000)); + + QElapsedTimer stopTimer; + stopTimer.start(); + manager.stop(); + const qint64 elapsed = stopTimer.elapsed(); + QVERIFY2(elapsed < 200, qPrintable(QStringLiteral("stop() took %1 ms (round %2)").arg(elapsed).arg(round))); + QVERIFY(abandoned.load()); + QVERIFY(!finishedLate.load()); // the lister really was still stuck when stop() returned + + { + std::lock_guard lock(gateMutex); + release = true; + } + gate.notify_all(); + QVERIFY(waitUntil([&] { return finishedLate.load(); }, 2000)); + QTest::qWait(10); // give a (wrongly) delivered answer time to arrive + QCOMPARE(env->startedCount(), 0); // dropped: no tunnel was started for it + QCOMPARE(manager.snapshot().state, State::Stopped); + } + } }; QTEST_GUILESS_MAIN(ManagerTest) diff --git a/tests/usb_forwarding_session_glue/main.cpp b/tests/usb_forwarding_session_glue/main.cpp new file mode 100644 index 0000000000..f7e3ba2e07 --- /dev/null +++ b/tests/usb_forwarding_session_glue/main.cpp @@ -0,0 +1,238 @@ +// What a streaming session does with the USB forwarding preferences: nothing +// when the feature is off or unsupported, a refusal when the inputs are not +// safe, and one canonical address for both sides when they are. +#include "../../app/backend/usbforwardingsessionglue.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include + +using namespace UsbForwarding; + +namespace { + +QStringList g_Messages; + +void captureMessages(QtMsgType, const QMessageLogContext &, const QString &message) +{ + g_Messages.append(message); +} + +} + +class SessionGlueTest : public QObject +{ + Q_OBJECT + + TestIdentity m_Server; + TestIdentity m_Client; + + SessionInputs valid() const + { + SessionInputs in; + in.platformSupported = true; + in.enabled = true; + in.autoDevices = { QStringLiteral("054c:0ce6"), QStringLiteral("1234:5678#abc") }; + in.streamAddress = QStringLiteral("10.0.0.5"); + in.pairedAddress = QStringLiteral("10.0.0.5"); + in.pinnedServerCertificate = m_Server.certificate; + in.clientSsl = QSslConfiguration::defaultConfiguration(); + in.clientSsl.setLocalCertificate(m_Client.certificate); + in.clientSsl.setPrivateKey(m_Client.key); + return in; + } + + static void verifyUntouched(const SessionPlan &plan) + { + QVERIFY(plan.reason.isEmpty()); + QVERIFY(!plan.config.enabled); + QVERIFY(plan.config.streamAddress.isEmpty()); + QVERIFY(plan.config.pairedAddress.isEmpty()); + QVERIFY(plan.config.autoDevices.isEmpty()); + QVERIFY(plan.config.pinnedServerCertificate.isNull()); + } + +private slots: + void initTestCase() + { + m_Server = loadTestIdentity(QStringLiteral("a")); + m_Client = loadTestIdentity(QStringLiteral("b")); + if (!m_Server.valid() || !m_Client.valid()) { + QSKIP("test certificates are not available (USBFWD_TEST_CERTS)"); + } + } + + void validInputsStart() + { + const SessionPlan plan = planSession(valid()); + QCOMPARE(plan.kind, PlanKind::Start); + QVERIFY(plan.reason.isEmpty()); + } + + void otherPlatformsDoNothing() + { + SessionInputs in = valid(); + in.platformSupported = false; + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::NotApplicable); + verifyUntouched(plan); + } + + void disabledDoesNothing() + { + SessionInputs in = valid(); + in.enabled = false; + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Disabled); + verifyUntouched(plan); + } + + void disabledStaysQuietEvenWithBrokenInputs() + { + // The default (off) must never complain about a host it will not use. + SessionInputs in; + in.platformSupported = true; + in.enabled = false; + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Disabled); + verifyUntouched(plan); + } + + void missingPinnedCertificateIsRefused() + { + SessionInputs in = valid(); + in.pinnedServerCertificate = QSslCertificate(); + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Refused); + QVERIFY(!plan.reason.isEmpty()); + QVERIFY(!plan.config.enabled); + QVERIFY(plan.config.pinnedServerCertificate.isNull()); + } + + void missingClientIdentityIsRefused_data() + { + QTest::addColumn("dropCertificate"); + QTest::addColumn("dropKey"); + QTest::newRow("no certificate") << true << false; + QTest::newRow("no key") << false << true; + QTest::newRow("neither") << true << true; + } + void missingClientIdentityIsRefused() + { + QFETCH(bool, dropCertificate); + QFETCH(bool, dropKey); + SessionInputs in = valid(); + if (dropCertificate) in.clientSsl.setLocalCertificate(QSslCertificate()); + if (dropKey) in.clientSsl.setPrivateKey(QSslKey()); + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Refused); + QVERIFY(!plan.reason.isEmpty()); + } + + void missingOrWrongAddressIsRefused_data() + { + QTest::addColumn("stream"); + QTest::addColumn("paired"); + QTest::newRow("no stream address") << "" << "10.0.0.5"; + QTest::newRow("no paired address") << "10.0.0.5" << ""; + QTest::newRow("neither") << "" << ""; + QTest::newRow("stream has a port") << "10.0.0.5:47989" << "10.0.0.5"; + QTest::newRow("stream has a scheme") << "https://10.0.0.5" << "10.0.0.5"; + QTest::newRow("stream has spaces") << "10.0.0.5 " << "10.0.0.5"; + QTest::newRow("different hosts") << "10.0.0.5" << "10.0.0.6"; + QTest::newRow("name vs its ip") << "pc.local" << "10.0.0.5"; + QTest::newRow("other IPv6 scope") << "fe80::1%12" << "fe80::1%13"; + QTest::newRow("scoped vs unscoped") << "fe80::1%12" << "fe80::1"; + } + void missingOrWrongAddressIsRefused() + { + QFETCH(QString, stream); + QFETCH(QString, paired); + SessionInputs in = valid(); + in.streamAddress = stream; + in.pairedAddress = paired; + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Refused); + QVERIFY(!plan.reason.isEmpty()); + QVERIFY(!plan.config.enabled); + QVERIFY(plan.config.streamAddress.isEmpty()); + } + + void equivalentSpellingsGiveOneCanonicalAddress_data() + { + QTest::addColumn("stream"); + QTest::addColumn("paired"); + QTest::addColumn("expected"); + QTest::newRow("same ipv4") << "10.0.0.5" << "10.0.0.5" << "10.0.0.5"; + QTest::newRow("bracketed ipv6") << "[2001:db8::1]" << "2001:db8::1" << "2001:db8::1"; + QTest::newRow("ipv6 case and zeros") << "2001:DB8:0:0:0:0:0:1" << "[2001:db8::1]" << "2001:db8::1"; + QTest::newRow("trailing dot") << "pc.local." << "pc.local" << "pc.local"; + QTest::newRow("case") << "PC.Local" << "pc.LOCAL" << "pc.local"; + QTest::newRow("dot and case") << "PC.LOCAL." << "pc.local" << "pc.local"; + QTest::newRow("scoped ipv6 kept") << "[fe80::1%12]" << "fe80::1%12" << "fe80::1%12"; + } + void equivalentSpellingsGiveOneCanonicalAddress() + { + QFETCH(QString, stream); + QFETCH(QString, paired); + QFETCH(QString, expected); + SessionInputs in = valid(); + in.streamAddress = stream; + in.pairedAddress = paired; + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Start); + QCOMPARE(plan.config.streamAddress, expected); + QCOMPARE(plan.config.pairedAddress, expected); + // The manager's own validation must accept exactly what the glue built. + QString why; + QVERIFY2(plan.config.validate(&why), qPrintable(why)); + } + + void configCarriesPinnedCertificateAndClientIdentity() + { + const SessionInputs in = valid(); + const SessionPlan plan = planSession(in); + QCOMPARE(plan.kind, PlanKind::Start); + QVERIFY(plan.config.enabled); + QCOMPARE(plan.config.autoDevices, in.autoDevices); + QCOMPARE(plan.config.pinnedServerCertificate.toDer(), m_Server.certificate.toDer()); + QCOMPARE(plan.config.clientSsl.localCertificate().toDer(), m_Client.certificate.toDer()); + QCOMPARE(plan.config.clientSsl.privateKey().toDer(), m_Client.key.toDer()); + // The pinned certificate is the server's, never the client's. + QVERIFY(plan.config.pinnedServerCertificate.toDer() != m_Client.certificate.toDer()); + } + + void noSecretReachesTheLogOrTheReason() + { + g_Messages.clear(); + const QtMessageHandler previous = qInstallMessageHandler(captureMessages); + + QStringList reasons; + { + SessionInputs a = valid(); a.pinnedServerCertificate = QSslCertificate(); + SessionInputs b = valid(); b.clientSsl.setPrivateKey(QSslKey()); + SessionInputs c = valid(); c.streamAddress = QStringLiteral("10.9.9.9"); + SessionInputs d = valid(); d.streamAddress.clear(); + for (const SessionInputs &in : { a, b, c, d }) { + reasons.append(planSession(in).reason); + } + planSession(valid()); + } + qInstallMessageHandler(previous); + + QVERIFY(g_Messages.isEmpty()); // the glue itself writes no log at all + const QByteArray keyBody = m_Client.key.toPem().split('\n').value(1).trimmed(); + const QByteArray certBody = m_Server.certificate.toPem().split('\n').value(1).trimmed(); + QVERIFY(keyBody.size() > 20 && certBody.size() > 20); // the probe really has something to find + for (const QString &reason : reasons) { + QVERIFY(!reason.isEmpty()); + QVERIFY(!reason.contains(QStringLiteral("BEGIN"))); + QVERIFY(!reason.contains(QString::fromLatin1(keyBody))); + QVERIFY(!reason.contains(QString::fromLatin1(certBody))); + QVERIFY(!reason.contains(QStringLiteral("usbfwd-test"))); + QVERIFY(!reason.contains(QStringLiteral("10."))); + } + } +}; + +QTEST_GUILESS_MAIN(SessionGlueTest) +#include "main.moc" diff --git a/tests/usb_forwarding_session_glue/usb_forwarding_session_glue.pro b/tests/usb_forwarding_session_glue/usb_forwarding_session_glue.pro new file mode 100644 index 0000000000..6e7dc6d713 --- /dev/null +++ b/tests/usb_forwarding_session_glue/usb_forwarding_session_glue.pro @@ -0,0 +1,17 @@ +QT += core network testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_session_glue_test +SOURCES += main.cpp \ + ../../app/backend/usbforwardingsessionglue.cpp \ + ../../app/backend/usbforwardingmanager.cpp \ + ../../app/backend/usbforwardingtunnel.cpp +HEADERS += \ + ../../app/backend/usbforwardingsessionglue.h \ + ../../app/backend/usbforwardingmanager.h \ + ../../app/backend/usbforwardingtunnel.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/pairedcertificate.h \ + ../usb_forwarding_common/faketlsserver.h diff --git a/tests/usb_forwarding_ui_logic/main.cpp b/tests/usb_forwarding_ui_logic/main.cpp new file mode 100644 index 0000000000..a7e3ca143e --- /dev/null +++ b/tests/usb_forwarding_ui_logic/main.cpp @@ -0,0 +1,162 @@ +// The pure logic behind the USB forwarding settings section. +#include "../../app/backend/usbforwardinguilogic.h" + +#include +#include + +using namespace UsbForwarding::Ui; + +class UiLogicTest : public QObject +{ + Q_OBJECT + + static QVariantMap device(bool bound, bool connected, bool attached, bool supported) + { + QVariantMap m; + m.insert(QStringLiteral("isBound"), bound); + m.insert(QStringLiteral("isConnected"), connected); + m.insert(QStringLiteral("isAttached"), attached); + m.insert(QStringLiteral("isSupported"), supported); + return m; + } + +private slots: + void deviceStates_data() + { + QTest::addColumn("bound"); + QTest::addColumn("connected"); + QTest::addColumn("attached"); + QTest::addColumn("supported"); + QTest::addColumn("state"); + QTest::addColumn("canShare"); + QTest::addColumn("canStop"); + const auto s = [](DeviceShareState x) { return static_cast(x); }; + QTest::newRow("plugged in, not shared") << false << true << false << true << s(DeviceShareState::NotShared) << true << false; + QTest::newRow("shared") << true << true << false << true << s(DeviceShareState::Shared) << false << true; + QTest::newRow("shared, unplugged") << true << false << false << false << s(DeviceShareState::SharedNotPlugged) << false << true; + QTest::newRow("in use by a client") << true << true << true << true << s(DeviceShareState::Busy) << false << false; + QTest::newRow("busy beats everything") << false << true << true << true << s(DeviceShareState::Busy) << false << false; + QTest::newRow("cannot be shared") << false << true << false << false << s(DeviceShareState::Unsupported) << false << false; + QTest::newRow("not connected, never shared") << false << false << false << false << s(DeviceShareState::Unsupported) << false << false; + } + void deviceStates() + { + QFETCH(bool, bound); + QFETCH(bool, connected); + QFETCH(bool, attached); + QFETCH(bool, supported); + QFETCH(int, state); + QFETCH(bool, canShare); + QFETCH(bool, canStop); + const QVariantMap d = device(bound, connected, attached, supported); + QCOMPARE(static_cast(deviceShareState(d)), state); + QCOMPARE(UsbForwarding::Ui::canShare(d), canShare); + QCOMPARE(UsbForwarding::Ui::canStopSharing(d), canStop); + } + + void emptyDeviceMapIsNotShareable() + { + QCOMPARE(deviceShareState(QVariantMap()), DeviceShareState::Unsupported); + QVERIFY(!UsbForwarding::Ui::canShare(QVariantMap())); + QVERIFY(!UsbForwarding::Ui::canStopSharing(QVariantMap())); + } + + void everyDeviceStateHasItsOwnText() + { + QSet seen; + for (DeviceShareState s : { DeviceShareState::NotShared, DeviceShareState::Shared, DeviceShareState::SharedNotPlugged, + DeviceShareState::Busy, DeviceShareState::Unsupported }) { + const QString text = deviceShareStateText(s); + QVERIFY(!text.isEmpty()); + QVERIFY(!seen.contains(text)); + seen.insert(text); + } + QCOMPARE(deviceShareStateText(DeviceShareState::Shared), QStringLiteral("Shared")); + QCOMPARE(deviceShareStateText(DeviceShareState::NotShared), QStringLiteral("Not shared")); + QVERIFY(deviceShareStateText(DeviceShareState::Busy).startsWith(QStringLiteral("Busy"))); + } + + void environmentTextCoversEveryState() + { + using E = UsbForwardingEnvironment; + QSet seen; + for (E::State s : { E::Checking, E::NotInstalled, E::ServiceStopped, E::Ready, E::DriverStopped, + E::CheckFailed, E::Unsupported }) { + const QString text = environmentStatusText(s, QString()); + QVERIFY(!text.isEmpty()); + QVERIFY(!seen.contains(text)); + seen.insert(text); + // Plain text only: nothing that looks like a link or a command to run. + QVERIFY(!text.contains(QStringLiteral("http"))); + QVERIFY(!text.contains(QStringLiteral("://"))); + QVERIFY(!text.contains(QStringLiteral("winget"))); + QVERIFY(!text.contains(QStringLiteral("<"))); + } + QVERIFY(environmentStatusText(E::NotInstalled, QString()).contains(QStringLiteral("Install usbipd-win yourself"))); + QVERIFY(environmentStatusText(E::ServiceStopped, QString()).contains(QStringLiteral("service is not running"))); + } + + void readyTextShowsTheVersionOnlyWhenKnown() + { + using E = UsbForwardingEnvironment; + QVERIFY(environmentStatusText(E::Ready, QStringLiteral("5.1.0")).contains(QStringLiteral("5.1.0"))); + QVERIFY(!environmentStatusText(E::Ready, QString()).contains(QStringLiteral("%1"))); + // Only the Ready state may claim that usbipd-win is installed and running. + QVERIFY(!environmentStatusText(E::NotInstalled, QStringLiteral("5.1.0")).contains(QStringLiteral("5.1.0"))); + } + + void addingADeviceKeepsOrderAndAddsOnce() + { + const QStringList base = { QStringLiteral("aaaa:0001"), QStringLiteral("bbbb:0002#x") }; + QStringList list = autoDevicesWith(base, QStringLiteral("cccc:0003"), true); + QCOMPARE(list, (QStringList{ "aaaa:0001", "bbbb:0002#x", "cccc:0003" })); + list = autoDevicesWith(list, QStringLiteral("cccc:0003"), true); + QCOMPARE(list.count(QStringLiteral("cccc:0003")), 1); + QCOMPARE(list.size(), 3); + QCOMPARE(base.size(), 2); // the input is a value, never changed + } + + void removingADeviceRemovesEveryCopyAndOnlyThat() + { + const QStringList base = { QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("a"), QStringLiteral("a#1") }; + QCOMPARE(autoDevicesWith(base, QStringLiteral("a"), false), (QStringList{ "b", "a#1" })); + QCOMPARE(autoDevicesWith(base, QStringLiteral("zzz"), false), base); + } + + void emptyIdentityIsNeverStored() + { + const QStringList base = { QStringLiteral("a") }; + QCOMPARE(autoDevicesWith(base, QString(), true), base); + QCOMPARE(autoDevicesWith(base, QString(), false), base); + QVERIFY(!autoDevicesContain(QStringList{ QString() }, QString())); + } + + void hotkeyIsOnlyEnabledOnWindowsWithThePreferenceOn_data() + { + QTest::addColumn("windows"); + QTest::addColumn("preference"); + QTest::addColumn("enabled"); + QTest::newRow("windows, on") << true << true << true; + QTest::newRow("windows, off") << true << false << false; + QTest::newRow("other platform, on") << false << true << false; + QTest::newRow("other platform, off") << false << false << false; + } + void hotkeyIsOnlyEnabledOnWindowsWithThePreferenceOn() + { + QFETCH(bool, windows); + QFETCH(bool, preference); + QFETCH(bool, enabled); + QCOMPARE(usbForwardingHotkeyEnabled(windows, preference), enabled); + } + + void containsIsExact() + { + const QStringList base = { QStringLiteral("054c:0ce6#S1") }; + QVERIFY(autoDevicesContain(base, QStringLiteral("054c:0ce6#S1"))); + QVERIFY(!autoDevicesContain(base, QStringLiteral("054c:0ce6"))); + QVERIFY(!autoDevicesContain(base, QStringLiteral("054c:0ce6#S2"))); + } +}; + +QTEST_GUILESS_MAIN(UiLogicTest) +#include "main.moc" diff --git a/tests/usb_forwarding_ui_logic/usb_forwarding_ui_logic.pro b/tests/usb_forwarding_ui_logic/usb_forwarding_ui_logic.pro new file mode 100644 index 0000000000..737d054dd2 --- /dev/null +++ b/tests/usb_forwarding_ui_logic/usb_forwarding_ui_logic.pro @@ -0,0 +1,7 @@ +QT += core testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_ui_logic_test +SOURCES += main.cpp