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