diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f4e3cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,200 @@ +name: Tutorial Tests + +# ────────────────────────────────────────────────────────────────────────────── +# One-time setup required for the clickable report links to work: +# +# 1. Repo Settings → Pages → "Build and deployment" → Source: "Deploy from a +# branch", Branch: `gh-pages` / `(root)`. (The publish-report job creates +# the gh-pages branch on its first run.) +# 2. Nothing else — GITHUB_TOKEN already has the permissions granted below. +# +# Each run publishes the two-column HTML report to: +# https://.github.io//pr-// (pull requests) +# https://.github.io//main// (pushes to main/master) +# and (for PRs) posts/updates a comment with the links. +# +# Note: pull requests opened from forks get a read-only GITHUB_TOKEN, so the +# Pages push and PR comment are skipped for them — the downloadable artifact is +# still produced. PRs from branches in this repo get the full clickable links. +# ────────────────────────────────────────────────────────────────────────────── + +on: + pull_request: + branches: [master, main] + push: + branches: [master, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + tutorial-tests: + name: Tutorial Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Setup Cachix + uses: cachix/cachix-action@v15 + with: + name: logos-co + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Install PyYAML + run: pip3 install --break-system-packages pyyaml + + - name: Test - QML UI App (includes C Library tutorial via requires) + run: | + # --continue-on-fail so the run walks the whole Part 1 -> 2 -> 3 chain + # and the published report is complete. The job still fails (non-zero + # exit) if any step failed; this only changes whether we stop early. + python3 tools/tutorial_runner.py run \ + tests/tutorial-cpp-ui-app.test.yaml \ + --verbose \ + --continue-on-fail \ + --report "${{ runner.temp }}/tutorial-report.html" + + - name: Stage report for upload + if: always() + shell: bash + run: | + mkdir -p report-out + # Name it index.html so the published directory URL renders directly. + if [ -f "${{ runner.temp }}/tutorial-report.html" ]; then + cp "${{ runner.temp }}/tutorial-report.html" report-out/index.html + else + echo "

No report produced

" > report-out/index.html + fi + + - name: Upload tutorial execution report + if: always() + uses: actions/upload-artifact@v4 + with: + name: tutorial-report-${{ matrix.os }} + path: report-out/index.html + if-no-files-found: warn + + - name: Verify markdown generation + run: | + python3 tools/tutorial_runner.py generate \ + tests/tutorial-wrapping-c-library.test.yaml \ + -o /tmp/gen-part1.md + python3 tools/tutorial_runner.py generate \ + tests/tutorial-qml-ui-app.test.yaml \ + -o /tmp/gen-part2.md + echo "Generated markdown successfully" + + publish-report: + name: Publish report to GitHub Pages + needs: tutorial-tests + # Run even when tests fail — a failing run is exactly when you want to open + # the report. Skip on forks, where GITHUB_TOKEN can't push or comment. + if: ${{ always() && github.event.pull_request.head.repo.fork != true }} + runs-on: ubuntu-latest + + permissions: + contents: write # push to the gh-pages branch + pull-requests: write # post/update the PR comment + + # Serialize Pages pushes so two refs can't race on the gh-pages branch. + concurrency: + group: gh-pages-publish + cancel-in-progress: false + + steps: + - name: Download all reports + uses: actions/download-artifact@v4 + with: + path: artifacts + # No `name:` → downloads every artifact into artifacts//... + + - name: Arrange site directory + id: arrange + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="pr-${{ github.event.pull_request.number }}" + else + BASE="main" + fi + echo "base=$BASE" >> "$GITHUB_OUTPUT" + + mkdir -p "site/$BASE" + found="" + for os in ubuntu-latest macos-latest; do + src="artifacts/tutorial-report-$os/index.html" + if [ -f "$src" ]; then + mkdir -p "site/$BASE/$os" + cp "$src" "site/$BASE/$os/index.html" + found="$found $os" + fi + done + echo "found=$found" >> "$GITHUB_OUTPUT" + + # Landing page for this ref linking to each OS report. + { + echo "" + echo "Tutorial reports — $BASE" + echo "" + echo "

Tutorial execution reports

" + echo "

$BASE · commit ${GITHUB_SHA::7}

    " + for os in ubuntu-latest macos-latest; do + if [ -d "site/$BASE/$os" ]; then + echo "
  • $os
  • " + fi + done + echo "
" + } > "site/$BASE/index.html" + + - name: Deploy to gh-pages + if: steps.arrange.outputs.found != '' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + keep_files: true # don't wipe other PRs' directories + commit_message: "Publish tutorial report for ${{ steps.arrange.outputs.base }} (${{ github.sha }})" + + - name: Comment on PR with report links + if: ${{ github.event_name == 'pull_request' && steps.arrange.outputs.found != '' }} + uses: actions/github-script@v7 + with: + script: | + const base = "${{ steps.arrange.outputs.base }}"; + const owner = context.repo.owner; + const repo = context.repo.repo; + const root = `https://${owner}.github.io/${repo}/${base}`; + const oses = "${{ steps.arrange.outputs.found }}".trim().split(/\s+/).filter(Boolean); + + const links = oses.map(os => `- [\`${os}\` report](${root}/${os}/)`).join("\n"); + const marker = ""; + const body = + `${marker}\n` + + `### 📊 Tutorial execution report\n\n` + + `Rendered tutorial alongside the commands actually run and their output ` + + `(updated each run, commit \`${context.sha.slice(0,7)}\`):\n\n` + + `${links}\n\n` + + `_Pages can take a minute to update after the run finishes._`; + + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: context.issue.number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: context.issue.number, body }); + } diff --git a/README.md b/README.md index 5d70d9b..9128cc6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,35 @@ Step-by-step tutorials that build on each other. Each creates a working module y - **logos-dev-boost:** [Scaffolding Modules with logos-dev-boost](tutorial-dev-boost.md) — use the `logos-dev-boost` CLI to auto-generate modules from C library directories. Wraps libcalc (source-only) and sqlcipher (pre-built `.so`), including integration tests that create encrypted databases. Covers `--type module`, `--type full-app`, and `--lib-dir`. +## Executable Tutorials + +Tutorials have YAML specs in `tests/` that can be both **executed** (to verify they work) and used to **generate** the `.md` files. See [docs/spec.md](docs/spec.md) for the full format reference. + +```bash +# Run a tutorial end-to-end, writing to a directory you can inspect afterwards +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \ + --workdir /tmp/my-tutorial-test --verbose + +# Run only specific phases (scaffold, files, build, inspect, logoscore) +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \ + --phase scaffold,files,build --verbose + +# Re-run a single phase against a previous build +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \ + --workdir /tmp/my-tutorial-test --phase inspect --verbose + +# Generate the .md tutorial from the YAML spec +python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml + +# Pin all GitHub URLs to a specific release tag +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --release tutorial-v2 +python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml --release tutorial-v2 +``` + +The `--workdir` flag lets you point the runner at a directory of your choice — all files, builds, and artifacts end up there so you can inspect or re-use them. Without it, a temp directory is created and deleted after the run (use `--keep-workdir` to preserve it). + +The `--release` flag (or the `release` field in the YAML) pins all `{release}` placeholders in GitHub URLs to a git tag, so `github:logos-co/repo{release}#output` becomes `github:logos-co/repo/tutorial-v2#output`. Set it to `""` or omit it for latest. + ## Example Modules Working module source code used by the tutorials: diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 0000000..b3f6aff --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,471 @@ +# Tutorial YAML Spec Format + +This document describes the YAML format used by `tools/tutorial_runner.py` to define executable tutorials. Each `.test.yaml` file is the **single source of truth** — it drives both: + +- **Execution** (`run`): steps are executed in a temp directory, commands run, outputs verified +- **Markdown generation** (`generate`): a `.md` tutorial is produced from the same YAML + +## Quick example + +```yaml +name: "My Tutorial" +output: my-tutorial.md +release: "" + +intro: | + One-paragraph description of what this tutorial covers. + +what_you_build: "A short sentence describing the end result." + +what_you_learn: + - First learning objective + - Second learning objective + +prerequisites: + - "**Nix** with flakes enabled." + +sections: + - title: "Set Up the Project" + phase: scaffold + text: | + Intro paragraph for this section. + steps: + - title: "Create the directory" + run: "mkdir -p my-project" + + - title: "Write the config" + text: "Create `config.json`:" + file: + path: config.json + language: json + content: | + { "name": "example" } + + - title: "Build" + run: "nix build" + expect_contains: + - "Build successful" +``` + +## Top-level fields + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `name` | yes | string | Tutorial title. Used as the `# heading` in generated markdown and in runner output. | +| `output` | no | string | Default output filename for `generate` (relative to the tutorial directory, e.g., `tutorial-wrapping-c-library.md`). Can be overridden with `-o`. | +| `project_name` | no | string | Directory name for this tutorial's project (e.g., `logos-calc-module`). Used when chaining tutorials via `requires:` — each tutorial runs in a subdirectory of a shared parent. Ignored when running standalone. | +| `requires` | no | list of strings | Paths to prerequisite `.test.yaml` specs (relative to the current spec). The runner executes each prerequisite first in a sibling subdirectory (named by its `project_name`), then runs the current tutorial. Prerequisites are resolved **transitively** — if A requires B and B requires C, the run order is C, B, A. Shared prerequisites run once (deduped), and circular `requires:` are reported as an error. This enables cross-tutorial references like `../logos-calc-module`. Requires `project_name` on both the current and prerequisite specs. | +| `intro` | no | string | Introductory paragraph(s). Rendered after the title in the markdown. Supports full markdown. | +| `what_you_build` | no | string | One-line summary prefixed with "**What you'll build:**" in the markdown. | +| `what_you_learn` | no | list of strings | Bullet list prefixed with "**What you'll learn:**". | +| `comparison` | no | string | Free-form markdown block rendered after the learning objectives (useful for comparison tables). | +| `prerequisites` | no | list of strings | Rendered as a bullet list under a prerequisites heading. Each item can contain markdown (code blocks, links, etc.). | +| `release` | no | string | Git tag applied to all `{release}` placeholders in GitHub URLs (e.g., `tutorial-v2`). See [Release tags](#release-tags). | +| `build_overrides` | no | map | Nix `--override-input` flags for the runner. Keys are input names, values are relative paths to local repos. Only affects execution, not generation. | +| `sections` | yes | list | The tutorial content. See below. | + +## Sections + +Each section becomes a `## heading` in the markdown. Sections with `step: true` get auto-numbered as "Step N: Title". + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `title` | yes | string | Section heading. | +| `step` | no | boolean | If `true`, this section is numbered as "Step N: Title" in markdown and gets a `---` separator after it (except the last section). Sections without `step` render as plain `## Title`. | +| `text` | no | string | Introductory prose rendered before the steps. Supports full markdown (tables, blockquotes, code blocks, etc.). | +| `steps` | no | list | Ordered list of steps. See below. | + +Sections without `steps` are prose-only — the `text` is rendered as-is. This is useful for reference sections like "Troubleshooting" or "Common Patterns". + +## Steps + +Steps are the core building blocks. Each step can combine multiple fields. The rendering order in the generated markdown is: + +1. `title` → `### heading` +2. `text` → prose paragraph +3. `file` → code block with file contents +4. `run` → bash code block +5. `post_text` → prose after the action +6. `extra_run` → additional command block (no heading) + +A step without a `title` renders its content inline under the previous heading — useful for continuation content like "Then run:" followed by a code block. + +### Step fields + +#### `title` (string, optional) + +Rendered as a `### heading` in the markdown. Steps without a title don't get a heading — their content flows under the previous step's heading. + +#### `text` (string, optional) + +Prose rendered before any action. Supports full markdown. + +#### `file` (object, optional) + +Writes a file to disk during execution and renders it as a code block in the markdown. + +| Subfield | Type | Description | +|----------|------|-------------| +| `path` | string | Relative path within the project (e.g., `src/main.cpp`, `lib/libcalc.h`). | +| `content` | string | The file contents. | +| `language` | string | Syntax highlighting hint for the markdown code block. Auto-detected from extension if omitted (`.c` → `c`, `.h`/`.cpp` → `cpp`, `.json` → `json`, `.nix` → `nix`, etc.). | +| `encoding` | string | Set to `base64` for binary files. Renders as `*Binary file: \`path\`*` instead of a code block. | + +**Runner behavior:** Creates parent directories and writes the file to disk. + +**Generator behavior:** Renders ` ```language ` code block with the content. + +#### `run` (string, optional) + +A shell command to execute and display. + +| Subfield | Type | Description | +|----------|------|-------------| +| (value) | string | The command to execute. Supports `{ext}` (expands to `so` or `dylib`) and `{shared_flags}` (expands to `-shared -fPIC` or `-dynamiclib`). | + +**Runner behavior:** Expands platform placeholders, injects nix overrides if applicable, and runs the command. Checks exit code (0 = pass, non-zero = fail). + +**Generator behavior:** Renders the command in a ` ```bash ` block. If `code_block` is present on the step, renders that instead (see below). + +#### `code_block` (string, optional) + +The exact content to show in the generated markdown, used **instead of** the `run` command. This is for cases where: + +- The executed command differs from what readers should see (e.g., `&&`-chained commands displayed as separate lines) +- Platform-specific variants should be shown (Linux and macOS versions) +- The display should include comments, blank lines, or additional context + +The runner ignores `code_block` — it only uses `run` for execution. + +```yaml +- title: "Build the shared library" + run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .." + code_block: | + cd lib + + # Linux + gcc -shared -fPIC -o libcalc.so libcalc.c + + # macOS + # gcc -shared -fPIC -o libcalc.dylib libcalc.c + + cd .. +``` + +#### `expect_contains` (list of strings, optional) + +Assertions checked by the runner against command output. Not rendered in the markdown. + +```yaml +run: "./lm/bin/lm metadata result/lib/calc_module_plugin.{ext}" +expect_contains: + - "Name: calc_module" + - "Version: 1.0.0" +``` + +**Runner behavior:** Captures stdout+stderr, checks that every string appears in the output. + +**Generator behavior:** Ignored — not rendered. Use `post_text` to show expected output to readers. + +#### `check_file` (string, optional) + +Verifies a file exists. Runner-only, not rendered in the markdown. + +```yaml +- check_file: "result/lib/calc_module_plugin.{ext}" +``` + +**Runner behavior:** Expands `{ext}`, globs for the file, passes if found. + +**Generator behavior:** Ignored — not rendered. + +#### `ui_test` (object, optional) + +Runs headless UI tests against a Qt app using [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp). The app is launched with `QT_QPA_PLATFORM=offscreen` (no display needed) and tests connect to the QML inspector to verify elements, click buttons, and check results. + +Two modes: +- **Launch mode** (preferred): `launch` runs the app as a background process, tests connect to its inspector, app is killed when done. The `launch` command is rendered in the generated markdown. +- **Binary mode**: `build` + `binary` let the test framework manage the app via `--ci`. Not rendered in markdown. + +| Subfield | Type | Description | +|----------|------|-------------| +| `launch` | string | Command to launch the app (e.g., `nix run .`). Launched as a background process with offscreen Qt. **Rendered** in generated markdown as a bash code block. | +| `build` | string | Command to build the app binary (binary mode only, not rendered). | +| `binary` | string | Path to the app binary or `nix-app` to auto-resolve from flake (binary mode only). | +| `qt_mcp` | string | Path to the logos-qt-mcp package, relative to workdir (e.g., `result-mcp`). Falls back to `--qt-mcp` CLI flag or `LOGOS_QT_MCP` env var. | +| `setup` | list of strings | Commands to run before testing (e.g., `nix build 'github:logos-co/logos-qt-mcp' -o result-mcp`). | +| `inspector_port` | integer | TCP port for the QML inspector (default: 3768). | +| `tests` | list of objects | Test actions to execute. See below. | + +**Test actions:** + +| Action | Fields | What it does | +|--------|--------|--------------| +| `click` | `target` | Find element by text and click it | +| `wait_for` | `texts`, `timeout` (ms, default 10000), `name` | Poll until all texts are visible | +| `expect_texts` | `texts` | Assert all texts are visible now | +| `set_text` | `find_by`, `find_value`, `value` | Find element by property and set its `text` property | +| `sleep` | `ms` | Wait a fixed duration | + +**Runner behavior (launch mode):** Runs setup commands, launches the app in the background with `QT_QPA_PLATFORM=offscreen`, waits for the QML inspector to be available, generates a `.mjs` test file, runs it, then kills the app. Reports pass/fail. + +**Runner behavior (binary mode):** Runs setup + build, generates a `.mjs` test file, runs it via `node test.mjs --ci --verbose`. Reports pass/fail. + +**Generator behavior:** In launch mode, `launch` is rendered as a ` ```bash ` code block. In binary mode, nothing is rendered. Use `text:` and `post_text:` for additional user-facing prose. + +```yaml +# Launch mode (preferred) — what's shown is what's executed +- ui_test: + launch: "nix run ." + setup: + - "nix build 'github:logos-co/logos-qt-mcp' -o result-mcp" + qt_mcp: "result-mcp" + tests: + - name: "Title visible" + action: wait_for + texts: ["Logos Calculator"] + timeout: 15000 + - name: "Enter number" + action: set_text + find_by: "placeholderText" + find_value: "a" + value: "3" + - name: "Click Add" + action: click + target: "Add" + - name: "Result shows 3" + action: wait_for + texts: ["3"] + timeout: 10000 +``` + +#### `post_text` (string, optional) + +Prose rendered **after** the step's action (file, run). Supports full markdown including code blocks, tables, blockquotes. + +Use this for: +- Expected output blocks +- Explanations of what just happened +- Callout boxes and tips + +```yaml +- title: "View metadata" + run: "./lm/bin/lm metadata result/lib/plugin.{ext}" + post_text: | + Output: + + ``` + Plugin Metadata: + ================ + Name: my_module + Version: 1.0.0 + ``` +``` + +#### `extra_run` (object, optional) + +A continuation command rendered under the same step heading (no separate `###`). Useful when a step has two related commands (e.g., build then verify). + +| Subfield | Type | Description | +|----------|------|-------------| +| `run` | string | Command to execute. | +| `code_block` | string | Display override (same as step-level `code_block`). | +| `post_text` | string | Prose after the extra command. | + +```yaml +- title: "Build the shared library" + run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .." + code_block: | + cd lib + gcc -shared -fPIC -o libcalc.so libcalc.c + cd .. + post_text: "Verify the symbols are exported:" + extra_run: + run: "nm -gU lib/libcalc.{ext} | grep calc" + code_block: | + # Linux + nm -D lib/libcalc.so | grep calc + + # macOS + # nm -gU lib/libcalc.dylib | grep calc + post_text: | + You should see symbols marked with `T`. +``` + +## Platform placeholders + +These placeholders are expanded at execution time by the runner and at generation time in rendered content: + +| Placeholder | Linux | macOS | +|-------------|-------|-------| +| `{ext}` | `so` | `dylib` | +| `{shared_flags}` | `-shared -fPIC` | `-dynamiclib` | + +Platform placeholders work in `run`, `check_file`, `extra_run.run`, and `file.content` fields. They are **not** expanded in `code_block`, `text`, or `post_text` — those are rendered verbatim. + +When a command uses platform placeholders, provide a `code_block` showing both platform variants for the markdown. + +## Release tags + +The `release` field lets you pin all GitHub URLs to a specific git tag. This avoids updating every URL individually when you want all `nix build 'github:logos-co/...'` commands to use the same release. + +Use the `{release}` placeholder in `run` commands, `code_block`, and `file.content`: + +```yaml +release: "tutorial-v2" + +sections: + - title: "Set Up" + steps: + - run: "nix flake init -t github:logos-co/logos-module-builder{release}#with-external-lib" + - run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm" +``` + +When `release` is set to `"tutorial-v2"`, `{release}` expands to `/tutorial-v2`: + +```bash +nix build 'github:logos-co/logos-module/tutorial-v2#lm' --out-link ./lm +``` + +When `release` is empty or omitted, `{release}` expands to nothing: + +```bash +nix build 'github:logos-co/logos-module#lm' --out-link ./lm +``` + +The `--release` CLI flag overrides the YAML field: + +```bash +# Use a specific tag (overrides whatever is in the YAML) +python3 tools/tutorial_runner.py run spec.yaml --release tutorial-v3 + +# Generate markdown with a tag +python3 tools/tutorial_runner.py generate spec.yaml --release tutorial-v2 + +# Clear the tag even if the YAML sets one +python3 tools/tutorial_runner.py run spec.yaml --release "" +``` + +## Runner behavior + +- Creates a fresh temp directory (or uses `--workdir`) — all files, builds, and commands happen there +- Walks sections and steps in order +- Executes `file`, `run`, `check_file`, `ui_test` actions +- Tracks pass/fail/skip counts +- **Stops on first failure** by default (use `--continue-on-fail` to override) +- Prints a summary report at the end +- By default the temp directory is **deleted** when the run finishes + +### Working directory + +The runner needs a directory to work in. There are three modes: + +1. **Default (temp dir, auto-deleted):** A fresh `/tmp/tutorial-test-XXXXX/` is created and removed after the run. +2. **`--keep-workdir`:** Same temp dir, but it's kept after the run so you can inspect the results (built artifacts, installed modules, etc.). +3. **`--workdir `:** Use your own directory. It is never deleted. Useful for re-running specific phases against a previous build or for debugging. + +The workdir path is printed at the top of every run: + +``` + workdir : /tmp/tutorial-test-abc123 +``` + +### Tutorial chaining (`requires`) + +When a spec has `requires:`, the runner creates a shared parent directory and runs each prerequisite before the main tutorial. Each tutorial gets its own subdirectory named by `project_name`: + +``` +# Part 2 requires Part 1: +requires: + - tutorial-wrapping-c-library.test.yaml +project_name: logos-calc-ui +``` + +``` +# Running Part 2 automatically runs Part 1 first: +python3 tools/tutorial_runner.py run tests/tutorial-qml-ui-app.test.yaml + +# Resulting directory structure: +/tmp/tutorial-chain-XXXXX/ +├── logos-calc-module/ # Part 1 (prerequisite) +└── logos-calc-ui/ # Part 2 (main) +``` + +Commands like `../logos-calc-module` in Part 2 resolve to Part 1's output. Results are cumulative — a failure in any tutorial stops the chain (unless `--continue-on-fail`). + +`requires:` is resolved **transitively**. A spec only needs to declare its *direct* prerequisites; their prerequisites are pulled in automatically. For the three-part series, Part 3 can simply declare Part 2: + +``` +# Part 3 requires Part 2, which itself requires Part 1: +requires: + - tutorial-qml-ui-app.test.yaml +project_name: logos-calc-ui-cpp +``` + +``` +# Running Part 3 resolves the whole graph and runs in dependency order: +python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml + +# Resulting directory structure (Part 1 → Part 2 → Part 3): +/tmp/tutorial-chain-XXXXX/ +├── logos-calc-module/ # Part 1 (transitive prerequisite, runs first) +├── logos-calc-ui/ # Part 2 (direct prerequisite, runs second) +└── logos-calc-ui-cpp/ # Part 3 (main, runs last) +``` + +The graph is walked depth-first in post-order, so a prerequisite always runs before the spec that needs it. A prerequisite shared by multiple specs runs only once, and a circular `requires:` chain is reported as an error rather than looping forever. + +When running standalone (no `requires:`), `project_name` is ignored and the runner behaves as before. + +## Generator behavior + +- Walks the same YAML structure +- Produces markdown with proper headings, code blocks, and prose +- Sections with `phase` get numbered as "Step 1:", "Step 2:", etc. +- Sections without `phase` get plain `## Title` headings +- `expect_contains` and `check_file` are not rendered (runner-only) +- `code_block` overrides the `run` value for display +- `file` content is rendered as a fenced code block with syntax highlighting +- Triple blank lines are collapsed to double + +## File layout + +``` +repos/logos-tutorial/ +├── tests/ +│ └── tutorial-wrapping-c-library.test.yaml # The spec +├── tools/ +│ ├── tutorial_runner.py # Runner + generator +│ └── run-tutorial # Nix wrapper script +├── tutorial-wrapping-c-library.md # Generated output +└── docs/ + └── spec.md # This file +``` + +## Usage + +```bash +# Run a tutorial (all phases) +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --verbose + +# Run specific phases +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --phase scaffold,files,build + +# Run into a specific directory (kept after run, useful for inspecting results) +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --verbose + +# Keep the auto-generated temp directory for debugging +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --keep-workdir --verbose + +# Re-run just the logoscore phase against a previous build +python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --phase logoscore --verbose + +# Generate markdown +python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml + +# Generate to a specific file +python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml -o my-output.md + +# Use nix wrapper (ensures python3 + pyyaml are available) +./tools/run-tutorial run tests/tutorial-wrapping-c-library.test.yaml --verbose +``` diff --git a/tests/tutorial-cpp-ui-app.test.yaml b/tests/tutorial-cpp-ui-app.test.yaml new file mode 100644 index 0000000..c6feebf --- /dev/null +++ b/tests/tutorial-cpp-ui-app.test.yaml @@ -0,0 +1,776 @@ +name: "Tutorial Part 3: Building a C++ UI Module (Process-Isolated)" +output: tutorial-cpp-ui-app.md +project_name: logos-calc-ui-cpp +requires: + - tutorial-qml-ui-app.test.yaml +release: "" + +intro: | + This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-app.md) 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_build: | + A `calc_ui_cpp` module with: + + - A `.rep` file defining the remote interface (slots) + - 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 + +comparison: | + **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](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`)" + - Nix with flakes enabled + +sections: + # ── Architecture (prose only) ─────────────────────────────────────────────── + - title: "Architecture" + text: | + ``` + 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 ──────────────────────────────────────────────────────── + - title: "Scaffold" + step: true + text: | + Create a new directory and initialise it from the C++ backend UI template: + + `mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp` + steps: + - run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend" + code_block: | + nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend + post_text: | + This creates the template. We'll customize it for our calculator. + + - run: "git init && git add -A" + + # ── Step 2: metadata.json ─────────────────────────────────────────────────── + - title: "`metadata.json`" + step: true + text: | + Replace the template contents with your plugin's details: + steps: + - file: + path: metadata.json + language: json + content: | + { + "name": "calc_ui_cpp", + "version": "1.0.0", + "type": "ui_qml", + "category": "tools", + "description": "Calculator C++ UI — QML view with process-isolated backend for calc_module", + "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": [], + "extra_include_dirs": [], + "extra_link_libraries": [] + } + } + } + post_text: | + Create the icon directory and add a placeholder icon (displayed in the `logos-basecamp` sidebar when the module is loaded): + + - run: "mkdir -p icons && echo 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=' | base64 -d > icons/calc.png" + code_block: | + mkdir -p icons + # Copy any PNG here — or generate a 64×64 placeholder: + echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png + post_text: | + 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 ───────────────────────────────────────────────────── + - title: "The `.rep` File" + step: true + text: | + Create `src/calc_ui_cpp.rep`: + steps: + - file: + path: src/calc_ui_cpp.rep + language: rep + content: | + class CalcUiCpp + { + 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()) + } + post_text: | + This is the **single source of truth** for the remote interface. `repc` generates: + + - `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots the backend overrides + - `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods + + **SLOT** return values are delivered as `QRemoteObjectPendingReply` — use `logos.watch()` in QML to get them as JS Promises. You can also declare **PROP** entries (e.g. `PROP(QString status READWRITE)`) which auto-sync from the backend to the QML replica. + + # ── Step 4: Interface header ────────────────────────────────────────────────── + - title: "Interface header" + step: true + text: | + The scaffolded template creates a set of `ui_example` files (`src/ui_example.rep`, `src/ui_example_interface.h`, `src/ui_example_plugin.{h,cpp}`). We replace them with `calc_ui_cpp` equivalents, so remove the example sources first — leaving them around with mismatched class/IID names just invites build errors or plugin-load failures at runtime: + steps: + - run: "rm -f src/ui_example.rep src/ui_example_interface.h src/ui_example_plugin.h src/ui_example_plugin.cpp" + post_text: | + Now create `src/calc_ui_cpp_interface.h`: + - file: + path: src/calc_ui_cpp_interface.h + language: cpp + content: | + #ifndef CALC_UI_CPP_INTERFACE_H + #define CALC_UI_CPP_INTERFACE_H + + #include + #include + #include "interface.h" + + class CalcUiCppInterface : public PluginInterface + { + public: + virtual ~CalcUiCppInterface() = default; + }; + + #define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface" + Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid) + + #endif // CALC_UI_CPP_INTERFACE_H + post_text: | + Your plugin header should then include `calc_ui_cpp_interface.h` and use: + + - `Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")` + - `Q_INTERFACES(CalcUiCppInterface)` + + If the interface filename or IID symbol doesn't match, you'll typically get build errors (missing header/symbol) or plugin-load failures at runtime. + + # ── Step 5: CMakeLists.txt ──────────────────────────────────────────────────── + - title: "`CMakeLists.txt`" + step: true + steps: + - file: + path: CMakeLists.txt + language: cmake + content: | + 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 + ) + post_text: | + `REP_FILE` tells `logos_module()` to: + + 1. Run `repc` to generate source/replica headers + 2. Generate `CalcUiCppViewPluginBase` (typed remoting base class) + 3. Build a separate `calc_ui_cpp_replica_factory` shared library + + # ── Step 6: C++ Backend Plugin ──────────────────────────────────────────────── + - title: "C++ Backend Plugin" + step: true + steps: + - title: "`src/calc_ui_cpp_plugin.h`" + file: + path: src/calc_ui_cpp_plugin.h + language: cpp + content: | + #ifndef CALC_UI_CPP_PLUGIN_H + #define CALC_UI_CPP_PLUGIN_H + + #include + #include + #include "calc_ui_cpp_interface.h" + #include "LogosViewPluginBase.h" + #include "rep_calc_ui_cpp_source.h" + + class LogosAPI; + class LogosModules; + + // Inherits CalcUiCppSimpleSource (generated from calc_ui_cpp.rep) so + // enableRemoting() can publish the typed source and QML replicas get + // auto-synced properties + callable slots. + 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 calc_ui_cpp.rep — return values directly. The QML replica + // receives QRemoteObjectPendingReply; use logos.watch() in QML to get the value. + 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; + }; + + #endif // CALC_UI_CPP_PLUGIN_H + post_text: | + 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()` + + - title: "`src/calc_ui_cpp_plugin.cpp`" + file: + path: src/calc_ui_cpp_plugin.cpp + language: cpp + content: | + #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) + { + if (m_logos) return; + m_logosAPI = api; + m_logos = new LogosModules(api); + // Register this object as the Remote Objects source so the QML replica + // can see its properties and call its slots. + 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(); + } + post_text: | + 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 7: QML View ────────────────────────────────────────────────────────── + - title: "QML View" + step: true + text: | + Create `src/qml/Main.qml`: + steps: + - file: + path: src/qml/Main.qml + language: qml + content: | + 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 (generated from calc_ui_cpp.rep). + readonly property var backend: logos.module("calc_ui_cpp") + + // logos.watch() delivers the result of a replica slot call via callbacks. + // No QtRemoteObjects import needed — the bridge handles it. + function callCalc(method, args) { + if (!backend) { + root.errorText = "Backend not available" + return + } + root.errorText = "" + root.result = "..." + logos.watch(backend[method].apply(backend, args), + function(value) { root.result = String(value) }, + function(error) { root.errorText = String(error) } + ) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 16 + + Text { + text: "Logos Calculator (C++ backend)" + font.pixelSize: 20 + color: "#ffffff" + Layout.alignment: Qt.AlignHCenter + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + 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]) + } + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + TextField { + id: inputN + placeholderText: "n" + Layout.preferredWidth: 80 + validator: IntValidator { bottom: 0 } + } + + Button { + text: "Factorial" + onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0]) + } + + Button { + text: "Fibonacci" + onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0]) + } + + Button { + text: "libcalc version" + onClicked: root.callCalc("libVersion", []) + } + } + + Rectangle { + Layout.fillWidth: true + height: 56 + color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a" + radius: 8 + + Text { + anchors.centerIn: parent + text: root.errorText.length > 0 ? root.errorText + : (root.result.length > 0 ? root.result : "Enter values and press a button") + color: root.errorText.length > 0 ? "#f85149" : "#56d364" + font.pixelSize: 15 + } + } + + Item { Layout.fillHeight: true } + } + } + post_text: | + Key patterns: + + - `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties) + - `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise + - The `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed + + # ── Step 8: Use the Logos Design System (prose only) ────────────────────────── + - title: "Use the Logos Design System in your QML" + step: true + text: | + The QML you load above runs inside the host (`logos-basecamp` / `logos-standalone-app`), which already has `logos-design-system` on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves. + + ```qml + import Logos.Theme + import Logos.Controls + import Logos.Icons // optional shared icon assets + + LogosButton { + text: qsTr("Add") + onClicked: root.callCalc("add", [parseInt(inputA.text) || 0, + parseInt(inputB.text) || 0]) + } + + LogosTextField { + id: inputA + placeholderText: qsTr("a") + } + + Rectangle { + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusSmall + LogosText { text: qsTr("Result"); color: Theme.palette.text } + } + ``` + + **Discover what's available** by running the storybook: + + ```bash + cd repos/logos-design-system && nix run + ``` + + The sidebar splits components into: + + - **Controls** — designed per Figma, production-ready (`LogosButton`, `LogosBadge`, `LogosCheckbox`, `LogosComboBox`, `LogosIconButton`, `LogosPaginator`, `LogosSearchBar`, `LogosTabBar`, `LogosTable`, `LogosText`, `LogosTextField`, `LogosToolTip`, …). + - **Controls (not designed)** — placeholders with stable APIs but unstyled visuals (`LogosDialog`, `LogosDrawer`, `LogosScrollView`, `LogosSpinner`, `LogosTextArea`, `LogosSwitch`, …). You can ship with them; they'll get the polished look applied later without you having to change your QML. + + **Theme tokens** (use these instead of hex literals or magic font sizes): + + - `Theme.palette.*` — `background`, `backgroundSecondary`, `surface`, `text`, `textSecondary`, `border`, `primary`, `success`, `warning`, `error`, `info`, `hover`, `pressed`, … + - `Theme.spacing.*` — `tiny`, `small`, `medium`, `large`, `xlarge`, `xxlarge`, `radiusSmall`, `radiusMedium`, `radiusLarge` + - `Theme.typography.*` — `pageTitleText` (36), `titleText` (30), `panelTitleText` (24), `subtitleText` (16), `primaryText` (14), `secondaryText` (12); `weightRegular` / `weightMedium` / `weightBold`; `publicSans` + - `Logos.Icons.LogosIcons.*` — `arrowLeft`, `arrowRight`, `refresh`, `install`, `trash`, `more`, `search`, … + + **Feedback and contributions** + + Feel free to report bugs, file feature requests, or contribute components / theme tokens upstream — all welcome at `logos-co/logos-design-system`. The same fix lifts every consumer, so upstreaming is the most impactful path. If you can sketch the public API you'd like to use in a feature request, it makes review and implementation much faster. + + # ── Step 9: flake.nix ───────────────────────────────────────────────────────── + - title: "`flake.nix`" + step: true + text: | + The template already wires everything up. Update the description and point `calc_module` at your dependency: + steps: + - file: + path: flake.nix + language: nix + content: | + { + description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + + # Option A: point to a remote repo (for CI or when calc_module is published) + calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"; + + # Option B: point to your local checkout (for local development) + # calc_module.url = "path:../logos-calc-module"; + }; + + outputs = inputs@{ logos-module-builder, calc_module, ... }: + logos-module-builder.lib.mkLogosQmlModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + post_text: | + The `calc_module` input attribute name must match the dependency name in `metadata.json`. The URL can be: + + - **`github:`** — fetches from a remote GitHub repo. Use for CI or when `calc_module` is published. + - **`path:`** — points to a local directory on disk (e.g., `path:../logos-calc-module`). Use during local development. + + > **Important:** Whichever URL scheme you use, `calc_module` must be built with its shared library (`.so` on Linux, `.dylib` on macOS) present in `lib/`. If it's missing, the nix build will fail with linker errors. See [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library). + + `mkLogosQmlModule` handles everything: compiles the C++ backend (because `main` is set), bundles the QML view, generates LGX packages, and wires up `nix run`. + + # ── Step 10: Build and Run ──────────────────────────────────────────────────── + - title: "Build and Run" + step: true + text: | + First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): + steps: + - title: "Ensure `calc_module` is built" + run: "ls ../logos-calc-module/lib/libcalc.{ext}" + code_block: | + ls ../logos-calc-module/lib/libcalc.so # Linux + ls ../logos-calc-module/lib/libcalc.dylib # macOS + post_text: | + If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): + extra_run: + run: "cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd ../../logos-calc-ui-cpp" + code_block: | + cd ../logos-calc-module/lib + gcc -shared -fPIC -o libcalc.so libcalc.c # Linux + # gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS + cd ../../logos-calc-ui-cpp + + - title: "Lock and build" + text: | + Stage your files and lock the flake. Then build and run — choose the approach that matches your `flake.nix` setup. + run: "git add -A" + - run: "nix flake update" + - run: "git add flake.lock" + post_text: | + ```bash + # If flake.nix uses path:../logos-calc-module — just run directly: + nix run + + # If flake.nix uses github: — override to use your local checkout: + nix run --override-input calc_module path:../logos-calc-module + + # Or from the workspace: + ./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module + ``` + + - title: "Launch and verify the UI" + text: | + Launch the app and confirm the view loads with all of its controls. The backend runs in a separate `ui-host` process; clicking **Add** sends the call over Qt Remote Objects and the result comes back through `logos.watch()`. + ui_test: + launch: "nix run . --override-input calc_module path:../logos-calc-module" + setup: + - "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp" + qt_mcp: "result-mcp" + tests: + - name: "App window opens with title" + action: wait_for + texts: ["Logos Calculator (C++ backend)"] + timeout: 30000 + - name: "Operation buttons visible" + action: wait_for + texts: ["Add", "Multiply", "Factorial", "Fibonacci"] + timeout: 5000 + - name: "Enter operands" + action: set_text + find_by: "placeholderText" + find_value: "a" + value: "3" + - name: "Set second operand" + action: set_text + find_by: "placeholderText" + find_value: "b" + value: "5" + - name: "Click Add" + action: click + target: "Add" + - name: "Result of 3 + 5 shows 8" + action: wait_for + texts: ["8"] + timeout: 10000 + post_text: | + The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end. + + # ── Step 11: Live reloading (prose only) ────────────────────────────────────── + - title: "Live reloading QML with `DEV_QML_PATH`" + step: true + text: | + For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `src/qml/`): + + ```bash + DEV_QML_PATH=$PWD/src/qml nix run . + ``` + + When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits to `Main.qml` (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files. + + **Important — what this does *not* skip.** `nix run` always re-evaluates the flake and rehashes the source tree before launching. By default `src = ./.` includes every tracked file, including `*.qml` — so: + + - **Any source change, including QML edits, rebuilds the plugin** before the app starts. `DEV_QML_PATH` only kicks in *after* the build is done; it doesn't shortcut the rebuild itself. + - **C++ / `.rep` / `metadata.json` / CMake changes** rebuild as normal. + - The flake-evaluation overhead on each `nix run` is fixed and unavoidable while invoking through nix. + + For the absolute fastest loop (no nix involvement after the first build), do the build once and run the resulting binary directly: + + ```bash + # Build once — populates result/ in the nix store + nix build . + + # Subsequent runs: invoke the bundled standalone wrapper directly, + # skipping nix entirely. DEV_QML_PATH still redirects QML loading. + DEV_QML_PATH=$PWD/src/qml ./result/bin/run-logos-standalone-ui + ``` + + (Adjust the binary name to whatever `ls result/bin/` shows on your build.) + + > **Naming:** Only `DEV_QML_PATH` is honored by `logos-standalone-app`. See `repos/logos-standalone-app/README.md`. + + > This does not work with `logos-basecamp` — Basecamp loads QML plugins from its own install tree, so source edits are not picked up until you rebuild and reinstall the `.lgx`. + + # ── Step 12: How the Pieces Connect (prose only) ────────────────────────────── + - title: "How the Pieces Connect" + step: true + text: | + 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 + + # ── Step 13: UI Integration Tests ───────────────────────────────────────────── + - title: "UI Integration Tests" + step: true + text: | + Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework. Just create `.mjs` files in `tests/` and `logos-module-builder` auto-wires `nix build .#integration-test`. + + Tests connect to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots. + steps: + - title: "Create a test file" + text: | + Create `tests/ui-tests.mjs`: + file: + path: tests/ui-tests.mjs + language: javascript + content: | + 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(["Logos Calculator (C++ backend)"]); + }, + { timeout: 15000, interval: 500, description: "UI to load" }, + ); + }); + + test("calc_ui_cpp: operation buttons visible", async (app) => { + await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]); + }); + + run(); + + - title: "Run the tests" + run: "git add tests/" + - run: "nix build .#integration-test -L --override-input calc_module path:../logos-calc-module" + code_block: | + # Hermetic CI test + nix build .#integration-test -L + post_text: | + The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`. + + To run tests interactively (against an already-running app): + + ```bash + 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 (prose only) ────────────────────────── + - title: "Comparison: .rep Interface Patterns" + text: | + | 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), 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 (prose only) ─────────────────────────────────────────────────── + - title: "Next Steps" + text: | + - 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` + - **Use the Logos Design System** in your QML — see [Step 8](#step-8-use-the-logos-design-system-in-your-qml). Browse components in the storybook (`cd repos/logos-design-system && nix run`); file issues at `logos-co/logos-design-system`. + - See [logos-package-manager-ui](https://github.com/logos-co/logos-package-manager-ui) for a production example diff --git a/tests/tutorial-qml-ui-app.test.yaml b/tests/tutorial-qml-ui-app.test.yaml new file mode 100644 index 0000000..e4879b4 --- /dev/null +++ b/tests/tutorial-qml-ui-app.test.yaml @@ -0,0 +1,883 @@ +name: "Tutorial Part 2: Building a QML UI for Your Logos Module" +output: tutorial-qml-ui-app.md +project_name: logos-calc-ui +requires: + - tutorial-wrapping-c-library.test.yaml +release: "" + +intro: | + This is Part 2 of the Logos module tutorial series. In [Part 1](tutorial-wrapping-c-library.md) you wrapped a C library as a Logos core module. Now you'll build a **QML user interface** that calls that module — first isolated with `nix run`, then packaged and loaded into `logos-basecamp`. + +what_you_build: "A `calc_ui` QML plugin with input fields and buttons that call `calc_module` methods (add, multiply, factorial, fibonacci) through the Logos bridge." + +what_you_learn: + - How QML UI plugins work in the Logos platform + - "The `logos.callModule()` bridge that connects QML to core modules" + - The project structure and metadata for a QML plugin + - "How to package and install your UI into `logos-basecamp`" + +prerequisites: + - "Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`)" + - Nix with flakes enabled (same as Part 1) + - "Basic familiarity with QML (Qt's declarative UI language)" + +sections: + # ── How QML UI Plugins Work (prose only) ──────────────────────────────────── + - title: "How QML UI Plugins Work" + text: | + Before writing code, let's understand the architecture: + + ``` + +-------------------+ logos.callModule() +-------------------+ + | calc_ui | --------------------------> | calc_module | + | Main.qml (QML) | IPC (Qt Remote Objects) | C++ plugin | + +-------------------+ +-------------------+ + ^ ^ + └──────────────── loaded by ───────────────────────┘ + logos-basecamp / logos-standalone-app + ``` + + Key points: + + - **No compilation.** A QML plugin is just `.qml` files and a `metadata.json`. + - **Sandboxed.** No network access, no filesystem access outside the module directory. + - **The `logos` bridge** is injected by the host. Call core modules with `logos.callModule("module", "method", [args])`. + - **Entry point** is defined by the required `"view"` field in `metadata.json` (for this tutorial it is `Main.qml`). + + # ── Step 1: Scaffold ─────────────────────────────────────────────────────── + - title: "Scaffold" + step: true + text: | + Create a new directory and initialise it from the QML module template: + + `mkdir logos-calc-ui && cd logos-calc-ui` + steps: + - run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml" + post_text: | + > **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in [Step 4](#step-4-update-flakenix) to ensure reproducible builds. + + - run: "git init" + - run: "git add -A" + post_text: | + This gives you: + + ``` + logos-calc-ui/ + ├── flake.nix # Nix build + nix run support + ├── metadata.json # Plugin metadata + └── Main.qml # Your UI (starter template) + ``` + + # ── Step 2: Update metadata.json ─────────────────────────────────────────── + - title: "Update `metadata.json`" + step: true + text: | + Replace the template contents with your plugin's details. The template may generate an extra `nix` section — keep it as-is, it's used by the builder: + steps: + - file: + path: metadata.json + language: json + content: | + { + "name": "calc_ui", + "version": "1.0.0", + "description": "Calculator UI - QML frontend for the calc_module", + "type": "ui_qml", + "view": "Main.qml", + "dependencies": ["calc_module"], + "category": "tools", + "icon": "icons/calc.png", + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": [], + "extra_link_libraries": [] + } + } + } + post_text: | + Create the icon directory and add a placeholder icon. The icon is displayed in the `logos-basecamp` sidebar when the module is loaded: + + - run: "mkdir -p icons && echo 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=' | base64 -d > icons/calc.png" + code_block: | + mkdir -p icons + # Copy any PNG here — or generate a 64×64 placeholder: + echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png + post_text: | + The `view` field tells the host which QML file to load for the UI. The `dependencies` field tells the host to load `calc_module` before showing your UI. + + > **Naming convention:** Each entry in `dependencies` must match the `name` field in that module's own `metadata.json`. When adding a dependency as a flake input, the **input attribute name** must also match the dependency name — e.g., `calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"`. The URL can point to any repo, but the attribute name is how the builder resolves dependencies. + + # ── Step 3: Write Main.qml ───────────────────────────────────────────────── + - title: "Write `Main.qml`" + step: true + text: | + Replace the starter file with the calculator UI. This demonstrates two communication patterns: + + 1. **Direct calls** — `logos.callModule()` sends a request and returns the result immediately + 2. **Event-based** — `logos.callModule()` fires-and-forgets, the module emits an event, and QML receives it via `logos.onModuleEvent()` + steps: + - file: + path: Main.qml + language: qml + content: | + import QtQuick + import QtQuick.Controls + import QtQuick.Layouts + + Item { + id: root + + property string result: "" + property string errorText: "" + property string versionFromEvent: "" + + // ── Event subscription ──────────────────────────────────── + // Subscribe to "versionReady" events pushed from calc_module. + Component.onCompleted: { + if (typeof logos !== "undefined" && logos.onModuleEvent) + logos.onModuleEvent("calc_module", "versionReady") + } + + Connections { + target: typeof logos !== "undefined" ? logos : null + function onModuleEventReceived(moduleName, eventName, data) { + if (eventName === "versionReady") + root.versionFromEvent = data[0] + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 16 + + // ── Title ────────────────────────────────────────────── + Text { + text: "Logos Calculator" + font.pixelSize: 20 + font.weight: Font.DemiBold + color: "#ffffff" + Layout.alignment: Qt.AlignHCenter + } + + // ── Pattern 1: Direct call (request -> response) ────── + Text { + text: "Direct calls (logos.callModule -> returns result)" + color: "#8b949e" + font.pixelSize: 12 + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + TextField { + id: inputA + placeholderText: "a" + Layout.preferredWidth: 80 + validator: IntValidator {} + } + + TextField { + id: inputB + placeholderText: "b" + Layout.preferredWidth: 80 + validator: IntValidator {} + } + + Button { + text: "Add" + onClicked: callTwoOp("add", inputA.text, inputB.text) + } + + Button { + text: "Multiply" + onClicked: callTwoOp("multiply", inputA.text, inputB.text) + } + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + TextField { + id: inputN + placeholderText: "n" + Layout.preferredWidth: 80 + validator: IntValidator { bottom: 0 } + } + + Button { + text: "Factorial" + onClicked: callOneOp("factorial", inputN.text) + } + + Button { + text: "Fibonacci" + onClicked: callOneOp("fibonacci", inputN.text) + } + + Button { + text: "libcalc version" + onClicked: callModule("libVersion", []) + } + } + + // Direct call result + Rectangle { + Layout.fillWidth: true + height: 56 + color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a" + radius: 8 + + Text { + anchors.centerIn: parent + text: root.errorText.length > 0 ? root.errorText + : (root.result.length > 0 ? root.result : "Enter values and press a button") + color: root.errorText.length > 0 ? "#f85149" : "#56d364" + font.pixelSize: 15 + } + } + + // ── Pattern 2: Event-based (fire-and-forget -> event) ─ + Text { + text: "Event-based (fire-and-forget call -> result via event)" + color: "#8b949e" + font.pixelSize: 12 + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + Button { + text: "libcalc version (event)" + onClicked: { + if (typeof logos !== "undefined" && logos.callModule) + logos.callModule("calc_module", "libVersionNotify", []) + } + } + } + + // Event result + Rectangle { + Layout.fillWidth: true + height: 56 + color: "#1a1a2d" + radius: 8 + + Text { + anchors.centerIn: parent + text: root.versionFromEvent.length > 0 + ? ("Version (via event): " + root.versionFromEvent) + : "Press the event button — result arrives via event" + color: "#7ab8ff" + font.pixelSize: 15 + } + } + + Item { Layout.fillHeight: true } + } + + // ── Direct call helpers ─────────────────────────────────── + + function callModule(method, args) { + root.errorText = "" + root.result = "" + + if (typeof logos === "undefined" || !logos.callModule) { + root.errorText = "Logos bridge not available" + return + } + + root.result = String(logos.callModule("calc_module", method, args)) + } + + function callTwoOp(method, a, b) { + if (a === "" || b === "") { root.errorText = "Enter values for a and b"; return } + callModule(method, [parseInt(a), parseInt(b)]) + } + + function callOneOp(method, n) { + if (n === "") { root.errorText = "Enter a value for n"; return } + callModule(method, [parseInt(n)]) + } + } + post_text: | + The UI demonstrates two communication patterns: + + - **Green section (direct calls):** `logos.callModule("calc_module", "libVersion", [])` sends a request to `calc_module` and returns the result synchronously. Simple request/response. + + - **Blue section (event-based):** `logos.callModule("calc_module", "libVersionNotify", [])` calls the module but ignores the return value. Instead, the module emits a `"versionReady"` event via `eventResponse`, and the QML receives it through the `logos.onModuleEvent()` subscription set up in `Component.onCompleted`. + + The `logos` object is injected by the host at runtime. + + # ── Step 4: Update flake.nix ─────────────────────────────────────────────── + - title: "Update `flake.nix`" + step: true + text: | + The template already has everything wired up. Update the description and add `calc_module` as a dependency input: + steps: + - file: + path: flake.nix + language: nix + content: | + { + description = "Calculator QML UI Plugin for Logos - frontend for calc_module"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + + # Option A: point to a remote repo (for CI or when calc_module is published) + calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"; + + # Option B: point to your local checkout (for local development) + # calc_module.url = "path:../logos-calc-module"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosQmlModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + post_text: | + The input attribute name (`calc_module`) must match the dependency name in `metadata.json`. + + The `calc_module.url` can be either: + + - **`github:`** — fetches from a remote GitHub repo. Use this for CI or when `calc_module` has been published. + - **`path:`** — points to a local directory on disk. Use this during development when both repos live side by side (e.g., `path:../logos-calc-module`). + + > **Important:** Whichever URL scheme you use, `calc_module` must be built with its shared library (`.so` on Linux, `.dylib` on macOS) present in `lib/`. If the library is missing, the nix build will fail with linker errors. See [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library) for build instructions. + + `mkLogosQmlModule` handles everything — it stages QML files, metadata, and icons into a plugin directory, bundles all module dependencies (direct and transitive) from their LGX packages, and automatically wires up `apps.default` so `nix run .` launches the UI in a standalone window with all required backend modules self-contained. `flakeInputs = inputs` passes all inputs so that dependencies declared in `metadata.json` are resolved automatically. + + > **Tip:** Even if `flake.nix` uses a `github:` URL, you can override it at build time with `--override-input calc_module path:../logos-calc-module` to use your local checkout without editing `flake.nix`. This is covered in [Step 5.2](#52-full-functionality-with-modules). + + # ── Step 5: Test with nix run ────────────────────────────────────────────── + - title: "Test with `nix run`" + step: true + steps: + - title: "UI only (layout preview)" + run: "git add -A" + - run: "nix flake update" + - run: "git add flake.lock" + - ui_test: + launch: "nix run ." + setup: + - "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp" + qt_mcp: "result-mcp" + tests: + - name: "App window opens with title" + action: wait_for + texts: ["Logos Calculator"] + timeout: 15000 + - name: "Add button visible" + action: wait_for + texts: ["Add"] + timeout: 5000 + - name: "Multiply button visible" + action: wait_for + texts: ["Multiply"] + timeout: 5000 + - name: "Factorial button visible" + action: wait_for + texts: ["Factorial"] + timeout: 5000 + - name: "Fibonacci button visible" + action: wait_for + texts: ["Fibonacci"] + timeout: 5000 + post_text: | + The app opens immediately. No modules are loaded, so clicking buttons shows "Logos bridge not available" — but you can verify the layout and styling look correct. + + # ── Step 5b: Full functionality (with modules) ─────────────────────────── + # Requires ../logos-calc-module from Part 1. Use --phase modules to enable. + - title: "Full functionality (with modules)" + step: true + text: | + The standalone app automatically bundles and loads all module dependencies declared in `metadata.json`. To test with your local `calc_module` from Part 1, you first need to make sure it has been built and its shared library (`.so` on Linux, `.dylib` on macOS) is present. + steps: + - title: "Ensure `calc_module` is built" + text: | + Go back to your `logos-calc-module` directory and verify the shared library exists: + run: "ls ../logos-calc-module/lib/libcalc.{ext}" + code_block: | + ls ../logos-calc-module/lib/libcalc.so # Linux + ls ../logos-calc-module/lib/libcalc.dylib # macOS + post_text: | + If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): + extra_run: + run: "cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd ../../logos-calc-ui" + code_block: | + cd ../logos-calc-module/lib + gcc -shared -fPIC -o libcalc.so libcalc.c # Linux + # gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS + cd ../../logos-calc-ui + post_text: | + Also make sure the module itself builds successfully: + + - run: "cd ../logos-calc-module && git add -A && nix build && cd ../logos-calc-ui" + code_block: | + cd ../logos-calc-module + git add -A + nix build + cd ../logos-calc-ui + post_text: | + The `nix build` produces `result/lib/calc_module_plugin.so` (or `.dylib`), which is the compiled Qt plugin. The `lib/libcalc.so` (or `.dylib`) inside the source tree is the underlying C library that gets linked in during the build. + + - title: "Option A: Use `--override-input` (quick, no flake.nix edits)" + text: | + If your `flake.nix` points to a `github:` URL, you can override it at build time to use your local checkout: + ui_test: + launch: "nix run . --override-input calc_module path:../logos-calc-module" + setup: + - "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp" + qt_mcp: "result-mcp" + tests: + - name: "App title visible" + action: wait_for + texts: ["Logos Calculator"] + timeout: 15000 + - name: "All operation buttons visible" + action: wait_for + texts: ["Add", "Multiply", "Factorial", "Fibonacci"] + timeout: 5000 + post_text: | + This tells nix to resolve the `calc_module` flake input from your local directory instead of from the remote URL. Any changes you've made to `calc_module` locally (including the built `.so`/`.dylib` in `lib/`) are picked up immediately — no need to push to GitHub first. + + - title: "Option B: Set `path:` in `flake.nix` (persistent local development)" + text: | + If you're iterating on both repos side by side, you can point the flake input directly to your local `calc_module` checkout. In `flake.nix`, change: + + ```nix + # From remote: + calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"; + # To local: + calc_module.url = "path:../logos-calc-module"; + ``` + + Then run normally without overrides: + + ```bash + nix flake update # re-lock with the local path + git add flake.lock + nix run . + ``` + + This is convenient when you always want to build against the local copy. Switch back to `github:` when you're ready to pin to a published version. + + - title: "Option C: Pin to the remote repo" + text: | + If `calc_module` has been pushed to the remote repository (with the `.so`/`.dylib` committed in `lib/`), the `github:` URL in `flake.nix` already points to it. A plain `nix run .` fetches and builds `calc_module` from the remote: + + ```bash + nix run . + ``` + + > **Important:** The remote repo must contain the built `.so`/`.dylib` in `lib/` (or the nix build must produce it). If the shared library is missing, the `calc_module` build will fail with linker errors. + + Whichever option you choose, clicking **Add**, **Multiply**, **Factorial**, or **Fibonacci** now calls the real module. + + # ── Step 6: Using the Logos Design System ────────────────────────────────── + - title: "Using the Logos Design System" + step: true + text: | + `logos-basecamp` (and `logos-standalone-app`) has `logos-design-system` on its QML import path. Use its themed components directly — no extra setup in your module. + + ```qml + import Logos.Theme + import Logos.Controls + import Logos.Icons // optional: shared icon assets (LogosIcons.search, .install, .refresh, …) + ``` + + ### Why use it + + Hardcoding colors, font sizes, or rolling your own button means your module looks subtly different from every other module in basecamp, drifts as the design evolves, and re-implements work the design system already does. Using `Logos.Controls` + `Theme` tokens means your module gets the polished look automatically as the design system is updated — no churn on your side. + + ### What's available + + Run the storybook to browse every component interactively with live property editors: + + ```bash + cd repos/logos-design-system + nix run # or: ws run logos-design-system + ``` + + The sidebar splits components into two sections: + + - **Controls** — *designed per Figma, production-ready*. Use these directly. Examples: `LogosButton`, `LogosBadge`, `LogosCheckbox`, `LogosComboBox`, `LogosIconButton`, `LogosPaginator`, `LogosSearchBar`, `LogosTabBar` / `LogosTabButton`, `LogosTable` / `LogosTableColumn`, `LogosText`, `LogosTextField`, `LogosToolTip`. + - **Controls (not designed)** — *placeholders with stable APIs but unstyled visuals*. Functional, you can ship with them, and you'll inherit the polished look automatically when each gets its design pass — no QML changes on your side. Examples: `LogosDialog`, `LogosDrawer`, `LogosFrame`, `LogosGroupBox`, `LogosItemDelegate`, `LogosMenu`, `LogosProgressBar`, `LogosRadioButton`, `LogosScrollBar` / `LogosScrollView`, `LogosSlider`, `LogosSpinBox`, `LogosSpinner`, `LogosStackView`, `LogosSwitch`, `LogosTextArea`, `LogosToolBar`. + + Each storybook page exposes a `designed: true/false` flag if you want to see at a glance which it is. + + ### Replace raw Qt controls with Logos equivalents + + ```qml + // Instead of Button: + LogosButton { + text: qsTr("Add") + onClicked: callTwoOp("add", inputA.text, inputB.text) + } + + // Instead of TextField: + LogosTextField { + id: inputA + placeholderText: qsTr("a") + } + + // Use theme colors instead of hardcoded hex values: + Rectangle { + color: Theme.palette.backgroundSecondary + Text { color: Theme.palette.text } + } + ``` + + ### Theme tokens — avoid hardcoding magic numbers + + ```qml + // Palette — Theme.palette.* + // background, backgroundSecondary, backgroundMuted, surface, + // text, textSecondary, textMuted, textTertiary, + // border, borderSubtle, primary, success, warning, error, info, hover, pressed, … + + // Spacing — Theme.spacing.* + // tiny, small, medium, large, xlarge, xxlarge, + // radiusSmall, radiusMedium, radiusLarge + + // Typography — Theme.typography.* + // pageTitleText (36), titleText (30), panelTitleText (24), + // subtitleText (16), primaryText (14), secondaryText (12), + // weightRegular (400), weightMedium (500), weightBold (700), + // publicSans (font family) + + // Icons — Logos.Icons.LogosIcons.* + // arrowLeft, arrowRight, refresh, install, trash, more, search, … + ``` + + If a token you need is missing, file a feature issue — don't inline a hex literal or a magic number; that just stores up drift. + + ### Feedback and contributions + + Feel free to report bugs, file feature requests, or contribute components / theme tokens upstream — all welcome at `logos-co/logos-design-system`. The same fix lifts every consumer, so upstreaming is the most impactful path. If you can sketch the public API you'd like to use in a feature request, it makes review and implementation much faster. + + # ── Step 7: Load in logos-basecamp ───────────────────────────────────────── + - title: "Load in `logos-basecamp`" + step: true + steps: + - title: "Bundle as LGX packages" + text: | + Create `.lgx` packages for both dev and portable variants. Use `--out-link` to avoid overwriting the `result` symlink: + run: "cd ../logos-calc-module && nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable && cd ../logos-calc-ui" + code_block: | + # Package calc_module (from Part 1) + cd ../logos-calc-module + nix build '.#lgx' --out-link result-lgx + nix build '.#lgx-portable' --out-link result-lgx-portable + + # Package the QML UI plugin + cd ../logos-calc-ui + nix build '.#lgx' --out-link result-lgx + nix build '.#lgx-portable' --out-link result-lgx-portable + extra_run: + run: "nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable" + post_text: | + > For more bundling options (standalone bundler syntax, cross-platform packaging), see the [Developer Guide — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx). + + - title: "Build and run logos-basecamp" + text: | + Build logos-basecamp, launch it once to preinstall its bundled modules, then install your modules. + + > **Note:** `logos-basecamp` does not accept `--modules-dir` or `--ui-plugins-dir` CLI flags. It manages its own data directory and preinstalls bundled modules (main_ui, package_manager, etc.) on first launch. + run: "nix build 'github:logos-co/logos-basecamp{release}' -o basecamp-result" + post_text: | + ```bash + # Launch once to preinstall bundled modules, then close it + ./basecamp-result/bin/logos-basecamp + ``` + + Basecamp creates its data directory on first launch. To find where it is, check the log output for `plugins directory` or look for the directory that contains `modules/` and `plugins/` subdirectories: + + ```bash + # macOS (typical path, may vary): + ls ~/Library/Application\ Support/Logos/ + + # Linux (typical path, may vary): + ls ~/.local/share/Logos/ + ``` + + The dev build directory is named `LogosBasecampDev` (portable builds use `LogosBasecamp`). + + - title: "Install modules with lgpm" + text: | + Install your modules using `lgpm`. First, set `BASECAMP_DIR` to your platform's path: + + ```bash + # macOS: + BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecampDev" + + # Linux: + BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev" + ``` + run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm" + post_text: | + ```bash + # Install core module + ./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \ + install --file ../logos-calc-module/result-lgx/*.lgx + + # Install UI plugin + ./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \ + install --file result-lgx/*.lgx + + # Launch basecamp -- your modules appear alongside the built-in ones + ./basecamp-result/bin/logos-basecamp + ``` + + - title: "Portable basecamp build (optional)" + text: | + The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix: + run: "nix build 'github:logos-co/logos-basecamp{release}#bin-bundle-dir' -o basecamp-portable" + post_text: | + ```bash + # Launch once to preinstall bundled modules + ./basecamp-portable/bin/logos-basecamp + ``` + + The portable build uses a different data directory (`LogosBasecamp` instead of `LogosBasecampDev`). Set `BASECAMP_DIR` to your platform's path: + + ```bash + # macOS: + BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecamp" + + # Linux: + BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecamp" + ``` + + Install your modules using the **portable** `.lgx` variants: + + ```bash + # Install core module (use portable variant) + ./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \ + install --file ../logos-calc-module/result-lgx-portable/*.lgx + + # Install UI plugin (use portable variant) + ./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \ + install --file result-lgx-portable/*.lgx + + # Launch + ./basecamp-portable/bin/logos-basecamp + ``` + + > **Important:** Portable basecamp requires portable `.lgx` variants (`result-lgx-portable`), and the dev build requires dev variants (`result-lgx`). Mixing them will cause loading failures. + + - title: "Install via logos-basecamp UI" + text: | + Instead of using `lgpm` on the command line, you can install modules through the basecamp UI: + + 1. Launch `logos-basecamp` + 2. Go to **Package Manager** + 3. Click **Install from file** + 4. Select `../logos-calc-module/result-lgx/*.lgx` — installs `calc_module` + 5. Repeat for `result-lgx/*.lgx` — installs `calc_ui` + + The "Calculator UI" tab appears in the sidebar. Clicking it loads your `Main.qml`. + + - title: "Live reloading with `logos-standalone-app`" + text: | + For QML iteration, set `DEV_QML_PATH` to the directory that contains your view entry file (the basename from `metadata.json` `view` must exist under that directory). For this tutorial's layout (`view`: `Main.qml` at repo root): + + ```bash + DEV_QML_PATH=$PWD nix run . + ``` + + When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits in `Main.qml` are picked up on the next relaunch without you having to manually re-sync files. + + **Important — what this does *not* skip.** `nix run` always re-evaluates the flake and rehashes the source tree before launching. By default `src = ./.` includes every tracked file, including `*.qml` — so: + + - **Any source change, including QML edits, rebuilds the plugin** before the app starts. `DEV_QML_PATH` only kicks in *after* the build is done; it doesn't shortcut the rebuild itself. + - **C++ / `.rep` / `metadata.json` / CMake changes** rebuild as normal. + - The flake-evaluation overhead on each `nix run` is fixed and unavoidable while invoking through nix. + + For the absolute fastest loop (no nix involvement after the first build), do the build once and run the resulting binary directly: + + ```bash + # Build once — populates result/ in the nix store + nix build . + + # Subsequent runs: invoke the bundled standalone wrapper directly, + # skipping nix entirely. DEV_QML_PATH still redirects QML loading. + DEV_QML_PATH=$PWD ./result/bin/run-logos-standalone-ui + ``` + + (Adjust the binary name to whatever `ls result/bin/` shows on your build.) + + > **Naming:** Only `DEV_QML_PATH` is honored. See `repos/logos-standalone-app/README.md`. + + > This does not work with `logos-basecamp`. Basecamp loads QML plugins from its own data directory, so changes to your source files are not reflected until you rebuild and reinstall the `.lgx` package. + + - title: "Testing without any runtime" + text: | + You can open `Main.qml` in any QML viewer (e.g., `qml` from Qt) to test the layout. + + #### Install + + You'll need to have QML and any included modules (`QtQuick` and submodules `Controls`, and `Layout`). + + Eg, to simply install on linux (apt package manager): + + ```bash + sudo apt install qml-qt6 qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts + ``` + + #### Viewing the QML + + The `logos` bridge won't be available, so clicking buttons will show "Logos bridge not available" -- but you can verify the layout and styling work correctly. + + ```bash + # If you have Qt and included modules installed + # macOS: + qml Main.qml + + # Linux: + qml6 Main.qml + ``` + + # ── Step 8: UI Integration Tests ───────────────────────────────────────── + - title: "UI Integration Tests" + step: true + text: | + You can add automated UI tests that verify your QML plugin renders correctly. The test infrastructure is built into `logos-module-builder` — just add `.mjs` test files to a `tests/` directory and you get `nix build .#integration-test` for free. + + Tests use the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework, which connects to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots. + steps: + - title: "Create a test file" + text: | + Create `tests/ui-tests.mjs`: + file: + path: tests/ui-tests.mjs + language: javascript + content: | + 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: loads and shows title", async (app) => { + await app.waitFor( + async () => { + await app.expectTexts(["Logos Calculator"]); + }, + { timeout: 15000, interval: 500, description: "calc_ui to load" }, + ); + }); + + test("calc_ui: add button visible", async (app) => { + await app.expectTexts(["Add"]); + }); + + test("calc_ui: click add shows validation", async (app) => { + await app.click("Add"); + await app.waitFor( + async () => { + await app.expectTexts(["Enter values for a and b"]); + }, + { timeout: 5000, interval: 500, description: "validation message to appear" }, + ); + }); + + run(); + + - title: "Run the tests" + run: "git add tests/" + - run: "nix build .#integration-test -L --override-input calc_module path:../logos-calc-module" + code_block: | + nix build .#integration-test -L + post_text: | + The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`. + + You can have multiple test files (e.g., `tests/smoke.mjs`, `tests/interactions.mjs`) — they are all discovered and run automatically. + + To run tests interactively (against an already-running app): + + ```bash + nix build .#test-framework -o result-mcp + nix run . # start the app with inspector on :3768 + node tests/ui-tests.mjs # in another terminal + ``` + + # ── Known Limitations (prose only) ───────────────────────────────────────── + - title: "Known Limitations" + text: | + ### QML-to-C++ type coercion + + When calling C++ module methods from QML via `logos.callModule()`, arguments are passed through IPC as `QVariant` values. The runtime automatically coerces mismatched types to match the target method signature — for example, a `double` sent from QML will be converted to `int` if the method expects `int`, and numeric strings will be converted to their numeric types. + + This means you can define methods with their natural parameter types (`int`, `bool`, `double`, etc.) and calls from QML will work without manual conversion: + + ```cpp + // This works — the runtime coerces arguments automatically + Q_INVOKABLE int add(int a, int b) { return a + b; } + ``` + + > **Note:** Type coercion uses `QVariant::convert()`, which rounds (not truncates) when converting `double` to `int` — e.g., `3.7` becomes `4`. + + ### QML changes not appearing after rebuild + + Qt caches compiled QML on disk. If you update your `Main.qml`, rebuild and reinstall the `.lgx`, but the old UI still appears, the cache is stale. Fix by disabling the cache before launching: + + ```bash + QML_DISABLE_DISK_CACHE=1 ./basecamp-result/bin/logos-basecamp + ``` + + ### UI module not loading or basecamp behaving unexpectedly + + When switching between portable and dev builds of basecamp, or running multiple basecamp instances, the data directory can get into a bad state (stale modules, mixed variants, corrupted preinstall). Clear it and let basecamp re-preinstall on next launch: + + ```bash + # Remove basecamp's data directory + # macOS: + rm -rf ~/Library/Application\ Support/Logos/LogosBasecampDev + + # Linux: + rm -rf ~/.local/share/Logos/LogosBasecampDev + + # Relaunch — basecamp will re-preinstall its bundled modules + ./basecamp-result/bin/logos-basecamp + ``` + + Then reinstall your custom modules. + + # ── Recap (prose only) ───────────────────────────────────────────────────── + - title: "Recap" + text: | + | | Core Module (Part 1) | QML UI Plugin (Part 2) | + | ------------------- | ----------------------------------------------- | ----------------------------- | + | Language | C++ | QML / JavaScript | + | Files | `.cpp`, `.h`, `CMakeLists.txt`, `metadata.json` | `Main.qml`, `metadata.json` | + | Compilation | Yes (CMake → `.so`) | No (file copy) | + | `metadata.type` | `"core"` | `"ui_qml"` | + | Test command | `logoscore -m ./result/lib -l calc_module` | `nix run .` | + | Calls other modules | Via `LogosAPI*` (C++) | Via `logos.callModule()` (JS) | + + # ── What's Next (prose only) ─────────────────────────────────────────────── + - title: "What's Next" + text: | + - **Add more methods** to `calc_module` and call them from QML + - **Use Logos Design System** styled components for consistent look and feel + - **Build a C++ UI module** for cases where QML sandboxing is too restrictive — see [Developer Guide](logos-developer-guide.md), Section 7.2 diff --git a/tests/tutorial-wrapping-c-library.test.yaml b/tests/tutorial-wrapping-c-library.test.yaml new file mode 100644 index 0000000..a8301a8 --- /dev/null +++ b/tests/tutorial-wrapping-c-library.test.yaml @@ -0,0 +1,1054 @@ +name: "Tutorial: Wrapping a C Library as a Logos Module" +output: tutorial-wrapping-c-library.md +project_name: logos-calc-module +release: "" + +intro: | + This tutorial walks you through wrapping a C shared library (`.so` on Linux, `.dylib` on macOS) as a Logos module. By the end, you will have a module that compiles, loads, and responds to method calls via `logoscore`. + +what_you_build: "A `calc_module` that wraps a tiny C calculator library (`libcalc`), exposing arithmetic functions to the Logos platform." + +what_you_learn: + - How a Logos module wraps a C library + - The role of each file in the module project + - How to build, inspect, and test your module + - How `logoscore` discovers, loads, and calls your module + +prerequisites: + - | + **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes: + + ```bash + mkdir -p ~/.config/nix + echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf + ``` + + Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"` + - "**A C compiler** (gcc or clang) for building the C library. Only needed if you're building the `.so`/`.dylib` yourself rather than using a pre-built library." + - "Basic familiarity with C and C++." + +sections: + # ── Step 1: Scaffold ────────────────────────────────────────────────────── + - title: "Scaffold the Module Project" + step: true + text: | + Before writing any C code, scaffold the Logos module project using the official template. This gives you the correct `flake.nix`, `metadata.json`, directory structure, and build configuration out of the box. + steps: + - title: "Create the project using the module builder template" + text: | + For a module that wraps an external C library: + + `mkdir logos-calc-module && cd logos-calc-module` + run: "nix flake init -t github:logos-co/logos-module-builder{release}#with-external-lib" + code_block: | + nix flake init -t github:logos-co/logos-module-builder{release}#with-external-lib + + # Or for a plain module (no external library): + # nix flake init -t github:logos-co/logos-module-builder{release} + post_text: | + This generates the skeleton files (`flake.nix`, `metadata.json`, `CMakeLists.txt`, etc.) pre-configured for the logos-module-builder. You then customize them for your specific library. + + > **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in the flake.nix step below to ensure reproducible builds. + + > **Alternative approach:** You can also create the C library as a separate project, build it there, then copy the resulting `.so`/`.dylib` and header files into the module's `lib/` directory. This can be cleaner for larger libraries with their own build systems. + + # ── Step 2: Write the C library ──────────────────────────────────────────── + - title: "Write the C Library" + step: true + text: | + Create the C library that your module will wrap. Place the header and implementation in the `lib/` directory. + steps: + - title: "Create the lib directory" + run: "mkdir -p lib" + + - title: "Write the C header" + text: "Create `lib/libcalc.h`:" + file: + path: lib/libcalc.h + language: c + content: | + #ifndef LIBCALC_H + #define LIBCALC_H + + #ifdef __cplusplus + extern "C" { + #endif + + /** Add two integers. */ + int calc_add(int a, int b); + + /** Multiply two integers. */ + int calc_multiply(int a, int b); + + /** Compute factorial of n (n must be >= 0). Returns -1 on error. */ + int calc_factorial(int n); + + /** Compute the nth Fibonacci number (n must be >= 0). Returns -1 on error. */ + int calc_fibonacci(int n); + + /** Return the library version string. Caller must NOT free. */ + const char* calc_version(void); + + #ifdef __cplusplus + } + #endif + + #endif /* LIBCALC_H */ + post_text: | + The `extern "C"` block is essential — it prevents C++ name mangling so the Logos module can find the symbols. + + - title: "Write the C implementation" + text: "Create `lib/libcalc.c`:" + file: + path: lib/libcalc.c + language: c + content: | + #include "libcalc.h" + + int calc_add(int a, int b) + { + return a + b; + } + + int calc_multiply(int a, int b) + { + return a * b; + } + + int calc_factorial(int n) + { + if (n < 0) return -1; + if (n <= 1) return 1; + int result = 1; + for (int i = 2; i <= n; i++) { + result *= i; + } + return result; + } + + int calc_fibonacci(int n) + { + if (n < 0) return -1; + if (n == 0) return 0; + if (n == 1) return 1; + int a = 0, b = 1; + for (int i = 2; i <= n; i++) { + int tmp = a + b; + a = b; + b = tmp; + } + return b; + } + + const char* calc_version(void) + { + return "1.0.0"; + } + + - title: "Build the shared library" + run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .." + code_block: | + cd lib + + # Linux + gcc -shared -fPIC -o libcalc.so libcalc.c + + # macOS + # gcc -shared -fPIC -o libcalc.dylib libcalc.c + + cd .. + post_text: "Verify the symbols are exported:" + extra_run: + run: "nm -gU lib/libcalc.{ext} | grep calc" + code_block: | + # Linux + nm -D lib/libcalc.so | grep calc + + # macOS + # nm -gU lib/libcalc.dylib | grep calc + post_text: | + You should see each symbol marked with `T` (text/code section). Addresses will vary: + + ``` + 0000000000001139 T calc_add + 0000000000001179 T calc_factorial + 00000000000011f5 T calc_fibonacci + 0000000000001159 T calc_multiply + 0000000000001299 T calc_version + ``` + + > **Wrapping a third-party library?** If you're wrapping an existing library (e.g., from a system package or a GitHub repo), you don't need to write the C code — just place the pre-built `.so`/`.dylib` and its header file in `lib/`. + + # ── Step 3: Configure the Logos Module ────────────────────────────────────── + - title: "Configure the Logos Module" + step: true + text: | + The template generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**. + + After editing, your project should look like this: + + | File | What to change | + | ---------------------- | ----------------------------------------------------------------- | + | `metadata.json` | Module name, description, library name, include dirs | + | `CMakeLists.txt` | Project name, module name, source filenames, library name | + | `flake.nix` | Description (and dependency inputs if needed) | + | `src/*.h`, `src/*.cpp` | Rename files, replace class/method names, add your wrapping logic | + + ``` + logos-calc-module/ + ├── flake.nix # Nix build configuration (~10 lines) + ├── metadata.json # Module metadata, build settings, and runtime config + ├── CMakeLists.txt # CMake build file + ├── lib/ + │ ├── libcalc.h # C library header + │ └── libcalc.c # C library source (compiled by CMake) + └── src/ + ├── calc_module_interface.h # Interface declaration + ├── calc_module_plugin.h # Plugin header + └── calc_module_plugin.cpp # Plugin implementation (wrapping logic) + ``` + steps: + - title: "`metadata.json` — Module Configuration" + text: | + > **Edit:** Change `name`, `description`, `main`, `nix.external_libraries[].name`, and `nix.cmake.extra_include_dirs` to match your module and library. + + This is the single source of truth for your module. It is embedded into the plugin binary by Qt's `Q_PLUGIN_METADATA` macro (for runtime metadata), read by `logos-module-builder` to configure the Nix build, used by CMake to resolve external dependencies and link libraries (via the `nix` section), and used by `nix-bundle-lgx` to generate the LGX manifest. + file: + path: metadata.json + language: json + content: | + { + "name": "calc_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "Calculator module wrapping libcalc C library", + "main": "calc_module_plugin", + "dependencies": [], + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [ + { + "name": "calc", + "vendor_path": "lib" + } + ], + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": ["lib"], + "extra_link_libraries": [] + } + } + } + post_text: | + **Key fields explained:** + + | Field | What it does | + | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `name` | Module name — must be a valid C identifier (used in filenames, method calls) | + | `nix.external_libraries` | Declares C/C++ libraries vendored in the repo. Each entry has a `name` (used for the Nix derivation and CMake target) and `vendor_path` (directory containing the source). The build system compiles the library and makes it available as a CMake target | + | `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "libcalc.h"` | + + - title: "`CMakeLists.txt` — Build File" + text: | + > **Edit:** Change `project()` name, `NAME`, `SOURCES` filenames, and `EXTERNAL_LIBS` to match your module and library. + file: + path: CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(CalcModulePlugin LANGUAGES CXX) + + # Include the Logos Module CMake helper (provided by logos-module-builder) + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) + else() + message(FATAL_ERROR "LogosModule.cmake not found") + endif() + + # Define the module with its external library dependency + logos_module( + NAME calc_module + SOURCES + src/calc_module_interface.h + src/calc_module_plugin.h + src/calc_module_plugin.cpp + EXTERNAL_LIBS + calc + ) + post_text: | + The template generates this with default names (e.g., `external_lib`). You **must** update: + + - **`project()`** — rename to match your module (e.g., `CalcModulePlugin`) + - **`NAME`** — your module name (must match `name` in `metadata.json`, e.g., `calc_module`) + - **`SOURCES`** — your renamed source files + - **`EXTERNAL_LIBS`** — names of external libraries to link (must match `nix.external_libraries[].name` in `metadata.json`) + + The `if/elseif/else` block above it is boilerplate — don't change it. + + > **Common mistake:** If `NAME` doesn't match `name` in `metadata.json`, the build will succeed but the install phase will fail because it looks for `_plugin.dylib` based on `metadata.json`. + + **How `EXTERNAL_LIBS calc` works:** The `logos_module()` CMake function searches `lib/` for `libcalc.so` (Linux) or `libcalc.dylib` (macOS), links it to your plugin, and sets up RPATH so the library is found at runtime. + + - title: "`flake.nix` — Nix Build Config" + text: | + Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source. + file: + path: flake.nix + language: nix + content: | + { + description = "Calculator module - wraps libcalc C library for Logos"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + post_text: | + That's it — `mkLogosModule` handles all the Nix complexity (fetching Qt, the SDK, the code generator, setting up include paths, etc.). Note that `configFile` points to `metadata.json` (the single source of truth) and `flakeInputs = inputs` passes all flake inputs to the builder so that dependencies declared in `metadata.json` are resolved automatically. + + > **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`. + + - title: "`src/calc_module_interface.h` — Interface Declaration" + text: | + This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK). Every method you want callable by other modules must be `Q_INVOKABLE` and `virtual`. + file: + path: src/calc_module_interface.h + language: cpp + content: | + #ifndef CALC_MODULE_INTERFACE_H + #define CALC_MODULE_INTERFACE_H + + #include + #include + #include "interface.h" + + class CalcModuleInterface : public PluginInterface + { + public: + virtual ~CalcModuleInterface() = default; + + Q_INVOKABLE virtual int add(int a, int b) = 0; + Q_INVOKABLE virtual int multiply(int a, int b) = 0; + Q_INVOKABLE virtual int factorial(int n) = 0; + Q_INVOKABLE virtual int fibonacci(int n) = 0; + Q_INVOKABLE virtual QString libVersion() = 0; + }; + + #define CalcModuleInterface_iid "org.logos.CalcModuleInterface" + Q_DECLARE_INTERFACE(CalcModuleInterface, CalcModuleInterface_iid) + + #endif // CALC_MODULE_INTERFACE_H + post_text: | + **Rules for the interface:** + + - Every method you want callable by other modules must be `Q_INVOKABLE` and `virtual` + - Supported parameter/return types: `int`, `bool`, `QString`, `QByteArray`, `QVariant`, `QJsonArray`, `QStringList`, `LogosResult` + - The interface ID string (e.g., `"org.logos.CalcModuleInterface"`) must be unique across all modules + + - title: "`src/calc_module_plugin.h` — Plugin Header" + text: | + This is the actual plugin class. It inherits from both `QObject` (for Qt's meta-object system) and your interface. + file: + path: src/calc_module_plugin.h + language: cpp + content: | + #ifndef CALC_MODULE_PLUGIN_H + #define CALC_MODULE_PLUGIN_H + + #include + #include + #include "calc_module_interface.h" + + // Include the C library header + #include "lib/libcalc.h" + + class LogosAPI; + + class CalcModulePlugin : public QObject, public CalcModuleInterface + { + Q_OBJECT + Q_PLUGIN_METADATA(IID CalcModuleInterface_iid FILE "metadata.json") + Q_INTERFACES(CalcModuleInterface PluginInterface) + + public: + explicit CalcModulePlugin(QObject* parent = nullptr); + ~CalcModulePlugin() override; + + // PluginInterface + QString name() const override { return "calc_module"; } + QString version() const override { return "1.0.0"; } + + Q_INVOKABLE void initLogos(LogosAPI* api); + + // CalcModuleInterface + Q_INVOKABLE int add(int a, int b) override; + Q_INVOKABLE int multiply(int a, int b) override; + Q_INVOKABLE int factorial(int n) override; + Q_INVOKABLE int fibonacci(int n) override; + Q_INVOKABLE QString libVersion() override; + Q_INVOKABLE void libVersionNotify(); + + signals: + void eventResponse(const QString& eventName, const QVariantList& args); + + }; + + #endif // CALC_MODULE_PLUGIN_H + post_text: | + **Critical details:** + + - `Q_PLUGIN_METADATA(IID ... FILE "metadata.json")` — embeds the metadata into the binary + - `Q_INTERFACES(CalcModuleInterface PluginInterface)` — registers both interfaces with Qt's plugin system + - `initLogos` must be `Q_INVOKABLE` but **not** `override` — the base class `PluginInterface` does not declare it as virtual; the Logos host calls it reflectively via `QMetaObject::invokeMethod` + - `eventResponse` signal is required for event forwarding between modules. Emit it to push data to subscribers (e.g., QML UIs listening via `logos.onModuleEvent()`) + - `name()` must return the same string as the `name` field in `metadata.json` + - **No `m_logosAPI` member variable** — the `LogosAPI*` pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below. + + - title: "`src/calc_module_plugin.cpp` — Plugin Implementation" + text: | + This is where the wrapping happens. Each method calls the corresponding C function. + file: + path: src/calc_module_plugin.cpp + language: cpp + content: | + #include "calc_module_plugin.h" + #include "logos_api.h" + #include + + CalcModulePlugin::CalcModulePlugin(QObject* parent) + : QObject(parent) + { + qDebug() << "CalcModulePlugin: created"; + } + + CalcModulePlugin::~CalcModulePlugin() + { + qDebug() << "CalcModulePlugin: destroyed"; + } + + void CalcModulePlugin::initLogos(LogosAPI* api) + { + logosAPI = api; + qDebug() << "CalcModulePlugin: LogosAPI initialized"; + } + + int CalcModulePlugin::add(int a, int b) + { + int result = calc_add(a, b); + qDebug() << "CalcModulePlugin::add" << a << "+" << b << "=" << result; + return result; + } + + int CalcModulePlugin::multiply(int a, int b) + { + int result = calc_multiply(a, b); + qDebug() << "CalcModulePlugin::multiply" << a << "*" << b << "=" << result; + return result; + } + + int CalcModulePlugin::factorial(int n) + { + int result = calc_factorial(n); + qDebug() << "CalcModulePlugin::factorial" << n << "! =" << result; + return result; + } + + int CalcModulePlugin::fibonacci(int n) + { + int result = calc_fibonacci(n); + qDebug() << "CalcModulePlugin::fibonacci fib(" << n << ") =" << result; + return result; + } + + QString CalcModulePlugin::libVersion() + { + const char* ver = calc_version(); + QString result = QString::fromUtf8(ver); + qDebug() << "CalcModulePlugin::libVersion" << result; + return result; + } + + void CalcModulePlugin::libVersionNotify() + { + const char* ver = calc_version(); + QString result = QString::fromUtf8(ver); + qDebug() << "CalcModulePlugin::libVersionNotify" << result; + emit eventResponse("versionReady", {result}); + } + post_text: | + **The wrapping pattern** is always the same: + + 1. Call the C function with the arguments + 2. Convert the C result to a Qt type if needed (e.g., `const char*` → `QString`) + 3. Return the Qt type + + # ── Step 4: Build the Module ──────────────────────────────────────────────── + - title: "Build the Module" + step: true + steps: + - title: "Initialize the Git repo" + text: | + Nix flakes require a git repository. + + Before staging files, create a `.gitignore` to exclude build artifacts: + file: + path: .gitignore + language: text + content: | + # Nix build output + result + result-* + + # CMake build directory + build/ + + - text: "Then initialise the repo:" + run: "git init" + - run: "git add -A" + - run: "nix flake update" + - run: "git add flake.lock" + + - title: "Build the plugin library" + text: | + Build just the plugin library (`.so` / `.dylib`): + run: "nix build '.#lib'" + post_text: | + > **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character. + + The first build takes a while (5–15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching. + + - title: "Build the full package" + text: "Build everything (library + generated SDK headers):" + run: "nix build" + + - title: "Inspect the output" + run: "ls -la result/lib/" + post_text: | + You should see two files (extensions depend on your platform): + + ``` + # Linux + calc_module_plugin.so # Your Logos module plugin + libcalc.so # The C library (copied alongside) + + # macOS + calc_module_plugin.dylib + libcalc.dylib + ``` + + Both library files are placed together so the plugin can find the C library at runtime via RPATH. + + - check_file: "result/lib/calc_module_plugin.{ext}" + + # ── Step 5: Inspect the Module ────────────────────────────────────────────── + - title: "Inspect the Module" + step: true + text: | + Use the `lm` CLI tool (from `logos-module`) to inspect the compiled module binary. + steps: + - title: "Build the `lm` tool" + text: | + The `lm` CLI inspects compiled module binaries. Build it from the `logos-module` repo: + run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm" + + - title: "View metadata" + run: "./lm/bin/lm metadata result/lib/calc_module_plugin.{ext}" + code_block: | + # Linux + ./lm/bin/lm metadata result/lib/calc_module_plugin.so + + # macOS + ./lm/bin/lm metadata result/lib/calc_module_plugin.dylib + expect_contains: + - "Name: calc_module" + - "Version: 1.0.0" + - "Type: core" + post_text: | + Output: + + ``` + Plugin Metadata: + ================ + Name: calc_module + Version: 1.0.0 + Description: Calculator module wrapping libcalc C library + Author: + Type: core + Dependencies: (none) + ``` + + - title: "List methods" + run: "./lm/bin/lm methods result/lib/calc_module_plugin.{ext}" + code_block: | + # Linux + ./lm/bin/lm methods result/lib/calc_module_plugin.so + + # macOS + ./lm/bin/lm methods result/lib/calc_module_plugin.dylib + expect_contains: + - "int add(int a, int b)" + - "int multiply(int a, int b)" + - "int factorial(int n)" + - "int fibonacci(int n)" + - "QString libVersion()" + post_text: | + Output: + + ``` + Plugin Methods: + =============== + + void eventResponse(QString eventName, QVariantList args) + Signature: eventResponse(QString,QVariantList) + Invokable: no + + void initLogos(LogosAPI* api) + Signature: initLogos(LogosAPI*) + Invokable: yes + + int add(int a, int b) + Signature: add(int,int) + Invokable: yes + + int multiply(int a, int b) + Signature: multiply(int,int) + Invokable: yes + + int factorial(int n) + Signature: factorial(int) + Invokable: yes + + int fibonacci(int n) + Signature: fibonacci(int) + Invokable: yes + + QString libVersion() + Signature: libVersion() + Invokable: yes + ``` + + All five wrapping methods are visible and invokable. The `initLogos` method is automatically called by the Logos host when loading the module. + + - title: "JSON output" + text: "For scripting and CI, use `--json`:" + run: "./lm/bin/lm methods result/lib/calc_module_plugin.{ext} --json" + code_block: | + # Linux + ./lm/bin/lm methods result/lib/calc_module_plugin.so --json + + # macOS + ./lm/bin/lm methods result/lib/calc_module_plugin.dylib --json + expect_contains: + - '"name": "add"' + post_text: | + ```json + [ + { + "isInvokable": true, + "name": "add", + "parameters": [ + { "name": "a", "type": "int" }, + { "name": "b", "type": "int" } + ], + "returnType": "int", + "signature": "add(int,int)" + }, + ... + ] + ``` + + # ── Step 6: Test with logoscore ───────────────────────────────────────────── + - title: "Test with `logoscore`" + step: true + steps: + - title: "Build logoscore" + run: "nix build 'github:logos-co/logos-logoscore-cli{release}' --out-link ./logos" + + - title: "Set up the modules directory" + text: | + `logoscore` expects modules in subdirectories, each with a `manifest.json`. Rather than copying files and writing the manifest manually, use the Nix derivation to create an LGX package and install it with the package manager: + run: "nix build '.#lgx'" + - run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm" + - run: "mkdir -p modules" + - run: "./pm/bin/lgpm --modules-dir ./modules install --file result/*.lgx" + post_text: | + This extracts the plugin, external libraries, and manifest into the correct directory structure: + + ``` + modules/calc_module/ + ├── calc_module_plugin.dylib # (or .so on Linux) + ├── libcalc.dylib # (or .so on Linux) + ├── manifest.json # Auto-generated by lgx + └── variant # Platform variant identifier + ``` + + - title: "Call methods" + text: "Start the daemon and call methods:" + run: "./logos/bin/logoscore -D -m ./modules &" + + - run: "sleep 3" + + - run: "./logos/bin/logoscore load-module calc_module" + + - run: "./logos/bin/logoscore call calc_module add 3 5" + expect_contains: + - '"result":8' + + - run: "./logos/bin/logoscore call calc_module factorial 5" + expect_contains: + - '"result":120' + + - run: "./logos/bin/logoscore call calc_module fibonacci 10" + expect_contains: + - '"result":55' + + - run: "./logos/bin/logoscore call calc_module libVersion" + expect_contains: + - '"result":"1.0.0"' + + - run: "./logos/bin/logoscore stop" + post_text: | + > For inline (legacy) mode and other logoscore options, see the [Developer Guide -- Running with logoscore](logos-developer-guide.md#51-running-with-logoscore). + + **What happens under the hood:** + + 1. `logoscore` scans `./modules/` for subdirectories containing `manifest.json` + 2. It finds `calc_module` and extracts metadata from the plugin binary + 3. It spawns a `logos_host` process that loads `calc_module_plugin.so` + 4. `logos_host` calls `initLogos()` on the plugin, providing a `LogosAPI*` for inter-module communication + 5. The call command is parsed: module name `calc_module`, method `add`, args `[3, 5]` + 6. `logoscore` sends the call to `logos_host` via Qt Remote Objects (IPC) + 7. `logos_host` invokes `CalcModulePlugin::add(3, 5)` which calls `calc_add(3, 5)` from libcalc + 8. The result is returned via IPC to `logoscore` + + You'll see debug output like: + + ``` + Debug: Found plugin: "./modules/calc_module/calc_module_plugin.so" + Debug: Plugin Metadata: + Debug: - Name: "calc_module" + Debug: - Version: "1.0.0" + Debug: - Description: "Calculator module wrapping libcalc C library" + Debug: Loading plugin: "calc_module" in separate process + Debug: Executing call: "calc_module" . "add" with 2 params + Method call successful. Result: ... + ``` + + # ── Package for Distribution (prose only) ────────────────────────────────── + - title: "Package for Distribution (Optional)" + text: | + The LGX package created in Step 5.2 is a **local** package — its libraries still reference `/nix/store` paths, so it only works on the machine that built it. To create a **portable** package that can be distributed to other machines: + + ```bash + nix build '.#lgx-portable' + ``` + + Portable LGX packages are fully self-contained with no `/nix/store` references at runtime. These are the packages used by the Logos App Package Manager UI and published to [logos-modules](https://github.com/logos-co/logos-modules) releases. + + To create both dev and portable variants (the dev variant works with local `nix build` of basecamp; the portable variant works with standalone basecamp builds), use `--out-link` to avoid overwriting the `result` symlink: + + ```bash + nix build '.#lgx' --out-link result-lgx + nix build '.#lgx-portable' --out-link result-lgx-portable + ``` + + > For more bundling options (standalone bundler syntax, cross-platform packaging), see the [Developer Guide — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx). + + To install a portable package on another machine: + + ```bash + nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm + ./pm/bin/lgpm --modules-dir ./modules install --file result-lgx-portable/*.lgx + ``` + + > **Note:** Local builds of `logoscore` / `logos-basecamp` (via `nix build`) expect **local** `.lgx` packages. Portable builds (via `nix build '.#bin-bundle-dir'`, `.#bin-appimage`, or `.#bin-macos-app`) expect **portable** `.lgx` packages. See the [logos-basecamp README](https://github.com/logos-co/logos-basecamp/blob/master/README.md) for details. + + # ── Common Wrapping Patterns (prose only) ────────────────────────────────── + - title: "Common Wrapping Patterns" + text: | + ### Wrapping C functions with opaque pointers + + Many C libraries use opaque pointers (handles) for state management: + + ```c + // C API + typedef struct db_ctx db_ctx_t; + db_ctx_t* db_open(const char* path); + int db_get(db_ctx_t* ctx, const char* key, char* buf, int buf_len); + void db_close(db_ctx_t* ctx); + ``` + + Store the handle in your plugin class: + + ```cpp + class DbModulePlugin : public QObject, public DbModuleInterface + { + // ... + private: + db_ctx_t* m_ctx = nullptr; + + public: + Q_INVOKABLE bool open(const QString& path) { + m_ctx = db_open(path.toUtf8().constData()); + return m_ctx != nullptr; + } + + Q_INVOKABLE QString get(const QString& key) { + if (!m_ctx) return QString(); + char buf[4096]; + int len = db_get(m_ctx, key.toUtf8().constData(), buf, sizeof(buf)); + if (len < 0) return QString(); + return QString::fromUtf8(buf, len); + } + + ~DbModulePlugin() { + if (m_ctx) db_close(m_ctx); + } + }; + ``` + + ### Wrapping C callbacks + + C libraries often use callbacks for async operations: + + ```c + typedef void (*event_cb)(int code, const char* msg, void* user_data); + void lib_set_callback(void* ctx, event_cb cb, void* user_data); + ``` + + Use a static method as the callback, passing `this` as `user_data`: + + ```cpp + class MyPlugin : public QObject, public MyInterface + { + // ... + static void c_callback(int code, const char* msg, void* user_data) { + auto* self = static_cast(user_data); + // Forward to Qt signal (thread-safe) + emit self->eventResponse("lib_event", + QVariantList() << code << QString::fromUtf8(msg)); + } + + Q_INVOKABLE void startListening() { + lib_set_callback(m_ctx, c_callback, this); + } + }; + ``` + + ### Wrapping C libraries that allocate strings + + If the C library returns allocated strings that must be freed: + + ```cpp + Q_INVOKABLE QString getData() { + char* c_str = lib_get_data(m_ctx); // Library allocates + QString result = QString::fromUtf8(c_str); + lib_free_string(c_str); // Library deallocates + return result; + } + ``` + + ### String conversion reference + + | C type | Qt type | C → Qt | Qt → C | + | ---------------------- | ----------------- | -------------------------- | -------------------------- | + | `const char*` | `QString` | `QString::fromUtf8(c_str)` | `str.toUtf8().constData()` | + | `const char*` (binary) | `QByteArray` | `QByteArray(data, len)` | `ba.data()`, `ba.size()` | + | `int` | `int` | direct | direct | + | `bool` / `int` | `bool` | `result != 0` | direct | + | `void*` | (store in member) | — | — | + + # ── Advanced: Wrapping a Library from a Flake Input (prose only) ────────── + - title: "Advanced: Wrapping a Library from a Flake Input" + text: | + Instead of pre-building the library and placing it in `lib/`, you can have Nix fetch and build it from source. This is useful for libraries hosted on GitHub. + + ### flake.nix with external library input + + ```nix + { + description = "Module wrapping libfoo from GitHub"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + + # Fetch the library source (non-flake) + libfoo-src = { + url = "github:example/libfoo"; + flake = false; + }; + }; + + outputs = inputs@{ logos-module-builder, libfoo-src, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + + # Pass the fetched source to the builder + externalLibInputs = { + foo = libfoo-src; + }; + }; + } + ``` + + ### metadata.json for flake input + + ```json + { + "name": "foo_module", + "version": "1.0.0", + "type": "core", + "description": "Module wrapping libfoo", + "main": "foo_module_plugin", + "dependencies": [], + + "nix": { + "packages": { "build": [], "runtime": [] }, + "external_libraries": [ + { + "name": "foo", + "flake_input": "github:example/libfoo", + "build_command": "make shared", + "output_pattern": "build/libfoo.*" + } + ], + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": ["lib"], + "extra_link_libraries": [] + } + } + } + ``` + + **Key difference:** The `externalLibInputs` key in flake.nix (`foo`) must match the `name` field in `nix.external_libraries` (`foo`). The builder will: + + 1. Clone the source from the flake input + 2. Run `build_command` (`make shared`) + 3. Search for output files matching `output_pattern` + 4. Copy the resulting `.so`/`.dylib` and headers to `lib/` + 5. Proceed with the normal module build + + ### For Go libraries + + If the external library is written in Go with C bindings (`cgo`), set `go_build: true` in the `nix.external_libraries` entry within `metadata.json`: + + ```json + { + "nix": { + "external_libraries": [ + { + "name": "mygolib", + "flake_input": "github:example/mygolib", + "go_build": true, + "output_pattern": "libmygolib.*" + } + ] + } + } + ``` + + Setting `go_build: true` enables the Go toolchain and sets `CGO_ENABLED=1`. + + # ── Real-World Example (prose only) ────────────────────────────────────── + - title: "Real-World Example: logos-libp2p-module" + text: | + The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a production module that wraps the `nim-libp2p` library (compiled to a C shared library). Key files: + + - `**flake.nix**` — Uses `externalLibInputs` to fetch the nim-libp2p C bindings from a GitHub flake + - `**metadata.json**` — Declares `nim_libp2p` as an external library with `go_build: false` in the `nix` section + - `**src/plugin.cpp**` — Wraps ~40 C functions (`libp2p_new`, `libp2p_start`, `libp2p_connect`, `libp2p_dial`, `libp2p_gossipsub_subscribe`, etc.) as `Q_INVOKABLE` methods + - `**tests/**` — Qt test suite that exercises every wrapped function + + It follows the exact same pattern as this tutorial, just at a larger scale. + + # ── Troubleshooting (prose only) ──────────────────────────────────────────── + - title: "Troubleshooting" + text: | + ### `initLogos` marked 'override', but does not override + + ``` + error: 'void MyPlugin::initLogos(LogosAPI*)' marked 'override', but does not override + ``` + + **Fix:** Remove the `override` keyword from `initLogos`. The base `PluginInterface` class does not declare it as virtual. The Logos host calls it reflectively via `QMetaObject::invokeMethod`. Declare it as: + + ```cpp + Q_INVOKABLE void initLogos(LogosAPI* api); // No override! + ``` + + ### Library not found at runtime + + ``` + Cannot load library calc_module_plugin.so: libcalc.so: cannot open shared object file + ``` + + **Fix:** Ensure `libcalc.so` / `libcalc.dylib` is in the same directory as the plugin. The build system sets RPATH to `$ORIGIN` (Linux) / `@loader_path` (macOS) so the plugin looks for libraries in its own directory. + + ### `initLogos` stores API pointer in wrong variable + + If inter-module calls or API features silently fail, check that `initLogos` assigns to the **global** `logosAPI` variable (defined in the Logos SDK / liblogos), not to a class member like `m_logosAPI`: + + ```cpp + // CORRECT — uses the global variable from liblogos + void MyPlugin::initLogos(LogosAPI* api) + { + logosAPI = api; + } + + // WRONG — stores in a local member, API calls won't work + void MyPlugin::initLogos(LogosAPI* api) + { + m_logosAPI = api; + } + ``` + + ### Plugin not discovered by logoscore + + **Check:** + + 1. The module is in a **subdirectory** of the modules dir (e.g., `modules/calc_module/`) + 2. The subdirectory contains a `manifest.json` with a valid `main` object + 3. The platform key in `main` matches your OS/arch (e.g., `linux-aarch64`, `darwin-arm64`) + + ### `nix build .#lib` does nothing or fails silently + + Some shells (notably zsh) treat `#` as a comment character. Always quote the flake reference: + + ```bash + # Correct + nix build '.#lib' + + # May fail in zsh + nix build .#lib + ``` + + ### First build is slow + + The first `nix build` downloads Qt 6, the Logos C++ SDK, the code generator, and other dependencies. This is a one-time cost — subsequent builds use the Nix cache and are fast (usually under 30 seconds). + + ### Symbol not found errors + + If you get "undefined symbol" errors for your C library functions: + + 1. Verify the `.so`/`.dylib` is in `lib/` before building + 2. Verify the header has `extern "C"` guards + 3. Check the symbols are exported: `nm -D lib/libcalc.so | grep calc` diff --git a/tools/run-tutorial b/tools/run-tutorial new file mode 100755 index 0000000..89cc61f --- /dev/null +++ b/tools/run-tutorial @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Wrapper that ensures python3 + pyyaml are available via nix-shell. +# Usage: run-tutorial run [OPTIONS] +# run-tutorial generate [-o output.md] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# If python3 + yaml are already available, run directly +if python3 -c "import yaml" 2>/dev/null; then + exec python3 "$SCRIPT_DIR/tutorial_runner.py" "$@" +fi + +# Otherwise, use nix-shell to provide them +exec nix-shell -p python3 python3Packages.pyyaml --run \ + "python3 \"$SCRIPT_DIR/tutorial_runner.py\" $*" diff --git a/tools/tutorial_runner.py b/tools/tutorial_runner.py new file mode 100644 index 0000000..6cd4676 --- /dev/null +++ b/tools/tutorial_runner.py @@ -0,0 +1,1751 @@ +#!/usr/bin/env python3 +""" +Tutorial Runner & Markdown Generator + +Executes YAML tutorial specs (write files, run commands, build, inspect, test) +and generates .md documentation from them. + +Usage: + tutorial_runner.py run [OPTIONS] + tutorial_runner.py generate [-o output.md] + +Run options: + --keep-workdir Don't delete the temp working directory on exit + --workdir Use existing directory instead of creating a fresh one + --verbose Print commands as they execute + --basecamp-bin Path to LogosBasecamp binary (for basecamp sections) + --qt-mcp Path to logos-qt-mcp package (for basecamp/ui_test sections) + --call-timeout Timeout for logoscore calls (default: 60) + +Generate options: + -o Output file (default: uses spec's 'output' field) +""" + +import argparse +import base64 +import glob +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import textwrap + +import yaml + +# ── Platform Detection ──────────────────────────────────────────────────────── + +IS_MACOS = platform.system() == "Darwin" +LIB_EXT = "dylib" if IS_MACOS else "so" +SHARED_FLAGS = "-dynamiclib" if IS_MACOS else "-shared -fPIC" + + +# ── Colors ──────────────────────────────────────────────────────────────────── + +USE_COLOR = sys.stdout.isatty() + + +def _c(code, text): + if USE_COLOR: + return f"\033[{code}m{text}\033[0m" + return text + + +def green(t): + return _c("32", t) + + +def red(t): + return _c("31", t) + + +def yellow(t): + return _c("33", t) + + +def bold(t): + return _c("1", t) + + +def dim(t): + return _c("2", t) + + +# ── Result Tracking ────────────────────────────────────────────────────────── + +class StopEarly(Exception): + """Raised when fail-fast is enabled and a test fails.""" + + +class Results: + def __init__(self, fail_fast=True): + self.passed = 0 + self.failed = 0 + self.skipped = 0 + self.failures = [] + self.fail_fast = fail_fast + + @property + def total(self): + return self.passed + self.failed + self.skipped + + def pass_(self, name): + self.passed += 1 + print(f" {green('PASS')} {name}") + + def fail(self, name, reason=""): + self.failed += 1 + self.failures.append((name, reason)) + print(f" {red('FAIL')} {name}") + if reason: + print(f" {dim(reason)}") + if self.fail_fast: + raise StopEarly() + + def skip(self, name, reason=""): + self.skipped += 1 + print(f" {yellow('SKIP')} {name} ({reason})") + + def summary(self): + print() + print("=" * 65) + print(f" Results: {self.passed} passed, {self.failed} failed, " + f"{self.skipped} skipped (of {self.total} run)") + print("=" * 65) + + if self.failures: + print() + print("Failures:") + for name, reason in self.failures: + print(f" {red('FAIL')} {name}") + if reason: + print(f" {reason}") + print() + return self.failed == 0 + + +# ── Execution capture for --report ──────────────────────────────────────────── + +# When `run --report ` is active this holds a ReportCollector; otherwise +# it stays None and the handlers' recording calls are cheap no-ops. It's a +# module global (like RELEASE_TAG) so the existing handler signatures don't all +# have to grow a parameter. +_REPORT = None + + +class ReportCollector: + """Accumulates what each step actually executed, keyed by the identity of + the step dict so the report builder (which walks the same spec objects) can + line up real execution against rendered markdown. Spec objects are held in + `specs` so their step dicts stay alive and their ids stay stable/unique.""" + + def __init__(self): + self.specs = [] # [{spec, spec_path, workdir, meta}] in run order + self._by_step = {} # id(step) -> [exec record dict, ...] + + def begin_spec(self, spec, spec_path, workdir, meta): + self.specs.append({ + "spec": spec, "spec_path": spec_path, + "workdir": workdir, "meta": meta, + }) + + def record(self, step, **rec): + """Attach one execution record to a step. `rec` keys: kind, cmd, + status (pass|fail|info), exit_code, output, note.""" + self._by_step.setdefault(id(step), []).append(rec) + + def execs_for(self, step): + return self._by_step.get(id(step), []) + + +def _describe_ui_actions(tests): + """One-line-per-action human summary of a ui_test's test list.""" + lines = [] + for t in tests: + action = t.get("action", "") + name = t.get("name", "") + if action == "wait_for": + detail = f"wait for {t.get('texts', [])}" + elif action == "expect_texts": + detail = f"expect {t.get('texts', [])}" + elif action == "click": + detail = f"click {t.get('target', '')!r}" + elif action == "set_text": + detail = f"set {t.get('find_by','')}={t.get('find_value','')!r} to {t.get('value','')!r}" + elif action == "sleep": + detail = f"sleep {t.get('ms', 0)}ms" + else: + detail = action + lines.append(f"- {name + ': ' if name else ''}{detail}") + return "\n".join(lines) + + +def _rec_ui(step, launch_cmd, tests, status, note, output): + """Record a ui_test execution: the launch command, the action list, and the + captured output/log.""" + if not _REPORT: + return + cmd = launch_cmd + "\n\n# test actions:\n" + _describe_ui_actions(tests) + _REPORT.record(step, kind="ui_test", cmd=cmd, status=status, + output=output, note=note) + + +# ── Variable Expansion ──────────────────────────────────────────────────────── + +RELEASE_TAG = "" + + +def set_release(tag): + global RELEASE_TAG + RELEASE_TAG = f"/{tag}" if tag else "" + + +def expand_vars(s): + """Replace {ext}, {shared_flags}, and {release} with resolved values.""" + s = s.replace("{ext}", LIB_EXT) + s = s.replace("{shared_flags}", SHARED_FLAGS) + s = s.replace("{release}", RELEASE_TAG) + return s + + +# ── Nix Override Injection ──────────────────────────────────────────────────── + +def parse_build_overrides(spec, spec_dir): + """Parse build_overrides from spec into --override-input flags string.""" + overrides = spec.get("build_overrides", {}) + if not overrides: + return "" + flags = [] + for key, rel_path in overrides.items(): + abs_path = os.path.normpath(os.path.join(spec_dir, rel_path)) + if os.path.isdir(abs_path): + flags.append(f"--override-input {key} path:{abs_path}") + else: + print(f" WARNING: build_overrides.{key} path not found: {abs_path}") + return " ".join(flags) + + +def inject_nix_overrides(cmd, override_flags): + """Append nix override flags to nix build commands.""" + if override_flags and "nix build" in cmd: + return f"{cmd} {override_flags}" + return cmd + + +# ── Command Execution ───────────────────────────────────────────────────────── + +def run_cmd(cmd, workdir, verbose=False, capture=False, timeout=None): + """Run a shell command. Returns (exit_code, stdout) if capture=True.""" + if verbose: + print(f" cmd: {dim(cmd)}") + + actual_cmd = cmd + if cmd.rstrip().endswith("&"): + actual_cmd = cmd.rstrip().rstrip("&") + " >/dev/null 2>&1 &" + + try: + if capture: + result = subprocess.run( + actual_cmd, + shell=True, + cwd=workdir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + ) + return result.returncode, result.stdout or "" + else: + result = subprocess.run( + actual_cmd, + shell=True, + cwd=workdir, + timeout=timeout, + ) + return result.returncode, "" + except subprocess.TimeoutExpired: + return 124, "command timed out" + except Exception as e: + return 1, str(e) + + +# ── Step Handlers ───────────────────────────────────────────────────────────── + +def handle_file(step, workdir, results, verbose): + file_spec = step.get("file", {}) + path = file_spec.get("path", "") + if not path: + return + path = expand_vars(path) + content = expand_vars(file_spec.get("content", "")) + encoding = file_spec.get("encoding", "") + + full_path = os.path.join(workdir, path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + if encoding == "base64": + with open(full_path, "wb") as f: + f.write(base64.b64decode(content)) + else: + with open(full_path, "w") as f: + f.write(content) + + if os.path.isfile(full_path): + print(f" wrote {path}") + if _REPORT: + _REPORT.record(step, kind="file", cmd=f"write {path}", + status="pass", note=f"Wrote {path}") + else: + if _REPORT: + _REPORT.record(step, kind="file", cmd=f"write {path}", + status="fail", note="file was not created") + results.fail(f"write {path}", "file was not created") + + +def _exec_run(cmd, title, workdir, results, verbose, override_flags, + expect_contains=None, step=None): + """Execute a single run command with optional output assertions.""" + cmd = expand_vars(cmd) + cmd = inject_nix_overrides(cmd, override_flags) + expect_contains = expect_contains or [] + + rc, output = run_cmd(cmd, workdir, verbose, capture=True) + + def _rec(status, note=""): + if _REPORT: + _REPORT.record(step, kind="run", cmd=cmd, status=status, + exit_code=rc, output=output, note=note) + + if rc != 0: + if output and output.strip(): + lines = output.strip().split("\n") + tail = lines[-20:] + if len(lines) > 20: + print(f" {dim(f'... ({len(lines) - 20} lines omitted)')}") + for line in tail: + print(f" {dim(line)}") + _rec("fail", f"exit code {rc}") + results.fail(title, f"command failed with exit code {rc}") + return + + if expect_contains: + all_found = True + for expected in expect_contains: + if expected not in output: + trimmed = output.strip() + out_msg = trimmed[:500] if trimmed else "(empty)" + _rec("fail", f"expected '{expected}' not found in output") + results.fail(title, f"expected '{expected}' not found in output\n actual: {out_msg}") + all_found = False + break + if all_found: + _rec("pass") + results.pass_(title) + else: + _rec("pass") + results.pass_(title) + + +def handle_run(step, workdir, results, verbose, override_flags): + cmd = step.get("run", "") + if not cmd: + return + title = step.get("title", cmd) + _exec_run(cmd, title, workdir, results, verbose, override_flags, + step.get("expect_contains", []), step=step) + + extra = step.get("extra_run", {}) + if extra: + extra_cmd = extra.get("run", "") + if extra_cmd: + _exec_run(extra_cmd, f"{title} (verify)", workdir, results, verbose, + override_flags, extra.get("expect_contains", []), step=step) + + +def handle_check_file(step, workdir, results, verbose): + pattern = step.get("check_file", "") + if not pattern: + return + title = step.get("title", f"check {pattern}") + pattern = expand_vars(pattern) + full_pattern = os.path.join(workdir, pattern) + matches = glob.glob(full_pattern) + if matches: + if _REPORT: + _REPORT.record(step, kind="check_file", cmd=f"check file: {pattern}", + status="pass", note=f"Found: {matches[0]}") + results.pass_(title) + else: + if _REPORT: + _REPORT.record(step, kind="check_file", cmd=f"check file: {pattern}", + status="fail", note=f"file not found: {pattern}") + results.fail(title, f"file not found: {pattern}") + + +def handle_logoscore(section, workdir, results, verbose, override_flags, + call_timeout, module_name): + logoscore_spec = section.get("logoscore", {}) + if not logoscore_spec: + return + + setup_cmds = logoscore_spec.get("setup", []) + tests = logoscore_spec.get("tests", []) + + setup_ok = True + if setup_cmds: + print(" -- Setup --") + for cmd in setup_cmds: + cmd = expand_vars(cmd) + cmd = inject_nix_overrides(cmd, override_flags) + rc, output = run_cmd(cmd, workdir, verbose, capture=verbose) + if rc != 0: + results.fail(f"logoscore setup: {cmd}", "setup command failed") + setup_ok = False + break + if setup_ok: + print(" setup completed") + + logoscore_bin = "" + candidate = os.path.join(workdir, "logos", "bin", "logoscore") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + logoscore_bin = candidate + elif shutil.which("logoscore"): + logoscore_bin = "logoscore" + + if not logoscore_bin: + results.skip("logoscore", "logoscore not found (build it in setup or add to PATH)") + return + + if not setup_ok: + return + + print(" -- Tests --") + for test in tests: + name = test.get("name", "") + call = test.get("call", "") + expect = test.get("expect", "") + + cmd = f"timeout {call_timeout} {logoscore_bin} -m ./modules -l {module_name} -c \"{call}\"" + rc, output = run_cmd(cmd, workdir, verbose, capture=True) + + if rc != 0: + results.fail(name, f"logoscore exit code {rc}") + if verbose: + print(f" output: {dim(output[:300])}") + elif expect in output: + results.pass_(name) + else: + results.fail(name, f"expected '{expect}' in output") + if verbose: + print(f" output: {dim(output[:300])}") + + +def handle_basecamp(section, workdir, results, verbose, override_flags, + basecamp_bin, qt_mcp_path, spec_dir, section_count, spec): + basecamp_spec = section.get("basecamp", {}) + if not basecamp_spec: + return + + if not basecamp_bin: + results.skip("basecamp", "no --basecamp-bin provided") + return + if not os.path.isfile(basecamp_bin) or not os.access(basecamp_bin, os.X_OK): + results.skip("basecamp", f"basecamp binary not found: {basecamp_bin}") + return + + qt_mcp = qt_mcp_path or os.environ.get("LOGOS_QT_MCP", "") + if not qt_mcp: + results.skip("basecamp", "no --qt-mcp or LOGOS_QT_MCP provided") + return + + install_as = basecamp_spec.get("install_as", "") + tests = basecamp_spec.get("tests", []) + + user_dir = tempfile.mkdtemp(dir=workdir, prefix="basecamp-data-") + env = { + **os.environ, + "LOGOS_USER_DIR": user_dir, + "QT_QPA_PLATFORM": "offscreen", + "QT_FORCE_STDERR_LOGGING": "1", + "QT_LOGGING_RULES": "qt.*.debug=false;default.debug=true", + "LOGOS_QT_MCP": qt_mcp, + } + + # Install core deps if specified + core_deps = basecamp_spec.get("core_deps", []) + if core_deps: + os.makedirs(os.path.join(user_dir, "modules"), exist_ok=True) + for dep_path in core_deps: + full_dep = os.path.join(spec_dir, dep_path) + if os.path.isdir(full_dep): + print(f" Installing core dependency: {dep_path}") + install_dir = tempfile.mkdtemp() + cmd = f"nix build '.#install' -o {install_dir}/result" + rc, _ = run_cmd(cmd, full_dep, verbose) + if rc == 0: + src = os.path.join(install_dir, "result", "modules") + if os.path.isdir(src): + for item in os.listdir(src): + shutil.copytree( + os.path.join(src, item), + os.path.join(user_dir, "modules", item), + dirs_exist_ok=True + ) + + if install_as: + print(" Building install package...") + cmd = f"nix build '.#install' -o result-install" + cmd = inject_nix_overrides(cmd, override_flags) + rc, _ = run_cmd(cmd, workdir, verbose) + if rc == 0: + if install_as == "core": + src = os.path.join(workdir, "result-install", "modules") + dst = os.path.join(user_dir, "modules") + os.makedirs(dst, exist_ok=True) + if os.path.isdir(src): + for item in os.listdir(src): + shutil.copytree( + os.path.join(src, item), + os.path.join(dst, item), + dirs_exist_ok=True + ) + elif install_as == "ui": + for subdir in ["plugins", "modules"]: + src = os.path.join(workdir, "result-install", subdir) + dst = os.path.join(user_dir, "plugins") + os.makedirs(dst, exist_ok=True) + if os.path.isdir(src): + for item in os.listdir(src): + shutil.copytree( + os.path.join(src, item), + os.path.join(dst, item), + dirs_exist_ok=True + ) + break + else: + results.fail("basecamp install", "nix build '.#install' failed") + return + + mjs_path = generate_mjs_tests( + tests, qt_mcp, f"{spec.get('name', 'tutorial')}: basecamp UI verification", + os.path.join(workdir, "basecamp-test.mjs") + ) + + cmd = f'node "{mjs_path}" --ci "{basecamp_bin}" --verbose' + rc, output = run_cmd(cmd, workdir, verbose, capture=True) + if rc == 0: + results.pass_("basecamp UI tests") + else: + results.fail("basecamp UI tests", "test runner exited with non-zero") + if verbose: + print(f" {dim(output[:500])}") + + +# ── Shared .mjs test generation ────────────────────────────────────────────── + +def generate_mjs_tests(tests, qt_mcp_path, test_name, output_path): + """Generate a .mjs test file from YAML test actions for logos-qt-mcp.""" + import json as _json + + with open(output_path, "w") as f: + f.write('import { resolve } from "node:path";\n') + f.write(f'const qtMcpRoot = "{qt_mcp_path}";\n') + f.write('const { test, run } = await import(resolve(qtMcpRoot, "test-framework/framework.mjs"));\n\n') + f.write(f'test("{test_name}", async (app) => {{\n') + + for t in tests: + action = t.get("action", "") + if action == "click": + target = t.get("target", "") + f.write(f' await app.click("{target}", {{ exact: true }});\n') + elif action == "expect_texts": + texts = _json.dumps(t.get("texts", [])) + f.write(f' await app.expectTexts({texts});\n') + elif action == "wait_for": + texts = _json.dumps(t.get("texts", [])) + timeout = t.get("timeout", 10000) + name = t.get("name", "") + f.write(f' await app.waitFor(\n') + f.write(f' async () => {{ await app.expectTexts({texts}); }},\n') + f.write(f' {{ timeout: {timeout}, interval: 500, description: "{name}" }}\n') + f.write(f' );\n') + elif action == "set_text": + prop = t.get("find_by", "") + val = t.get("find_value", "") + set_val = t.get("value", "") + f.write(f' {{\n') + f.write(f' const found = await app.inspector.send("findByProperty", {{ property: "{prop}", value: "{val}" }});\n') + f.write(f' if (!found.matches || found.matches.length === 0) throw new Error("set_text: element not found");\n') + f.write(f' await app.inspector.send("setProperty", {{ objectId: found.matches[0].id, property: "text", value: "{set_val}" }});\n') + f.write(f' }}\n') + elif action == "sleep": + ms = t.get("ms", 1000) + f.write(f' await new Promise(r => setTimeout(r, {ms}));\n') + + f.write('});\n\nrun();\n') + + return output_path + + +# ── UI Test Handler ────────────────────────────────────────────────────────── + +import signal +import socket +import time + + +def _wait_for_inspector(host="localhost", port=3768, timeout=60, proc=None): + """Poll until the QML inspector TCP port accepts connections. + + Returns "ok" once the port is connectable. If `proc` is given and the app + exits before the port opens, returns "died" immediately (no point waiting + out the full timeout for a process that is gone). Returns "timeout" if the + deadline passes while the app is still running (e.g. a slow cold-cache + `nix run` build that hasn't finished compiling the app yet). + """ + deadline = time.time() + timeout + while time.time() < deadline: + if proc is not None and proc.poll() is not None: + return "died" + try: + with socket.create_connection((host, port), timeout=2): + return "ok" + except (ConnectionRefusedError, OSError): + time.sleep(0.5) + return "timeout" + + +def _kill_process_tree(pid): + """Kill a process and its children.""" + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + + +def handle_ui_test(step, workdir, results, verbose, override_flags, qt_mcp_cli, spec): + """Run headless UI tests via logos-qt-mcp test framework. + + Supports two modes: + - launch mode: runs the app as a background process, connects tests to it + - binary mode: uses --ci flag to let the test framework manage the app + + YAML format (launch mode — preferred): + ui_test: + launch: "nix run ." + qt_mcp: "result-mcp" + setup: + - "nix build 'github:logos-co/logos-qt-mcp' -o result-mcp" + tests: + - name: "Title visible" + action: wait_for + texts: ["My App"] + timeout: 15000 + + YAML format (binary mode): + ui_test: + build: "nix build" + binary: "nix-app" + qt_mcp: "result-mcp" + setup: [...] + tests: [...] + """ + ui_spec = step.get("ui_test", {}) + if not ui_spec: + return + + tests = ui_spec.get("tests", []) + if not tests: + return + + title = step.get("title", "UI tests") + + qt_mcp = ui_spec.get("qt_mcp", "") or qt_mcp_cli or os.environ.get("LOGOS_QT_MCP", "") + + # Run setup commands + for cmd in ui_spec.get("setup", []): + cmd = expand_vars(cmd) + cmd = inject_nix_overrides(cmd, override_flags) + print(f" Setup: {cmd}") + rc, _ = run_cmd(cmd, workdir, verbose) + if rc != 0: + results.fail(title, f"setup command failed: {cmd}") + return + + # Resolve qt_mcp path + if qt_mcp and not os.path.isabs(qt_mcp): + qt_mcp = os.path.join(workdir, qt_mcp) + if not qt_mcp or not os.path.isdir(qt_mcp): + results.fail(title, f"logos-qt-mcp not found: {qt_mcp}") + return + + # Generate .mjs test file + test_name = f"{spec.get('name', 'tutorial')}: {title}" + mjs_path = generate_mjs_tests( + tests, qt_mcp, test_name, + os.path.join(workdir, "ui-test.mjs") + ) + + launch_cmd = ui_spec.get("launch", "") + + if launch_cmd: + # Launch mode: start app in background, run tests against it, kill it + launch_cmd = expand_vars(launch_cmd) + launch_cmd = inject_nix_overrides(launch_cmd, override_flags) + + env = { + **os.environ, + "QT_QPA_PLATFORM": "offscreen", + "QT_FORCE_STDERR_LOGGING": "1", + "QT_LOGGING_RULES": "qt.*.debug=false;default.debug=true", + } + + # Pre-build the app before starting the inspector clock. `nix run .` + # builds the app closure on first use; on a cold cache (e.g. an x86_64 + # CI runner with no binary cache) that compile can take minutes and + # would otherwise be counted against the inspector timeout — the app + # never even boots before we give up. Building first (best effort) + # means the subsequent launch only has to boot, not compile. Output is + # streamed with -L so a real build failure is visible in CI logs. + if launch_cmd.lstrip().startswith("nix run"): + warm_cmd = "nix build" + launch_cmd.lstrip()[len("nix run"):] + if " -L" not in warm_cmd: + warm_cmd += " -L" + build_timeout = ui_spec.get("build_timeout", 1800) + print(f" Pre-building app: {warm_cmd}") + wrc, _ = run_cmd(warm_cmd, workdir, verbose, timeout=build_timeout) + if wrc != 0: + print(f" {yellow('pre-build returned non-zero; launching anyway')}") + + app_log_path = os.path.join(workdir, "ui-test-app.log") + app_log = open(app_log_path, "w") + + print(f" Launching: {launch_cmd}") + app_proc = subprocess.Popen( + launch_cmd, shell=True, cwd=workdir, env=env, + stdout=app_log, stderr=subprocess.STDOUT, + preexec_fn=os.setsid, + ) + + def _dump_app_log(reason): + app_log.flush() + try: + with open(app_log_path) as f: + tail = f.read().strip()[-1500:] + except OSError: + tail = "" + print(f" {dim(reason)}") + print(f" {dim('app output (' + app_log_path + '):')}") + print(f" {dim(tail if tail else '(no output captured)')}") + + def _app_log_tail(): + app_log.flush() + try: + with open(app_log_path) as f: + return f.read().strip()[-1500:] + except OSError: + return "" + + try: + port = ui_spec.get("inspector_port", 3768) + timeout = ui_spec.get("launch_timeout", 120) + print(f" Waiting for inspector on port {port} (timeout {timeout}s)...") + status = _wait_for_inspector(port=port, timeout=timeout, proc=app_proc) + if status == "died": + _rec_ui(step, launch_cmd, tests, "fail", + f"app exited (code {app_proc.returncode}) before inspector opened on port {port}", + _app_log_tail()) + results.fail(title, f"app exited (code {app_proc.returncode}) before inspector opened on port {port}") + _dump_app_log("app process exited before opening the inspector port") + return + if status == "timeout": + _rec_ui(step, launch_cmd, tests, "fail", + f"inspector not available on port {port} after {timeout}s", + _app_log_tail()) + results.fail(title, f"inspector not available on port {port} after {timeout}s") + _dump_app_log("inspector port never opened (app still running — likely slow boot or wrong port)") + return + + print(f" Running UI tests ({len(tests)} actions)...") + cmd = f'node "{mjs_path}" --verbose' + rc, output = run_cmd(cmd, workdir, verbose, capture=True, timeout=120) + if rc == 0: + _rec_ui(step, launch_cmd, tests, "pass", "", output) + results.pass_(title) + else: + _rec_ui(step, launch_cmd, tests, "fail", "UI tests failed", + (output or "") + "\n\n--- app log ---\n" + _app_log_tail()) + results.fail(title, "UI tests failed") + trimmed = output.strip()[-800:] if output else "(no output)" + print(f" {dim(trimmed)}") + _dump_app_log("app output during test run") + finally: + try: + os.killpg(os.getpgid(app_proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + try: + app_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + app_log.close() + + else: + # Binary mode: use --ci flag (test framework manages the app) + build_cmd = ui_spec.get("build", "") + if build_cmd: + build_cmd = expand_vars(build_cmd) + build_cmd = inject_nix_overrides(build_cmd, override_flags) + print(f" Build: {build_cmd}") + rc, _ = run_cmd(build_cmd, workdir, verbose) + if rc != 0: + results.fail(title, "build failed") + return + + binary = ui_spec.get("binary", "") + if not binary: + results.fail(title, "no binary or launch command specified in ui_test") + return + + if binary == "nix-app": + print(f" Resolving app binary from flake...") + try: + proc = subprocess.run( + 'nix eval .#apps."$(nix eval --impure --expr builtins.currentSystem --raw)".default.program --raw', + shell=True, cwd=workdir, capture_output=True, text=True, timeout=60 + ) + binary_path = (proc.stdout or "").strip() + if proc.returncode != 0 or not binary_path: + results.fail(title, f"failed to resolve nix app binary: {(proc.stderr or '').strip()}") + return + except Exception as e: + results.fail(title, f"nix eval failed: {e}") + return + else: + binary_path = os.path.join(workdir, binary) + + if not os.path.isfile(binary_path): + results.fail(title, f"binary not found: {binary_path}") + return + + env_prefix = "QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1" + cmd = f'{env_prefix} node "{mjs_path}" --ci "{binary_path}" --verbose' + print(f" Running UI tests ({len(tests)} actions)...") + rc, output = run_cmd(cmd, workdir, verbose, capture=True, timeout=120) + if rc == 0: + _rec_ui(step, cmd, tests, "pass", "", output) + results.pass_(title) + else: + _rec_ui(step, cmd, tests, "fail", "UI tests failed", output) + results.fail(title, "UI tests failed") + trimmed = output.strip()[-800:] if output else "(no output)" + print(f" {dim(trimmed)}") + + +# ── Find module name from spec ──────────────────────────────────────────────── + +def find_module_name(spec): + """Extract module name from the metadata.json file step in the spec.""" + for section in spec.get("sections", []): + for step in section.get("steps", []): + file_spec = step.get("file", {}) + if file_spec.get("path") == "metadata.json": + content = file_spec.get("content", "") + try: + import json + meta = json.loads(content) + return meta.get("name", "") + except (json.JSONDecodeError, TypeError): + pass + return "" + + +# ══════════════════════════════════════════════════════════════════════════════ +# RUN COMMAND +# ══════════════════════════════════════════════════════════════════════════════ + +def run_single_spec(spec, spec_path, workdir, args, results): + """Run a single tutorial spec in the given workdir. May raise StopEarly.""" + spec_dir = os.path.dirname(spec_path) + + release = args.release if args.release is not None else spec.get("release", "") + set_release(release) + + override_flags = parse_build_overrides(spec, spec_dir) + module_name = find_module_name(spec) + + tutorial_name = spec.get("name", "tutorial") + print("=" * 65) + print(f" Tutorial Test: {bold(tutorial_name)}") + print("=" * 65) + print() + print(f" spec : {spec_path}") + print(f" workdir : {workdir}") + print(f" platform : {platform.system()} (ext={LIB_EXT})") + print(f" release : {release or '(none)'}") + if override_flags: + print(f" overrides: {override_flags}") + print() + + if _REPORT: + _REPORT.begin_spec(spec, spec_path, workdir, { + "name": tutorial_name, + "platform": f"{platform.system()} (ext={LIB_EXT})", + "release": release or "(none)", + }) + + sections = spec.get("sections", []) + + for si, section in enumerate(sections): + sec_title = section.get("title", f"Section {si + 1}") + + print("-" * 65) + print(f" Section: {bold(sec_title)}") + print("-" * 65) + + # Handle logoscore sections + if section.get("logoscore"): + handle_logoscore( + section, workdir, results, args.verbose, + override_flags, args.call_timeout, module_name + ) + print() + continue + + # Handle basecamp sections + if section.get("basecamp"): + handle_basecamp( + section, workdir, results, args.verbose, + override_flags, args.basecamp_bin, args.qt_mcp, + spec_dir, len(sections), spec + ) + print() + continue + + steps = section.get("steps", []) + if not steps: + if args.verbose: + print(f" {dim('(prose-only section, skipping)')}") + print() + continue + + for step in steps: + if step.get("file"): + handle_file(step, workdir, results, args.verbose) + elif step.get("run"): + handle_run(step, workdir, results, args.verbose, override_flags) + elif step.get("ui_test"): + handle_ui_test(step, workdir, results, args.verbose, + override_flags, args.qt_mcp, spec) + elif step.get("check_file"): + handle_check_file(step, workdir, results, args.verbose) + elif args.verbose: + title = step.get("title", "untitled") + print(f" {dim(f'(prose-only step: {title})')}") + + print() + + +def cmd_run(args): + spec_path = os.path.abspath(args.spec) + spec_dir = os.path.dirname(spec_path) + + with open(spec_path) as f: + spec = yaml.safe_load(f) + + requires = spec.get("requires", []) + fail_fast = not args.continue_on_fail + results = Results(fail_fast=fail_fast) + + global _REPORT + if getattr(args, "report", None): + _REPORT = ReportCollector() + + if args.workdir: + workdir = os.path.abspath(args.workdir) + if not os.path.isdir(workdir): + print(f"ERROR: --workdir path does not exist: {workdir}", file=sys.stderr) + sys.exit(2) + root_dir = None + created_root = False + elif requires: + root_dir = tempfile.mkdtemp(prefix="tutorial-chain-") + created_root = True + + project_name = spec.get("project_name") + if not project_name: + print(f"ERROR: spec with requires: must also have project_name", file=sys.stderr) + sys.exit(2) + + workdir = os.path.join(root_dir, project_name) + os.makedirs(workdir, exist_ok=True) + else: + workdir = tempfile.mkdtemp(prefix="tutorial-test-") + root_dir = None + created_root = False + + def cleanup(): + if not args.keep_workdir: + target = root_dir if created_root else workdir + if target: + shutil.rmtree(target, ignore_errors=True) + + try: + # Run prerequisite tutorials first, resolved transitively. requires: is + # walked depth-first in post-order, so if A requires B and B requires C, + # the run order is C, then B, then A. Shared prerequisites run once + # (deduped by spec path) and cycles are reported rather than looping. + if requires and created_root: + ordered = [] # spec paths in dependency order (deepest first) + ran = set() # spec paths already added to `ordered` + in_progress = set() # spec paths on the current DFS stack (cycle guard) + + def resolve(req_path, referenced_by): + req_path = os.path.abspath(req_path) + if req_path in ran: + return + if req_path in in_progress: + print(f"ERROR: circular requires: detected at {req_path} " + f"(referenced by {referenced_by})", file=sys.stderr) + sys.exit(2) + if not os.path.isfile(req_path): + print(f"ERROR: required spec not found: {req_path} " + f"(referenced by {referenced_by})", file=sys.stderr) + sys.exit(2) + + with open(req_path) as f: + req_spec = yaml.safe_load(f) + + if not req_spec.get("project_name"): + print(f"ERROR: required spec {req_path} has no project_name field", + file=sys.stderr) + sys.exit(2) + + in_progress.add(req_path) + req_dir = os.path.dirname(req_path) + for nested_rel in req_spec.get("requires", []): + resolve(os.path.join(req_dir, nested_rel), req_path) + in_progress.discard(req_path) + + ran.add(req_path) + ordered.append((req_path, req_spec)) + + for req_rel in requires: + resolve(os.path.join(spec_dir, req_rel), spec_path) + + for req_path, req_spec in ordered: + req_workdir = os.path.join(root_dir, req_spec["project_name"]) + os.makedirs(req_workdir, exist_ok=True) + run_single_spec(req_spec, req_path, req_workdir, args, results) + + # Run the main tutorial + run_single_spec(spec, spec_path, workdir, args, results) + + except StopEarly: + pass + + if args.keep_workdir or args.workdir: + print(f" workdir: {root_dir or workdir}") + + ok = results.summary() + + # Build the HTML report before cleanup removes the working directories. + if _REPORT is not None: + try: + report_path = os.path.abspath(args.report) + write_html_report(_REPORT, report_path, results) + print(f" report : {report_path}") + except Exception as e: + print(f" {yellow(f'report generation failed: {e}')}") + + try: + cleanup() + except Exception: + pass + + sys.exit(0 if ok else 1) + + +# ══════════════════════════════════════════════════════════════════════════════ +# HTML REPORT (run --report) +# ══════════════════════════════════════════════════════════════════════════════ + +def _step_to_markdown(step, sec_num, sub_step): + """Render a single step to the same markdown the generator produces. + Returns (markdown_str, next_sub_step). Mirrors the per-step block in + cmd_generate so the report's left column matches the published tutorial.""" + out = [] + + def emit(t=""): + out.append(t) + + def emit_block(t): + out.extend(t.rstrip("\n").split("\n")) + + title = step.get("title", "") + if title: + if sec_num is not None: + emit(f"### {sec_num}.{sub_step} {title}") + sub_step += 1 + else: + emit(f"### {title}") + emit() + + text = step.get("text", "") + if text: + emit_block(expand_vars(text)) + emit() + + file_spec = step.get("file", {}) + if file_spec: + path = file_spec.get("path", "") + encoding = file_spec.get("encoding", "") + lang = lang_for_path(path, file_spec.get("language")) + if encoding == "base64": + emit(f"*Binary file: `{path}`*") + else: + emit(f"```{lang}") + emit_block(expand_vars(file_spec.get("content", ""))) + emit("```") + emit() + + run_cmd_str = step.get("run", "") + if run_cmd_str: + code_block = step.get("code_block", "") + emit("```bash") + if code_block: + emit_block(expand_vars(code_block)) + else: + emit(expand_vars(run_cmd_str)) + emit("```") + emit() + + ui_test_spec = step.get("ui_test", {}) + if ui_test_spec: + launch = ui_test_spec.get("launch", "") + if launch: + emit("```bash") + emit(expand_vars(launch)) + emit("```") + emit() + + post_text = step.get("post_text", "") + if post_text: + emit_block(expand_vars(post_text)) + emit() + + extra = step.get("extra_run", {}) + if extra: + extra_code = extra.get("code_block", "") + extra_cmd = extra.get("run", "") + emit("```bash") + if extra_code: + emit_block(expand_vars(extra_code)) + elif extra_cmd: + emit(expand_vars(extra_cmd)) + emit("```") + emit() + extra_post = extra.get("post_text", "") + if extra_post: + emit_block(expand_vars(extra_post)) + emit() + + return "\n".join(out).strip(), sub_step + + +def _section_preamble_markdown(section, step_number, is_step): + """Markdown for a section heading + its intro text (no steps).""" + out = [] + title = section.get("title", "") + if is_step: + out.append(f"## Step {step_number}: {title}") + else: + out.append(f"## {title}") + out.append("") + sec_text = section.get("text", "") + if sec_text: + out.extend(expand_vars(sec_text).rstrip("\n").split("\n")) + return "\n".join(out).strip() + + +def build_report_model(collector): + """Turn the collector's per-spec execution data into a JSON-serializable + model: a list of tutorials, each with rows. A row has rendered markdown + (left column) and a list of execution records (right column).""" + tutorials = [] + for entry in collector.specs: + spec = entry["spec"] + rows = [] + + # ── Preamble row (title + intro + objectives + prerequisites) ────── + pre = [] + pre.append(f"# {spec.get('name', 'Tutorial')}") + pre.append("") + if spec.get("intro"): + pre.append(spec["intro"].rstrip("\n")) + pre.append("") + if spec.get("what_you_build"): + pre.append(f"**What you'll build:** {spec['what_you_build']}") + pre.append("") + if spec.get("what_you_learn"): + pre.append("**What you'll learn:**") + pre.append("") + for it in spec["what_you_learn"]: + pre.append(f"- {it}") + pre.append("") + if spec.get("comparison"): + pre.append(spec["comparison"].rstrip("\n")) + pre.append("") + if spec.get("prerequisites"): + pre.append("## Prerequisites") + pre.append("") + for p in spec["prerequisites"]: + pre.append(f"- {p}") + rows.append({"md": "\n".join(pre).strip(), "execs": []}) + + # ── Sections and steps ───────────────────────────────────────────── + step_number = 1 + for section in spec.get("sections", []): + is_step = section.get("step", False) + rows.append({ + "md": _section_preamble_markdown(section, step_number, is_step), + "execs": [], + }) + if is_step: + step_number += 1 + + steps = section.get("steps", []) + sec_num = (step_number - 1) if is_step else None + sub_step = 1 + for step in steps: + md, sub_step = _step_to_markdown(step, sec_num, sub_step) + execs = collector.execs_for(step) + # Runner-only steps (e.g. check_file) render no markdown. Give + # them a small left-column note so the row isn't visually empty. + if not md and execs: + md = "*(verification step — not shown in the published tutorial)*" + rows.append({"md": md, "execs": execs}) + + tutorials.append({"meta": entry["meta"], "rows": rows}) + return tutorials + + +def write_html_report(collector, output_path, results): + import json as _json + model = build_report_model(collector) + payload = { + "generated_platform": f"{platform.system()}", + "summary": { + "passed": results.passed, + "failed": results.failed, + "skipped": results.skipped, + }, + "tutorials": model, + } + data_json = _json.dumps(payload) + # Close any literal in the data so it can't terminate the tag. + data_json = data_json.replace(" + + + + +Tutorial Execution Report + + + + +
+

Tutorial Execution Report

+ + +
+
+ + + + + +""" + + +# ══════════════════════════════════════════════════════════════════════════════ +# GENERATE COMMAND +# ══════════════════════════════════════════════════════════════════════════════ + +LANG_MAP = { + ".c": "c", + ".h": "cpp", + ".cpp": "cpp", + ".hpp": "cpp", + ".json": "json", + ".nix": "nix", + ".qml": "qml", + ".rep": "rep", + ".mjs": "javascript", + ".js": "javascript", + ".cmake": "cmake", +} + + +def lang_for_path(path, explicit=None): + if explicit: + return explicit + _, ext = os.path.splitext(path) + if not ext and os.path.basename(path) == "CMakeLists.txt": + return "cmake" + return LANG_MAP.get(ext, "") + + +def cmd_generate(args): + spec_path = os.path.abspath(args.spec) + spec_dir = os.path.dirname(spec_path) + + with open(spec_path) as f: + spec = yaml.safe_load(f) + + release = args.release if args.release is not None else spec.get("release", "") + set_release(release) + + if args.output: + output_path = os.path.abspath(args.output) + else: + output_name = spec.get("output", "") + if not output_name: + print("ERROR: No output file specified (use -o or set 'output:' in YAML)", + file=sys.stderr) + sys.exit(2) + output_path = os.path.normpath(os.path.join(spec_dir, "..", output_name)) + + lines = [] + + def emit(text=""): + lines.append(text) + + def emit_block(text): + for line in text.rstrip("\n").split("\n"): + lines.append(line) + + # ── Title ───────────────────────────────────────────────────────────── + emit(f"# {spec.get('name', 'Tutorial')}") + emit() + + # ── Intro ───────────────────────────────────────────────────────────── + intro = spec.get("intro", "") + if intro: + emit_block(intro) + emit() + + # ── What you'll build ───────────────────────────────────────────────── + what_build = spec.get("what_you_build", "") + if what_build: + emit(f"**What you'll build:** {what_build}") + emit() + + # ── What you'll learn ───────────────────────────────────────────────── + items = spec.get("what_you_learn", []) + if items: + emit("**What you'll learn:**") + emit() + for item in items: + emit(f"- {item}") + emit() + + # ── Comparison table ────────────────────────────────────────────────── + comparison = spec.get("comparison", "") + if comparison: + emit_block(comparison) + emit() + + # ── Prerequisites ───────────────────────────────────────────────────── + prereqs = spec.get("prerequisites", []) + if prereqs: + emit("## Prerequisites") + emit() + for p in prereqs: + emit(f"- {p}") + emit() + emit("---") + emit() + + # ── Sections ────────────────────────────────────────────────────────── + step_number = 1 + + all_sections = spec.get("sections", []) + for si, section in enumerate(all_sections): + sec_title = section.get("title", "") + is_step = section.get("step", False) + is_last = (si == len(all_sections) - 1) + show_sep = is_step and not is_last + + if is_step: + emit(f"## Step {step_number}: {sec_title}") + step_number += 1 + else: + emit(f"## {sec_title}") + emit() + + sec_text = section.get("text", "") + if sec_text: + emit_block(expand_vars(sec_text)) + emit() + + # ── Logoscore section ───────────────────────────────────────── + logoscore_spec = section.get("logoscore") + if logoscore_spec: + setup_cmds = logoscore_spec.get("setup", []) + tests = logoscore_spec.get("tests", []) + + if setup_cmds: + emit("First, prepare the module for loading:") + emit() + emit("```bash") + for cmd in setup_cmds: + emit(cmd) + emit("```") + emit() + + if tests: + emit("Call methods and verify results:") + emit() + emit("```bash") + for i, t in enumerate(tests): + call = t.get("call", "") + expect = t.get("expect", "") + emit(f'logoscore -m ./modules -l calc_module -c "{call}"') + emit(f"# Expected: {expect}") + if i < len(tests) - 1: + emit() + emit("```") + emit() + + if show_sep: + emit("---") + emit() + continue + + # ── Basecamp section ────────────────────────────────────────── + basecamp_spec = section.get("basecamp") + if basecamp_spec: + install_as = basecamp_spec.get("install_as", "") + tests = basecamp_spec.get("tests", []) + + if install_as: + emit(f"Install the module as a **{install_as}** module, then verify:") + emit() + + if tests: + for t in tests: + emit(f"- {t.get('name', '')}") + emit() + + if show_sep: + emit("---") + emit() + continue + + # ── Steps ───────────────────────────────────────────────────── + steps = section.get("steps", []) + if not steps: + if show_sep: + emit("---") + emit() + continue + + sub_step = 1 + sec_num = step_number - 1 if is_step else None + + for step in steps: + title = step.get("title", "") + if title: + if sec_num is not None: + emit(f"### {sec_num}.{sub_step} {title}") + sub_step += 1 + else: + emit(f"### {title}") + emit() + + text = step.get("text", "") + if text: + emit_block(expand_vars(text)) + emit() + + # file action + file_spec = step.get("file", {}) + if file_spec: + path = file_spec.get("path", "") + encoding = file_spec.get("encoding", "") + lang = lang_for_path(path, file_spec.get("language")) + + if encoding == "base64": + emit(f"*Binary file: `{path}`*") + else: + emit(f"```{lang}") + content = file_spec.get("content", "") + emit_block(expand_vars(content)) + emit("```") + emit() + + # run action + run_cmd_str = step.get("run", "") + if run_cmd_str: + code_block = step.get("code_block", "") + emit("```bash") + if code_block: + emit_block(expand_vars(code_block)) + else: + emit(expand_vars(run_cmd_str)) + emit("```") + emit() + + # ui_test: render launch command if present + ui_test_spec = step.get("ui_test", {}) + if ui_test_spec: + launch = ui_test_spec.get("launch", "") + if launch: + emit("```bash") + emit(expand_vars(launch)) + emit("```") + emit() + + # check_file is runner-only verification, not rendered in markdown + + # post_text + post_text = step.get("post_text", "") + if post_text: + emit_block(expand_vars(post_text)) + emit() + + # extra_run (continuation command under the same heading) + extra = step.get("extra_run", {}) + if extra: + extra_code = extra.get("code_block", "") + extra_cmd = extra.get("run", "") + emit("```bash") + if extra_code: + emit_block(expand_vars(extra_code)) + elif extra_cmd: + emit(expand_vars(extra_cmd)) + emit("```") + emit() + extra_post = extra.get("post_text", "") + if extra_post: + emit_block(expand_vars(extra_post)) + emit() + + if show_sep: + emit("---") + emit() + + # Clean up triple+ blank lines to double + output_text = "\n".join(lines).rstrip() + "\n" + while "\n\n\n" in output_text: + output_text = output_text.replace("\n\n\n", "\n\n") + + with open(output_path, "w") as f: + f.write(output_text) + + print(f"Generated: {output_path}") + + +# ══════════════════════════════════════════════════════════════════════════════ +# CLI +# ══════════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + prog="tutorial_runner", + description="Tutorial Runner & Markdown Generator" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # ── run ─────────────────────────────────────────────────────────────── + run_parser = subparsers.add_parser("run", help="Execute a tutorial spec") + run_parser.add_argument("spec", help="Path to the YAML spec file") + run_parser.add_argument("--keep-workdir", action="store_true", + help="Don't delete the temp working directory on exit") + run_parser.add_argument("--workdir", default=None, + help="Use existing directory instead of creating a fresh one") + run_parser.add_argument("--verbose", action="store_true", + help="Print commands as they execute") + run_parser.add_argument("--basecamp-bin", default="", + help="Path to LogosBasecamp binary") + run_parser.add_argument("--qt-mcp", default="", + help="Path to logos-qt-mcp package") + run_parser.add_argument("--call-timeout", type=int, default=60, + help="Timeout for logoscore calls (default: 60)") + run_parser.add_argument("--continue-on-fail", action="store_true", + help="Don't stop at the first failure (default: stop)") + run_parser.add_argument("--release", default=None, + help="Git tag for GitHub URLs (overrides spec's 'release' field)") + run_parser.add_argument("--report", default=None, metavar="PATH", + help="Write a two-column HTML report (rendered tutorial + " + "the commands actually run and their output) to PATH") + + # ── generate ────────────────────────────────────────────────────────── + gen_parser = subparsers.add_parser("generate", help="Generate markdown from a spec") + gen_parser.add_argument("spec", help="Path to the YAML spec file") + gen_parser.add_argument("-o", "--output", default=None, + help="Output file path (default: uses spec's 'output' field)") + gen_parser.add_argument("--release", default=None, + help="Git tag for GitHub URLs (overrides spec's 'release' field)") + + args = parser.parse_args() + + if args.command == "run": + cmd_run(args) + elif args.command == "generate": + cmd_generate(args) + + +if __name__ == "__main__": + main() diff --git a/tutorial-cpp-ui-app.md b/tutorial-cpp-ui-app.md index e411dfb..ae85ae9 100644 --- a/tutorial-cpp-ui-app.md +++ b/tutorial-cpp-ui-app.md @@ -4,7 +4,7 @@ This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui- **What you'll build:** A `calc_ui_cpp` module with: -- A `.rep` file defining the remote interface (slots + properties) +- A `.rep` file defining the remote interface (slots) - 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 @@ -20,7 +20,7 @@ This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui- | QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) | | `.rep` file | Not needed | Required — defines the remote interface | -**Prerequisites:** +## Prerequisites - Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`) - Nix with flakes enabled @@ -63,21 +63,27 @@ The `.rep` file declares the interface. At build time, Qt's `repc` compiler gene - **`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 +Create a new directory and initialise it from the C++ backend UI template: + +`mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp` + ```bash -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. +```bash +git init && git add -A +``` + --- -## Step 2: metadata.json +## Step 2: `metadata.json` + +Replace the template contents with your plugin's details: ```json { @@ -85,20 +91,36 @@ This creates the template. We'll customize it for our calculator. "version": "1.0.0", "type": "ui_qml", "category": "tools", - "description": "Calculator C++ UI — QML view with process-isolated backend", + "description": "Calculator C++ UI — QML view with process-isolated backend for calc_module", "main": "calc_ui_cpp_plugin", "view": "qml/Main.qml", "icon": "icons/calc.png", "dependencies": ["calc_module"], "nix": { - "packages": { "build": [], "runtime": [] }, + "packages": { + "build": [], + "runtime": [] + }, "external_libraries": [], - "cmake": { "find_packages": [], "extra_sources": [] } + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": [], + "extra_link_libraries": [] + } } } ``` +Create the icon directory and add a placeholder icon (displayed in the `logos-basecamp` sidebar when the module is loaded): + +```bash +mkdir -p icons +# Copy any PNG here — or generate a 64×64 placeholder: +echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png +``` + Key fields: - `"type": "ui_qml"` — tells the builder this is a QML view module @@ -115,8 +137,6 @@ Create `src/calc_ui_cpp.rep`: ```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)) @@ -128,25 +148,25 @@ class CalcUiCpp This is the **single source of truth** for the remote interface. `repc` generates: - `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots the backend overrides -- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods and auto-synced properties +- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods -**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. +**SLOT** return values are delivered as `QRemoteObjectPendingReply` — use `logos.watch()` in QML to get them as JS Promises. You can also declare **PROP** entries (e.g. `PROP(QString status READWRITE)`) which auto-sync from the backend to the QML replica. --- -## Step 3.1: Update the interface header +## Step 4: Interface header -The scaffolded template may create an interface file like `src/ui_example_interface.h`. Rename it to match this tutorial and make sure the class/IID names are updated, or the plugin metadata wiring will break. +The scaffolded template creates a set of `ui_example` files (`src/ui_example.rep`, `src/ui_example_interface.h`, `src/ui_example_plugin.{h,cpp}`). We replace them with `calc_ui_cpp` equivalents, so remove the example sources first — leaving them around with mismatched class/IID names just invites build errors or plugin-load failures at runtime: ```bash -# If your scaffold created ui_example files, rename the interface header: -mv src/ui_example_interface.h src/calc_ui_cpp_interface.h +rm -f src/ui_example.rep src/ui_example_interface.h src/ui_example_plugin.h src/ui_example_plugin.cpp ``` -Set `src/calc_ui_cpp_interface.h` to: +Now create `src/calc_ui_cpp_interface.h`: ```cpp -#pragma once +#ifndef CALC_UI_CPP_INTERFACE_H +#define CALC_UI_CPP_INTERFACE_H #include #include @@ -160,9 +180,12 @@ public: #define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface" Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid) + +#endif // CALC_UI_CPP_INTERFACE_H ``` Your plugin header should then include `calc_ui_cpp_interface.h` and use: + - `Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")` - `Q_INTERFACES(CalcUiCppInterface)` @@ -170,7 +193,7 @@ If the interface filename or IID symbol doesn't match, you'll typically get buil --- -## Step 4: CMakeLists.txt +## Step 5: `CMakeLists.txt` ```cmake cmake_minimum_required(VERSION 3.14) @@ -195,17 +218,18 @@ logos_module( `REP_FILE` tells `logos_module()` to: 1. Run `repc` to generate source/replica headers -2. Generate `LogosViewPluginBase` (typed remoting base class) +2. Generate `CalcUiCppViewPluginBase` (typed remoting base class) 3. Build a separate `calc_ui_cpp_replica_factory` shared library --- -## Step 5: C++ Backend Plugin +## Step 6: C++ Backend Plugin -### `src/calc_ui_cpp_plugin.h` +### 6.1 `src/calc_ui_cpp_plugin.h` ```cpp -#pragma once +#ifndef CALC_UI_CPP_PLUGIN_H +#define CALC_UI_CPP_PLUGIN_H #include #include @@ -216,6 +240,9 @@ logos_module( class LogosAPI; class LogosModules; +// Inherits CalcUiCppSimpleSource (generated from calc_ui_cpp.rep) so +// enableRemoting() can publish the typed source and QML replicas get +// auto-synced properties + callable slots. class CalcUiCppPlugin : public CalcUiCppSimpleSource, public CalcUiCppInterface, public CalcUiCppViewPluginBase @@ -233,7 +260,8 @@ public: Q_INVOKABLE void initLogos(LogosAPI* api); - // Slots from .rep — override the generated virtuals + // Slots from calc_ui_cpp.rep — return values directly. The QML replica + // receives QRemoteObjectPendingReply; use logos.watch() in QML to get the value. int add(int a, int b) override; int multiply(int a, int b) override; int factorial(int n) override; @@ -247,6 +275,8 @@ private: LogosAPI* m_logosAPI = nullptr; LogosModules* m_logos = nullptr; }; + +#endif // CALC_UI_CPP_PLUGIN_H ``` Three base classes: @@ -255,23 +285,23 @@ Three base classes: - **`CalcUiCppInterface`** — standard Logos plugin interface (`name()`, `version()`) - **`CalcUiCppViewPluginBase`** — generated, provides `setBackend()` and `enableRemoting()` -### `src/calc_ui_cpp_plugin.cpp` +### 6.2 `src/calc_ui_cpp_plugin.cpp` ```cpp #include "calc_ui_cpp_plugin.h" #include "logos_api.h" #include "logos_sdk.h" -CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) - : CalcUiCppSimpleSource(parent) {} - +CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) : CalcUiCppSimpleSource(parent) {} CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; } void CalcUiCppPlugin::initLogos(LogosAPI* api) { + if (m_logos) return; m_logosAPI = api; m_logos = new LogosModules(api); - // Register this object as the Remote Objects source + // Register this object as the Remote Objects source so the QML replica + // can see its properties and call its slots. setBackend(this); } @@ -310,7 +340,7 @@ Key points: --- -## Step 6: QML View +## Step 7: QML View Create `src/qml/Main.qml`: @@ -319,19 +349,17 @@ 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 + // Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep). 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 : "" - + // logos.watch() delivers the result of a replica slot call via callbacks. + // No QtRemoteObjects import needed — the bridge handles it. function callCalc(method, args) { if (!backend) { root.errorText = "Backend not available" @@ -339,7 +367,6 @@ Item { } root.errorText = "" root.result = "..." - // logos.watch() wraps the pending reply in a JS Promise logos.watch(backend[method].apply(backend, args), function(value) { root.result = String(value) }, function(error) { root.errorText = String(error) } @@ -352,52 +379,84 @@ Item { spacing: 16 Text { - text: "Calculator (C++ backend)" + text: "Logos Calculator (C++ backend)" font.pixelSize: 20 color: "#ffffff" + Layout.alignment: Qt.AlignHCenter } RowLayout { spacing: 12 + Layout.fillWidth: true TextField { - id: inputA; placeholderText: "a" + id: inputA + placeholderText: "a" Layout.preferredWidth: 80 validator: IntValidator {} } + TextField { - id: inputB; placeholderText: "b" + id: inputB + placeholderText: "b" Layout.preferredWidth: 80 validator: IntValidator {} } + Button { text: "Add" - onClicked: root.callCalc("add", [parseInt(inputA.text) || 0, - parseInt(inputB.text) || 0]) + 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]) + onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0]) + } + } + + RowLayout { + spacing: 12 + Layout.fillWidth: true + + TextField { + id: inputN + placeholderText: "n" + Layout.preferredWidth: 80 + validator: IntValidator { bottom: 0 } + } + + Button { + text: "Factorial" + onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0]) + } + + Button { + text: "Fibonacci" + onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0]) + } + + Button { + text: "libcalc version" + onClicked: root.callCalc("libVersion", []) } } Rectangle { - Layout.fillWidth: true; height: 56 - color: root.errorText ? "#3d1a1a" : "#1a2d1a" + Layout.fillWidth: true + height: 56 + color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a" radius: 8 + Text { anchors.centerIn: parent - text: root.errorText || root.result || "Press a button" - color: root.errorText ? "#f85149" : "#56d364" + text: root.errorText.length > 0 ? root.errorText + : (root.result.length > 0 ? root.result : "Enter values and press a button") + color: root.errorText.length > 0 ? "#f85149" : "#56d364" font.pixelSize: 15 } } - Text { - text: "Backend status: " + root.status - color: "#8b949e"; font.pixelSize: 13 - } + Item { Layout.fillHeight: true } } } ``` @@ -405,13 +464,12 @@ Item { 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), ...)` — SLOT return value as JS Promise -- ``— required for`logos.watch()` +- The `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed --- -## Step 6.5: Use the Logos Design System in your QML +## Step 8: Use the Logos Design System in your QML The QML you load above runs inside the host (`logos-basecamp` / `logos-standalone-app`), which already has `logos-design-system` on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves. @@ -462,11 +520,13 @@ Feel free to report bugs, file feature requests, or contribute components / them --- -## Step 7: flake.nix +## Step 9: `flake.nix` + +The template already wires everything up. Update the description and point `calc_module` at your dependency: ```nix { - description = "Calculator C++ UI plugin — QML view with process-isolated backend"; + description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module"; inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; @@ -478,7 +538,7 @@ Feel free to report bugs, file feature requests, or contribute components / them # calc_module.url = "path:../logos-calc-module"; }; - outputs = inputs@{ logos-module-builder, ... }: + outputs = inputs@{ logos-module-builder, calc_module, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; @@ -498,20 +558,43 @@ The `calc_module` input attribute name must match the dependency name in `metada --- -## Step 8: Build and Run +## Step 10: Build and Run -First, make sure your local `calc_module` is built and its `.so`/`.dylib` is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): +First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): + +### 10.1 Ensure `calc_module` is built ```bash ls ../logos-calc-module/lib/libcalc.so # Linux ls ../logos-calc-module/lib/libcalc.dylib # macOS ``` -Then build and run. Choose the approach that matches your `flake.nix` setup: +If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)): + +```bash +cd ../logos-calc-module/lib +gcc -shared -fPIC -o libcalc.so libcalc.c # Linux +# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS +cd ../../logos-calc-ui-cpp +``` + +### 10.2 Lock and build + +Stage your files and lock the flake. Then build and run — choose the approach that matches your `flake.nix` setup. ```bash git add -A +``` +```bash +nix flake update +``` + +```bash +git add flake.lock +``` + +```bash # If flake.nix uses path:../logos-calc-module — just run directly: nix run @@ -522,12 +605,24 @@ nix run --override-input calc_module path:../logos-calc-module ./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module ``` -### Live reloading QML with `DEV_QML_PATH` +### 10.3 Launch and verify the UI -For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `qml/` at the repo root): +Launch the app and confirm the view loads with all of its controls. The backend runs in a separate `ui-host` process; clicking **Add** sends the call over Qt Remote Objects and the result comes back through `logos.watch()`. ```bash -DEV_QML_PATH=$PWD/qml nix run . +nix run . --override-input calc_module path:../logos-calc-module +``` + +The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end. + +--- + +## Step 11: Live reloading QML with `DEV_QML_PATH` + +For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `src/qml/`): + +```bash +DEV_QML_PATH=$PWD/src/qml nix run . ``` When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits to `Main.qml` (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files. @@ -546,7 +641,7 @@ nix build . # Subsequent runs: invoke the bundled standalone wrapper directly, # skipping nix entirely. DEV_QML_PATH still redirects QML loading. -DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui +DEV_QML_PATH=$PWD/src/qml ./result/bin/run-logos-standalone-ui ``` (Adjust the binary name to whatever `ls result/bin/` shows on your build.) @@ -557,7 +652,7 @@ DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui --- -## Step 9: How the Pieces Connect +## Step 12: How the Pieces Connect 1. `nix build` → compiles the C++ plugin + replica factory, bundles QML view 2. `nix run` → launches `logos-standalone-app` which: @@ -568,14 +663,17 @@ DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui 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) +## Step 13: UI Integration Tests Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework. Just create `.mjs` files in `tests/` and `logos-module-builder` auto-wires `nix build .#integration-test`. +Tests connect to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots. + +### 13.1 Create a test file + Create `tests/ui-tests.mjs`: ```javascript @@ -592,30 +690,35 @@ const { test, run } = await import( test("calc_ui_cpp: loads and shows title", async (app) => { await app.waitFor( async () => { - await app.expectTexts(["UI Example (C++ backend)"]); + await app.expectTexts(["Logos Calculator (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"]); +test("calc_ui_cpp: operation buttons visible", async (app) => { + await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]); }); run(); ``` +### 13.2 Run the tests + ```bash git add tests/ +``` +```bash # Hermetic CI test nix build .#integration-test -L +``` -# Interactive +The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`. + +To run tests interactively (against an already-running app): + +```bash nix build .#test-framework -o result-mcp nix run . # app with inspector on :3768 node tests/ui-tests.mjs # in another terminal @@ -632,12 +735,10 @@ node tests/ui-tests.mjs # in another terminal | **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` -- **Use the Logos Design System** in your QML — see [Step 6.5](#step-65-use-the-logos-design-system-in-your-qml). Browse components in the storybook (`cd repos/logos-design-system && nix run`); file issues at `logos-co/logos-design-system` (bugs on designed components, *feature* type for new components / variants / theme tokens). +- **Use the Logos Design System** in your QML — see [Step 8](#step-8-use-the-logos-design-system-in-your-qml). Browse components in the storybook (`cd repos/logos-design-system && nix run`); file issues at `logos-co/logos-design-system`. - See [logos-package-manager-ui](https://github.com/logos-co/logos-package-manager-ui) for a production example diff --git a/tutorial-qml-ui-app.md b/tutorial-qml-ui-app.md index a84a372..1217eef 100644 --- a/tutorial-qml-ui-app.md +++ b/tutorial-qml-ui-app.md @@ -11,7 +11,7 @@ This is Part 2 of the Logos module tutorial series. In [Part 1](tutorial-wrappin - The project structure and metadata for a QML plugin - How to package and install your UI into `logos-basecamp` -**Prerequisites:** +## Prerequisites - Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`) - Nix with flakes enabled (same as Part 1) @@ -40,20 +40,26 @@ Key points: - **The `logos` bridge** is injected by the host. Call core modules with `logos.callModule("module", "method", [args])`. - **Entry point** is defined by the required `"view"` field in `metadata.json` (for this tutorial it is `Main.qml`). ---- - ## Step 1: Scaffold -Use the QML module template from `logos-module-builder`: +Create a new directory and initialise it from the QML module template: + +`mkdir logos-calc-ui && cd logos-calc-ui` ```bash -mkdir logos-calc-ui && cd logos-calc-ui nix flake init -t github:logos-co/logos-module-builder#ui-qml -git init && git add -A ``` > **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in [Step 4](#step-4-update-flakenix) to ensure reproducible builds. +```bash +git init +``` + +```bash +git add -A +``` + This gives you: ``` @@ -361,18 +367,29 @@ The `calc_module.url` can be either: ```bash git add -A -nix flake update # regenerate flake.lock to match the pinned inputs in flake.nix +``` + +```bash +nix flake update +``` + +```bash git add flake.lock +``` + +```bash nix run . ``` The app opens immediately. No modules are loaded, so clicking buttons shows "Logos bridge not available" — but you can verify the layout and styling look correct. -### 5.2 Full functionality (with modules) +--- + +## Step 6: Full functionality (with modules) The standalone app automatically bundles and loads all module dependencies declared in `metadata.json`. To test with your local `calc_module` from Part 1, you first need to make sure it has been built and its shared library (`.so` on Linux, `.dylib` on macOS) is present. -#### Ensure `calc_module` is built +### 6.1 Ensure `calc_module` is built Go back to your `logos-calc-module` directory and verify the shared library exists: @@ -401,7 +418,7 @@ cd ../logos-calc-ui The `nix build` produces `result/lib/calc_module_plugin.so` (or `.dylib`), which is the compiled Qt plugin. The `lib/libcalc.so` (or `.dylib`) inside the source tree is the underlying C library that gets linked in during the build. -#### Option A: Use `--override-input` (quick, no flake.nix edits) +### 6.2 Option A: Use `--override-input` (quick, no flake.nix edits) If your `flake.nix` points to a `github:` URL, you can override it at build time to use your local checkout: @@ -411,7 +428,7 @@ nix run . --override-input calc_module path:../logos-calc-module This tells nix to resolve the `calc_module` flake input from your local directory instead of from the remote URL. Any changes you've made to `calc_module` locally (including the built `.so`/`.dylib` in `lib/`) are picked up immediately — no need to push to GitHub first. -#### Option B: Set `path:` in `flake.nix` (persistent local development) +### 6.3 Option B: Set `path:` in `flake.nix` (persistent local development) If you're iterating on both repos side by side, you can point the flake input directly to your local `calc_module` checkout. In `flake.nix`, change: @@ -432,7 +449,7 @@ nix run . This is convenient when you always want to build against the local copy. Switch back to `github:` when you're ready to pin to a published version. -#### Option C: Pin to the remote repo +### 6.4 Option C: Pin to the remote repo If `calc_module` has been pushed to the remote repository (with the `.so`/`.dylib` committed in `lib/`), the `github:` URL in `flake.nix` already points to it. A plain `nix run .` fetches and builds `calc_module` from the remote: @@ -446,7 +463,7 @@ Whichever option you choose, clicking **Add**, **Multiply**, **Factorial**, or * --- -## Step 6: Using the Logos Design System +## Step 7: Using the Logos Design System `logos-basecamp` (and `logos-standalone-app`) has `logos-design-system` on its QML import path. Use its themed components directly — no extra setup in your module. @@ -528,9 +545,9 @@ Feel free to report bugs, file feature requests, or contribute components / them --- -## Step 7: Load in `logos-basecamp` +## Step 8: Load in `logos-basecamp` -### 7.1 Bundle as LGX packages +### 8.1 Bundle as LGX packages Create `.lgx` packages for both dev and portable variants. Use `--out-link` to avoid overwriting the `result` symlink: @@ -548,16 +565,21 @@ nix build '.#lgx-portable' --out-link result-lgx-portable > For more bundling options (standalone bundler syntax, cross-platform packaging), see the [Developer Guide — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx). -### 7.2 Build and run logos-basecamp +```bash +nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable +``` + +### 8.2 Build and run logos-basecamp Build logos-basecamp, launch it once to preinstall its bundled modules, then install your modules. > **Note:** `logos-basecamp` does not accept `--modules-dir` or `--ui-plugins-dir` CLI flags. It manages its own data directory and preinstalls bundled modules (main_ui, package_manager, etc.) on first launch. ```bash -# Build logos-basecamp nix build 'github:logos-co/logos-basecamp' -o basecamp-result +``` +```bash # Launch once to preinstall bundled modules, then close it ./basecamp-result/bin/logos-basecamp ``` @@ -574,6 +596,8 @@ ls ~/.local/share/Logos/ The dev build directory is named `LogosBasecampDev` (portable builds use `LogosBasecamp`). +### 8.3 Install modules with lgpm + Install your modules using `lgpm`. First, set `BASECAMP_DIR` to your platform's path: ```bash @@ -585,9 +609,10 @@ BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev" ``` ```bash -# Build lgpm CLI nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm +``` +```bash # Install core module ./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \ install --file ../logos-calc-module/result-lgx/*.lgx @@ -600,14 +625,15 @@ nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm ./basecamp-result/bin/logos-basecamp ``` -### 7.3 Portable basecamp build (optional) +### 8.4 Portable basecamp build (optional) The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix: ```bash -# Build portable basecamp (bundles all Qt frameworks/libraries) nix build 'github:logos-co/logos-basecamp#bin-bundle-dir' -o basecamp-portable +``` +```bash # Launch once to preinstall bundled modules ./basecamp-portable/bin/logos-basecamp ``` @@ -639,7 +665,7 @@ Install your modules using the **portable** `.lgx` variants: > **Important:** Portable basecamp requires portable `.lgx` variants (`result-lgx-portable`), and the dev build requires dev variants (`result-lgx`). Mixing them will cause loading failures. -### 7.4 Install via logos-basecamp UI +### 8.5 Install via logos-basecamp UI Instead of using `lgpm` on the command line, you can install modules through the basecamp UI: @@ -651,7 +677,7 @@ Instead of using `lgpm` on the command line, you can install modules through the The "Calculator UI" tab appears in the sidebar. Clicking it loads your `Main.qml`. -### 7.5 Live reloading with `logos-standalone-app` +### 8.6 Live reloading with `logos-standalone-app` For QML iteration, set `DEV_QML_PATH` to the directory that contains your view entry file (the basename from `metadata.json` `view` must exist under that directory). For this tutorial's layout (`view`: `Main.qml` at repo root): @@ -684,7 +710,7 @@ DEV_QML_PATH=$PWD ./result/bin/run-logos-standalone-ui > This does not work with `logos-basecamp`. Basecamp loads QML plugins from its own data directory, so changes to your source files are not reflected until you rebuild and reinstall the `.lgx` package. -### 7.6 Testing without any runtime +### 8.7 Testing without any runtime You can open `Main.qml` in any QML viewer (e.g., `qml` from Qt) to test the layout. @@ -713,13 +739,13 @@ qml6 Main.qml --- -## Step 8: UI Integration Tests (Optional) +## Step 9: UI Integration Tests You can add automated UI tests that verify your QML plugin renders correctly. The test infrastructure is built into `logos-module-builder` — just add `.mjs` test files to a `tests/` directory and you get `nix build .#integration-test` for free. Tests use the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework, which connects to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots. -### 8.1 Create a test file +### 9.1 Create a test file Create `tests/ui-tests.mjs`: @@ -737,7 +763,7 @@ const { test, run } = await import( test("calc_ui: loads and shows title", async (app) => { await app.waitFor( async () => { - await app.expectTexts(["Calculator"]); + await app.expectTexts(["Logos Calculator"]); }, { timeout: 15000, interval: 500, description: "calc_ui to load" }, ); @@ -747,38 +773,41 @@ test("calc_ui: add button visible", async (app) => { await app.expectTexts(["Add"]); }); -test("calc_ui: click add and check result", async (app) => { +test("calc_ui: click add shows validation", async (app) => { await app.click("Add"); - // Verify the result appears (depends on your UI) await app.waitFor( async () => { - await app.expectTexts(["Result:"]); + await app.expectTexts(["Enter values for a and b"]); }, - { timeout: 5000, interval: 500, description: "result to appear" }, + { timeout: 5000, interval: 500, description: "validation message to appear" }, ); }); run(); ``` -### 8.2 Run the tests +### 9.2 Run the tests ```bash git add tests/ +``` -# Hermetic CI test (builds everything, runs headless) +```bash nix build .#integration-test -L - -# Interactive: build test framework, run against a running app -nix build .#test-framework -o result-mcp -nix run . # start the app with inspector on :3768 -node tests/ui-tests.mjs # in another terminal ``` The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`. You can have multiple test files (e.g., `tests/smoke.mjs`, `tests/interactions.mjs`) — they are all discovered and run automatically. +To run tests interactively (against an already-running app): + +```bash +nix build .#test-framework -o result-mcp +nix run . # start the app with inspector on :3768 +node tests/ui-tests.mjs # in another terminal +``` + --- ## Known Limitations @@ -822,8 +851,6 @@ rm -rf ~/.local/share/Logos/LogosBasecampDev Then reinstall your custom modules. ---- - ## Recap | | Core Module (Part 1) | QML UI Plugin (Part 2) | @@ -835,8 +862,6 @@ Then reinstall your custom modules. | Test command | `logoscore -m ./result/lib -l calc_module` | `nix run .` | | Calls other modules | Via `LogosAPI*` (C++) | Via `logos.callModule()` (JS) | ---- - ## What's Next - **Add more methods** to `calc_module` and call them from QML diff --git a/tutorial-wrapping-c-library.md b/tutorial-wrapping-c-library.md index d9f66c7..0144c99 100644 --- a/tutorial-wrapping-c-library.md +++ b/tutorial-wrapping-c-library.md @@ -13,16 +13,15 @@ This tutorial walks you through wrapping a C shared library (`.so` on Linux, `.d ## Prerequisites -- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes globally: - ```bash - # Add to ~/.config/nix/nix.conf (create the file if it doesn't exist): - mkdir -p ~/.config/nix - echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf - ``` - Verify it works: - ```bash - nix flake --help >/dev/null 2>&1 && echo "Flakes enabled" || echo "Flakes NOT enabled — check nix.conf" - ``` +- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes: + +```bash +mkdir -p ~/.config/nix +echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf +``` + +Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"` + - **A C compiler** (gcc or clang) for building the C library. Only needed if you're building the `.so`/`.dylib` yourself rather than using a pre-built library. - Basic familiarity with C and C++. @@ -34,28 +33,36 @@ Before writing any C code, scaffold the Logos module project using the official ### 1.1 Create the project using the module builder template +For a module that wraps an external C library: + +`mkdir logos-calc-module && cd logos-calc-module` + ```bash -# For a module that wraps an external C library: -mkdir logos-calc-module && cd logos-calc-module nix flake init -t github:logos-co/logos-module-builder#with-external-lib # Or for a plain module (no external library): # nix flake init -t github:logos-co/logos-module-builder ``` -> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in [Step 2.3](#23-flakenix--nix-build-config) to ensure reproducible builds. - This generates the skeleton files (`flake.nix`, `metadata.json`, `CMakeLists.txt`, etc.) pre-configured for the logos-module-builder. You then customize them for your specific library. +> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in the flake.nix step below to ensure reproducible builds. + > **Alternative approach:** You can also create the C library as a separate project, build it there, then copy the resulting `.so`/`.dylib` and header files into the module's `lib/` directory. This can be cleaner for larger libraries with their own build systems. -### 1.2 Create the lib directory +--- + +## Step 2: Write the C Library + +Create the C library that your module will wrap. Place the header and implementation in the `lib/` directory. + +### 2.1 Create the lib directory ```bash mkdir -p lib ``` -### 1.3 Write the C header +### 2.2 Write the C header Create `lib/libcalc.h`: @@ -91,7 +98,7 @@ const char* calc_version(void); The `extern "C"` block is essential — it prevents C++ name mangling so the Logos module can find the symbols. -### 1.4 Write the C implementation +### 2.3 Write the C implementation Create `lib/libcalc.c`: @@ -139,7 +146,7 @@ const char* calc_version(void) } ``` -### 1.5 Build the shared library +### 2.4 Build the shared library ```bash cd lib @@ -177,9 +184,11 @@ You should see each symbol marked with `T` (text/code section). Addresses will v --- -## Step 2: Configure the Logos Module +## Step 3: Configure the Logos Module -The template from Step 1.1 generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**: +The template generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**. + +After editing, your project should look like this: | File | What to change | | ---------------------- | ----------------------------------------------------------------- | @@ -188,24 +197,21 @@ The template from Step 1.1 generated skeleton files with placeholder names (`ext | `flake.nix` | Description (and dependency inputs if needed) | | `src/*.h`, `src/*.cpp` | Rename files, replace class/method names, add your wrapping logic | -After renaming and editing, your project should look like this: - ``` logos-calc-module/ ├── flake.nix # Nix build configuration (~10 lines) -├── metadata.json # Module metadata, build settings, and runtime config (~25 lines) -├── CMakeLists.txt # CMake build file (~20 lines) +├── metadata.json # Module metadata, build settings, and runtime config +├── CMakeLists.txt # CMake build file ├── lib/ │ ├── libcalc.h # C library header -│ ├── libcalc.c # C library source -│ └── libcalc.so # Pre-built shared library +│ └── libcalc.c # C library source (compiled by CMake) └── src/ ├── calc_module_interface.h # Interface declaration ├── calc_module_plugin.h # Plugin header └── calc_module_plugin.cpp # Plugin implementation (wrapping logic) ``` -### 2.1 `metadata.json` — Module Configuration +### 3.1 `metadata.json` — Module Configuration > **Edit:** Change `name`, `description`, `main`, `nix.external_libraries[].name`, and `nix.cmake.extra_include_dirs` to match your module and library. @@ -247,11 +253,10 @@ This is the single source of truth for your module. It is embedded into the plug | Field | What it does | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Module name — must be a valid C identifier (used in filenames, method calls) | -| `nix.external_libraries[].name` | Library name **without the `lib` prefix** — the builder looks for `lib.so` / `lib.dylib` in the directory specified by `vendor_path`. So `name: calc` matches the file `libcalc.so` / `libcalc.dylib`. This follows the standard Unix library naming convention where `-lcalc` links against `libcalc`. | -| `nix.external_libraries[].vendor_path` | Where to find the pre-built library. `"lib"` means the `lib/` directory in your project root | -| `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "lib/libcalc.h"` | +| `nix.external_libraries` | Declares C/C++ libraries vendored in the repo. Each entry has a `name` (used for the Nix derivation and CMake target) and `vendor_path` (directory containing the source). The build system compiles the library and makes it available as a CMake target | +| `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "libcalc.h"` | -### 2.2 `CMakeLists.txt` — Build File +### 3.2 `CMakeLists.txt` — Build File > **Edit:** Change `project()` name, `NAME`, `SOURCES` filenames, and `EXTERNAL_LIBS` to match your module and library. @@ -293,9 +298,9 @@ The `if/elseif/else` block above it is boilerplate — don't change it. **How `EXTERNAL_LIBS calc` works:** The `logos_module()` CMake function searches `lib/` for `libcalc.so` (Linux) or `libcalc.dylib` (macOS), links it to your plugin, and sets up RPATH so the library is found at runtime. -### 2.3 `flake.nix` — Nix Build Config +### 3.3 `flake.nix` — Nix Build Config -> **Edit:** Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source (see [Advanced: Wrapping a Library from a Flake Input](#advanced-wrapping-a-library-from-a-flake-input)). +Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source. ```nix { @@ -316,13 +321,11 @@ The `if/elseif/else` block above it is boilerplate — don't change it. That's it — `mkLogosModule` handles all the Nix complexity (fetching Qt, the SDK, the code generator, setting up include paths, etc.). Note that `configFile` points to `metadata.json` (the single source of truth) and `flakeInputs = inputs` passes all flake inputs to the builder so that dependencies declared in `metadata.json` are resolved automatically. -> **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`. The URL can point to any repo, but the attribute name is how the builder resolves dependencies. +> **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`. -### 2.4 `src/calc_module_interface.h` — Interface Declaration +### 3.4 `src/calc_module_interface.h` — Interface Declaration -> **Edit:** Rename from `external_lib_interface.h`. Replace the class name, interface ID, include guard, and declare your module's methods as `Q_INVOKABLE virtual` pure-virtual functions. - -This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK). +This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK). Every method you want callable by other modules must be `Q_INVOKABLE` and `virtual`. ```cpp #ifndef CALC_MODULE_INTERFACE_H @@ -356,9 +359,7 @@ Q_DECLARE_INTERFACE(CalcModuleInterface, CalcModuleInterface_iid) - Supported parameter/return types: `int`, `bool`, `QString`, `QByteArray`, `QVariant`, `QJsonArray`, `QStringList`, `LogosResult` - The interface ID string (e.g., `"org.logos.CalcModuleInterface"`) must be unique across all modules -### 2.5 `src/calc_module_plugin.h` — Plugin Header - -> **Edit:** Rename from `external_lib_plugin.h`. Replace class name, interface references, `name()`/`version()` return values, and declare your `Q_INVOKABLE` wrapper methods. Add `#include` for your C library header. +### 3.5 `src/calc_module_plugin.h` — Plugin Header This is the actual plugin class. It inherits from both `QObject` (for Qt's meta-object system) and your interface. @@ -385,15 +386,13 @@ public: explicit CalcModulePlugin(QObject* parent = nullptr); ~CalcModulePlugin() override; - // PluginInterface — required by every module + // PluginInterface QString name() const override { return "calc_module"; } QString version() const override { return "1.0.0"; } - // Called by the Logos host when the module is loaded. - // NOT marked override — it is invoked reflectively via QMetaObject. Q_INVOKABLE void initLogos(LogosAPI* api); - // CalcModuleInterface — each wraps a libcalc C function + // CalcModuleInterface Q_INVOKABLE int add(int a, int b) override; Q_INVOKABLE int multiply(int a, int b) override; Q_INVOKABLE int factorial(int n) override; @@ -416,11 +415,9 @@ signals: - `initLogos` must be `Q_INVOKABLE` but **not** `override` — the base class `PluginInterface` does not declare it as virtual; the Logos host calls it reflectively via `QMetaObject::invokeMethod` - `eventResponse` signal is required for event forwarding between modules. Emit it to push data to subscribers (e.g., QML UIs listening via `logos.onModuleEvent()`) - `name()` must return the same string as the `name` field in `metadata.json` -- **No `m_logosAPI` member variable** — the `LogosAPI`\* pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below. +- **No `m_logosAPI` member variable** — the `LogosAPI*` pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below. -### 2.6 `src/calc_module_plugin.cpp` — Plugin Implementation - -> **Edit:** Rename from `external_lib_plugin.cpp`. Replace the placeholder implementations with actual calls to your C library functions. +### 3.6 `src/calc_module_plugin.cpp` — Plugin Implementation This is where the wrapping happens. Each method calls the corresponding C function. @@ -442,16 +439,12 @@ CalcModulePlugin::~CalcModulePlugin() void CalcModulePlugin::initLogos(LogosAPI* api) { - // IMPORTANT: Use the global `logosAPI` variable from liblogos, NOT a class member. - // `logosAPI` is defined in the Logos SDK headers and is used by the API - // internally. Storing the pointer in a local `m_logosAPI` member will NOT work. logosAPI = api; qDebug() << "CalcModulePlugin: LogosAPI initialized"; } int CalcModulePlugin::add(int a, int b) { - // Call the C library function int result = calc_add(a, b); qDebug() << "CalcModulePlugin::add" << a << "+" << b << "=" << result; return result; @@ -498,20 +491,20 @@ void CalcModulePlugin::libVersionNotify() **The wrapping pattern** is always the same: 1. Call the C function with the arguments -2. Convert the C result to a Qt type if needed (e.g., `const char`\* → `QString`) +2. Convert the C result to a Qt type if needed (e.g., `const char*` → `QString`) 3. Return the Qt type --- -## Step 3: Build the Module +## Step 4: Build the Module -### 3.1 Initialize the Git repo +### 4.1 Initialize the Git repo Nix flakes require a git repository. Before staging files, create a `.gitignore` to exclude build artifacts: -``` +```text # Nix build output result result-* @@ -523,28 +516,42 @@ build/ Then initialise the repo: ```bash -cd logos-calc-module git init +``` + +```bash git add -A +``` + +```bash nix flake update +``` + +```bash git add flake.lock ``` -### 3.2 Build with Nix +### 4.2 Build the plugin library + +Build just the plugin library (`.so` / `.dylib`): ```bash -# Build just the plugin library (.so / .dylib) nix build '.#lib' +``` -# Build everything (library + generated SDK headers) +> **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character. + +The first build takes a while (5–15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching. + +### 4.3 Build the full package + +Build everything (library + generated SDK headers): + +```bash nix build ``` -> **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character, causing the command to silently build the wrong thing or fail. - -The first build takes a while (5-15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching. - -### 3.3 Inspect the output +### 4.4 Inspect the output ```bash ls -la result/lib/ @@ -566,17 +573,19 @@ Both library files are placed together so the plugin can find the C library at r --- -## Step 4: Inspect the Module +## Step 5: Inspect the Module -### 4.1 Build the `lm` tool +Use the `lm` CLI tool (from `logos-module`) to inspect the compiled module binary. -The `lm` CLI tool (from `logos-module`) inspects compiled module binaries: +### 5.1 Build the `lm` tool + +The `lm` CLI inspects compiled module binaries. Build it from the `logos-module` repo: ```bash nix build 'github:logos-co/logos-module#lm' --out-link ./lm ``` -### 4.2 View metadata +### 5.2 View metadata ```bash # Linux @@ -599,7 +608,7 @@ Type: core Dependencies: (none) ``` -### 4.3 List methods +### 5.3 List methods ```bash # Linux @@ -646,7 +655,7 @@ QString libVersion() All five wrapping methods are visible and invokable. The `initLogos` method is automatically called by the Logos host when loading the module. -### 4.4 JSON output +### 5.4 JSON output For scripting and CI, use `--json`: @@ -676,25 +685,31 @@ For scripting and CI, use `--json`: --- -## Step 5: Test with `logoscore` +## Step 6: Test with `logoscore` -### 5.1 Build logoscore +### 6.1 Build logoscore ```bash nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos ``` -### 5.2 Set up the modules directory +### 6.2 Set up the modules directory `logoscore` expects modules in subdirectories, each with a `manifest.json`. Rather than copying files and writing the manifest manually, use the Nix derivation to create an LGX package and install it with the package manager: ```bash -# Bundle the module into an LGX package nix build '.#lgx' +``` -# Install it into a modules directory using the Logos Package Manager +```bash nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm +``` + +```bash mkdir -p modules +``` + +```bash ./pm/bin/lgpm --modules-dir ./modules install --file result/*.lgx ``` @@ -708,24 +723,39 @@ modules/calc_module/ └── variant # Platform variant identifier ``` -### 5.3 Call methods +### 6.3 Call methods Start the daemon and call methods: ```bash -# Start logoscore daemon with modules directory ./logos/bin/logoscore -D -m ./modules & +``` -# Load the module +```bash +sleep 3 +``` + +```bash ./logos/bin/logoscore load-module calc_module +``` -# Call methods +```bash ./logos/bin/logoscore call calc_module add 3 5 -./logos/bin/logoscore call calc_module factorial 5 -./logos/bin/logoscore call calc_module fibonacci 10 -./logos/bin/logoscore call calc_module libVersion +``` -# Stop the daemon when done +```bash +./logos/bin/logoscore call calc_module factorial 5 +``` + +```bash +./logos/bin/logoscore call calc_module fibonacci 10 +``` + +```bash +./logos/bin/logoscore call calc_module libVersion +``` + +```bash ./logos/bin/logoscore stop ``` @@ -757,7 +787,7 @@ Method call successful. Result: ... --- -## Step 6: Package for Distribution (Optional) +## Package for Distribution (Optional) The LGX package created in Step 5.2 is a **local** package — its libraries still reference `/nix/store` paths, so it only works on the machine that built it. To create a **portable** package that can be distributed to other machines: @@ -785,8 +815,6 @@ nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm > **Note:** Local builds of `logoscore` / `logos-basecamp` (via `nix build`) expect **local** `.lgx` packages. Portable builds (via `nix build '.#bin-bundle-dir'`, `.#bin-appimage`, or `.#bin-macos-app`) expect **portable** `.lgx` packages. See the [logos-basecamp README](https://github.com/logos-co/logos-basecamp/blob/master/README.md) for details. ---- - ## Common Wrapping Patterns ### Wrapping C functions with opaque pointers @@ -881,8 +909,6 @@ Q_INVOKABLE QString getData() { | `bool` / `int` | `bool` | `result != 0` | direct | | `void*` | (store in member) | — | — | ---- - ## Advanced: Wrapping a Library from a Flake Input Instead of pre-building the library and placing it in `lib/`, you can have Nix fetch and build it from source. This is useful for libraries hosted on GitHub. @@ -977,8 +1003,6 @@ If the external library is written in Go with C bindings (`cgo`), set `go_build: Setting `go_build: true` enables the Go toolchain and sets `CGO_ENABLED=1`. ---- - ## Real-World Example: logos-libp2p-module The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a production module that wraps the `nim-libp2p` library (compiled to a C shared library). Key files: @@ -990,8 +1014,6 @@ The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a It follows the exact same pattern as this tutorial, just at a larger scale. ---- - ## Troubleshooting ### `initLogos` marked 'override', but does not override