implement inspectPackage, uninstallPackage, resolveDependencies, resolveDependents, and gated uninstall/upgrade (#47)

* implement inspectPackage, uninstallPackage, resolveDependencies, resolveDependents, and gated uninstall/upgrade

* fix: require ack before confirmUpgrade proceeds

Agent-Logs-Url: https://github.com/logos-co/logos-package-manager-module/sessions/2fc80a97-a06e-4a07-99f2-11d4a7a9ea99

Co-authored-by: dlipicar <11161531+dlipicar@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* pr comments

* add tests

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: dlipicar <11161531+dlipicar@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dario Lipicar
2026-04-16 19:34:03 -03:00
committed by GitHub
co-authored by dlipicar Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent 34ef2a140a
commit aead48cefd
10 changed files with 2539 additions and 100 deletions
+79 -3
View File
@@ -28,11 +28,13 @@ All methods are accessible via LogosAPI from other modules and UI plugins.
| `setUserModulesDirectory(dir)` | Set directory for user-installed core modules |
| `setUserUiPluginsDirectory(dir)` | Set directory for user-installed UI plugins |
### Installation
### Installation & Inspection
| Method | Return | Description |
|--------|--------|-------------|
| `installPlugin(path, skipIfNotNewer)` | `QVariantMap` | Install a local `.lgx` file. Returns `{name, path, isCoreModule, signatureStatus, error}`. When `signatureStatus` is `"signed"` or `"invalid"`, also includes `signerDid`, `signerName`, `signerUrl`, `trustedAs`. When `signatureStatus` is `"error"`, includes `signatureError`. |
| `inspectPackage(lgxPath)` | `QVariantMap` | Inspect an LGX file **without installing**. Returns metadata + install status: `{name, version, type, description, category, rootHash, signatureStatus, signerDid?, signerName?, isAlreadyInstalled, installedVersion?, installedHash?, installedDependents?, variants}`. `rootHash` is the Merkle tree root from `manifest.hashes.root` — the same identifier the online catalog exposes. When `isAlreadyInstalled` is true, `installedHash` is the corresponding value from the on-disk manifest. Used by callers (e.g. Basecamp) to show a confirmation dialog before committing. |
| `uninstallPackage(packageName)` | `QVariantMap` | Remove a user-installed package immediately (ungated). Refuses embedded packages. Returns `{success, error?, removedFiles?}`. On success emits `corePluginUninstalled` or `uiPluginUninstalled`. **Headless callers** (lgpm, scripts) should use this. GUI callers should prefer `requestUninstall` below. |
### Scanning
@@ -43,7 +45,43 @@ All methods are accessible via LogosAPI from other modules and UI plugins.
| `getInstalledUiPlugins()` | `QVariantList` | Installed UI plugins only |
| `getValidVariants()` | `QStringList` | Platform variants this build accepts (e.g. `["darwin-arm64-dev"]`) |
Each item in the scan results contains all `manifest.json` fields plus `installDir` and `mainFilePath`.
Each item in the scan results contains all `manifest.json` fields plus `installDir`, `mainFilePath`, and `installType` (`"embedded"` or `"user"`).
### Dependency Resolution
| Method | Return | Description |
|--------|--------|-------------|
| `resolveDependencies(packageName, recursive)` | `QVariantMap` | Forward dependency tree rooted at `packageName`. Shape: `{name, status, version, installType, children: [...]}`. `recursive=false` walks only depth-1 (children have empty `children`); `recursive=true` walks the full tree, stopping at NotInstalled/Cycle nodes. Unknown root → `{}`. |
| `resolveDependents(packageName, recursive)` | `QVariantMap` | Reverse dependency tree rooted at `packageName`. Shape: `{name, version, type, installType, installDir, children: [...]}`. Same depth semantics as `resolveDependencies`. Unknown root → `{}`. |
| `resolveFlatDependencies(packageName, recursive)` | `QVariantList` | Flat projection of the forward walk. Each entry: `{name, status, version, installType}` (no `children`). `recursive=false` → direct children only; `recursive=true` → every descendant, BFS-ordered, deduped by name. |
| `resolveFlatDependents(packageName, recursive)` | `QVariantList` | Flat projection of the reverse walk. Each entry: `{name, version, type, installType, installDir}`. Same `recursive` semantics as `resolveFlatDependencies`. |
### Gated Uninstall / Upgrade Flow
For **GUI callers** that need to show a confirmation dialog before destructive operations. The protocol ensures destructive work never runs without a live listener driving the dialog.
**Protocol:**
1. Caller invokes `requestUninstall(name)` or `requestUpgrade(name, releaseTag, mode)`. Returns `{success, error?}` synchronously. On success, sets pending state, emits `beforeUninstall` / `beforeUpgrade`, and starts a **3-second ack timer**.
2. A listener receiving the event **must immediately** call `ackPendingAction(name)` to cancel the ack timer. This says "I'm driving the dialog — wait indefinitely for the user decision."
3. If the ack timer fires without an ack (no listener present, or event loop stalled >3s), the module clears pending state and emits `uninstallCancelled` / `upgradeCancelled`. No files are removed.
4. Once acked, the listener shows a confirmation dialog. On user confirm: `confirmUninstall(name)` / `confirmUpgrade(name, releaseTag)`. On cancel: `cancelUninstall(name)` / `cancelUpgrade(name, releaseTag)`.
Only **one** gated flow can be pending globally (across all packages and both operations). A second `requestXxx` while one is pending returns `{success: false, error: "Another <op> is in progress for '<name>'"}`.
| Method | Return | Description |
|--------|--------|-------------|
| `requestUninstall(name)` | `QVariantMap` | Start gated uninstall. Emits `beforeUninstall`, starts ack timer. |
| `requestUpgrade(name, releaseTag, mode)` | `QVariantMap` | Start gated upgrade. `mode`: 0=upgrade, 1=downgrade, 2=sidegrade. Emits `beforeUpgrade`, starts ack timer. |
| `ackPendingAction(name)` | `QVariantMap` | Acknowledge receipt of a `before*` event. Cancels the ack timer. Idempotent. |
| `confirmUninstall(name)` | `QVariantMap` | Proceed with uninstall. Removes files, emits `corePluginUninstalled` / `uiPluginUninstalled`. |
| `cancelUninstall(name)` | `QVariantMap` | Abort uninstall. Emits `uninstallCancelled(name, "user cancelled")`. |
| `confirmUpgrade(name, releaseTag)` | `QVariantMap` | Proceed with upgrade. Uninstalls old version, emits `upgradeUninstallDone` for the caller to drive the download+install of the new version. |
| `cancelUpgrade(name, releaseTag)` | `QVariantMap` | Abort upgrade. Emits `upgradeCancelled(name, releaseTag, "user cancelled")`. |
| `resetPendingAction()` | `QVariantMap` | Clear any pending state. Called by Basecamp at startup to recover from a prior crash mid-dialog. |
### Signature Policy
@@ -63,10 +101,24 @@ Each item in the scan results contains all `manifest.json` fields plus `installD
### Events
**Installation events:**
| Event | Data | Description |
|-------|------|-------------|
| `corePluginFileInstalled` | `[path]` | Emitted after a core module `.lgx` is installed |
| `uiPluginFileInstalled` | `[path]` | Emitted after a UI plugin `.lgx` is installed |
| `corePluginUninstalled` | `[name]` | Emitted after a core module is uninstalled |
| `uiPluginUninstalled` | `[name]` | Emitted after a UI plugin is uninstalled |
**Gated flow events** (see "Gated Uninstall / Upgrade Flow" above):
| Event | Data | Description |
|-------|------|-------------|
| `beforeUninstall` | `{name, installedDependents}` | A gated uninstall was requested. Listener must ack within 3s. |
| `beforeUpgrade` | `{name, releaseTag, mode, installedDependents}` | A gated upgrade was requested. Listener must ack within 3s. |
| `uninstallCancelled` | `{name, reason}` | Uninstall was cancelled — either by ack timeout or user cancel. |
| `upgradeCancelled` | `{name, releaseTag, reason}` | Upgrade was cancelled — either by ack timeout or user cancel. |
| `upgradeUninstallDone` | `{name, releaseTag, mode}` | Old version uninstalled during upgrade; caller should now download+install the new version. |
### Usage from another module
@@ -101,13 +153,37 @@ logos.package_manager.removeTrustedKey("logos-official");
QVariantMap sigInfo = logos.package_manager.verifyPackage("/path/to/waku_module.lgx");
// sigInfo: {isSigned, signatureValid, packageValid, signerDid, signerName, signerUrl, trustedAs, error}
// Install a downloaded .lgx file
// Inspect an LGX before installing (shows metadata in a confirmation dialog)
QVariantMap info = logos.package_manager.inspectPackage("/path/to/waku_module.lgx");
// info: {name, version, type, signatureStatus, isAlreadyInstalled, installedVersion?, ...}
// Install a downloaded .lgx file (ungated — for headless/scripted use)
QVariantMap result = logos.package_manager.installPlugin("/path/to/waku_module.lgx", false);
if (result.contains("error")) {
qWarning() << "Install failed:" << result["error"].toString();
}
// result also includes: signatureStatus ("signed"/"unsigned"/"invalid"), signerDid, signerName, signerUrl, trustedAs
// Flat deduped list of every reverse dependent (BFS over the reverse tree).
QVariantList dependents = logos.package_manager.resolveFlatDependents("my_module", true);
// Or fetch the tree shape directly when you want parent/child structure.
QVariantMap dependentsTree = logos.package_manager.resolveDependents("my_module", true);
// GUI-mode gated uninstall (requires a listener to drive the confirmation dialog)
logos.package_manager.requestUninstallAsync("my_module", [](QVariantMap r) {
if (!r.value("success").toBool()) qWarning() << r.value("error").toString();
});
// Listen for gated flow events
logos.package_manager.on("beforeUninstall", [&logos](const QVariantList& data) {
QString name = data[0].toMap().value("name").toString();
// Ack immediately to cancel the 3s timer
logos.package_manager.ackPendingActionAsync(name, [](QVariantMap) {});
// Show dialog... then confirm or cancel:
// logos.package_manager.confirmUninstallAsync(name, ...);
// logos.package_manager.cancelUninstallAsync(name, ...);
});
// Listen for installation events
logos.package_manager.on("corePluginFileInstalled", [](const QVariantList& data) {
QString path = data[0].toString();
Generated
+132 -73
View File
@@ -94,11 +94,11 @@
]
},
"locked": {
"lastModified": 1775831954,
"narHash": "sha256-XrYR9l3YZSfX/f90Fsedzz3XZufWfP75OaJQnHIH29Q=",
"lastModified": 1776101366,
"narHash": "sha256-HxkzOs2xv0grkNAJMBLXKDjVl8Z+z3YFn+sC4eFKy/8=",
"owner": "logos-co",
"repo": "logos-cpp-sdk",
"rev": "2a21637e0238b4e629f4b707d13f0b803810cc7d",
"rev": "1468180b2567f4c59346bb94f74951e76341f5c5",
"type": "github"
},
"original": {
@@ -201,11 +201,11 @@
"process-stats": "process-stats"
},
"locked": {
"lastModified": 1775764743,
"narHash": "sha256-nPWoaPLoeE+pjPg9fP5sc5038ZenbePlD6Hl0/Q4Rpc=",
"lastModified": 1776084938,
"narHash": "sha256-0UL6tG6mK00HN99fm9CLJu3JA9ay2ry6dgeHfyApiWo=",
"owner": "logos-co",
"repo": "logos-liblogos",
"rev": "522c56b4fafb4ef30615231ef6616316a104b935",
"rev": "b293e9d70a04983778ef2ef3ef42596f76f41161",
"type": "github"
},
"original": {
@@ -256,11 +256,11 @@
]
},
"locked": {
"lastModified": 1776101574,
"narHash": "sha256-VfvZW5efQtgt8QOJhS35btP6beG+E/UrnfP9YMRItEw=",
"lastModified": 1776372591,
"narHash": "sha256-uuY3FvSbWF11DR7JO9jyMDV25fLJoxT9TpDW7l/BngM=",
"owner": "logos-co",
"repo": "logos-module-builder",
"rev": "d3616e8410320a2bddc39e56348c64e7dfffb918",
"rev": "e14aaa89f9ef652daf201cfaf3d06817ac8a440e",
"type": "github"
},
"original": {
@@ -709,11 +709,11 @@
"nixpkgs": "nixpkgs_25"
},
"locked": {
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
@@ -745,11 +745,11 @@
"nixpkgs": "nixpkgs_27"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"type": "github"
},
"original": {
@@ -781,11 +781,11 @@
"nixpkgs": "nixpkgs_29"
},
"locked": {
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
@@ -835,11 +835,11 @@
"nixpkgs": "nixpkgs_31"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"type": "github"
},
"original": {
@@ -871,11 +871,11 @@
"nixpkgs": "nixpkgs_33"
},
"locked": {
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
@@ -907,11 +907,11 @@
"nixpkgs": "nixpkgs_35"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"type": "github"
},
"original": {
@@ -979,11 +979,11 @@
"nixpkgs": "nixpkgs_39"
},
"locked": {
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
@@ -1015,11 +1015,11 @@
"nixpkgs": "nixpkgs_40"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"type": "github"
},
"original": {
@@ -1051,11 +1051,11 @@
"nixpkgs": "nixpkgs_42"
},
"locked": {
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
@@ -1069,11 +1069,11 @@
"nixpkgs": "nixpkgs_43"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"lastModified": 1774455309,
"narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2",
"type": "github"
},
"original": {
@@ -1136,6 +1136,24 @@
"type": "github"
}
},
"logos-nix_47": {
"inputs": {
"nixpkgs": "nixpkgs_47"
},
"locked": {
"lastModified": 1773955630,
"narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=",
"owner": "logos-co",
"repo": "logos-nix",
"rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88",
"type": "github"
},
"original": {
"owner": "logos-co",
"repo": "logos-nix",
"type": "github"
}
},
"logos-nix_5": {
"inputs": {
"nixpkgs": "nixpkgs_5"
@@ -1284,7 +1302,7 @@
},
"logos-package-manager_2": {
"inputs": {
"logos-nix": "logos-nix_34",
"logos-nix": "logos-nix_35",
"logos-package": "logos-package_4",
"nix-bundle-appimage": "nix-bundle-appimage_2",
"nix-bundle-dir": "nix-bundle-dir_6",
@@ -1312,7 +1330,7 @@
},
"logos-package-manager_3": {
"inputs": {
"logos-nix": "logos-nix_42",
"logos-nix": "logos-nix_43",
"logos-package": "logos-package_6",
"nix-bundle-appimage": "nix-bundle-appimage_3",
"nix-bundle-dir": "nix-bundle-dir_9",
@@ -1323,11 +1341,11 @@
]
},
"locked": {
"lastModified": 1775680583,
"narHash": "sha256-0Bh48zTfi4lPL78ZLgmiX+QMW+nvjWKXHp5iJPEhvLg=",
"lastModified": 1776374462,
"narHash": "sha256-HMkuqSLdScAWTwXEWjhqx9Yk82GiPzPIfRaHTvjG730=",
"owner": "logos-co",
"repo": "logos-package-manager",
"rev": "8110734252edf9ca4266f475ace1c7c9bee68018",
"rev": "9101875bc103214855bc6217834e22e66802ed86",
"type": "github"
},
"original": {
@@ -1338,7 +1356,7 @@
},
"logos-package_2": {
"inputs": {
"logos-nix": "logos-nix_27",
"logos-nix": "logos-nix_28",
"nixpkgs": [
"logos-module-builder",
"logos-standalone-app",
@@ -1364,7 +1382,7 @@
},
"logos-package_3": {
"inputs": {
"logos-nix": "logos-nix_31",
"logos-nix": "logos-nix_32",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-lgx",
@@ -1389,7 +1407,7 @@
},
"logos-package_4": {
"inputs": {
"logos-nix": "logos-nix_35",
"logos-nix": "logos-nix_36",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-logos-module-install",
@@ -1415,7 +1433,7 @@
},
"logos-package_5": {
"inputs": {
"logos-nix": "logos-nix_40",
"logos-nix": "logos-nix_41",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-logos-module-install",
@@ -1441,7 +1459,7 @@
},
"logos-package_6": {
"inputs": {
"logos-nix": "logos-nix_43",
"logos-nix": "logos-nix_44",
"nixpkgs": [
"logos-package-manager",
"logos-package",
@@ -1450,11 +1468,11 @@
]
},
"locked": {
"lastModified": 1775677349,
"narHash": "sha256-G+0E1mkmG3QDeTR4Pgy+xkiole/TDq+FYrvHwNp9Yrc=",
"lastModified": 1775835037,
"narHash": "sha256-Cti0DhkzyLQs98BSzcHWMLtGXpa3n+R+5upfSw6vKdQ=",
"owner": "logos-co",
"repo": "logos-package",
"rev": "64edea0e64309e1c9f91259d16f8f81e5e39e40e",
"rev": "ff93a0df15ceab255f27687d22d962ea2737efbe",
"type": "github"
},
"original": {
@@ -1513,6 +1531,30 @@
"type": "github"
}
},
"logos-qt-mcp": {
"inputs": {
"logos-nix": "logos-nix_25",
"nixpkgs": [
"logos-module-builder",
"logos-standalone-app",
"logos-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1774455349,
"narHash": "sha256-rebrtH1UxC1hDuwQBwyYbGzNCrnuuqiVL7OvzUhk65k=",
"owner": "logos-co",
"repo": "logos-qt-mcp",
"rev": "c5223b4b640add09e461983b8fddbd12c8b31f4f",
"type": "github"
},
"original": {
"owner": "logos-co",
"repo": "logos-qt-mcp",
"type": "github"
}
},
"logos-standalone-app": {
"inputs": {
"logos-capability-module": "logos-capability-module",
@@ -1520,6 +1562,7 @@
"logos-design-system": "logos-design-system",
"logos-liblogos": "logos-liblogos",
"logos-nix": "logos-nix_24",
"logos-qt-mcp": "logos-qt-mcp",
"logos-view-module-runtime": "logos-view-module-runtime",
"nix-bundle-lgx": "nix-bundle-lgx",
"nixpkgs": [
@@ -1530,11 +1573,11 @@
]
},
"locked": {
"lastModified": 1775836431,
"narHash": "sha256-2zQcNzbO3RnC8zZBB2y1s1ItZNwYzqgJjlevYjzKQHQ=",
"lastModified": 1776372380,
"narHash": "sha256-kleKGfcRgtIjeltogH0NCFrp5QwetlXsHJxOTllP13E=",
"owner": "logos-co",
"repo": "logos-standalone-app",
"rev": "84d24c6a0d5c73bb81160e2a13de9995caa34c0f",
"rev": "5e122402898c1d896cc6b72db04c6277697a2f2f",
"type": "github"
},
"original": {
@@ -1549,7 +1592,7 @@
"logos-module-builder",
"logos-cpp-sdk"
],
"logos-nix": "logos-nix_29",
"logos-nix": "logos-nix_30",
"nixpkgs": [
"logos-module-builder",
"logos-test-framework",
@@ -1578,7 +1621,7 @@
"logos-standalone-app",
"logos-cpp-sdk"
],
"logos-nix": "logos-nix_25",
"logos-nix": "logos-nix_26",
"nixpkgs": [
"logos-module-builder",
"logos-standalone-app",
@@ -1587,11 +1630,11 @@
]
},
"locked": {
"lastModified": 1775835902,
"narHash": "sha256-jQ1ZJis3stGgj1jQvQI0JuxPpzeZtilAwLEUVqT0lkA=",
"lastModified": 1776372308,
"narHash": "sha256-21SqqdOuHBLUGcYxGvjtC4iKp+wLGEQOKn64qLVl/+0=",
"owner": "logos-co",
"repo": "logos-view-module-runtime",
"rev": "b5799b743ef92afefacb8510a2935fc9585c308d",
"rev": "5dc32e0131e9abf0a86c085119aa082d56486d9e",
"type": "github"
},
"original": {
@@ -1630,7 +1673,7 @@
},
"nix-bundle-appimage_2": {
"inputs": {
"logos-nix": "logos-nix_36",
"logos-nix": "logos-nix_37",
"nix-bundle-dir": "nix-bundle-dir_5",
"nixpkgs": [
"logos-module-builder",
@@ -1657,7 +1700,7 @@
},
"nix-bundle-appimage_3": {
"inputs": {
"logos-nix": "logos-nix_44",
"logos-nix": "logos-nix_45",
"nix-bundle-dir": "nix-bundle-dir_8",
"nixpkgs": [
"logos-package-manager",
@@ -1735,7 +1778,7 @@
},
"nix-bundle-dir_3": {
"inputs": {
"logos-nix": "logos-nix_28",
"logos-nix": "logos-nix_29",
"nixpkgs": [
"logos-module-builder",
"logos-standalone-app",
@@ -1761,7 +1804,7 @@
},
"nix-bundle-dir_4": {
"inputs": {
"logos-nix": "logos-nix_32",
"logos-nix": "logos-nix_33",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-lgx",
@@ -1786,7 +1829,7 @@
},
"nix-bundle-dir_5": {
"inputs": {
"logos-nix": "logos-nix_37",
"logos-nix": "logos-nix_38",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-logos-module-install",
@@ -1811,7 +1854,7 @@
},
"nix-bundle-dir_6": {
"inputs": {
"logos-nix": "logos-nix_38",
"logos-nix": "logos-nix_39",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-logos-module-install",
@@ -1837,7 +1880,7 @@
},
"nix-bundle-dir_7": {
"inputs": {
"logos-nix": "logos-nix_41",
"logos-nix": "logos-nix_42",
"nixpkgs": [
"logos-module-builder",
"nix-bundle-logos-module-install",
@@ -1863,7 +1906,7 @@
},
"nix-bundle-dir_8": {
"inputs": {
"logos-nix": "logos-nix_45",
"logos-nix": "logos-nix_46",
"nixpkgs": [
"logos-package-manager",
"nix-bundle-appimage",
@@ -1886,7 +1929,7 @@
},
"nix-bundle-dir_9": {
"inputs": {
"logos-nix": "logos-nix_46",
"logos-nix": "logos-nix_47",
"nixpkgs": [
"logos-package-manager",
"nix-bundle-dir",
@@ -1910,7 +1953,7 @@
},
"nix-bundle-lgx": {
"inputs": {
"logos-nix": "logos-nix_26",
"logos-nix": "logos-nix_27",
"logos-package": "logos-package_2",
"nix-bundle-dir": "nix-bundle-dir_3",
"nixpkgs": [
@@ -1936,7 +1979,7 @@
},
"nix-bundle-lgx_2": {
"inputs": {
"logos-nix": "logos-nix_30",
"logos-nix": "logos-nix_31",
"logos-package": "logos-package_3",
"nix-bundle-dir": "nix-bundle-dir_4",
"nixpkgs": [
@@ -1962,7 +2005,7 @@
},
"nix-bundle-lgx_3": {
"inputs": {
"logos-nix": "logos-nix_39",
"logos-nix": "logos-nix_40",
"logos-package": "logos-package_5",
"nix-bundle-dir": "nix-bundle-dir_7",
"nixpkgs": [
@@ -1989,7 +2032,7 @@
},
"nix-bundle-logos-module-install": {
"inputs": {
"logos-nix": "logos-nix_33",
"logos-nix": "logos-nix_34",
"logos-package-manager": "logos-package-manager_2",
"nix-bundle-lgx": "nix-bundle-lgx_3",
"nixpkgs": [
@@ -2669,6 +2712,22 @@
"type": "github"
}
},
"nixpkgs_47": {
"locked": {
"lastModified": 1759036355,
"narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_5": {
"locked": {
"lastModified": 1759036355,
+715 -3
View File
@@ -2,8 +2,121 @@
#include <package_manager_lib.h>
#include <lgx.h>
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <iostream>
#include <limits>
// ---------------------------------------------------------------------------
// Struct → LogosMap / LogosList conversion helpers
// ---------------------------------------------------------------------------
//
// Wire format is identical to what PackageManagerLib / the lgpm CLI emit via
// package_manager_json.cpp's nlohmann ADL hooks. We re-hand-roll here (rather
// than reuse those hooks) so the unit tests can compile against the stub
// header in tests/stubs/package_manager_lib.h without pulling in the lib's
// JSON module.
//
// Keep these in sync with package_manager_json.cpp's to_json definitions.
// ---------------------------------------------------------------------------
namespace {
LogosMap toLogosMap(const Hashes& h)
{
LogosMap m = LogosMap::object();
m["root"] = h.root;
return m;
}
LogosMap toLogosMap(const InstalledPackage& p)
{
LogosMap m = LogosMap::object();
m["name"] = p.name;
m["version"] = p.version;
m["description"] = p.description;
m["type"] = p.type;
m["category"] = p.category;
m["author"] = p.author;
m["license"] = p.license;
m["icon"] = p.icon;
m["view"] = p.view;
LogosList deps = LogosList::array();
for (const auto& d : p.dependencies) deps.push_back(d);
m["dependencies"] = deps;
m["hashes"] = toLogosMap(p.hashes);
m["installType"] = std::string(installTypeToString(p.installType));
m["installDir"] = p.installDir;
m["mainFilePath"] = p.mainFilePath;
return m;
}
LogosList toLogosList(const std::vector<InstalledPackage>& v)
{
LogosList out = LogosList::array();
for (const auto& p : v) out.push_back(toLogosMap(p));
return out;
}
// Flat per-node projection — just the node's own fields, no `children`.
// Shared between the flat list APIs (resolveFlatDependencies /
// resolveFlatDependents) and the tree APIs (where each recursive step is
// "this node's fields plus its children").
LogosMap toFlatLogosMap(const DependencyTreeNode& n)
{
LogosMap m = LogosMap::object();
m["name"] = n.name;
m["status"] = std::string(dependencyStatusToString(n.status));
if (n.status == DependencyStatus::Installed) {
m["version"] = n.version;
m["installType"] = std::string(installTypeToString(n.installType));
} else {
m["version"] = "";
m["installType"] = "";
}
return m;
}
LogosMap toFlatLogosMap(const DependentTreeNode& n)
{
LogosMap m = LogosMap::object();
m["name"] = n.name;
m["version"] = n.version;
m["type"] = n.type;
m["installType"] = std::string(installTypeToString(n.installType));
m["installDir"] = n.installDir;
return m;
}
// Depth-clipped tree serialisation — root always emitted with its own
// fields; `maxDepth` bounds how far we recurse into `children`. Used by
// resolveDependencies/resolveDependents: maxDepth=1 for !recursive (root
// + direct children with empty children arrays), maxDepth=INT_MAX for
// the full tree.
template <typename Node>
LogosMap toLogosTreeMap(const Node& n, int maxDepth)
{
LogosMap m = toFlatLogosMap(n);
LogosList children = LogosList::array();
if (maxDepth > 0) {
for (const auto& c : n.children)
children.push_back(toLogosTreeMap(c, maxDepth - 1));
}
m["children"] = children;
return m;
}
template <typename Node>
LogosList toFlatLogosList(const std::vector<Node>& v)
{
LogosList out = LogosList::array();
for (const auto& n : v) out.push_back(toFlatLogosMap(n));
return out;
}
} // namespace
PackageManagerImpl::PackageManagerImpl()
: m_lib(nullptr)
@@ -13,6 +126,19 @@ PackageManagerImpl::PackageManagerImpl()
PackageManagerImpl::~PackageManagerImpl()
{
// Signal any running worker thread to exit, then join it before
// tearing down state it might still reference (m_pendingAction,
// emitEvent). The lock is taken briefly to publish m_ackShutdown and
// bump m_ackGeneration atomically; notify + join happen outside the
// lock so the worker can re-acquire and exit its wait_for.
{
std::lock_guard<std::mutex> lk(m_stateMutex);
m_ackShutdown = true;
++m_ackGeneration;
}
m_ackCv.notify_all();
if (m_ackThread.joinable()) m_ackThread.join();
delete m_lib;
m_lib = nullptr;
}
@@ -71,19 +197,199 @@ LogosMap PackageManagerImpl::installPlugin(const std::string& pluginPath, bool s
return response;
}
LogosMap PackageManagerImpl::inspectPackage(const std::string& lgxPath)
{
LogosMap result;
lgx_package_t pkg = lgx_load(lgxPath.c_str());
if (!pkg) {
result["error"] = std::string("Failed to load LGX package: ")
+ (lgx_get_last_error() ? lgx_get_last_error() : "unknown");
return result;
}
const char* rawName = lgx_get_name(pkg);
const char* rawVersion = lgx_get_version(pkg);
const char* rawDesc = lgx_get_description(pkg);
const char* rawManifest = lgx_get_manifest_json(pkg);
std::string pkgName = rawName ? rawName : "";
std::string pkgVersion = rawVersion ? rawVersion : "";
result["name"] = pkgName;
result["version"] = pkgVersion;
result["description"] = rawDesc ? std::string(rawDesc) : "";
// Extract type, category, and root content hash from the embedded
// manifest. The root hash (Merkle tree root over the package content,
// `manifest.hashes.root`) is the same identifier PMU renders when
// browsing the online catalog — surfacing it here lets the install
// confirmation dialog show a stable per-release fingerprint.
if (rawManifest) {
try {
auto doc = LogosMap::parse(rawManifest);
result["type"] = doc.value("type", "");
result["category"] = doc.value("category", "");
if (doc.contains("hashes") && doc["hashes"].is_object()) {
result["rootHash"] = doc["hashes"].value("root", "");
} else {
result["rootHash"] = "";
}
} catch (...) {
result["type"] = "";
result["category"] = "";
result["rootHash"] = "";
}
}
// Available platform variants.
const char** variants = lgx_get_variants(pkg);
LogosList variantList = LogosList::array();
if (variants) {
for (int i = 0; variants[i]; ++i)
variantList.push_back(std::string(variants[i]));
lgx_free_string_array(variants);
}
result["variants"] = variantList;
lgx_free_package(pkg);
// Signature verification — standalone, no install side effects.
auto sig = m_lib->verifyPackageSignature(lgxPath);
if (sig.is_signed) {
bool valid = sig.signature_valid && sig.package_valid;
result["signatureStatus"] = valid ? std::string("signed")
: std::string("invalid");
result["signerDid"] = sig.signer_did;
result["signerName"] = sig.signer_name;
} else if (!sig.error.empty()) {
result["signatureStatus"] = std::string("error");
} else {
result["signatureStatus"] = std::string("unsigned");
}
// Check if this package is already installed.
bool isAlreadyInstalled = false;
std::string installedVersion;
std::string installedHash;
std::vector<InstalledPackage> scan = m_lib->getInstalledPackages();
for (const auto& entry : scan) {
if (entry.name == pkgName) {
isAlreadyInstalled = true;
installedVersion = entry.version;
// Passthrough from the installed manifest.json; same field PMU
// reads in the online catalog (`manifest.hashes.root`).
installedHash = entry.hashes.root;
break;
}
}
result["isAlreadyInstalled"] = isAlreadyInstalled;
result["installedVersion"] = installedVersion;
result["installedHash"] = installedHash;
// If already installed, compute reverse dependents so the dialog can
// show what would be affected by an upgrade.
if (isAlreadyInstalled) {
auto deps = installedDependentsNames(pkgName);
LogosList depList = LogosList::array();
for (const auto& d : deps) depList.push_back(d);
result["installedDependents"] = depList;
}
return result;
}
LogosList PackageManagerImpl::getInstalledPackages()
{
return LogosList::parse(m_lib->getInstalledPackages());
return toLogosList(m_lib->getInstalledPackages());
}
LogosList PackageManagerImpl::getInstalledModules()
{
return LogosList::parse(m_lib->getInstalledModules());
return toLogosList(m_lib->getInstalledModules());
}
LogosList PackageManagerImpl::getInstalledUiPlugins()
{
return LogosList::parse(m_lib->getInstalledUiPlugins());
return toLogosList(m_lib->getInstalledUiPlugins());
}
LogosMap PackageManagerImpl::uninstallPackage(const std::string& packageName)
{
return doUninstall(packageName);
}
LogosMap PackageManagerImpl::doUninstall(const std::string& packageName)
{
// Inspect the package before removal so we know whether to emit a core or UI event.
std::vector<InstalledPackage> scan = m_lib->getInstalledPackages();
std::string moduleType;
for (const auto& entry : scan) {
if (entry.name == packageName) {
moduleType = entry.type;
break;
}
}
UninstallResult r = m_lib->uninstallPackage(packageName);
LogosMap response;
response["success"] = r.success;
if (!r.success) {
response["error"] = r.errorMsg;
} else {
LogosList removed = LogosList::array();
for (const auto& f : r.removedFiles) removed.push_back(f);
response["removedFiles"] = removed;
if (emitEvent) {
const std::string eventName =
(moduleType == "core")
? "corePluginUninstalled"
: "uiPluginUninstalled";
emitEvent(eventName, packageName);
}
}
return response;
}
LogosMap PackageManagerImpl::resolveDependencies(const std::string& packageName, bool recursive)
{
// Unknown roots surface as nullopt from the library; keep an empty
// object on the wire so callers can `.contains(...)` without branching.
auto tree = m_lib->resolveDependencies(packageName);
if (!tree) return LogosMap::object();
// maxDepth=1 clips to root + direct children (children with empty
// `children` arrays); INT_MAX walks the full tree.
return toLogosTreeMap(*tree, recursive ? std::numeric_limits<int>::max() : 1);
}
LogosMap PackageManagerImpl::resolveDependents(const std::string& packageName, bool recursive)
{
// Same shape treatment as resolveDependencies — the library returns a
// tree, we either clip it at depth 1 or walk the full reverse subtree.
auto tree = m_lib->resolveDependents(packageName);
if (!tree) return LogosMap::object();
return toLogosTreeMap(*tree, recursive ? std::numeric_limits<int>::max() : 1);
}
LogosList PackageManagerImpl::resolveFlatDependencies(const std::string& packageName, bool recursive)
{
// Flat list of per-node maps (no `children`). recursive=false emits
// only the root's direct children; recursive=true emits every
// descendant, BFS-ordered and deduped by name (via DependencyTreeNode::flatten()).
auto tree = m_lib->resolveDependencies(packageName);
if (!tree) return LogosList::array();
return recursive ? toFlatLogosList(tree->flatten())
: toFlatLogosList(tree->children);
}
LogosList PackageManagerImpl::resolveFlatDependents(const std::string& packageName, bool recursive)
{
auto tree = m_lib->resolveDependents(packageName);
if (!tree) return LogosList::array();
return recursive ? toFlatLogosList(tree->flatten())
: toFlatLogosList(tree->children);
}
std::vector<std::string> PackageManagerImpl::getValidVariants()
@@ -215,3 +521,409 @@ LogosList PackageManagerImpl::listTrustedKeys()
lgx_free_keyring_list(list);
return result;
}
// ---------------------------------------------------------------------------
// Gated uninstall / upgrade flow
// ---------------------------------------------------------------------------
const char* PackageManagerImpl::opName(PendingOp op)
{
switch (op) {
case PendingOp::Uninstall: return "uninstall";
case PendingOp::Upgrade: return "upgrade";
case PendingOp::None: return "none";
}
return "none";
}
bool PackageManagerImpl::isEmbedded(const std::string& packageName) const
{
std::vector<InstalledPackage> scan = m_lib->getInstalledPackages();
for (const auto& entry : scan) {
if (entry.name == packageName)
return entry.installType == InstallType::Embedded;
}
return false;
}
std::vector<std::string> PackageManagerImpl::installedDependentsNames(const std::string& packageName) const
{
std::vector<std::string> names;
auto tree = m_lib->resolveDependents(packageName);
if (!tree) return names;
auto flat = tree->flatten();
names.reserve(flat.size());
for (const auto& d : flat) {
if (!d.name.empty()) names.push_back(d.name);
}
return names;
}
// ---------------------------------------------------------------------------
// Pure-C++ ack timer — std::thread + std::condition_variable replacing QTimer.
// See detailed protocol comment in the header.
// ---------------------------------------------------------------------------
void PackageManagerImpl::startAckTimerLocked(std::unique_lock<std::mutex>& lock)
{
// Precondition: caller holds m_stateMutex via `lock`.
// Bump the generation and wake any previously-running worker. If one is
// still waiting on the CV, it'll re-acquire the mutex, see its captured
// generation is stale, and bail.
++m_ackGeneration;
m_ackCv.notify_all();
// Join the previous worker (if any) before replacing m_ackThread —
// assigning to a joinable std::thread is undefined behaviour. Release
// the lock during join so the worker can proceed past its wait_for;
// otherwise we'd deadlock (we hold the lock the worker needs).
if (m_ackThread.joinable()) {
lock.unlock();
m_ackThread.join();
lock.lock();
}
const uint64_t gen = m_ackGeneration;
m_ackThread = std::thread([this, gen]() { ackTimerWorker(gen); });
}
void PackageManagerImpl::stopAckTimerLocked()
{
// Caller holds m_stateMutex. Bump the generation and notify so any
// running worker wakes up and exits silently. Do NOT join here: the
// slot calling us is likely running on the module thread and the
// worker might be mid-wait needing the mutex we hold. The worker
// will exit on its own; the next startAckTimerLocked (or the
// destructor) reaps the std::thread handle.
++m_ackGeneration;
m_ackCv.notify_all();
}
void PackageManagerImpl::ackTimerWorker(uint64_t myGeneration)
{
std::unique_lock<std::mutex> lock(m_stateMutex);
// wait_for returns true when the predicate is satisfied, false on
// timeout. Predicate: "stop waiting" — either the process is shutting
// down or our generation is stale (a newer request / ack / cancel
// has superseded us).
bool cancelled = m_ackCv.wait_for(
lock,
std::chrono::milliseconds(m_ackTimeoutMs),
[this, myGeneration]() {
return m_ackShutdown || m_ackGeneration != myGeneration;
}
);
if (cancelled) return;
// Full timeout with no cancellation — but recheck state now that we
// hold the lock. An ack or a different state change could have
// landed between the last CV check and here (unlikely, but cheap to
// verify).
if (m_ackShutdown) return;
if (m_ackGeneration != myGeneration) return;
if (m_pendingAction.op == PendingOp::None || m_pendingAction.acked) return;
// Claim the pending action so slot-side code sees a clean slate.
PendingAction pa = m_pendingAction;
m_pendingAction = {};
const std::string reason = "no listener acknowledged within "
+ std::to_string(m_ackTimeoutMs) + "ms";
// Release the lock before emitting — emitEvent marshals through a
// Qt signal; a listener synchronously calling back into this impl
// (e.g. a headless runtime that calls uninstallPackage on cancel
// notification) would otherwise re-enter the mutex and deadlock.
lock.unlock();
emitCancellation(pa, reason);
}
void PackageManagerImpl::emitCancellation(const PendingAction& pa, const std::string& reason)
{
if (!emitEvent) return;
LogosMap payload;
payload["name"] = pa.name;
payload["reason"] = reason;
if (pa.op == PendingOp::Upgrade) {
payload["releaseTag"] = pa.releaseTag;
emitEvent("upgradeCancelled", payload.dump());
} else if (pa.op == PendingOp::Uninstall) {
emitEvent("uninstallCancelled", payload.dump());
}
}
LogosMap PackageManagerImpl::requestUninstall(const std::string& packageName)
{
LogosMap response;
// Empty packageName would be persisted into m_pendingAction.name and then
// broadcast via beforeUninstall(payload{name:""}), causing listeners to
// open a dialog titled "Uninstall ''?" with no dependents. Reject early
// with a distinct error so callers can surface a sane toast and callers
// that ARE the GUI can avoid showing a stray dialog.
if (packageName.empty()) {
response["success"] = false;
response["error"] = "Package name cannot be empty";
return response;
}
std::unique_lock<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::None) {
response["success"] = false;
response["error"] = std::string("Another ") + opName(m_pendingAction.op)
+ " is in progress for '" + m_pendingAction.name + "'";
return response;
}
if (isEmbedded(packageName)) {
response["success"] = false;
response["error"] = "Cannot uninstall embedded module '" + packageName + "'";
return response;
}
m_pendingAction = {};
m_pendingAction.op = PendingOp::Uninstall;
m_pendingAction.name = packageName;
m_pendingAction.acked = false;
// Build the event payload while we still hold the lock (so m_lib reads
// don't race against a concurrent slot). emitEvent itself is deferred
// until after the unlock — see the reentrancy note in ackTimerWorker.
LogosMap payload;
payload["name"] = packageName;
LogosList deps = LogosList::array();
for (const auto& d : installedDependentsNames(packageName))
deps.push_back(d);
payload["installedDependents"] = deps;
// Start the ack timer (this may briefly release + re-acquire `lock`
// while joining a previous worker).
startAckTimerLocked(lock);
lock.unlock();
if (emitEvent) emitEvent("beforeUninstall", payload.dump());
response["success"] = true;
return response;
}
LogosMap PackageManagerImpl::requestUpgrade(const std::string& packageName,
const std::string& releaseTag,
int64_t mode)
{
LogosMap response;
// Same rationale as requestUninstall: empty name has to be rejected
// before we set pending state, otherwise beforeUpgrade(name="") leads
// listeners into an empty-title dialog.
if (packageName.empty()) {
response["success"] = false;
response["error"] = "Package name cannot be empty";
return response;
}
std::unique_lock<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::None) {
response["success"] = false;
response["error"] = std::string("Another ") + opName(m_pendingAction.op)
+ " is in progress for '" + m_pendingAction.name + "'";
return response;
}
if (isEmbedded(packageName)) {
response["success"] = false;
response["error"] = "Cannot upgrade embedded module '" + packageName + "'";
return response;
}
m_pendingAction = {};
m_pendingAction.op = PendingOp::Upgrade;
m_pendingAction.name = packageName;
m_pendingAction.releaseTag = releaseTag;
m_pendingAction.mode = mode;
m_pendingAction.acked = false;
LogosMap payload;
payload["name"] = packageName;
payload["releaseTag"] = releaseTag;
payload["mode"] = mode;
LogosList deps = LogosList::array();
for (const auto& d : installedDependentsNames(packageName))
deps.push_back(d);
payload["installedDependents"] = deps;
startAckTimerLocked(lock);
lock.unlock();
if (emitEvent) emitEvent("beforeUpgrade", payload.dump());
response["success"] = true;
return response;
}
LogosMap PackageManagerImpl::ackPendingAction(const std::string& packageName)
{
std::lock_guard<std::mutex> lock(m_stateMutex);
LogosMap response;
if (m_pendingAction.op == PendingOp::None || m_pendingAction.name != packageName) {
response["success"] = false;
response["error"] = "No matching pending action to ack for '" + packageName + "'";
return response;
}
// Idempotent — re-acking an already-acked request is a no-op.
m_pendingAction.acked = true;
stopAckTimerLocked();
response["success"] = true;
return response;
}
LogosMap PackageManagerImpl::confirmUninstall(const std::string& packageName)
{
{
std::lock_guard<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::Uninstall || m_pendingAction.name != packageName) {
LogosMap response;
response["success"] = false;
response["error"] = "No matching pending uninstall for '" + packageName + "'";
return response;
}
if (!m_pendingAction.acked) {
LogosMap response;
response["success"] = false;
response["error"] = "Pending uninstall for '" + packageName + "' has not been acknowledged";
return response;
}
m_pendingAction = {};
stopAckTimerLocked();
}
// Lock released before doUninstall — it emits corePluginUninstalled /
// uiPluginUninstalled, and listeners may synchronously call back into
// this impl (the whole point of the event is to trigger cleanup).
return doUninstall(packageName);
}
LogosMap PackageManagerImpl::cancelUninstall(const std::string& packageName)
{
PendingAction pa;
{
std::lock_guard<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::Uninstall || m_pendingAction.name != packageName) {
LogosMap response;
response["success"] = false;
response["error"] = "No matching pending uninstall for '" + packageName + "'";
return response;
}
// Symmetric with confirmUninstall: the gated protocol requires the
// owning listener to ack before driving the decision either way.
// An un-acked pending state is owned by the ack-reception timer;
// letting cancel short-circuit it would bypass the protocol and
// suppress the "no listener acknowledged" timeout event that
// initiators otherwise rely on.
if (!m_pendingAction.acked) {
LogosMap response;
response["success"] = false;
response["error"] = "Pending uninstall for '" + packageName + "' has not been acknowledged";
return response;
}
pa = m_pendingAction;
m_pendingAction = {};
stopAckTimerLocked();
}
// Uniform cancellation notification — same event the ack-timeout path emits.
// Initiators (PMU) subscribe once and handle every cancellation consistently.
emitCancellation(pa, "user cancelled");
LogosMap response;
response["success"] = true;
return response;
}
LogosMap PackageManagerImpl::confirmUpgrade(const std::string& packageName,
const std::string& releaseTag)
{
int64_t mode = 0;
{
std::lock_guard<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::Upgrade
|| m_pendingAction.name != packageName
|| m_pendingAction.releaseTag != releaseTag) {
LogosMap response;
response["success"] = false;
response["error"] = "No matching pending upgrade for '" + packageName + "'";
return response;
}
if (!m_pendingAction.acked) {
LogosMap response;
response["success"] = false;
response["error"] = "Pending upgrade for '" + packageName + "' has not been acknowledged";
return response;
}
mode = m_pendingAction.mode;
m_pendingAction = {};
stopAckTimerLocked();
}
LogosMap uninstallResult = doUninstall(packageName);
// On successful uninstall, tell PMU to drive the download+install step
// for the new version. The impl layer has no LogosAPI access (it only
// communicates outward via the emitEvent callback), so we can't call
// package_downloader directly. Instead we emit upgradeUninstallDone
// with the pinned releaseTag — PMU subscribes to this event and reuses
// its existing download+install chain (downloadPackageAsync →
// installOnePackage). The user sees the row flip to "Installing" while
// the download runs, then to "Installed" (or "Failed") when it finishes.
bool ok = uninstallResult.value("success", false);
if (ok && emitEvent) {
LogosMap payload;
payload["name"] = packageName;
payload["releaseTag"] = releaseTag;
payload["mode"] = mode;
emitEvent("upgradeUninstallDone", payload.dump());
}
return uninstallResult;
}
LogosMap PackageManagerImpl::cancelUpgrade(const std::string& packageName,
const std::string& releaseTag)
{
PendingAction pa;
{
std::lock_guard<std::mutex> lock(m_stateMutex);
if (m_pendingAction.op != PendingOp::Upgrade
|| m_pendingAction.name != packageName
|| m_pendingAction.releaseTag != releaseTag) {
LogosMap response;
response["success"] = false;
response["error"] = "No matching pending upgrade for '" + packageName + "'";
return response;
}
// See cancelUninstall for why cancel also requires prior ack.
if (!m_pendingAction.acked) {
LogosMap response;
response["success"] = false;
response["error"] = "Pending upgrade for '" + packageName + "' has not been acknowledged";
return response;
}
pa = m_pendingAction;
m_pendingAction = {};
stopAckTimerLocked();
}
emitCancellation(pa, "user cancelled");
LogosMap response;
response["success"] = true;
return response;
}
LogosMap PackageManagerImpl::resetPendingAction()
{
std::lock_guard<std::mutex> lock(m_stateMutex);
m_pendingAction = {};
stopAckTimerLocked();
LogosMap response;
response["success"] = true;
return response;
}
+166 -1
View File
@@ -3,6 +3,10 @@
#include <string>
#include <vector>
#include <functional>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <cstdint>
#include <logos_json.h>
class PackageManagerLib;
@@ -12,6 +16,9 @@ public:
PackageManagerImpl();
~PackageManagerImpl();
PackageManagerImpl(const PackageManagerImpl&) = delete;
PackageManagerImpl& operator=(const PackageManagerImpl&) = delete;
// Event callback — wired automatically by the generated glue layer.
// Call this to emit named events to other modules / the host application.
std::function<void(const std::string& eventName, const std::string& data)> emitEvent;
@@ -19,6 +26,16 @@ public:
// Install from local LGX file — returns LogosMap {name, path, error, isCoreModule, ...}
LogosMap installPlugin(const std::string& pluginPath, bool skipIfNotNewerVersion);
// Inspect an LGX file without installing. Returns package metadata plus
// already-installed status and dependents so callers can show a confirmation
// dialog before committing. Shape:
// { name, version, type, description, category,
// signatureStatus ("signed"|"unsigned"|"invalid"|"error"),
// signerDid?, signerName?,
// isAlreadyInstalled, installedVersion?,
// installedDependents? }
LogosMap inspectPackage(const std::string& lgxPath);
// Directory configuration — embedded (multiple, read-only)
void setEmbeddedModulesDirectory(const std::string& dir);
void addEmbeddedModulesDirectory(const std::string& dir);
@@ -29,11 +46,44 @@ public:
void setUserModulesDirectory(const std::string& dir);
void setUserUiPluginsDirectory(const std::string& dir);
// Scanning — each returns LogosList (JSON array with all manifest fields + installDir + mainFilePath)
// Scanning — each returns LogosList (JSON array with all manifest fields
// + installDir + mainFilePath + installType ("embedded"|"user"))
LogosList getInstalledPackages();
LogosList getInstalledModules();
LogosList getInstalledUiPlugins();
// Uninstall a user-installed package. Refuses embedded packages.
// Returns { success: bool, error?: string, removedFiles?: [string] }.
// On success also emits "corePluginUninstalled" or "uiPluginUninstalled".
//
// This is the ungated path — it performs the uninstall immediately.
// Headless callers (lgpm, scripts, logoscore-driven automation) use this
// directly. GUI callers should prefer requestUninstall below, which gates
// the destructive work behind a listener-driven confirmation dialog.
LogosMap uninstallPackage(const std::string& packageName);
// Forward dependency walk for an installed package. Returns a tree of
// { name, status, version, installType, children: [...] } rooted at the
// queried package. Stops at NotInstalled/Cycle nodes. `recursive=false`
// walks only one level deep (children have empty `children` arrays);
// `recursive=true` walks the full tree.
LogosMap resolveDependencies(const std::string& packageName, bool recursive);
// Reverse dependency walk — same tree shape as resolveDependencies but
// for the inverse edge. Returns a tree of { name, version, type,
// installType, installDir, children: [...] } rooted at the queried
// package. `recursive=false` walks only depth-1 (direct reverse
// neighbours, with empty `children`); `recursive=true` walks the full
// reverse subtree.
LogosMap resolveDependents(const std::string& packageName, bool recursive);
// Flat projections of the two walks above. Each returns a LogosList of
// per-node maps (same fields as the tree version minus `children`).
// `recursive=false` emits only direct neighbours; `recursive=true`
// emits every descendant, BFS-ordered and deduplicated by name.
LogosList resolveFlatDependencies(const std::string& packageName, bool recursive);
LogosList resolveFlatDependents(const std::string& packageName, bool recursive);
// Platform variants this build accepts (e.g. ["darwin-arm64-dev"] or ["darwin-arm64"])
std::vector<std::string> getValidVariants();
@@ -50,6 +100,121 @@ public:
LogosMap removeTrustedKey(const std::string& name);
LogosList listTrustedKeys();
// ----------------------------------------------------------------
// Gated uninstall / upgrade flow with two-phase listener ack.
// ----------------------------------------------------------------
//
// Protocol (GUI-only — headless callers must use the ungated slots above):
//
// 1. Caller invokes requestUninstall(name) / requestUpgrade(name, tag, mode).
// Returns LogosResult-shaped LogosMap { success, error? } synchronously.
// On success, sets pending state, emits "beforeUninstall" / "beforeUpgrade",
// and starts a 3s ack-reception timer.
//
// 2. Any listener handling the event MUST immediately call
// ackPendingAction(name). This cancels the ack timer and tells the module
// "I'm driving the dialog — wait indefinitely for my decision."
//
// 3a. If the ack timer fires with no ack, the module clears pending state
// and emits "uninstallCancelled" / "upgradeCancelled" with a timeout
// reason. Destructive work never runs without an owning listener.
//
// 3b. Once acked, confirmUninstall / confirmUpgrade performs the work;
// cancelUninstall / cancelUpgrade aborts and emits the cancellation
// event with reason "user cancelled". Confirm and cancel both
// require a prior ack — an un-acked pending state is owned by the
// ack-reception timer, and bypassing it would short-circuit the
// "no listener acknowledged" timeout path that initiators rely on.
// Calling confirm/cancel before ack returns { success: false,
// error: "Pending <op> for '<name>' has not been acknowledged" }.
//
// Only one gated flow can be pending globally (across packages and ops).
// A second requestXxx while one is pending returns { success: false, error: ... }.
LogosMap requestUninstall(const std::string& packageName);
LogosMap requestUpgrade(const std::string& packageName, const std::string& releaseTag, int64_t mode);
LogosMap ackPendingAction(const std::string& packageName);
LogosMap confirmUninstall(const std::string& packageName);
LogosMap cancelUninstall(const std::string& packageName);
LogosMap confirmUpgrade(const std::string& packageName, const std::string& releaseTag);
LogosMap cancelUpgrade(const std::string& packageName, const std::string& releaseTag);
// Belt-and-braces: clears any pending state. Called by Basecamp at startup
// so a crash mid-dialog in a previous session doesn't block new requests.
LogosMap resetPendingAction();
// Test-only hook — override the ack-reception timeout so timeout-path
// tests complete in milliseconds instead of the production 3-second
// default. Must be called before any request*() on this instance (i.e.
// while no ack-timer worker is running). Not thread-safe against a
// concurrent worker.
void setAckTimeoutMsForTest(int ms) { m_ackTimeoutMs = ms; }
private:
enum class PendingOp { None, Uninstall, Upgrade };
struct PendingAction {
PendingOp op = PendingOp::None;
std::string name;
std::string releaseTag; // upgrade only
int64_t mode = 0; // upgrade only (UpgradeMode enum as int)
bool acked = false;
};
// Production ack-reception timeout. Overridable per-instance via
// setAckTimeoutMsForTest so timeout-path tests don't need to wait
// 3 real seconds each.
int m_ackTimeoutMs = 3000;
static const char* opName(PendingOp op);
// ----------------------------------------------------------------
// Pure-C++ ack timer.
// ----------------------------------------------------------------
//
// When a gated operation (requestUninstall / requestUpgrade) is initiated,
// a worker thread is spawned that waits on m_ackCv for up to m_ackTimeoutMs.
// Any concurrent state change — ackPendingAction, confirmXxx, cancelXxx,
// resetPendingAction, a new requestXxx, or the destructor — bumps
// m_ackGeneration and notifies the CV. The worker wakes up, observes that
// its captured generation no longer matches the current one (or m_ackShutdown
// is set), and exits silently. Only a worker whose captured generation still
// matches after a full timeout proceeds to emit the cancellation event.
//
// Threading rules:
// - startAckTimerLocked / stopAckTimerLocked must be called with
// m_stateMutex held.
// - startAckTimerLocked temporarily releases the lock while joining the
// previous worker thread (if any), to avoid a deadlock where the
// worker can't exit its wait_for because we're holding the lock.
// - stopAckTimerLocked never joins — it only signals. The worker exits
// on its own; the next startAckTimerLocked (or the destructor) joins
// the now-finished thread.
// - The destructor sets m_ackShutdown, notifies the CV, and joins the
// worker before destroying any other state.
//
// Everything else on this class (m_lib access, file scanning, emitEvent
// from user code) runs serially on the module thread via the glue layer's
// queued connection, so no additional locking is needed beyond
// m_stateMutex guarding the pending-action state.
void startAckTimerLocked(std::unique_lock<std::mutex>& lock);
void stopAckTimerLocked();
void ackTimerWorker(uint64_t myGeneration);
// Helpers used by the gated-flow slots.
bool isEmbedded(const std::string& packageName) const;
std::vector<std::string> installedDependentsNames(const std::string& packageName) const;
LogosMap doUninstall(const std::string& packageName);
void emitCancellation(const PendingAction& pa, const std::string& reason);
PackageManagerLib* m_lib;
// Guards m_pendingAction and the ack-timer generation/shutdown flags.
mutable std::mutex m_stateMutex;
std::condition_variable m_ackCv;
std::thread m_ackThread;
uint64_t m_ackGeneration = 0;
bool m_ackShutdown = false;
PendingAction m_pendingAction;
};
+57
View File
@@ -46,4 +46,61 @@ void lgx_free_keyring_list(lgx_keyring_list_t list) {
(void)list;
}
// ---------------------------------------------------------------------------
// Package loading / inspection — unit tests never invoke inspectPackage, but
// the symbols must exist at link time because package_manager_impl.cpp
// references them unconditionally. Stubs return benign zeroes/nulls.
// ---------------------------------------------------------------------------
lgx_package_t lgx_load(const char* path) {
LOGOS_CMOCK_RECORD("lgx_load");
(void)path;
return nullptr;
}
void lgx_free_package(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_free_package");
(void)pkg;
}
const char* lgx_get_last_error(void) {
LOGOS_CMOCK_RECORD("lgx_get_last_error");
return nullptr;
}
const char* lgx_get_name(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_get_name");
(void)pkg;
return nullptr;
}
const char* lgx_get_version(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_get_version");
(void)pkg;
return nullptr;
}
const char* lgx_get_description(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_get_description");
(void)pkg;
return nullptr;
}
const char* lgx_get_manifest_json(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_get_manifest_json");
(void)pkg;
return nullptr;
}
const char** lgx_get_variants(lgx_package_t pkg) {
LOGOS_CMOCK_RECORD("lgx_get_variants");
(void)pkg;
return nullptr;
}
void lgx_free_string_array(const char** array) {
LOGOS_CMOCK_RECORD("lgx_free_string_array");
(void)array;
}
} // extern "C"
+203 -7
View File
@@ -1,17 +1,102 @@
// Mock PackageManagerLib for package_manager_module unit tests (link-time substitution).
//
// Everything the impl reads back from the library is registered via the
// setMock*() helpers in mock_package_manager_lib.h. Registries are file-static
// because each struct type carries std::string / std::vector members and
// can't go through LogosCMockStore's memcpy-based return slot.
//
// State reset: LogosTestContext constructor calls LogosCMockStore::reset(),
// which zeroes every recorded call. We hook into that by checking a sentinel
// call count on entry to every mock — count == 0 means the store was just
// reset, so we also clear our struct registries. Tests can then set up
// registries AFTER constructing LogosTestContext with the usual pattern.
#include <logos_clib_mock.h>
#include <package_manager_lib.h>
static std::string mockDupCStr(const char* key, const char* fallback) {
#include "mock_package_manager_lib.h"
#include <deque>
#include <optional>
#include <string>
#include <unordered_set>
#include <vector>
namespace {
// ---------------------------------------------------------------------------
// File-static registries populated by setMock*()
// ---------------------------------------------------------------------------
std::vector<InstalledPackage> s_installedPackages;
std::vector<InstalledPackage> s_installedModules;
std::vector<InstalledPackage> s_installedUiPlugins;
std::optional<DependencyTreeNode> s_dependencyTree;
std::optional<DependentTreeNode> s_dependentTree;
// Sentinel key recorded on the first mock interaction per-test. When
// LogosCMockStore::reset() zeroes all call counts (inside LogosTestContext
// ctor), the sentinel disappears too — the next mock call sees count == 0,
// wipes the struct registries, and re-arms the sentinel.
constexpr const char* kResetSentinel = "__pm_mock_reset_sentinel__";
void ensureFreshStateForTest() {
auto& store = LogosCMockStore::instance();
if (store.callCount(kResetSentinel) == 0) {
s_installedPackages.clear();
s_installedModules.clear();
s_installedUiPlugins.clear();
s_dependencyTree.reset();
s_dependentTree.reset();
store.recordCall(kResetSentinel);
}
}
std::string mockDupCStr(const char* key, const char* fallback) {
const char* ret = LOGOS_CMOCK_RETURN_STRING(key);
if (ret && ret[0])
return std::string(ret);
return fallback ? std::string(fallback) : std::string();
}
} // namespace
// ---------------------------------------------------------------------------
// Setters (exposed via mock_package_manager_lib.h)
// ---------------------------------------------------------------------------
void setMockInstalledPackages(std::vector<InstalledPackage> v) {
ensureFreshStateForTest();
s_installedPackages = std::move(v);
}
void setMockInstalledModules(std::vector<InstalledPackage> v) {
ensureFreshStateForTest();
s_installedModules = std::move(v);
}
void setMockInstalledUiPlugins(std::vector<InstalledPackage> v) {
ensureFreshStateForTest();
s_installedUiPlugins = std::move(v);
}
void setMockDependencyTree(std::optional<DependencyTreeNode> tree) {
ensureFreshStateForTest();
s_dependencyTree = std::move(tree);
}
void setMockDependentTree(std::optional<DependentTreeNode> tree) {
ensureFreshStateForTest();
s_dependentTree = std::move(tree);
}
// ---------------------------------------------------------------------------
// PackageManagerLib method impls
// ---------------------------------------------------------------------------
PackageManagerLib::PackageManagerLib() {
LOGOS_CMOCK_RECORD("PackageManagerLib_ctor");
ensureFreshStateForTest();
}
PackageManagerLib::~PackageManagerLib() {
@@ -72,19 +157,22 @@ std::string PackageManagerLib::installPluginFile(const std::string& pluginPath,
return mockDupCStr("installPluginFile_result", "");
}
std::string PackageManagerLib::getInstalledPackages() {
std::vector<InstalledPackage> PackageManagerLib::getInstalledPackages() {
LOGOS_CMOCK_RECORD("getInstalledPackages");
return mockDupCStr("getInstalledPackages", "[]");
ensureFreshStateForTest();
return s_installedPackages;
}
std::string PackageManagerLib::getInstalledModules() {
std::vector<InstalledPackage> PackageManagerLib::getInstalledModules() {
LOGOS_CMOCK_RECORD("getInstalledModules");
return mockDupCStr("getInstalledModules", "[]");
ensureFreshStateForTest();
return s_installedModules;
}
std::string PackageManagerLib::getInstalledUiPlugins() {
std::vector<InstalledPackage> PackageManagerLib::getInstalledUiPlugins() {
LOGOS_CMOCK_RECORD("getInstalledUiPlugins");
return mockDupCStr("getInstalledUiPlugins", "[]");
ensureFreshStateForTest();
return s_installedUiPlugins;
}
std::vector<std::string> PackageManagerLib::platformVariantsToTry() {
@@ -111,6 +199,91 @@ std::string PackageManagerLib::keyringDirectory() {
return mockDupCStr("keyringDirectory", "");
}
UninstallResult PackageManagerLib::uninstallPackage(const std::string& packageName) {
LOGOS_CMOCK_RECORD("uninstallPackage");
(void)packageName;
UninstallResult r;
r.success = LOGOS_CMOCK_RETURN(bool, "uninstallPackage_success");
const char* err = LOGOS_CMOCK_RETURN_STRING("uninstallPackage_error");
if (err && err[0]) {
r.errorMsg = err;
}
const char* removed = LOGOS_CMOCK_RETURN_STRING("uninstallPackage_removed");
if (removed && removed[0]) {
// Comma-separated list for convenience
std::string s(removed);
std::string::size_type pos = 0, next;
while ((next = s.find(',', pos)) != std::string::npos) {
r.removedFiles.emplace_back(s.substr(pos, next - pos));
pos = next + 1;
}
r.removedFiles.emplace_back(s.substr(pos));
}
return r;
}
std::optional<DependencyTreeNode> PackageManagerLib::resolveDependencies(const std::string& packageName) {
LOGOS_CMOCK_RECORD("resolveDependencies");
(void)packageName;
ensureFreshStateForTest();
return s_dependencyTree;
}
std::optional<DependentTreeNode> PackageManagerLib::resolveDependents(const std::string& packageName) {
LOGOS_CMOCK_RECORD("resolveDependents");
(void)packageName;
ensureFreshStateForTest();
return s_dependentTree;
}
// ---------------------------------------------------------------------------
// flatten() — hand-rolled copies of the real-lib implementations. The stub
// header mirrors the real header's declarations, so the tree structs expose
// the same member function; the mock provides bodies so link-time tests can
// exercise the same BFS-dedup behaviour without linking the real lib.
// ---------------------------------------------------------------------------
std::vector<DependencyTreeNode> DependencyTreeNode::flatten() const {
std::vector<DependencyTreeNode> out;
std::unordered_set<std::string> seen;
std::deque<const DependencyTreeNode*> queue;
for (const auto& c : children) queue.push_back(&c);
while (!queue.empty()) {
const DependencyTreeNode* n = queue.front();
queue.pop_front();
if (!seen.insert(n->name).second) continue;
DependencyTreeNode copy;
copy.name = n->name;
copy.status = n->status;
copy.version = n->version;
copy.installType = n->installType;
out.push_back(std::move(copy));
for (const auto& c : n->children) queue.push_back(&c);
}
return out;
}
std::vector<DependentTreeNode> DependentTreeNode::flatten() const {
std::vector<DependentTreeNode> out;
std::unordered_set<std::string> seen;
std::deque<const DependentTreeNode*> queue;
for (const auto& c : children) queue.push_back(&c);
while (!queue.empty()) {
const DependentTreeNode* n = queue.front();
queue.pop_front();
if (!seen.insert(n->name).second) continue;
DependentTreeNode copy;
copy.name = n->name;
copy.version = n->version;
copy.type = n->type;
copy.installType = n->installType;
copy.installDir = n->installDir;
out.push_back(std::move(copy));
for (const auto& c : n->children) queue.push_back(&c);
}
return out;
}
SignatureVerificationResult PackageManagerLib::verifyPackageSignature(const std::string& lgxPath) {
LOGOS_CMOCK_RECORD("verifyPackageSignature");
(void)lgxPath;
@@ -140,3 +313,26 @@ SignatureVerificationResult PackageManagerLib::verifyPackageSignature(const std:
}
return r;
}
// installTypeToString / dependencyStatusToString are declared in
// package_manager_lib.h but referenced at link time by any JSON helper that
// serialises InstalledPackage / DependencyTreeNode. The real library supplies
// them; in unit tests they're not exercised because PackageManagerImpl builds
// LogosMap directly from the structs without touching them, but providing a
// stub keeps the mock self-contained should a future test need it.
const char* installTypeToString(InstallType t) {
switch (t) {
case InstallType::Embedded: return "embedded";
case InstallType::User: return "user";
}
return "user";
}
const char* dependencyStatusToString(DependencyStatus s) {
switch (s) {
case DependencyStatus::Installed: return "installed";
case DependencyStatus::NotInstalled: return "not_installed";
case DependencyStatus::Cycle: return "cycle";
}
return "not_installed";
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
// Test-side helpers that let tests register the structs the mocked
// PackageManagerLib should return. The mock implementation lives in
// mock_package_manager_lib.cpp and is wired in via the logos_test() CMake
// function (MOCK_C_SOURCES).
//
// Why a struct registry instead of `LogosCMockStore` returns?
// - LogosCMockStore uses `returnsRaw` + memcpy which is only safe for
// trivially-copyable types. Our struct returns carry std::string and
// std::vector, so memcpy would be undefined behaviour.
// - Recursive DependencyTreeNode would need a bespoke serialisation; a
// file-static std::optional<DependencyTreeNode> sidesteps that.
//
// Registries reset automatically on each new LogosTestContext. The mock
// piggybacks on LogosCMockStore::reset() via a sentinel call so tests can
// be written exactly like the old JSON-returning pattern — construct a
// context, set the mocks, instantiate the impl.
#include <optional>
#include <vector>
#include "package_manager_lib.h"
void setMockInstalledPackages(std::vector<InstalledPackage> v);
void setMockInstalledModules(std::vector<InstalledPackage> v);
void setMockInstalledUiPlugins(std::vector<InstalledPackage> v);
void setMockDependencyTree(std::optional<DependencyTreeNode> tree);
void setMockDependentTree(std::optional<DependentTreeNode> tree);
+18
View File
@@ -30,6 +30,11 @@ typedef struct {
size_t count;
} lgx_keyring_list_t;
/* Opaque package handle — used by inspectPackage(). Real layout lives in
* ../lib; unit tests never invoke these functions (they're only referenced
* from code paths that aren't exercised here), so a forward decl is enough. */
typedef struct lgx_package_opaque* lgx_package_t;
lgx_result_t lgx_keyring_add(const char* keyring_dir,
const char* name,
const char* did,
@@ -42,6 +47,19 @@ lgx_keyring_list_t lgx_keyring_list(const char* keyring_dir);
void lgx_free_keyring_list(lgx_keyring_list_t list);
/* Package loading / inspection — only declarations needed for unit-test
* compilation; the real implementations are provided by the lgx library
* at link time (integration tests) or stubbed out when unexercised. */
lgx_package_t lgx_load(const char* path);
void lgx_free_package(lgx_package_t pkg);
const char* lgx_get_last_error(void);
const char* lgx_get_name(lgx_package_t pkg);
const char* lgx_get_version(lgx_package_t pkg);
const char* lgx_get_description(lgx_package_t pkg);
const char* lgx_get_manifest_json(lgx_package_t pkg);
const char** lgx_get_variants(lgx_package_t pkg);
void lgx_free_string_array(const char** array);
#ifdef __cplusplus
}
#endif
+80 -3
View File
@@ -1,5 +1,7 @@
#pragma once
#include <functional>
#include <optional>
#include <string>
#include <vector>
@@ -12,6 +14,17 @@ enum class SignaturePolicy {
REQUIRE
};
enum class InstallType {
Embedded,
User,
};
enum class DependencyStatus {
Installed,
NotInstalled,
Cycle,
};
struct SignatureVerificationResult {
bool is_signed = false;
bool signature_valid = false;
@@ -23,6 +36,65 @@ struct SignatureVerificationResult {
std::string error;
};
struct UninstallResult {
bool success = false;
std::string errorMsg;
std::vector<std::string> removedFiles;
};
// Mirrors the real lib's Hashes struct. Only `root` is read today but keeping
// the nested shape matches manifest.json and leaves room for additions.
struct Hashes {
std::string root;
};
// Mirrors InstalledPackage in package_manager_lib.h.
struct InstalledPackage {
std::string name;
std::string version;
std::string description;
std::string type;
std::string category;
std::string author;
std::string license;
std::string icon;
std::string view;
std::vector<std::string> dependencies;
Hashes hashes;
InstallType installType = InstallType::User;
std::string installDir;
std::string mainFilePath;
};
// Mirrors DependencyTreeNode in package_manager_lib.h.
struct DependencyTreeNode {
std::string name;
DependencyStatus status = DependencyStatus::NotInstalled;
std::string version;
InstallType installType = InstallType::User;
std::vector<DependencyTreeNode> children;
// Matches the real lib — descendants-only BFS, name-deduped, children
// cleared on returned copies. Implementation lives in the mock .cpp so
// the stub header stays declaration-only.
std::vector<DependencyTreeNode> flatten() const;
};
// Mirrors DependentTreeNode in package_manager_lib.h.
struct DependentTreeNode {
std::string name;
std::string version;
std::string type;
InstallType installType = InstallType::User;
std::string installDir;
std::vector<DependentTreeNode> children;
std::vector<DependentTreeNode> flatten() const;
};
const char* installTypeToString(InstallType t);
const char* dependencyStatusToString(DependencyStatus s);
class PackageManagerLib {
public:
PackageManagerLib();
@@ -40,9 +112,9 @@ public:
std::string* installedPluginPath = nullptr,
bool* isCoreModule = nullptr);
std::string getInstalledPackages();
std::string getInstalledModules();
std::string getInstalledUiPlugins();
std::vector<InstalledPackage> getInstalledPackages();
std::vector<InstalledPackage> getInstalledModules();
std::vector<InstalledPackage> getInstalledUiPlugins();
static std::vector<std::string> platformVariantsToTry();
@@ -51,4 +123,9 @@ public:
std::string keyringDirectory();
SignatureVerificationResult verifyPackageSignature(const std::string& lgxPath);
UninstallResult uninstallPackage(const std::string& packageName);
std::optional<DependencyTreeNode> resolveDependencies(const std::string& packageName);
std::optional<DependentTreeNode> resolveDependents(const std::string& packageName);
};
File diff suppressed because it is too large Load Diff