From 470e06c6c365982dc3db226276e8ee1b2ee2a3bb Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 15 Aug 2026 00:39:29 -0300 Subject: [PATCH] feat(token-ui): integrate Basecamp token module Add portable Basecamp packaging and an inspector-driven end-to-end flow. --- apps/token/flake.nix | 11 ++ apps/token/icons/token.png | Bin 5767 -> 723 bytes apps/token/metadata.json | 1 + apps/token/qml/Logos/Wallet/qmldir | 5 - apps/token/qml/Main.qml | 3 + apps/token/qml/{ => chrome}/NavBar.qml | 1 + apps/token/qml/pages/CreatePage.qml | 59 +++++- apps/token/qml/pages/ManagePage.qml | 21 ++- apps/token/tests/README.md | 62 ++++++ apps/token/tests/run-basecamp-e2e.sh | 98 ++++++++++ apps/token/tests/token-definition.mjs | 249 +++++++++++++++++++++++++ flake.nix | 70 ++++++- 12 files changed, 563 insertions(+), 17 deletions(-) delete mode 100644 apps/token/qml/Logos/Wallet/qmldir rename apps/token/qml/{ => chrome}/NavBar.qml (98%) create mode 100644 apps/token/tests/README.md create mode 100755 apps/token/tests/run-basecamp-e2e.sh create mode 100644 apps/token/tests/token-definition.mjs diff --git a/apps/token/flake.nix b/apps/token/flake.nix index 870cc69..0cd5203 100644 --- a/apps/token/flake.nix +++ b/apps/token/flake.nix @@ -32,7 +32,18 @@ walletQmlInstallDir="$out/lib/Logos/Wallet" mkdir -p "$walletQmlInstallDir" cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + # Basecamp ui_qml views run in a sandboxed QML host and cannot load + # native plugins from an imported QML module. Token already exposes + # wallet operations through TokenUiBackend, so the shared wallet + # controls only need their pure-QML files here. + sed -i -E '/^(linktarget|optional plugin|classname|typeinfo|prefer)/d' \ + "$walletQmlInstallDir/qmldir" + find "$walletQmlInstallDir" -maxdepth 1 -type f \ + \( -name 'liblogos_wallet_qml*' -o -name 'plugins.qmltypes' \ + -o -name '*module_dir_map.qrc' \) -delete test -f "$walletQmlInstallDir/qmldir" + grep -q '^module Logos.Wallet$' "$walletQmlInstallDir/qmldir" + grep -q '^WalletControl 1.0 WalletControl.qml$' "$walletQmlInstallDir/qmldir" ''; }; } diff --git a/apps/token/icons/token.png b/apps/token/icons/token.png index 05731f5163b50419209621f4f7d251a0f689c181..7a50f956815f57b6c94433785b08ba0ea96207c9 100644 GIT binary patch delta 668 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 0 ? "#F08A76" : "#8E8780" font.pixelSize: 12 @@ -257,6 +260,8 @@ Item { Button { id: refreshDefinitionsButton + objectName: "tokenRefreshButton" + Layout.preferredWidth: 86 Layout.preferredHeight: 32 enabled: root.backend !== null && root.backend.isWalletOpen && !root.loading @@ -339,6 +344,8 @@ Item { TextField { id: searchField + objectName: "tokenSearchField" + Layout.fillWidth: true Layout.preferredHeight: 42 Accessible.name: qsTr("Search token definitions") @@ -364,6 +371,8 @@ Item { Button { id: allFilterButton + objectName: "tokenAllFilterButton" + Layout.preferredHeight: 30 activeFocusOnTab: true Accessible.name: qsTr("Show all token definitions") @@ -389,6 +398,8 @@ Item { Button { id: fungibleFilterButton + objectName: "tokenFungibleFilterButton" + Layout.preferredHeight: 30 activeFocusOnTab: true Accessible.name: qsTr("Show fungible definitions") @@ -414,6 +425,8 @@ Item { Button { id: nftFilterButton + objectName: "tokenNftFilterButton" + Layout.preferredHeight: 30 activeFocusOnTab: true Accessible.name: qsTr("Show non-fungible definitions") @@ -459,6 +472,8 @@ Item { delegate: Rectangle { id: definitionRow + objectName: "tokenDefinitionRow" + required property var modelData width: definitionList.width @@ -802,7 +817,7 @@ Item { Text { Layout.fillWidth: true - visible: root.hasSelection && root.selectedDefinition.definitionHex + visible: root.hasSelection && !!root.selectedDefinition.definitionHex Layout.preferredHeight: visible ? implicitHeight : 0 color: "#8E8780" elide: Text.ElideMiddle @@ -1118,7 +1133,7 @@ Item { visible: holdingRow.modelData.displayBalance !== undefined || holdingRow.modelData.printBalance !== undefined color: "#8E8780" font.pixelSize: 11 - text: holdingRow.modelData.printBalance !== undefined ? qsTr("print balance") : holdingRow.modelData.displayBalance + text: holdingRow.modelData.printBalance !== undefined ? qsTr("print balance") : root.valueOrDash(holdingRow.modelData.displayBalance) } } } @@ -1211,7 +1226,7 @@ Item { color: "#A9A098" font.pixelSize: 13 wrapMode: Text.Wrap - text: root.hasSelection ? root.selectedDefinition.description : "" + text: root.hasSelection ? (root.selectedDefinition.description || "") : "" } Rectangle { diff --git a/apps/token/tests/README.md b/apps/token/tests/README.md new file mode 100644 index 0000000..a51135b --- /dev/null +++ b/apps/token/tests/README.md @@ -0,0 +1,62 @@ +# Token UI Basecamp E2E + +`token-definition.mjs` drives the running Token UI through the Logos QML +inspector, the same path used by Basecamp UI tests. + +The hermetic Basecamp runner follows the AMM and Logos Palace harnesses: it +uses the inspector-enabled portable Basecamp bundle, stages portable installs +under a fresh `--user-dir`, launches with `-platform offscreen`, waits for the +inspector, isolates the wallet home under the run directory, then stores +screenshots from the same inspector connection. + +Run the full Basecamp flow: + +```bash +apps/token/tests/run-basecamp-e2e.sh +``` + +The runner builds these inputs when not supplied through environment +overrides: + +- `logos-qt-mcp` test framework +- inspector-enabled Basecamp bundle +- `logos_execution_zone` portable core module +- `token_module` portable core module +- `token_ui` portable UI plugin + +Override already-built inputs with `LOGOS_QT_MCP`, `TOKEN_BASECAMP_BUNDLE`, +`TOKEN_WALLET_INSTALL`, `TOKEN_MODULE_INSTALL`, or `TOKEN_UI_INSTALL`. + +Build only the inspector framework: + +```bash +nix build .#test-framework -o apps/token/result-mcp +``` + +For manual runs, stage the portable install outputs into a Basecamp user +directory: `.#install-portable` for `token_module`, +`.#token-ui-install-portable` for `token_ui`, and the matching portable +`logos_execution_zone` install. Launch the inspector-enabled bundle with +`--user-dir -platform offscreen`. + +Run the non-mutating visual flow: + +```bash +node apps/token/tests/token-definition.mjs +``` + +Run the live round trip against an open wallet and reachable sequencer: + +```bash +TOKEN_E2E_LIVE=1 \ +TOKEN_E2E_NAME="Basecamp Token E2E" \ +node apps/token/tests/token-definition.mjs +``` + +The live path creates fresh public wallet accounts, submits a fixed-supply +definition through `token_module`, switches to Inspect, and waits for the +definition to be read back from the connected wallet. + +Screenshots are written to `.3esmit/projects/lez-programs/docs/token-basecamp-e2e/` +after each major step. The inspector listens on `localhost:3768`; override it with +`QML_INSPECTOR_PORT` when another test is using that port. diff --git a/apps/token/tests/run-basecamp-e2e.sh b/apps/token/tests/run-basecamp-e2e.sh new file mode 100755 index 0000000..8015d41 --- /dev/null +++ b/apps/token/tests/run-basecamp-e2e.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P)" +output_dir="${TOKEN_E2E_OUTPUT:-${repo_root}/.3esmit/projects/lez-programs/docs/token-basecamp-e2e}" +run_root="${TOKEN_E2E_RUN_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/token-basecamp-e2e.XXXXXX")}" +inspector_port="${QML_INSPECTOR_PORT:-3768}" +basecamp_pid="" + +cleanup() { + local status=$? + set +e + if [[ -n "${basecamp_pid}" ]] && kill -0 "${basecamp_pid}" 2>/dev/null; then + kill "${basecamp_pid}" 2>/dev/null + wait "${basecamp_pid}" 2>/dev/null + fi + if (( status != 0 )); then + printf 'Basecamp E2E run retained for diagnosis: %s\n' "${run_root}" >&2 + if [[ -f "${run_root}/basecamp.log" ]]; then + tail -120 "${run_root}/basecamp.log" >&2 + fi + else + printf 'Basecamp E2E run: %s\n' "${run_root}" + fi + exit "${status}" +} +trap cleanup EXIT + +mkdir -p "${output_dir}" "${run_root}/user/modules" "${run_root}/user/plugins" +wallet_home="${run_root}/wallet" +mkdir -p "${wallet_home}" + +build_if_missing() { + local override="$1" + local output="$2" + shift 2 + if [[ -n "${override}" ]]; then + printf '%s\n' "${override}" + return + fi + nix build "$@" -o "${output}" + printf '%s\n' "${output}" +} + +mcp_root="$(build_if_missing "${LOGOS_QT_MCP:-}" "${run_root}/result-mcp" .#test-framework)" +bundle_root="$(build_if_missing "${TOKEN_BASECAMP_BUNDLE:-}" "${run_root}/result-bundle" github:logos-co/logos-basecamp#bin-bundle-dir-inspector)" +wallet_install="$(build_if_missing "${TOKEN_WALLET_INSTALL:-}" "${run_root}/wallet-install" \ + 'github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr#install-portable' \ + --override-input logos-execution-zone \ + 'github:logos-blockchain/logos-execution-zone?rev=415964d7f9043a1bfe28da8d0e8b3a6f64abb258')" +token_install="$(build_if_missing "${TOKEN_MODULE_INSTALL:-}" "${run_root}/token-install" .#install-portable)" +ui_install="$(build_if_missing "${TOKEN_UI_INSTALL:-}" "${run_root}/token-ui-install" .#token-ui-install-portable)" + +cp -RL "${wallet_install}/modules/." "${run_root}/user/modules/" +cp -RL "${token_install}/modules/." "${run_root}/user/modules/" +cp -RL "${ui_install}/plugins/." "${run_root}/user/plugins/" + +basecamp_bin="${bundle_root}/bin/LogosBasecamp" +if [[ ! -x "${basecamp_bin}" ]]; then + printf 'Basecamp binary unavailable: %s\n' "${basecamp_bin}" >&2 + exit 2 +fi + +QT_QPA_PLATFORM=offscreen \ +QT_FORCE_STDERR_LOGGING=1 \ +QML_DISABLE_DISK_CACHE=1 \ +QML_INSPECTOR_PORT="${inspector_port}" \ +LEE_WALLET_HOME_DIR="${wallet_home}" \ +NSSA_WALLET_HOME_DIR="${wallet_home}" \ +LD_LIBRARY_PATH="${bundle_root}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ +"${basecamp_bin}" --user-dir "${run_root}/user" -platform offscreen \ + >"${run_root}/basecamp.log" 2>&1 & +basecamp_pid=$! + +inspector_ready=0 +for _ in $(seq 1 120); do + if (exec 3<>"/dev/tcp/127.0.0.1/${inspector_port}") 2>/dev/null; then + exec 3>&- 3<&- + inspector_ready=1 + break + fi + if ! kill -0 "${basecamp_pid}" 2>/dev/null; then + printf 'Basecamp exited before inspector startup\n' >&2 + exit 1 + fi + sleep 0.5 +done + +if (( inspector_ready != 1 )); then + printf 'Inspector did not become ready on port %s\n' "${inspector_port}" >&2 + exit 1 +fi + +LOGOS_QT_MCP="${mcp_root}" \ +QML_INSPECTOR_PORT="${inspector_port}" \ +TOKEN_E2E_OUTPUT="${output_dir}" \ +node "${repo_root}/apps/token/tests/token-definition.mjs" diff --git a/apps/token/tests/token-definition.mjs b/apps/token/tests/token-definition.mjs new file mode 100644 index 0000000..c88b148 --- /dev/null +++ b/apps/token/tests/token-definition.mjs @@ -0,0 +1,249 @@ +// Token UI end-to-end test for Logos Basecamp. +// +// Normal mode drives the complete visible create -> inspect shell without +// mutating chain state. Set TOKEN_E2E_LIVE=1 to create fresh wallet accounts, +// submit a fixed-supply definition through token_module, and verify it appears +// in the live Inspect view. + +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; + +const fwRoot = + process.env.LOGOS_QT_MCP || + new URL("../result-mcp", import.meta.url).pathname; +const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); + +const live = process.env.TOKEN_E2E_LIVE === "1"; +const evidenceDir = process.env.TOKEN_E2E_OUTPUT + ? resolve(process.env.TOKEN_E2E_OUTPUT) + : fileURLToPath( + new URL( + "../../../.3esmit/projects/lez-programs/docs/token-basecamp-e2e/", + import.meta.url, + ), + ); +const tokenName = process.env.TOKEN_E2E_NAME || `Basecamp E2E ${Date.now()}`; + +async function idByObjectName(app, name) { + const result = await app.findByProperty("objectName", name); + if (result.error || !result.matches || result.matches.length === 0) + throw new Error(`no object with objectName="${name}"`); + return result.matches[0].id; +} + +async function prop(app, objectId, property) { + const result = await app.getProperties(objectId); + const properties = result.properties || []; + const found = properties.find((item) => item.name === property); + return found ? found.value : undefined; +} + +async function setProp(app, objectId, property, value) { + const result = await app.inspector.send("setProperty", { + objectId, + property, + value, + }); + if (result.error) + throw new Error(`setProperty ${property}: ${result.error}`); +} + +async function clickObject(app, name) { + const objectId = await idByObjectName(app, name); + const result = await app.inspector.send("click", { objectId }); + if (result.error) + throw new Error(`click ${name}: ${result.error}`); + return objectId; +} + +async function clickFirstVisible(app, names) { + for (const name of names) { + const result = await app.findByProperty("objectName", name); + for (const match of result.matches || []) { + if ((await prop(app, match.id, "visible")) !== true) + continue; + if ((await prop(app, match.id, "enabled")) === false) + continue; + const clickResult = await app.inspector.send("click", { objectId: match.id }); + if (clickResult.error) + throw new Error(`click ${name}: ${clickResult.error}`); + return match.id; + } + } + throw new Error(`no visible enabled object: ${names.join(", ")}`); +} + +async function waitForProperty(app, objectId, property, predicate, description, timeout = 10000) { + await app.waitFor( + async () => { + const value = await prop(app, objectId, property); + if (!predicate(value)) + throw new Error(`${property}=${JSON.stringify(value)}`); + }, + { timeout, interval: 300, description }, + ); +} + +async function saveShot(app, name) { + const shot = await app.screenshot(); + if (!shot || !shot.image) + throw new Error(`screenshot unavailable for ${name}`); + await mkdir(evidenceDir, { recursive: true }); + const path = resolve(evidenceDir, `${name}.png`); + await writeFile(path, Buffer.from(shot.image, "base64")); + console.log(` screenshot -> ${path}`); +} + +async function chooseFixedTemplate(app) { + await clickObject(app, "tokenExamplesButton"); + await app.waitFor( + async () => { + const menuId = await idByObjectName(app, "tokenExamplesMenu"); + if ((await prop(app, menuId, "visible")) !== true) + throw new Error("template menu is closed"); + }, + { timeout: 3000, interval: 150, description: "template menu to open" }, + ); + await clickObject(app, "tokenFixedTemplate"); + const nameFieldId = await idByObjectName(app, "tokenNameField"); + await waitForProperty( + app, + nameFieldId, + "text", + (value) => value === "Fixed supply token", + "fixed template values", + ); +} + +async function openTokenApp(app) { + await app.waitFor( + async () => { await app.expectTexts(["Applications", "Settings"]); }, + { timeout: 60000, interval: 500, description: "Basecamp shell to load" }, + ); + await saveShot(app, "basecamp-launch"); + + const labels = ["Token", "token_ui", "Logos Token"]; + await app.waitFor( + async () => { + for (const label of labels) { + const result = await app.findByProperty("text", label); + if (result.matches && result.matches.length > 0) { + await app.click(label); + return; + } + } + throw new Error(`Token app not visible in Basecamp sidebar (${labels.join(", ")})`); + }, + { timeout: 30000, interval: 500, description: "Token app in Basecamp sidebar" }, + ); +} + +test(live + ? "token definition: create through token_module and inspect live state" + : "token UI: create shell flows into Inspect", async (app) => { + await openTokenApp(app); + await app.waitFor( + async () => { await app.expectTexts(["Create definition", "Fungible", "Use template"]); }, + { timeout: 20000, interval: 400, description: "token create view to load" }, + ); + + const createPageId = await idByObjectName(app, "tokenCreatePage"); + await saveShot(app, "token-create-initial"); + + await chooseFixedTemplate(app); + const nameFieldId = await idByObjectName(app, "tokenNameField"); + await setProp(app, nameFieldId, "text", tokenName); + await saveShot(app, "token-create-template"); + + await clickFirstVisible(app, ["tokenContinueButton", "tokenSummaryContinueButton"]); + await waitForProperty( + app, + createPageId, + "step", + (value) => value === 1, + "account-target step", + ); + + if (!live) { + const createAccountsButtonId = await idByObjectName(app, "tokenCreateAccountsButton"); + if (await prop(app, createAccountsButtonId, "enabled") === true) + throw new Error("visual mode unexpectedly has a live wallet"); + await saveShot(app, "token-account-targets-disconnected"); + + await app.click("Inspect"); + await app.waitFor( + async () => { await app.expectTexts(["Inspect token definitions", "Definition index"]); }, + { timeout: 10000, interval: 300, description: "Inspect view to load" }, + ); + await app.waitFor( + async () => { + const result = await app.findByProperty("text", "Pebble"); + if (!result.matches || result.matches.length === 0) + throw new Error("example definitions not rendered"); + }, + { timeout: 5000, interval: 250, description: "example definitions" }, + ); + await saveShot(app, "token-inspect-examples"); + console.log(" mode: visual shell; chain state unchanged"); + return; + } + + const createAccountsButtonId = await idByObjectName(app, "tokenCreateAccountsButton"); + await waitForProperty( + app, + createAccountsButtonId, + "enabled", + (value) => value === true, + "connected wallet for fresh accounts", + 5000, + ); + await clickObject(app, "tokenCreateAccountsButton"); + + const definitionTargetId = await idByObjectName(app, "tokenDefinitionTargetField"); + const holdingTargetId = await idByObjectName(app, "tokenHoldingTargetField"); + const validAccount = (value) => typeof value === "string" && value.length > 0; + await waitForProperty(app, definitionTargetId, "text", validAccount, "definition target account", 20000); + await waitForProperty(app, holdingTargetId, "text", validAccount, "holding target account", 20000); + await saveShot(app, "token-account-targets-ready"); + + await clickObject(app, "tokenReviewButton"); + await waitForProperty(app, createPageId, "step", (value) => value === 2, "definition review"); + await saveShot(app, "token-definition-review"); + + const prepareButtonId = await idByObjectName(app, "tokenPrepareButton"); + await waitForProperty(app, prepareButtonId, "enabled", (value) => value === true, "create definition action"); + await clickObject(app, "tokenPrepareButton"); + await app.waitFor( + async () => { + const prepared = await prop(app, createPageId, "prepared"); + const error = await prop(app, createPageId, "errorMessage"); + if (prepared !== true && !error) + throw new Error("transaction is still pending"); + if (error) + throw new Error(`Token Program rejected request: ${error}`); + }, + { timeout: 60000, interval: 500, description: "Token Program submission" }, + ); + await saveShot(app, "token-definition-submitted"); + + await app.click("Inspect"); + await app.waitFor( + async () => { await app.expectTexts(["Inspect token definitions", "Definition index"]); }, + { timeout: 10000, interval: 300, description: "live Inspect view" }, + ); + await app.waitFor( + async () => { + try { await clickObject(app, "tokenRefreshButton"); } catch { /* refresh may be busy */ } + const result = await app.findByProperty("text", tokenName); + if (!result.matches || result.matches.length === 0) + throw new Error(`live definition ${tokenName} not indexed yet`); + }, + { timeout: 60000, interval: 1200, description: "created definition in live Inspect view" }, + ); + await app.expectTexts([tokenName, "Network"]); + await saveShot(app, "token-inspect-live-definition"); + console.log(` mode: live; verified ${tokenName} in wallet-backed Inspect view`); +}); + +run(); diff --git a/flake.nix b/flake.nix index e1ab704..5212fa5 100644 --- a/flake.nix +++ b/flake.nix @@ -179,9 +179,29 @@ exit 1 fi walletQmlDir="$(dirname "$walletQmlDescriptor")" + # Stage the shared Logos.Wallet module at the plugin ROOT (NOT under + # qml/), and strip every plugin/resource directive from its qmldir so + # it loads as PURE QML from the co-located .qml files. Two reasons: + # 1. Basecamp's QML sandbox rejects `prefer :/qt/qml/...` (and the + # `classname`/`typeinfo`/`linktarget` it pairs with) with + # "Invalid null URL" — that compiled resource isn't registered + # there. The .qml files are physically present, so the module + # resolves without any native plugin; Token drives wallet ops + # through TokenUiBackend regardless. + # 2. Basecamp adds the *containing dir* of every discovered Logos.* + # module to a shared QML import path. Keeping Logos.Wallet at the + # root means only the root is shared — putting it under qml/ would + # also leak qml/NavBar.qml et al. into that shared namespace and + # collide with other plugins' identically-named types (e.g. amm's + # NavBar). Standalone reaches this root module via QML_IMPORT_PATH + # (see the token-ui app wrapper), not a qml/ stub. Keep in sync + # with apps/amm. walletQmlInstallDir="$out/lib/Logos/Wallet" mkdir -p "$walletQmlInstallDir" cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + grep -vE '^(linktarget|optional plugin|plugin|classname|typeinfo|prefer)([[:space:]]|$)' \ + "$walletQmlInstallDir/qmldir" > "$walletQmlInstallDir/qmldir.pureqml" + mv "$walletQmlInstallDir/qmldir.pureqml" "$walletQmlInstallDir/qmldir" test -f "$walletQmlInstallDir/qmldir" ''; }; @@ -197,6 +217,18 @@ tokenAppApps = tokenAppOutputs.apps or { }; tokenAppPkgs = tokenAppOutputs.packages or { }; + # Keep the token UI's Basecamp install artifacts addressable after the + # core token module is merged into the same package set. Both builders + # expose an `lgx` attribute, so a named alias prevents the core module + # package from shadowing the UI package. + tokenUiPackages = builtins.mapAttrs ( + system: attrs: + (if attrs ? lgx then { token-ui-lgx = attrs.lgx; } else { }) + // (if attrs ? lgx-portable then { token-ui-lgx-portable = attrs.lgx-portable; } else { }) + // (if attrs ? install then { token-ui-install = attrs.install; } else { }) + // (if attrs ? install-portable then { token-ui-install-portable = attrs.install-portable; } else { }) + ) tokenAppPkgs; + # AMM core module (modules/amm): the AMM business logic as a headless # `core` Logos module. It links the amm_ffi crate (the transport- # independent AMM brain, resolved via `self`) and depends on the @@ -230,6 +262,16 @@ }; tokenModulePkgs = tokenModuleOutputs.packages or { }; + # Alias the token core module's Basecamp install artifacts. The bare + # `lgx` / `install` attrs collide across builders in the merged package + # set (last write wins), so expose an explicit `token-module-lgx` / + # `token-module-install` that resolves unambiguously. + tokenModuleAliases = builtins.mapAttrs ( + system: attrs: + (if attrs ? lgx then { token-module-lgx = attrs.lgx; } else { }) + // (if attrs ? install then { token-module-install = attrs.install; } else { }) + ) tokenModulePkgs; + # Wrap the app launcher to export DYLD_FALLBACK_LIBRARY_PATH pointing at the # amm_ffi lib. The logos module builder links the plugin against # @rpath/libamm_ffi.dylib but does NOT stage that dylib into the @@ -253,9 +295,31 @@ (builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { amm-ui = wrapWithDyld system attrs.default; } else { }) ) appApps; + # Prepend the token UI module's `lib` dir to QML_IMPORT_PATH before the + # standalone shell runs. The shared Logos.Wallet module is staged at the + # plugin ROOT (lib/Logos/Wallet), NOT under the view dir (lib/qml) — see + # the tokenAppOutputs postInstall for why (Basecamp shared-import-path + # collisions). But the standalone shell only searches the view dir for + # unqualified imports, so `import Logos.Wallet` from lib/qml/Main.qml would + # not resolve without help. logos-standalone-app appends any pre-existing + # QML_IMPORT_PATH to the paths it sets, so exporting lib here makes the + # root module resolvable with no qml/ stub. tokenAppPkgs..default is + # the module derivation whose /lib is the plugin dir the app loads. + wrapTokenQmlImportPath = system: app: + let + pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; + moduleDir = tokenAppPkgs.${system}.default; + in + app // { + program = "${pkgs.writeShellScript "run-token-ui" '' + export QML_IMPORT_PATH="${moduleDir}/lib''${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" + exec ${app.program} "$@" + ''}"; + }; + renamedTokenApps = builtins.mapAttrs ( system: attrs: - (builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { token-ui = attrs.default; } else { }) + (builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { token-ui = wrapTokenQmlImportPath system attrs.default; } else { }) ) tokenAppApps; mergedApps = builtins.mapAttrs ( @@ -268,18 +332,22 @@ let appSysPkgs = appPkgs.${system} or { }; tokenAppSysPkgs = tokenAppPkgs.${system} or { }; + tokenUiSysPkgs = tokenUiPackages.${system} or { }; ammModSysPkgs = ammModulePkgs.${system} or { }; tokenModSysPkgs = tokenModulePkgs.${system} or { }; + tokenModAliasPkgs = tokenModuleAliases.${system} or { }; in (builtins.removeAttrs cratePkgs [ "default" ]) // (builtins.removeAttrs appSysPkgs [ "default" ]) // (if appSysPkgs ? default then { amm-ui = appSysPkgs.default; } else { }) // (builtins.removeAttrs tokenAppSysPkgs [ "default" ]) // (if tokenAppSysPkgs ? default then { token-ui = tokenAppSysPkgs.default; } else { }) + // tokenUiSysPkgs // (builtins.removeAttrs ammModSysPkgs [ "default" ]) // (if ammModSysPkgs ? default then { amm-module = ammModSysPkgs.default; } else { }) // (builtins.removeAttrs tokenModSysPkgs [ "default" ]) // (if tokenModSysPkgs ? default then { token-module = tokenModSysPkgs.default; } else { }) + // tokenModAliasPkgs ) crateOutputs.packages; in (builtins.removeAttrs appOutputs [ "apps" "packages" ])