Files
logos-tutorial/tutorial-cpp-ui-app.md
T

15 KiB

Tutorial Part 3: Building a C++ UI Module (Process-Isolated)

This is Part 3 of the Logos module tutorial series. In Part 2 you built a QML-only UI plugin. Now you'll build a ui_qml module with a C++ backend — the backend runs in a separate ui-host process while the QML view loads in the host app (basecamp / standalone).

What you'll build: A calc_ui_cpp module with:

  • A .rep file defining the remote interface (slots + properties)
  • A C++ backend plugin that inherits from the generated SimpleSource base class
  • A QML view that calls the backend via a typed replica using logos.watch()
  • Process isolation: backend crashes can't bring down the host app

Why C++ backend over QML-only?

QML-only (Part 2) C++ backend (Part 3)
Compilation None CMake + Qt
Process isolation No (QML runs in-process) Yes (C++ in separate ui-host process)
Backend calls logos.callModule() / logos.callModuleAsync() to other modules LogosModules typed SDK in C++
Type safety Args travel as QVariant C++ types preserved
QML ↔ backend Direct bridge Qt Remote Objects (typed replica)
.rep file Not needed Required — defines the remote interface

Prerequisites:

  • Completed Part 1 — you have a working calc_module
  • Nix with flakes enabled

Architecture

  logos-basecamp / logos-standalone-app
  ┌─────────────────────────────────────────────┐
  │                                             │
  │   QML View (Main.qml)                      │
  │     readonly property var backend:          │
  │       logos.module("calc_ui_cpp")           │
  │     logos.watch(backend.add(1,2))│
  │          │                                  │
  │          │  Qt Remote Objects (socket)      │
  └──────────┼──────────────────────────────────┘
             │
  ui-host process (separate)
  ┌──────────┼──────────────────────────────────┐
  │          ▼                                  │
  │   CalcUiCppPlugin (backend)                 │
  │     : CalcUiCppSimpleSource                 │
  │     : CalcUiCppViewPluginBase               │
  │     int add(int a, int b) {                 │
  │       return m_logos->calc_module.add(a,b); │
  │     }                                       │
  │          │                                  │
  │          │  LogosModules typed SDK           │
  │          ▼                                  │
  │   calc_module (loaded in ui-host)           │
  └─────────────────────────────────────────────┘

The .rep file declares the interface. At build time, Qt's repc compiler generates:

  • CalcUiCppSimpleSource — base class the backend inherits from
  • CalcUiCppReplica — typed replica the QML view uses
  • calc_ui_cpp_replica_factory — separate plugin that the host loads to create typed replicas

Step 1: Scaffold

mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend
git init && git add -A

This creates the template. We'll customize it for our calculator.


Step 2: metadata.json

{
  "name": "calc_ui_cpp",
  "version": "1.0.0",
  "type": "ui_qml",
  "category": "tools",
  "description": "Calculator C++ UI — QML view with process-isolated backend",
  "main": "calc_ui_cpp_plugin",
  "view": "qml/Main.qml",
  "icon": "icons/calc.png",
  "dependencies": ["calc_module"],

  "nix": {
    "packages": { "build": [], "runtime": [] },
    "external_libraries": [],
    "cmake": { "find_packages": [], "extra_sources": [] }
  }
}

Key fields:

  • "type": "ui_qml" — tells the builder this is a QML view module
  • "main": "calc_ui_cpp_plugin" — the backend Qt plugin library (without extension)
  • "view": "qml/Main.qml" — the QML entry point
  • "dependencies": ["calc_module"] — core modules the backend calls

Step 3: The .rep File

Create src/calc_ui_cpp.rep:

class CalcUiCpp
{
    PROP(QString status READWRITE)

    SLOT(int add(int a, int b))
    SLOT(int multiply(int a, int b))
    SLOT(int factorial(int n))
    SLOT(int fibonacci(int n))
    SLOT(QString libVersion())
}

This is the single source of truth for the remote interface. repc generates:

  • rep_calc_ui_cpp_source.hCalcUiCppSimpleSource with virtual slots the backend overrides
  • rep_calc_ui_cpp_replica.hCalcUiCppReplica with typed methods and auto-synced properties

PROP values auto-sync from backend to QML replica. SLOT return values are delivered as QRemoteObjectPendingReply — use logos.watch() in QML to get them as JS Promises.


Step 4: CMakeLists.txt

cmake_minimum_required(VERSION 3.14)
project(CalcUiCppPlugin LANGUAGES CXX)

if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
    include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
    message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()

logos_module(
    NAME calc_ui_cpp
    REP_FILE src/calc_ui_cpp.rep
    SOURCES
        src/calc_ui_cpp_interface.h
        src/calc_ui_cpp_plugin.h
        src/calc_ui_cpp_plugin.cpp
)

REP_FILE tells logos_module() to:

  1. Run repc to generate source/replica headers
  2. Generate LogosViewPluginBase (typed remoting base class)
  3. Build a separate calc_ui_cpp_replica_factory shared library

Step 5: C++ Backend Plugin

src/calc_ui_cpp_plugin.h

#pragma once

#include <QString>
#include <QVariantList>
#include "calc_ui_cpp_interface.h"
#include "LogosViewPluginBase.h"
#include "rep_calc_ui_cpp_source.h"

class LogosAPI;
class LogosModules;

class CalcUiCppPlugin : public CalcUiCppSimpleSource,
                        public CalcUiCppInterface,
                        public CalcUiCppViewPluginBase
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
    Q_INTERFACES(CalcUiCppInterface)

public:
    explicit CalcUiCppPlugin(QObject* parent = nullptr);
    ~CalcUiCppPlugin() override;

    QString name()    const override { return "calc_ui_cpp"; }
    QString version() const override { return "1.0.0"; }

    Q_INVOKABLE void initLogos(LogosAPI* api);

    // Slots from .rep — override the generated virtuals
    int add(int a, int b) override;
    int multiply(int a, int b) override;
    int factorial(int n) override;
    int fibonacci(int n) override;
    QString libVersion() override;

signals:
    void eventResponse(const QString& eventName, const QVariantList& args);

private:
    LogosAPI* m_logosAPI = nullptr;
    LogosModules* m_logos = nullptr;
};

Three base classes:

  • CalcUiCppSimpleSource — generated from .rep, provides the typed source for Qt Remote Objects
  • CalcUiCppInterface — standard Logos plugin interface (name(), version())
  • CalcUiCppViewPluginBase — generated, provides setBackend() and enableRemoting()

src/calc_ui_cpp_plugin.cpp

#include "calc_ui_cpp_plugin.h"
#include "logos_api.h"
#include "logos_sdk.h"

CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent)
    : CalcUiCppSimpleSource(parent) {}

CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; }

void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
    m_logosAPI = api;
    m_logos = new LogosModules(api);
    // Register this object as the Remote Objects source
    setBackend(this);
}

int CalcUiCppPlugin::add(int a, int b)
{
    return m_logos->calc_module.add(a, b);
}

int CalcUiCppPlugin::multiply(int a, int b)
{
    return m_logos->calc_module.multiply(a, b);
}

int CalcUiCppPlugin::factorial(int n)
{
    return m_logos->calc_module.factorial(n);
}

int CalcUiCppPlugin::fibonacci(int n)
{
    return m_logos->calc_module.fibonacci(n);
}

QString CalcUiCppPlugin::libVersion()
{
    return m_logos->calc_module.libVersion();
}

Key points:

  • Constructor calls CalcUiCppSimpleSource(parent) — not QObject(parent)
  • initLogos() calls setBackend(this) to register with the Remote Objects host
  • Slots return values directly — they travel back to the QML replica via Qt Remote Objects
  • m_logos->calc_module.add(a, b) uses the generated typed SDK (type-safe, no QVariant)

Step 6: QML View

Create src/qml/Main.qml:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts


Item {
    id: root

    property string result: ""
    property string errorText: ""

    // Typed replica of the backend running in ui-host
    readonly property var backend: logos.module("calc_ui_cpp")

    // "status" property from the .rep — auto-synced via Qt Remote Objects
    readonly property string status: backend ? backend.status : ""

    function callCalc(method, args) {
        if (!backend) {
            root.errorText = "Backend not available"
            return
        }
        root.errorText = ""
        root.result = "..."
        // logos.watch() wraps the pending reply in a JS Promise
        logos.watch(backend[method].apply(backend, args)).then(
            function(value) { root.result = String(value) },
            function(error) { root.errorText = String(error) }
        )
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 24
        spacing: 16

        Text {
            text: "Calculator (C++ backend)"
            font.pixelSize: 20
            color: "#ffffff"
        }

        RowLayout {
            spacing: 12

            TextField {
                id: inputA; placeholderText: "a"
                Layout.preferredWidth: 80
                validator: IntValidator {}
            }
            TextField {
                id: inputB; placeholderText: "b"
                Layout.preferredWidth: 80
                validator: IntValidator {}
            }
            Button {
                text: "Add"
                onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
                                                  parseInt(inputB.text) || 0])
            }
            Button {
                text: "Multiply"
                onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0,
                                                       parseInt(inputB.text) || 0])
            }
        }

        Rectangle {
            Layout.fillWidth: true; height: 56
            color: root.errorText ? "#3d1a1a" : "#1a2d1a"
            radius: 8
            Text {
                anchors.centerIn: parent
                text: root.errorText || root.result || "Press a button"
                color: root.errorText ? "#f85149" : "#56d364"
                font.pixelSize: 15
            }
        }

        Text {
            text: "Backend status: " + root.status
            color: "#8b949e"; font.pixelSize: 13
        }
    }
}

Key patterns:

  • logos.module("calc_ui_cpp") — gets the typed replica (auto-synced properties)
  • backend.status — PROP from .rep, updates automatically
  • logos.watch(backend.add(1, 2)).then(...) — SLOT return value as JS Promise
  • `` — required for logos.watch()

Step 7: flake.nix

{
  description = "Calculator C++ UI plugin — QML view with process-isolated backend";

  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder";
    calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
  };

  outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
    };
}

mkLogosQmlModule handles everything: compiles the C++ backend (because main is set), bundles the QML view, generates LGX packages, and wires up nix run.


Step 8: Build and Run

git add -A
nix run . -- --load-modules calc_ui_cpp

# Or from the workspace:
./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module

Step 9: How the Pieces Connect

  1. nix build → compiles the C++ plugin + replica factory, bundles QML view
  2. nix run → launches logos-standalone-app which:
    • Loads calc_module (dependency)
    • Spawns a ui-host child process with calc_ui_cpp_plugin.so
    • ui-host calls initLogos()setBackend(this)enableRemoting(host)
    • Backend is now accessible over a local socket
  3. Host app loads calc_ui_cpp_replica_factory.dylib → creates a typed replica
  4. QML gets the replica via logos.module("calc_ui_cpp")
  5. backend.add(1, 2) → Qt Remote Objects sends call to ui-host → backend runs → returns result
  6. backend.status auto-syncs whenever the backend calls setStatus(...)

Step 10: UI Integration Tests (Optional)

Add automated UI tests using the logos-qt-mcp test framework. Just create .mjs files in tests/ and logos-module-builder auto-wires nix build .#integration-test.

Create tests/ui-tests.mjs:

import { resolve } from "node:path";

// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root = process.env.LOGOS_QT_MCP || new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(root, "test-framework/framework.mjs"));

test("calc_ui_cpp: loads and shows title", async (app) => {
  await app.waitFor(
    async () => { await app.expectTexts(["UI Example (C++ backend)"]); },
    { timeout: 15000, interval: 500, description: "UI to load" }
  );
});

test("calc_ui_cpp: shows connection status", async (app) => {
  await app.expectTexts(["Connecting to backend..."]);
});

test("calc_ui_cpp: add button visible", async (app) => {
  await app.expectTexts(["Add"]);
});

run();
git add tests/

# Hermetic CI test
nix build .#integration-test -L

# Interactive
nix build .#test-framework -o result-mcp
nix run .                     # app with inspector on :3768
node tests/ui-tests.mjs       # in another terminal

Comparison: .rep Interface Patterns

Pattern .rep declaration Backend C++ QML usage
Return value SLOT(int add(int a, int b)) int add(...) override { return ...; } logos.watch(backend.add(1,2)).then(cb)
Property PROP(QString status READWRITE) setStatus("Ready") (inherited) backend.status (auto-syncs)
Signal SIGNAL(errorOccurred(QString msg)) emit errorOccurred("fail") Connections { target: backend; function onErrorOccurred(msg) {...} }
Model (use Q_PROPERTY on backend) Q_PROPERTY(QAbstractItemModel* items ...) logos.model("calc_ui_cpp", "items")

Next Steps

  • Add more .rep properties/signals for richer UI state
  • Use logos.model() for list views backed by QAbstractItemModel
  • Package as .lgx for distribution: nix build .#lgx
  • See logos-package-manager-ui for a production example