13 KiB
logos-standalone-app
A generic Qt6 shell for loading and testing Logos UI plugins in isolation, without requiring a full Logos node or the complete logos-app stack.
Overview
logos-standalone is a minimal host application that:
- Starts the Logos core backend (
logos-liblogos) - Loads the backend modules a plugin declares as dependencies
- Displays the plugin's UI in a window
It supports three plugin formats:
- View modules (
type: "ui"with"view"field) — process-isolated C++ backend + QML view. The C++ plugin runs in a separateui-hostprocess; the QML view is loaded in the standalone app and communicates with the backend vialogos.callModuleAsync(). - QML plugins (
type: "ui_qml") — pure QML UI loaded into aQQuickWidget; thelogoscontext property exposes a bridge for calling backend modules from QML. - Legacy dylib plugins (
type: "ui", no"view"field) — loaded viaQPluginLoader, must export acreateWidget(LogosAPI*)method. Can be passed as a raw file path or as a directory containing ametadata.json.
Usage
logos-standalone [options] <plugin-path>
Options
| Flag | Short | Description |
|---|---|---|
--plugin <path> |
-p |
Path to the plugin directory (alternative to positional argument) |
--modules-dir <dir> |
-m |
Directory containing backend modules (default: ../modules relative to the binary) |
--user-dir <dir> |
-u |
Session data directory; isolates module state for this instance (default: the platform application data location) |
--load <module> |
-l |
Load a named backend module before showing the UI; can be repeated |
--title <title> |
-t |
Window title (default: name from metadata.json, then plugin filename) |
--width <px> |
Window width in pixels (default: 1024) |
|
--height <px> |
Window height in pixels (default: 768) |
|
--help |
-h |
Show help and exit |
Examples
# Load a view module directory (C++ backend + QML view)
logos-standalone ./calc_ui_cpp
# Load a pure QML plugin directory
logos-standalone ./wallet_ui
# Load a legacy dylib plugin directly (raw .dylib/.so file)
logos-standalone ./result/lib/accounts_ui.dylib
# Load a plugin directory with backend modules
# (capability_module is loaded by the runtime itself — no --load needed)
logos-standalone --plugin ./chat_ui --modules-dir ./modules --load waku_module
# Override the modules directory
logos-standalone --plugin ./chat_ui --modules-dir ./result/modules
# Run directly via Nix against a local plugin build
nix run github:logos-co/logos-standalone-app -- ./result/lib/chat_ui
--user-dir: one directory per session
Backend modules do not choose where they store their state: the host assigns
each module instance a directory, and the app derives it from the session
directory. A module instance gets <user-dir>/module_data/<module>/<instance>.
The default session directory is the platform application data location (on
Linux, ~/.local/share/Logos/LogosStandalone). Pass --user-dir to select
another one, which is how two instances of the same plugin run side by side
without sharing state:
logos-standalone --user-dir /tmp/alice ./chat_ui &
logos-standalone --user-dir /tmp/bob ./chat_ui &
The directory is created if it does not exist. Setting LOGOS_USER_DIR selects
the same directory, and the flag wins when both are given. This matches Logos
Basecamp's --user-dir / LOGOS_USER_DIR, so a session means the same thing
under either host.
Plugin Metadata
When given a plugin directory, the app reads metadata.json (or manifest.json) to determine the plugin type and auto-load declared dependencies before the UI is shown.
View module (C++ backend + QML view, process-isolated):
{
"name": "calc_ui",
"type": "ui",
"view": "qml/Main.qml",
"dependencies": ["calc_module"]
}
Pure QML plugin:
{
"type": "ui_qml",
"main": "Main.qml",
"dependencies": ["waku_module", "chat"]
}
Legacy dylib plugin (type: "ui" without "view", or a raw .dylib/.so file):
{
"type": "ui",
"dependencies": ["capability_module"]
}
The app determines the loading strategy from these fields:
type: "ui"+"view"present → view module (spawnui-host, load QML view)type: "ui"without"view"→ legacy dylib (load viaQPluginLoader, callcreateWidget)type: "ui_qml"→ pure QML (load intoQQuickWidget)
When a raw .dylib/.so/.dll file is passed directly (instead of a directory), the app loads it via QPluginLoader without requiring a metadata file.
Icon support
If metadata.json contains an "icon" field with a relative file path, the app sets it as the window icon. The path is resolved relative to the directory containing metadata.json.
QML hot reload — edit, save, see it
For ui_qml plugins, setting DEV_QML_PATH to a directory does two things: it
redirects the view entry file (named by metadata.json's view field) to that
directory, and it turns on hot reload for the whole tree underneath. Edit any
.qml/.js file and save — the view re-renders in roughly 200 ms. No rebuild,
no relaunch.
# view: "qml/MyView.qml" → loaded from $DEV_QML_PATH/MyView.qml
export DEV_QML_PATH=$PWD/src/qml
logos-standalone ./result/lib/my_ui_module
Most module authors do not need to set this by hand — nix build .#ui-dev
produces a launcher that detects it automatically. See
logos-module-builder's README.
The directory must contain the basename of the view entry. The engine's base
URL is set to DEV_QML_PATH, so relative imports (sub-components, icons)
resolve alongside the entry file. If the env var is unset, invalid, or missing
the entry file, the installed view is used and a warning is logged.
What a reload does and doesn't touch:
- Sub-components and new files are covered. The whole tree under
DEV_QML_PATHis watched recursively, including files and folders created after launch. - The backend keeps running. A module's C++ backend lives in a separate
ui-hostprocess; reloading rebuilds only the QML, so module state, connections and loaded data survive. - QML-side state resets — scroll position, text fields, current tab. This is inherent to re-instantiating the view.
- A syntax error is recoverable. The error is logged with its line number and the view goes blank; the next save that compiles brings it back.
- C++ changes still need a rebuild — only QML is reloaded.
Set LOGOS_QML_HOT_RELOAD=0 to keep the DEV_QML_PATH redirect but disable
watching.
Implementation: app/QmlLiveView.cpp. Each reload builds a fresh QQmlEngine
rather than re-setSource()-ing the existing one — clearComponentCache() only
frees compilation units nothing still references, so re-sourcing can silently
replay the previous QML while reporting success.
Adding nix run to a UI module
logos-standalone-app is bundled inside logos-module-builder. UI modules automatically get apps.default wired up — no separate flake input needed. Use the builder that matches your module type:
| Module type | Builder | When to use |
|---|---|---|
| C++ + QML view | mkViewModule |
Process-isolated UI: C++ backend in ui-host, QML view in host app |
| Pure QML | mkLogosQmlModule |
QML-only UI, no C++ compilation |
| Legacy C++ widget | mkLogosModule |
C++ plugin with createWidget() (legacy pattern) |
# View module (C++ + QML)
logos-module-builder.lib.mkViewModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
# Pure QML module
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
# Legacy C++ widget module (type: "ui", no "view" field)
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
Dependencies listed in metadata.json are automatically bundled from their LGX packages and loaded at runtime.
Then build and run:
nix build && nix run .
# Pass options after --
nix run . -- --title "My Plugin" --width 1280 --height 800
To override the standalone app with a custom build, pass logosStandalone to mkViewModule, mkLogosModule, or mkLogosQmlModule.
Building
Nix (recommended)
nix build # produces ./result/bin/logos-standalone
nix run -- <plugin-path> # build and run in one step
To use a local checkout of a dependency:
nix build --override-input logos-liblogos path:../logos-liblogos
nix build --override-input logos-cpp-sdk path:../logos-cpp-sdk
nix build --override-input logos-capability-module path:../logos-capability-module
Manual CMake
Requires Qt6, logos-liblogos, and logos-cpp-sdk to be available.
cmake -S app -B build -GNinja \
-DLOGOS_LIBLOGOS_ROOT=/path/to/logos-liblogos \
-DLOGOS_CPP_SDK_ROOT=/path/to/logos-cpp-sdk
cmake --build build
Dev shell
nix develop # sets LOGOS_CPP_SDK_ROOT and LOGOS_LIBLOGOS_ROOT automatically
QML Inspector
The app includes an optional QML Inspector server (logos-qt-mcp) that enables runtime UI introspection over TCP. This allows AI assistants (via MCP) and test frameworks to interact with the running UI — clicking buttons, reading properties, taking screenshots, etc.
The inspector is enabled by default in non-release builds. In nix builds it is controlled by the enableInspector parameter (default: true).
Using the inspector interactively
# Build the app (inspector enabled by default)
nix build
# Build the MCP server + test framework (one-time)
nix build .#logos-qt-mcp -o result-mcp
# Run the app with a plugin — inspector starts on localhost:3768
./result/bin/logos-standalone-app ./my-plugin/result/lib
# Connect Claude Code via .mcp.json (automatic when working from this directory)
# Or connect any MCP client to localhost:3768
The inspector port defaults to 3768 and can be changed via the QML_INSPECTOR_PORT environment variable.
Available MCP tools
| Tool | Description |
|---|---|
qml_screenshot |
Capture a screenshot of the current app state |
qml_find_and_click |
Find a UI element by text and click it |
qml_find_by_type |
Locate elements by QML type name |
qml_find_by_property |
Locate elements by property value |
qml_list_interactive |
List all clickable/interactive elements |
qml_get_tree |
Get the full QML element tree |
Disabling the inspector
# Via CMake
cmake -DENABLE_QML_INSPECTOR=OFF ...
# Via nix (pass enableInspector = false to nix/app.nix)
Plugin integration tests (lib.mkPluginTest)
logos-standalone-app exposes a reusable test builder that UI modules can use to run headless integration tests. This is wired up automatically by logos-module-builder — any UI module with .mjs files in its tests/ directory gets a nix build .#integration-test output for free.
If you need to use it directly:
# In a module's flake.nix
integration-test = logos-standalone-app.lib.${system}.mkPluginTest {
inherit pkgs;
pluginPkg = myModulePackage;
testFiles = [ ./tests/smoke.mjs ./tests/interactions.mjs ];
name = "my-module-integration-test";
};
The builder launches the plugin in logos-standalone-app with QT_QPA_PLATFORM=offscreen, connects to the QML inspector, and runs each test file sequentially.
Architecture
app/
main.cpp # CLI argument parsing, core init, module loading
mainwindow.h/cpp # Plugin loading (dylib + QML) and window setup
nix/
app.nix # Nix derivation: build, install, Qt wrapping, library bundling, capability_module bundling
mkPluginTest.nix # Reusable integration test builder for UI plugins
flake.nix # Flake outputs: packages, apps, lib, devShells (4 platforms)
The nix build bundles capability_module (via nix-bundle-lgx) into the output's modules/ directory so it is always available at runtime. The app loads capability_module first (required by all UI plugins), then any modules declared in the plugin's metadata.json, then any additional modules passed via --load. The default modules directory is ../modules relative to the binary; use --modules-dir to override.
Supported Platforms
aarch64-darwin(Apple Silicon)x86_64-darwin(Intel Mac)aarch64-linuxx86_64-linux
Related
- logos-liblogos — core C++ plugin system
- logos-cpp-sdk — code generation SDK for module wrappers
- logos-app — full Qt desktop application
- logos-module-builder — Nix helpers and templates for creating modules