diff --git a/CLAUDE.md b/CLAUDE.md index df8e7d5..89af9e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,19 +112,21 @@ into Qt events. - `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`): CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the modes (overlay, `--desktop`, `--desktop-novr`, `--snapshot`, `--benchmark`, manifest - install). + install, app commands). - **Inheritance for the app, components for the surfaces** (the user's decision, after trying composition): the app derives from `DashboardApplication`. In its constructor it configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own - command-line options (`QCommandLineOption` list) to the base constructor. It overrides - the hooks `prepare(QString*)` (first; mode and app key known, no engine/surfaces/SteamVR - yet; returning false ends `exec()` with exit code 1), `loading()` (backends + shared - engine exist, QML not loaded) and `started()` (everything runs). In the hooks, protected + command-line options and its commands (two `QCommandLineOption` lists, D38, D43) to the + base constructor. It overrides the hooks `prepare(QString*)` (first; mode and app key + known, no engine/surfaces/SteamVR yet; returning false ends `exec()` with exit code 1), + `loading()` (backends + shared engine exist, QML not loaded), `started()` (everything + runs) and `runCommand(name)` (mode `Command`, after `prepare()`, nothing else exists). In the hooks, protected accessors give the running state: `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`, `vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`. - - The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest - commands), so derived members can be QObjects with timers. + - The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest and + app commands: decided from the raw `argv` before parsing), so derived members can be + QObjects with timers. - All QML and surfaces are torn down **at the end of `exec()`**, while the derived object and its members (e.g. QML singletons) still exist. - `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render diff --git a/README.md b/README.md index fbe71e6..ad7a195 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). | `--max-fps ` | Frame-rate cap (default 60); rendering only happens on change | | `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH | | `--uninstall-manifest` | Unregister | +| `--ha-remove` | Remove this device from Home Assistant (clears its retained MQTT topics; needs the settings and the broker) and exit; `uninstall.sh` calls it. Works over SSH | | `--manifest ` | Manifest for the two options above (default: the app's own) | | `--log-file ` | Also log to a file (relative paths: relative to the install folder) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 355a367..89216fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,6 +41,9 @@ Your app derives from it: `setDisplayName()`, `setQuickStyle()`, `setQmlModule()`). The app's own command-line options go to the base constructor as a list of `QCommandLineOption`s; they are parsed and listed in `--help` with the built-in ones, and read with `isOptionSet()`/`optionValue()`. + Its commands (flags such as `--ha-remove`) go there as a second list: like the manifest + commands they get a `QCoreApplication` (no display needed, works over SSH), and `exec()` + runs `prepare()` and then `runCommand(name)` in mode `Command` instead of a mode. - **Hooks:** - `prepare(QString* error)`: first, once mode and app key are known, before the QML engine, the surfaces and SteamVR exist (not for manifest commands). Returning `false` @@ -48,10 +51,13 @@ Your app derives from it: - `loading()`: the shared QML engine, the surfaces and their render backends exist; QML isn't loaded yet. Set up what the QML needs here. - `started()`: everything runs. Connect to surfaces and input here. + - `runCommand(name)`: one of the app's commands, after `prepare()`; no QML engine, + surfaces or SteamVR. Its result is the exit code. - **Accessors:** `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`, `engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`, `navigation()`. -- **Lifetime:** the base constructor creates the `QGuiApplication`, so your members can be +- **Lifetime:** the base constructor creates the `QGuiApplication` (`QCoreApplication` for + commands), so your members can be QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your object and its members still exist. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e377188..504f9be 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -385,6 +385,8 @@ a `QGuiApplication` (no display needed, works over SSH), and `exec()` calls - `exec()`: after the manifest check, an app command runs `prepare()` and then a new hook `virtual int runCommand(const QString& name)`, whose result is the exit code. No engine, surfaces or SteamVR. +- The mode is `DashboardMode::Command` (so `prepare()` can tell); the name of the first app + command given goes to `runCommand()`. Tested in `tst_surfaces` (`surfaces_command`). - Moving the manifest commands onto this mechanism is possible later, not part of iteration 2. ### `--ha-remove` @@ -396,7 +398,11 @@ delete). - An app command (D43): loads the settings, connects, publishes **empty retained payloads** to discovery (HA then deletes the device and its entities), device availability, and each entity's state and availability topics; disconnects. -- Exit code 0 on success; ≠ 0 with a warning if the broker can't be reached within 10 s. +- Its client uses the client id `frameassistant__remove` (doesn't knock a running + instance off the broker) and no will. The `HaDevice` is created only after the connection + is up: a device that sees the connection come up publishes itself first. +- Exit code 0 on success; 1 with an error if the broker can't be reached within 10 s, or the + removal isn't acknowledged within 10 s. - `uninstall.sh`: `pkill` → `sleep 1` → `--ha-remove` (a failure doesn't stop the uninstall) → `--uninstall-manifest`. - Also usable by hand, e.g. after a device name change without `ha_unique_id`: diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1c46086..8237a36 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -25,7 +25,8 @@ CLAUDE.md ("Working rules"). - **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3 (`HaEntity`, `HaSensor`, `HaDevice`, unit tests) and 4 (app wiring, `--desktop-novr`, - UI-1) and 5 (TLS) done; next: step 6 (app commands, `--ha-remove`, `uninstall.sh`). + UI-1) 5 (TLS) and 6 (app commands, `--ha-remove`, `uninstall.sh`) done; next: step 7 + (packaging check, docs). ## Next steps @@ -178,6 +179,12 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter validation messages. - Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty); check on the device. +- Step 6: app commands in the platform (D43: second option list, `QCoreApplication`, mode + `Command`, hook `runCommand()`; `tst_surfaces` `surfaces_command`); `--ha-remove` in the + app; `uninstall.sh` stops the app, runs `--ha-remove` (failure doesn't stop it) and + unregisters. Checked end to end against a local mosquitto, without a display: retained + discovery, availability, state and entity availability before, none after (exit 0); + broker down → exit 1 after 10 s; missing settings → exit 1. ### 2026-10-05: requirements approved diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 1e8b045..e00e40a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -64,9 +64,14 @@ EOF cat > "$out/uninstall.sh" </dev/null +mkdir -p logs +# Stopped first, so it can't publish itself again; the broker sends its last will at once. +pkill -f "/bin/$exe( |\\$)" 2>/dev/null && sleep 1 +./bin/$exe --ha-remove --log-file logs/install.log || + echo "Could not remove the device from Home Assistant (see logs/install.log); unregistering anyway." >&2 exec ./bin/$exe --uninstall-manifest --log-file logs/install.log EOF chmod +x "$out/install.sh" "$out/uninstall.sh" @@ -92,7 +97,8 @@ chmod +x "$out/install.sh" "$out/uninstall.sh" echo "Install / update: ./install.sh (SteamVR must be running)" echo " SteamVR remembers this folder's location: after moving it," echo " run ./install.sh again." - echo "Uninstall: ./uninstall.sh" + echo "Uninstall: ./uninstall.sh (also removes the device from Home Assistant;" + echo " needs the settings file and the MQTT broker)" echo "Logs: logs/$exe.log (the app), logs/install.log (install.sh);" echo " run ./bin/$exe --help for options" echo diff --git a/src/Dashboard.cpp b/src/Dashboard.cpp index 3a85190..4191855 100644 --- a/src/Dashboard.cpp +++ b/src/Dashboard.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include "ha/HaDevice.h" #include "ha/HaSensor.h" @@ -11,10 +13,13 @@ namespace { const QString kConfigOption = QStringLiteral("config"); +const QString kHaRemoveCommand = QStringLiteral("ha-remove"); // How often the dummy sensor's value is updated. constexpr int kUptimeIntervalMs = 10000; // How long a normal exit waits for the broker to acknowledge "offline". constexpr int kDisconnectTimeoutMs = 2000; +// --ha-remove: how long it may take to connect, and then to publish. +constexpr int kRemoveTimeoutMs = 10000; } // namespace @@ -24,7 +29,11 @@ Dashboard::Dashboard(int& argc, char** argv) {QCommandLineOption(kConfigOption, QStringLiteral("Settings file (default: %1).") .arg(Config::defaultPath()), - QStringLiteral("path"))}) { + QStringLiteral("path"))}, + {QCommandLineOption( + kHaRemoveCommand, + QStringLiteral("Remove this device from Home Assistant (clears " + "its retained MQTT topics) and exit."))}) { addSurface({.qmlRootType = QStringLiteral("Main")}); } @@ -33,7 +42,7 @@ Dashboard::~Dashboard() = default; bool Dashboard::prepare(QString* error) { // Snapshot and benchmark only render the UI: no settings needed. if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop && - mode() != DashboardMode::DesktopNoVr) + mode() != DashboardMode::DesktopNoVr && mode() != DashboardMode::Command) return true; const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption) : Config::defaultPath(); @@ -46,30 +55,38 @@ bool Dashboard::prepare(QString* error) { return true; } +MosquittoClient::Settings Dashboard::mqttSettings() const { + MosquittoClient::Settings settings; + settings.broker = m_config->mqtt; + settings.clientId = QStringLiteral("frameassistant_") + m_config->deviceId; + settings.will = HaDevice::lastWill(m_config->deviceId); + return settings; +} + +HaDevice::Info Dashboard::deviceInfo() const { + return {.name = m_config->deviceName, + .id = m_config->deviceId, + .discoveryPrefix = m_config->discoveryPrefix, + .softwareVersion = QCoreApplication::applicationVersion()}; +} + +HaSensor* Dashboard::addEntities(HaDevice& device) { + return device.addSensor({.id = QStringLiteral("uptime"), + .name = QStringLiteral("Uptime"), + .deviceClass = QStringLiteral("duration"), + .unit = QStringLiteral("s"), + .category = QStringLiteral("diagnostic")}); +} + void Dashboard::loading() { if (!m_config) return; - const Config& config = *m_config; - MosquittoClient::Settings settings; - settings.broker = config.mqtt; - settings.clientId = QStringLiteral("frameassistant_") + config.deviceId; - settings.will = HaDevice::lastWill(config.deviceId); - m_mqtt = std::make_unique(settings); + m_mqtt = std::make_unique(mqttSettings()); + m_device = std::make_unique(deviceInfo(), m_mqtt.get()); + m_uptime = addEntities(*m_device); - m_device = std::make_unique( - HaDevice::Info{.name = config.deviceName, - .id = config.deviceId, - .discoveryPrefix = config.discoveryPrefix, - .softwareVersion = QCoreApplication::applicationVersion()}, - m_mqtt.get()); - m_uptime = m_device->addSensor({.id = QStringLiteral("uptime"), - .name = QStringLiteral("Uptime"), - .deviceClass = QStringLiteral("duration"), - .unit = QStringLiteral("s"), - .category = QStringLiteral("diagnostic")}); - - m_status.setDevice( - config.deviceName, - QStringLiteral("%1:%2").arg(config.mqtt.host).arg(config.mqtt.port)); + const MqttConfig& broker = m_config->mqtt; + m_status.setDevice(m_config->deviceName, + QStringLiteral("%1:%2").arg(broker.host).arg(broker.port)); connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status, &ConnectionStatus::setConnection); } @@ -91,3 +108,44 @@ void Dashboard::started() { }); m_mqtt->connectToBroker(); } + +int Dashboard::runCommand(const QString& name) { + if (name == kHaRemoveCommand) return removeFromHa(); + return DashboardApplication::runCommand(name); +} + +int Dashboard::removeFromHa() { + MosquittoClient::Settings settings = mqttSettings(); + // Its own client id, so it doesn't knock a running instance off the + // broker; no will, since it publishes nothing to keep. + settings.clientId += QStringLiteral("_remove"); + settings.will = {}; + MosquittoClient client(settings); + + QEventLoop loop; + QTimer::singleShot(kRemoveTimeoutMs, &loop, &QEventLoop::quit); + connect(&client, &MqttClient::connectionChanged, &loop, + [&loop](MqttClient::State state) { + if (state == MqttClient::State::Connected) loop.quit(); + }); + client.connectToBroker(); + loop.exec(); + if (client.state() != MqttClient::State::Connected) { + qCritical().noquote() << "Cannot remove the device from Home Assistant: " + "no connection to the broker"; + return 1; + } + // Created only now: a device that saw the connection come up would + // publish itself first. + HaDevice device(deviceInfo(), &client); + addEntities(device); + const bool published = device.removeFromHa(); + const bool acknowledged = client.disconnectFromBroker(kRemoveTimeoutMs); + if (!published || !acknowledged) { + qCritical().noquote() << "Removing the device from Home Assistant failed"; + return 1; + } + qInfo().noquote() << "Removed device" << m_config->deviceName + << "(" + m_config->deviceId + ") from Home Assistant"; + return 0; +} diff --git a/src/Dashboard.h b/src/Dashboard.h index 72555bc..34ea498 100644 --- a/src/Dashboard.h +++ b/src/Dashboard.h @@ -7,10 +7,10 @@ #include "ConnectionStatus.h" #include "app/DashboardApplication.h" #include "config/Config.h" +#include "ha/HaDevice.h" +#include "mqtt/MosquittoClient.h" -class HaDevice; class HaSensor; -class MosquittoClient; // The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt // (QML_URI FrameAssistant, files in src/qml/). @@ -30,8 +30,18 @@ class Dashboard : public DashboardApplication { void loading() override; // Connects to the broker and starts the sensors. void started() override; + // --ha-remove (docs/DESIGN.md, "--ha-remove"). + int runCommand(const QString& name) override; private: + // The settings for the MQTT client and the HA device. + MosquittoClient::Settings mqttSettings() const; + HaDevice::Info deviceInfo() const; + // Adds the entities to `device` (the same for the app and --ha-remove); + // returns the dummy sensor. + static HaSensor* addEntities(HaDevice& device); + int removeFromHa(); + std::optional m_config; // none in snapshot/benchmark mode // The device uses the client: declared after it, so destroyed before it. std::unique_ptr m_mqtt; diff --git a/src/app/DashboardApplication.cpp b/src/app/DashboardApplication.cpp index d1b0c8e..ac7f86d 100644 --- a/src/app/DashboardApplication.cpp +++ b/src/app/DashboardApplication.cpp @@ -115,6 +115,7 @@ std::unique_ptr startBackend(GraphicsApi api, bool apiExplicit, struct DashboardApplication::Options { QString error; // set: exec() reports it and fails bool manifestCommand = false; + QString appCommand; // name of the app command given, if any bool install = false; bool launch = false; QString manifest; @@ -144,6 +145,8 @@ const char* dashboardModeName(DashboardMode mode) { return "snapshot"; case DashboardMode::Benchmark: return "benchmark"; + case DashboardMode::Command: + return "command"; } return "?"; } @@ -170,7 +173,8 @@ void DashboardApplication::setDefaultQmlModule(const char* uri) { } DashboardApplication::DashboardApplication( - int& argc, char** argv, const QList& appOptions) + int& argc, char** argv, const QList& appOptions, + const QList& appCommands) : m_options(std::make_unique()), m_qmlModule(defaultQmlModule()) { // When launched by SteamVR, stderr is not a TTY and Qt would log to // journald only; keep messages on stderr so they show up wherever the @@ -178,14 +182,24 @@ DashboardApplication::DashboardApplication( if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING")) qputenv("QT_FORCE_STDERR_LOGGING", "1"); - // Manifest commands only talk to SteamVR: no GUI (and no display - // connection) needed, so they also work over SSH. + // Manifest commands only talk to SteamVR, app commands do their own thing: + // no GUI (and no display connection) needed, so they also work over SSH. + // Decided before parsing: the parser needs the application object. Options& o = *m_options; o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) { return qstrcmp(arg, "--install-manifest") == 0 || qstrcmp(arg, "--uninstall-manifest") == 0; }); - if (o.manifestCommand) + const bool appCommand = + std::any_of(argv + 1, argv + argc, [&appCommands](const char* arg) { + for (const QCommandLineOption& command : appCommands) { + for (const QString& name : command.names()) { + if (QLatin1String(arg) == QStringLiteral("--") + name) return true; + } + } + return false; + }); + if (o.manifestCommand || appCommand) m_app = std::make_unique(argc, argv); else m_app = std::make_unique(argc, argv); @@ -257,7 +271,7 @@ DashboardApplication::DashboardApplication( benchmarkOption, apiOption, keyOption, nameOption, fpsOption, installOption, uninstallOption, manifestOption, launchOption, logOption}); - for (const QCommandLineOption& option : appOptions) { + for (const QCommandLineOption& option : appOptions + appCommands) { if (!parser.addOption(option)) o.error = QStringLiteral("App option --%1 clashes with another option") .arg(option.names().constFirst()); @@ -283,6 +297,12 @@ DashboardApplication::DashboardApplication( ? resolvePath(parser.value(manifestOption)) : shareDir() + QStringLiteral("/manifest.vrmanifest"); if (o.manifestCommand) return; + for (const QCommandLineOption& command : appCommands) { + if (parser.isSet(command)) { + o.appCommand = command.names().constFirst(); + return; + } + } if (parser.isSet(apiOption)) { if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi)) @@ -338,6 +358,10 @@ void DashboardApplication::setQuickStyle(const QString& style) { bool DashboardApplication::prepare(QString*) { return true; } +int DashboardApplication::runCommand(const QString& name) { + return fail(QStringLiteral("Command --%1 is not implemented").arg(name)); +} + void DashboardApplication::loading() {} void DashboardApplication::started() {} @@ -400,7 +424,7 @@ int DashboardApplication::exec() { QString error; if (!validateConfig(&error)) return fail(error); - m_mode = o.mode; + m_mode = o.appCommand.isEmpty() ? o.mode : DashboardMode::Command; m_appKey = !o.appKey.isEmpty() ? o.appKey : !m_configAppKey.isEmpty() ? m_configAppKey @@ -411,6 +435,7 @@ int DashboardApplication::exec() { : QString::fromUtf8(AppConfig::kDisplayName); QCoreApplication::setApplicationName(m_appName); if (!prepare(&error)) return fail(error); + if (m_mode == DashboardMode::Command) return runCommand(o.appCommand); if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle); m_engine = std::make_unique(); @@ -432,6 +457,8 @@ int DashboardApplication::exec() { case DashboardMode::Overlay: result = runOverlay(); break; + case DashboardMode::Command: + break; // handled above } // Still within exec(): the derived object (and what it owns) outlives all // QML. Surfaces (their QML scenes, renderers, backends) before the engine. diff --git a/src/app/DashboardApplication.h b/src/app/DashboardApplication.h index 3fa2900..5a48a78 100644 --- a/src/app/DashboardApplication.h +++ b/src/app/DashboardApplication.h @@ -20,7 +20,15 @@ class VrRuntime; // Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises // never to connect to SteamVR (for apps that use SteamVR data otherwise). -enum class DashboardMode { Overlay, Desktop, DesktopNoVr, Snapshot, Benchmark }; +// Command: one of the app's commands runs (runCommand()); no GUI. +enum class DashboardMode { + Overlay, + Desktop, + DesktopNoVr, + Snapshot, + Benchmark, + Command +}; const char* dashboardModeName(DashboardMode mode); // Base class of a dashboard app. It owns everything app- and surface-related: @@ -59,8 +67,12 @@ class DashboardApplication : public QObject { // `appOptions` are the app's own command-line options: parsed and listed // in --help together with the built-in ones (names must not clash); read // them with isOptionSet() / optionValue(). + // `appCommands` are the app's commands (flags, e.g. --ha-remove): like the + // manifest commands they need no display (QCoreApplication), and exec() + // runs prepare() and then runCommand() instead of a mode. DashboardApplication(int& argc, char** argv, - const QList& appOptions = {}); + const QList& appOptions = {}, + const QList& appCommands = {}); ~DashboardApplication() override; // Runs the mode selected on the command line; returns the exit code. @@ -98,6 +110,10 @@ class DashboardApplication : public QObject { virtual void loading(); // Everything runs; called right before the event loop starts. virtual void started(); + // Command mode: runs the app command `name` (an appCommands option, see + // the constructor) after prepare(); no QML engine, surfaces or SteamVR. + // Returns the exit code. The default fails. + virtual int runCommand(const QString& name); // --- The running app (valid in the hooks and until exec() returns) --- DashboardMode mode() const { return m_mode; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c46c3c8..2798867 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,8 @@ add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl) set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1") add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl) set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1") +add_test(NAME surfaces_command COMMAND tst_surfaces) +set_tests_properties(surfaces_command PROPERTIES ENVIRONMENT "VRDASH_TEST_COMMAND=1") add_test(NAME surfaces_desktop_novr COMMAND tst_surfaces) set_tests_properties(surfaces_desktop_novr PROPERTIES ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen") diff --git a/tests/tst_surfaces.cpp b/tests/tst_surfaces.cpp index 0fc2f09..f9ff5cb 100644 --- a/tests/tst_surfaces.cpp +++ b/tests/tst_surfaces.cpp @@ -6,10 +6,14 @@ // without calling the other hooks. // VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare() // checks the mode and then fails (no window, no event loop). +// VRDASH_TEST_COMMAND=1: the app command --test-command instead of +// --snapshot: prepare(), then runCommand() without GUI, engine or surfaces; +// its result is exec()'s. // Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context). #include #include +#include #include #include #include @@ -34,7 +38,9 @@ class TwoSurfaceApp : public DashboardApplication { QStringLiteral("An app option."), QStringLiteral("value")), QCommandLineOption(QStringLiteral("test-flag"), - QStringLiteral("An app flag, never set."))}) { + QStringLiteral("An app flag, never set."))}, + {QCommandLineOption(QStringLiteral("test-command"), + QStringLiteral("An app command."))}) { setQmlModule(QStringLiteral("SurfacesTest")); setDisplayName(QStringLiteral("Test App")); setAppKey(QStringLiteral("config.key")); @@ -53,6 +59,7 @@ class TwoSurfaceApp : public DashboardApplication { int prepareCalls = 0; int loadingCalls = 0; int startedCalls = 0; + QStringList commands; bool ok = true; protected: @@ -94,6 +101,15 @@ class TwoSurfaceApp : public DashboardApplication { "texture size from config"); ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode"); } + int runCommand(const QString& name) override { + commands.append(name); + ok &= check(mode() == DashboardMode::Command, "mode in runCommand()"); + ok &= check(engine() == nullptr && surfaces().isEmpty(), + "no engine and no surfaces for a command"); + ok &= check(!qobject_cast(QCoreApplication::instance()), + "no GUI application for a command"); + return 7; + } void started() override { ++startedCalls; const QList all = surfaces(); @@ -113,11 +129,14 @@ int main(int argc, char* argv[]) { const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY"); const bool desktopNoVr = qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR"); + const bool command = qEnvironmentVariableIntValue("VRDASH_TEST_COMMAND"); const bool failPrepare = qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr; std::vector args = {argv[0], "--test-value", "42"}; if (desktopNoVr) { args.emplace_back("--desktop-novr"); + } else if (command) { + args.emplace_back("--test-command"); } else { args.emplace_back("--snapshot"); args.emplace_back(snapshot); @@ -136,7 +155,19 @@ int main(int argc, char* argv[]) { cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key"); app.failPrepare = failPrepare; if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr; + if (command) app.expectedMode = DashboardMode::Command; const int result = app.exec(); + if (command) { + bool ok = app.ok; + ok &= check(result == 7, "exec() returns runCommand()'s result"); + ok &= check( + app.prepareCalls == 1 && app.loadingCalls == 0 && app.startedCalls == 0, + "prepare(), but no other hooks for a command"); + ok &= check(app.commands == QStringList{QStringLiteral("test-command")}, + "runCommand() once with the command's name"); + fprintf(stderr, ok ? "PASS\n" : "FAILED\n"); + return ok ? 0 : 1; + } if (failPrepare) { bool ok = app.ok; ok &= check(result == 1, "exec() returns 1 when prepare() fails");