App commands and --ha-remove (iteration 2, step 6)

The platform accepts app commands (D43): flags that get a
QCoreApplication like the manifest commands and run prepare() and then
the new hook runCommand(). The app's --ha-remove clears the device's
retained topics, so HA deletes the device; uninstall.sh runs it after
stopping the app and before unregistering from SteamVR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:32:51 +02:00
co-authored by Claude Opus 5.5
parent 148ae663cf
commit 0bdcaddcd5
12 changed files with 219 additions and 47 deletions
+9 -7
View File
@@ -112,19 +112,21 @@ into Qt events.
- `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`): - `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`):
CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the
modes (overlay, `--desktop`, `--desktop-novr`, `--snapshot`, `--benchmark`, manifest 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 - **Inheritance for the app, components for the surfaces** (the user's decision, after
trying composition): the app derives from `DashboardApplication`. In its constructor it trying composition): the app derives from `DashboardApplication`. In its constructor it
configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own
command-line options (`QCommandLineOption` list) to the base constructor. It overrides command-line options and its commands (two `QCommandLineOption` lists, D38, D43) to the
the hooks `prepare(QString*)` (first; mode and app key known, no engine/surfaces/SteamVR base constructor. It overrides the hooks `prepare(QString*)` (first; mode and app key
yet; returning false ends `exec()` with exit code 1), `loading()` (backends + shared known, no engine/surfaces/SteamVR yet; returning false ends `exec()` with exit code 1),
engine exist, QML not loaded) and `started()` (everything runs). In the hooks, protected `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()`, accessors give the running state: `mode()`, `appKey()`, `appName()`, `isOptionSet()`,
`optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`, `optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`,
`vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`. `vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`.
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest - The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest and
commands), so derived members can be QObjects with timers. 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 - 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. 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 - `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render
+1
View File
@@ -135,6 +135,7 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
| `--max-fps <fps>` | Frame-rate cap (default 60); rendering only happens on change | | `--max-fps <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 | | `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH |
| `--uninstall-manifest` | Unregister | | `--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 <path>` | Manifest for the two options above (default: the app's own) | | `--manifest <path>` | Manifest for the two options above (default: the app's own) |
| `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) | | `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) |
+7 -1
View File
@@ -41,6 +41,9 @@ Your app derives from it:
`setDisplayName()`, `setQuickStyle()`, `setQmlModule()`). The app's own command-line `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 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()`. 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:** - **Hooks:**
- `prepare(QString* error)`: first, once mode and app key are known, before the QML - `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` 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 - `loading()`: the shared QML engine, the surfaces and their render backends exist; QML
isn't loaded yet. Set up what the QML needs here. isn't loaded yet. Set up what the QML needs here.
- `started()`: everything runs. Connect to surfaces and input 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()`, - **Accessors:** `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`,
`engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`, `engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`,
`navigation()`. `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 QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your
object and its members still exist. object and its members still exist.
+7 -1
View File
@@ -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 - `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, `virtual int runCommand(const QString& name)`, whose result is the exit code. No engine,
surfaces or SteamVR. 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. - Moving the manifest commands onto this mechanism is possible later, not part of iteration 2.
### `--ha-remove` ### `--ha-remove`
@@ -396,7 +398,11 @@ delete).
- An app command (D43): loads the settings, connects, publishes **empty retained payloads** - 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 to discovery (HA then deletes the device and its entities), device availability, and each
entity's state and availability topics; disconnects. 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_<device id>_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.sh`: `pkill` → `sleep 1` → `--ha-remove` (a failure doesn't stop the uninstall) →
`--uninstall-manifest`. `--uninstall-manifest`.
- Also usable by hand, e.g. after a device name change without `ha_unique_id`: - Also usable by hand, e.g. after a device name change without `ha_unique_id`:
+8 -1
View File
@@ -25,7 +25,8 @@ CLAUDE.md ("Working rules").
- **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in - **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3 CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3
(`HaEntity`, `HaSensor`, `HaDevice`, unit tests) and 4 (app wiring, `--desktop-novr`, (`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 ## Next steps
@@ -178,6 +179,12 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter
validation messages. validation messages.
- Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty); - Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty);
check on the device. 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 ### 2026-10-05: requirements approved
+9 -3
View File
@@ -64,9 +64,14 @@ EOF
cat > "$out/uninstall.sh" <<EOF cat > "$out/uninstall.sh" <<EOF
#!/bin/sh #!/bin/sh
# Unregisters $name from SteamVR and stops it. The folder can be deleted afterwards. # Stops $name, removes its device from Home Assistant (clears its retained MQTT
# topics) and unregisters it from SteamVR. The folder can be deleted afterwards.
cd "\$(dirname "\$0")" || exit 1 cd "\$(dirname "\$0")" || exit 1
pkill -f "/bin/$exe( |\\$)" 2>/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 exec ./bin/$exe --uninstall-manifest --log-file logs/install.log
EOF EOF
chmod +x "$out/install.sh" "$out/uninstall.sh" 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 "Install / update: ./install.sh (SteamVR must be running)"
echo " SteamVR remembers this folder's location: after moving it," echo " SteamVR remembers this folder's location: after moving it,"
echo " run ./install.sh again." 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 "Logs: logs/$exe.log (the app), logs/install.log (install.sh);"
echo " run ./bin/$exe --help for options" echo " run ./bin/$exe --help for options"
echo echo
+81 -23
View File
@@ -3,6 +3,8 @@
#include <QCommandLineOption> #include <QCommandLineOption>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QEventLoop>
#include <QTimer>
#include "ha/HaDevice.h" #include "ha/HaDevice.h"
#include "ha/HaSensor.h" #include "ha/HaSensor.h"
@@ -11,10 +13,13 @@
namespace { namespace {
const QString kConfigOption = QStringLiteral("config"); const QString kConfigOption = QStringLiteral("config");
const QString kHaRemoveCommand = QStringLiteral("ha-remove");
// How often the dummy sensor's value is updated. // How often the dummy sensor's value is updated.
constexpr int kUptimeIntervalMs = 10000; constexpr int kUptimeIntervalMs = 10000;
// How long a normal exit waits for the broker to acknowledge "offline". // How long a normal exit waits for the broker to acknowledge "offline".
constexpr int kDisconnectTimeoutMs = 2000; constexpr int kDisconnectTimeoutMs = 2000;
// --ha-remove: how long it may take to connect, and then to publish.
constexpr int kRemoveTimeoutMs = 10000;
} // namespace } // namespace
@@ -24,7 +29,11 @@ Dashboard::Dashboard(int& argc, char** argv)
{QCommandLineOption(kConfigOption, {QCommandLineOption(kConfigOption,
QStringLiteral("Settings file (default: %1).") QStringLiteral("Settings file (default: %1).")
.arg(Config::defaultPath()), .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")}); addSurface({.qmlRootType = QStringLiteral("Main")});
} }
@@ -33,7 +42,7 @@ Dashboard::~Dashboard() = default;
bool Dashboard::prepare(QString* error) { bool Dashboard::prepare(QString* error) {
// Snapshot and benchmark only render the UI: no settings needed. // Snapshot and benchmark only render the UI: no settings needed.
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop && if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop &&
mode() != DashboardMode::DesktopNoVr) mode() != DashboardMode::DesktopNoVr && mode() != DashboardMode::Command)
return true; return true;
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption) const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
: Config::defaultPath(); : Config::defaultPath();
@@ -46,30 +55,38 @@ bool Dashboard::prepare(QString* error) {
return true; 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() { void Dashboard::loading() {
if (!m_config) return; if (!m_config) return;
const Config& config = *m_config; m_mqtt = std::make_unique<MosquittoClient>(mqttSettings());
MosquittoClient::Settings settings; m_device = std::make_unique<HaDevice>(deviceInfo(), m_mqtt.get());
settings.broker = config.mqtt; m_uptime = addEntities(*m_device);
settings.clientId = QStringLiteral("frameassistant_") + config.deviceId;
settings.will = HaDevice::lastWill(config.deviceId);
m_mqtt = std::make_unique<MosquittoClient>(settings);
m_device = std::make_unique<HaDevice>( const MqttConfig& broker = m_config->mqtt;
HaDevice::Info{.name = config.deviceName, m_status.setDevice(m_config->deviceName,
.id = config.deviceId, QStringLiteral("%1:%2").arg(broker.host).arg(broker.port));
.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));
connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status, connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status,
&ConnectionStatus::setConnection); &ConnectionStatus::setConnection);
} }
@@ -91,3 +108,44 @@ void Dashboard::started() {
}); });
m_mqtt->connectToBroker(); 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;
}
+12 -2
View File
@@ -7,10 +7,10 @@
#include "ConnectionStatus.h" #include "ConnectionStatus.h"
#include "app/DashboardApplication.h" #include "app/DashboardApplication.h"
#include "config/Config.h" #include "config/Config.h"
#include "ha/HaDevice.h"
#include "mqtt/MosquittoClient.h"
class HaDevice;
class HaSensor; class HaSensor;
class MosquittoClient;
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt // The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
// (QML_URI FrameAssistant, files in src/qml/). // (QML_URI FrameAssistant, files in src/qml/).
@@ -30,8 +30,18 @@ class Dashboard : public DashboardApplication {
void loading() override; void loading() override;
// Connects to the broker and starts the sensors. // Connects to the broker and starts the sensors.
void started() override; void started() override;
// --ha-remove (docs/DESIGN.md, "--ha-remove").
int runCommand(const QString& name) override;
private: 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<Config> m_config; // none in snapshot/benchmark mode std::optional<Config> m_config; // none in snapshot/benchmark mode
// The device uses the client: declared after it, so destroyed before it. // The device uses the client: declared after it, so destroyed before it.
std::unique_ptr<MosquittoClient> m_mqtt; std::unique_ptr<MosquittoClient> m_mqtt;
+33 -6
View File
@@ -115,6 +115,7 @@ std::unique_ptr<RenderBackend> startBackend(GraphicsApi api, bool apiExplicit,
struct DashboardApplication::Options { struct DashboardApplication::Options {
QString error; // set: exec() reports it and fails QString error; // set: exec() reports it and fails
bool manifestCommand = false; bool manifestCommand = false;
QString appCommand; // name of the app command given, if any
bool install = false; bool install = false;
bool launch = false; bool launch = false;
QString manifest; QString manifest;
@@ -144,6 +145,8 @@ const char* dashboardModeName(DashboardMode mode) {
return "snapshot"; return "snapshot";
case DashboardMode::Benchmark: case DashboardMode::Benchmark:
return "benchmark"; return "benchmark";
case DashboardMode::Command:
return "command";
} }
return "?"; return "?";
} }
@@ -170,7 +173,8 @@ void DashboardApplication::setDefaultQmlModule(const char* uri) {
} }
DashboardApplication::DashboardApplication( DashboardApplication::DashboardApplication(
int& argc, char** argv, const QList<QCommandLineOption>& appOptions) int& argc, char** argv, const QList<QCommandLineOption>& appOptions,
const QList<QCommandLineOption>& appCommands)
: m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) { : m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) {
// When launched by SteamVR, stderr is not a TTY and Qt would log to // 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 // journald only; keep messages on stderr so they show up wherever the
@@ -178,14 +182,24 @@ DashboardApplication::DashboardApplication(
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING")) if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
qputenv("QT_FORCE_STDERR_LOGGING", "1"); qputenv("QT_FORCE_STDERR_LOGGING", "1");
// Manifest commands only talk to SteamVR: no GUI (and no display // Manifest commands only talk to SteamVR, app commands do their own thing:
// connection) needed, so they also work over SSH. // 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; Options& o = *m_options;
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) { o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
return qstrcmp(arg, "--install-manifest") == 0 || return qstrcmp(arg, "--install-manifest") == 0 ||
qstrcmp(arg, "--uninstall-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<QCoreApplication>(argc, argv); m_app = std::make_unique<QCoreApplication>(argc, argv);
else else
m_app = std::make_unique<QGuiApplication>(argc, argv); m_app = std::make_unique<QGuiApplication>(argc, argv);
@@ -257,7 +271,7 @@ DashboardApplication::DashboardApplication(
benchmarkOption, apiOption, keyOption, nameOption, benchmarkOption, apiOption, keyOption, nameOption,
fpsOption, installOption, uninstallOption, manifestOption, fpsOption, installOption, uninstallOption, manifestOption,
launchOption, logOption}); launchOption, logOption});
for (const QCommandLineOption& option : appOptions) { for (const QCommandLineOption& option : appOptions + appCommands) {
if (!parser.addOption(option)) if (!parser.addOption(option))
o.error = QStringLiteral("App option --%1 clashes with another option") o.error = QStringLiteral("App option --%1 clashes with another option")
.arg(option.names().constFirst()); .arg(option.names().constFirst());
@@ -283,6 +297,12 @@ DashboardApplication::DashboardApplication(
? resolvePath(parser.value(manifestOption)) ? resolvePath(parser.value(manifestOption))
: shareDir() + QStringLiteral("/manifest.vrmanifest"); : shareDir() + QStringLiteral("/manifest.vrmanifest");
if (o.manifestCommand) return; if (o.manifestCommand) return;
for (const QCommandLineOption& command : appCommands) {
if (parser.isSet(command)) {
o.appCommand = command.names().constFirst();
return;
}
}
if (parser.isSet(apiOption)) { if (parser.isSet(apiOption)) {
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi)) if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
@@ -338,6 +358,10 @@ void DashboardApplication::setQuickStyle(const QString& style) {
bool DashboardApplication::prepare(QString*) { return true; } 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::loading() {}
void DashboardApplication::started() {} void DashboardApplication::started() {}
@@ -400,7 +424,7 @@ int DashboardApplication::exec() {
QString error; QString error;
if (!validateConfig(&error)) return fail(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_appKey = !o.appKey.isEmpty() ? o.appKey
: !m_configAppKey.isEmpty() : !m_configAppKey.isEmpty()
? m_configAppKey ? m_configAppKey
@@ -411,6 +435,7 @@ int DashboardApplication::exec() {
: QString::fromUtf8(AppConfig::kDisplayName); : QString::fromUtf8(AppConfig::kDisplayName);
QCoreApplication::setApplicationName(m_appName); QCoreApplication::setApplicationName(m_appName);
if (!prepare(&error)) return fail(error); if (!prepare(&error)) return fail(error);
if (m_mode == DashboardMode::Command) return runCommand(o.appCommand);
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle); if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
m_engine = std::make_unique<QQmlEngine>(); m_engine = std::make_unique<QQmlEngine>();
@@ -432,6 +457,8 @@ int DashboardApplication::exec() {
case DashboardMode::Overlay: case DashboardMode::Overlay:
result = runOverlay(); result = runOverlay();
break; break;
case DashboardMode::Command:
break; // handled above
} }
// Still within exec(): the derived object (and what it owns) outlives all // Still within exec(): the derived object (and what it owns) outlives all
// QML. Surfaces (their QML scenes, renderers, backends) before the engine. // QML. Surfaces (their QML scenes, renderers, backends) before the engine.
+18 -2
View File
@@ -20,7 +20,15 @@ class VrRuntime;
// Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises // Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises
// never to connect to SteamVR (for apps that use SteamVR data otherwise). // 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); const char* dashboardModeName(DashboardMode mode);
// Base class of a dashboard app. It owns everything app- and surface-related: // 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 // `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 // in --help together with the built-in ones (names must not clash); read
// them with isOptionSet() / optionValue(). // 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, DashboardApplication(int& argc, char** argv,
const QList<QCommandLineOption>& appOptions = {}); const QList<QCommandLineOption>& appOptions = {},
const QList<QCommandLineOption>& appCommands = {});
~DashboardApplication() override; ~DashboardApplication() override;
// Runs the mode selected on the command line; returns the exit code. // Runs the mode selected on the command line; returns the exit code.
@@ -98,6 +110,10 @@ class DashboardApplication : public QObject {
virtual void loading(); virtual void loading();
// Everything runs; called right before the event loop starts. // Everything runs; called right before the event loop starts.
virtual void started(); 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) --- // --- The running app (valid in the hooks and until exec() returns) ---
DashboardMode mode() const { return m_mode; } DashboardMode mode() const { return m_mode; }
+2
View File
@@ -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") 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) 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") 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) add_test(NAME surfaces_desktop_novr COMMAND tst_surfaces)
set_tests_properties(surfaces_desktop_novr PROPERTIES set_tests_properties(surfaces_desktop_novr PROPERTIES
ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen") ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen")
+32 -1
View File
@@ -6,10 +6,14 @@
// without calling the other hooks. // without calling the other hooks.
// VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare() // VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare()
// checks the mode and then fails (no window, no event loop). // 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). // Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
#include <QCommandLineOption> #include <QCommandLineOption>
#include <QDir> #include <QDir>
#include <QGuiApplication>
#include <QImage> #include <QImage>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <cstdio> #include <cstdio>
@@ -34,7 +38,9 @@ class TwoSurfaceApp : public DashboardApplication {
QStringLiteral("An app option."), QStringLiteral("An app option."),
QStringLiteral("value")), QStringLiteral("value")),
QCommandLineOption(QStringLiteral("test-flag"), 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")); setQmlModule(QStringLiteral("SurfacesTest"));
setDisplayName(QStringLiteral("Test App")); setDisplayName(QStringLiteral("Test App"));
setAppKey(QStringLiteral("config.key")); setAppKey(QStringLiteral("config.key"));
@@ -53,6 +59,7 @@ class TwoSurfaceApp : public DashboardApplication {
int prepareCalls = 0; int prepareCalls = 0;
int loadingCalls = 0; int loadingCalls = 0;
int startedCalls = 0; int startedCalls = 0;
QStringList commands;
bool ok = true; bool ok = true;
protected: protected:
@@ -94,6 +101,15 @@ class TwoSurfaceApp : public DashboardApplication {
"texture size from config"); "texture size from config");
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode"); 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<QGuiApplication*>(QCoreApplication::instance()),
"no GUI application for a command");
return 7;
}
void started() override { void started() override {
++startedCalls; ++startedCalls;
const QList<Surface*> all = surfaces(); const QList<Surface*> all = surfaces();
@@ -113,11 +129,14 @@ int main(int argc, char* argv[]) {
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY"); const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
const bool desktopNoVr = const bool desktopNoVr =
qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR"); qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR");
const bool command = qEnvironmentVariableIntValue("VRDASH_TEST_COMMAND");
const bool failPrepare = const bool failPrepare =
qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr; qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr;
std::vector<std::string> args = {argv[0], "--test-value", "42"}; std::vector<std::string> args = {argv[0], "--test-value", "42"};
if (desktopNoVr) { if (desktopNoVr) {
args.emplace_back("--desktop-novr"); args.emplace_back("--desktop-novr");
} else if (command) {
args.emplace_back("--test-command");
} else { } else {
args.emplace_back("--snapshot"); args.emplace_back("--snapshot");
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"); cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
app.failPrepare = failPrepare; app.failPrepare = failPrepare;
if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr; if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr;
if (command) app.expectedMode = DashboardMode::Command;
const int result = app.exec(); 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) { if (failPrepare) {
bool ok = app.ok; bool ok = app.ok;
ok &= check(result == 1, "exec() returns 1 when prepare() fails"); ok &= check(result == 1, "exec() returns 1 when prepare() fails");