Files
logos-app/app/PackageCoordinator.cpp
Dario LipicarandClaude Opus 5 dadc7b6bbb feat(deps): refuse a UI plugin whose dependency version does not satisfy its range (#361)
* test(deps): pin what the load gate actually admits

The gate in front of a UI plugin load reads
package_manager.resolveFlatDependencies and classifies each row BY
EXCLUSION:

    if (m.value("status").toString() == "not_installed") missing << s;
    else                                                 installed << s;

`status` is a closed vocabulary owned by logos-package-manager, and it
gained a fourth word. Anything invented after that line was written lands
in `installed`, so a plugin is admitted on top of a dependency the
resolver rejected — with no diagnostic anywhere.

Lift the predicate into app/utils/DependencyBlocker.h VERBATIM, alongside
the message-building it will need, and put the desired behaviour under
test. The three fixtures are wire payloads captured from a real logoscore
daemon over a real installed tree, parsed with QJsonDocument so the suite
consumes exactly the bytes the module emits.

Red at this commit — the classification cases fail, which is master's
behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(deps): refuse a load on a version-mismatched dependency, and say which

The gate in front of a UI plugin load treated every status except
"not_installed" as satisfied, so a dependency the resolver had just
rejected for being the wrong version was admitted — the plugin mounted on
top of it, or liblogos refused with a bare "plugin load failed".

Name the blocking statuses instead of excluding one, and carry the reason
as far as the user:

  * PackageCoordinator gains m_blockingDepsByModule alongside the existing
    name list — same membership, filled in the same pass, one map per
    blocking dependency. missingDepsOf keeps returning names, so the
    sidebar marker, AppsModel::setMissingDeps and installStatus are
    untouched; blockingDepsOf is for callers that must TELL the user
    something.

  * ConfirmationDialog says one of three sentences. "cannot be loaded
    because the following modules are not installed" is a lie about a
    module that is installed at the wrong version, and it sends the user
    to reinstall something they already have. Each row now names the
    constraint and what was found — "depsvc — requires ^2.0.0, found
    1.0.0" — because a complaint the user cannot act on is barely better
    than silence.

  * The sidebar marker keeps the red cross for an absent dependency and
    draws an amber "!" for a version conflict. "mixed" keeps the cross:
    something IS absent, and that is the fact to act on first.

  * ModuleInstanceModel's badge reads "Version conflict" rather than
    "Missing deps" for the same reason. depBlockKind joins the diffRoles
    mask because replaceRows SKIPS a row whose mask is empty, so a row
    going absent -> mismatch would otherwise keep the stale word forever.

Deliberately still admitted, both pinned by tests: "cycle" (never driven
against a real cyclic install — blocking it would be a behaviour change
made blind) and an unrecognised status (this gate is advisory in front of
liblogos' own resolver; refusing on a word this build does not know would
block loads that work).

Consequence worth naming: a version-mismatched app now demotes to
installStatus NotInstalled in the App Manager, exactly as one with an
absent dependency already did. The remedy the button offers — reinstall,
which re-resolves dependencies — is right for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(plugins): pin what the core-dependency loader actually reads

PluginLoader::loadCoreDependencies read each `dependencies[]` entry as

    QString depName = dep.toString();
    if (depName.isEmpty()) continue;

QVariant::toString() on a QVariantMap returns a NULL QString — no
diagnostic, no exception. So the moment the module ABI is widened to send
the object form {"name": …, "version": "^2.0.0", "signer": "did:…"} that
the LGX spec already allows and lgpm already parses, every CONSTRAINED
core dependency is silently skipped and the ui plugin mounts on top of an
unloaded dependency.

This commit changes no behaviour. It gives that decision a name
(logos::readDependencyEntry) and a test, so the next commit's fix is
visible as a diff in outcomes rather than a diff in expressions.

Red, as expected — 5 of 14 cases fail against the extracted behaviour:

  FAIL!  : object_entry_yields_its_name()                    kind 1, want 0
  FAIL!  : object_entry_without_constraints_yields_its_name() kind 1, want 0
  FAIL!  : json_object_entry_yields_its_name()               kind 1, want 0
  FAIL!  : hash_object_entry_yields_its_name()               kind 1, want 0
  FAIL!  : a_number_is_unrecognised()                        kind 0, want 1

(1 = Unrecognised, i.e. skipped. The last one is the mirror image: a
non-string scalar is currently stringified and loaded as a module name.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plugins): read object-form core dependency entries, refuse the rest

The load gate now understands both forms the LGX spec allows, and reports
what it cannot understand instead of walking past it.

Green — 14/14, was 9/14:

  PASS   : object_entry_yields_its_name()
  PASS   : object_entry_without_constraints_yields_its_name()
  PASS   : json_object_entry_yields_its_name()
  PASS   : hash_object_entry_yields_its_name()
  PASS   : a_number_is_unrecognised()
  Totals: 14 passed, 0 failed

Two behaviour changes at PluginLoader::loadCoreDependencies:

* {"name": "wallet_module", "version": "^2.0.0", "signer": "did:…"} now
  loads wallet_module. Before, QVariant::toString() returned a null
  QString for it and the `if (depName.isEmpty()) continue;` skipped the
  dependency with no error and no log line — the ui plugin then mounted
  on top of a core module that was never loaded, and the first symptom
  was a dead endpoint somewhere else entirely.

  Latent today only because the module ABI still sends bare strings
  (package_manager_impl.cpp's toLogosMap flattens the constraint away).
  It arms itself the moment that is widened. Every other reader in the
  fleet already handles both forms — logos-module's module_metadata.cpp,
  logos-standalone-app's mainwindow.cpp, logos-cpp-sdk's
  metadata_dependencies.h, lgpm's manifest scan. Basecamp was the outlier.

* An entry that is neither a name nor an object with a string "name" is
  now a hard, logged load failure rather than a silent skip. Same reason:
  we cannot honour a dependency we cannot name, and mounting anyway hides
  it. This also stops a stray scalar being stringified into a module
  name — QVariant(42).toString() is "42", and the old loop would have
  gone looking for a module called that.

Unreachable from `lgpm install` today: logos-package's Manifest::fromJson
rejects a non-string/non-object entry outright, and lgpm's own scan warns
and drops what gets past it. The branch exists for the manifests that
bypass both — embedded installs written at build time, and anything
edited in place afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(deps): refuse a load on a dependency published by a different signer

A dependency can now fail its dependant in a third way, and it needs a third
sentence. The gate already separated "not installed" from "installed at a
version it does not accept", because "install it" and "get another version" are
different instructions. `signer_mismatch` is a package installed under the RIGHT
NAME by the WRONG PUBLISHER — somebody else's package — and neither of those
instructions works on it. Installing gets the same package back; no version of
what is on disk is the package the module named.

  blockSummary   headline                  body
  "absent"       Missing Dependencies      "…are not installed"
  "mismatch"     Incompatible Dependencies "…installed at a version it does
                                            not accept"
  "signer"       Unexpected Publisher      "…were published by a different
                                            signer than it requires … reinstall
                                            these from the publisher the module
                                            names"
  "mixed"        Missing Dependencies      "…missing, the wrong version, or from
                                            a different publisher"

Per row, the detail clause names BOTH DIDs: "published by a different signer;
requires <pin>, found <observed>". The pin alone does not say what went wrong;
the observation alone is an accusation with no charge attached.

THE DESIGN CALL, and it is pinned by a test rather than left as an omission.
`signer_unknown` — the edge pins a publisher and NOTHING RECORDS who published
the installed package — does NOT block. Absence of evidence is not evidence of
mismatch, and this is the expected state for two whole populations: every
EMBEDDED package (placed by the build, never through the installer that records
a publisher, so it can never acquire one) and everything installed before the
record existed. Blocking here would make a pin on an embedded dependency
unsatisfiable by construction, forever, with no action a user could take.
logos-package-manager owns the call and can flip it in one place
(UnknownSignerPolicy::Strict makes its scanner emit signer_mismatch, which this
gate already blocks), which is exactly why this gate must not second-guess it.

Three places in this header classified by testing ONE value and sweeping the
rest into a default, and all three are now switches or named alternatives:

  readDependencyBlocker      the trailing `else` that made version_mismatch
                             count as satisfied — already fixed, extended
  dependencyBlockerToMap     `VersionMismatch ? … : "not_installed"`, which
                             would have crossed into QML labelling a signer
                             mismatch "not_installed"
  summariseDependencyBlockers tested one kind and swept the rest into `absent`,
                             so a pure signer set summarised as "not installed"

dependencyIsPresent stays written by exclusion, and that is the one place it is
right: exactly one kind means "nothing under this name", so a kind added later
is by definition about a package that IS installed and should count as present
by default. The comment now says so.

The sidebar marker generalises from `_versionConflictOnly` to
`_presentButRejected` — the amber "!" means "everything needed is on disk and
this app rejects it", which is true of both mismatch kinds, while "mixed" keeps
the red cross because something really is absent. The marker's objectName
splits three ways so a UI test can tell the two amber states apart, which a
screenshot cannot. ModuleInstanceModel's badge gains "Signer conflict".

The signer wire rows in dependency_gate_test.cpp are VERBATIM payloads captured
from a real logoscore daemon over a real installed tree, like the rows beside
them, varying only the `signer` sidecar in the dependency's install directory.

RED (signer_mismatch unrecognised, and the summariser back to one kind):
  Totals: 27 passed, 6 failed
    a_signer_mismatch_blocks                      b.kind 0, expected 3
    a_signer_mismatched_dependency_blocks_…       b.kind 0, expected 3
    a_signer_mismatch_says_publisher_not_version… detail was empty
    the_wire_map_carries_the_signer_kind…         kind "not_installed"
    summary_of_only_signer_blockers_is_signer     summary "absent"
    summary_of_a_signer_and_an_absent_blocker…    summary "absent"

GREEN, every check individually (nix build --print-out-paths; empty = FAILED):
  unit-tests           /nix/store/kdml49yg3pcjazdynnq29311wvk7s03d-logos-basecamp-unit-tests-0.0.0
  qml-tests            /nix/store/k5gmfqwa749nbipam0w9sfdq8cnrdraj-logos-basecamp-qml-tests-0.0.0
  smoke-test           /nix/store/qhilqrrmlxgi1r6rkk3f2xdhzad1khbs-logos-basecamp-smoke-test
  integration-test     /nix/store/rda92q559abgp57y5nqn5db1n0p9zwjd-logos-basecamp-integration-test
  sandbox-test         /nix/store/ip1flrwh4sf05jn747rj9fgrfn8zk7h8-logos-basecamp-sandbox-test-0.0.0
  shutdown-test        /nix/store/q9a0z0d0c83kl3z17r1d3pps6qp1fgs0-logos-basecamp-shutdown-test
  host-services-test   /nix/store/9szcjfih4hvc40wvlsihf4yy2l7616gc-logos-basecamp-host-services-test
  symbol-gate          /nix/store/pbflp7xdnf5py676ii0mwqi0xpkdfg1c-logos-basecamp-symbol-gate
  symbol-gate-negative /nix/store/v5vmswffkfq82qhnl9z9q17zz17smbp9-logos-basecamp-symbol-gate-negative

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(deps): follow observedSigner to signerDid, and say what actually failed

logos-package-manager stopped recording who it verified at install and started
carrying the package's own manifest.sig into the install tree. The row key that
reaches this header changed name because it changed meaning, and the message
built from it was describing the old one.

`observedSigner` was the DID a signature had been VERIFIED against by the
installer. `signerDid` is what the installed signature says about itself, once
checked against the key its own DID carries. Nothing outside the document
corroborates it, so "found <did>" overstated it: it read as an established
publisher when it is the package's own claim, backed by a real key but not by
anything that says which key is the right one.

The detail line now says what failed:

  signed by a different key; requires <pin>, signed by <did>
  not signed by the required key; requires <pin>      (nothing usable installed)
  signed by a different key                           (neither DID available)

And a note this header needs to carry, because the shortcut is inviting and
unsound: do NOT re-derive the verdict by comparing `signerDid` to
`requiredSigner`. A signature document supplies both a DID and a signature, so
it can always be made to agree with itself; that comparison accepts somebody
else's genuine signature relabelled to name the pinned DID. The scanner decides
by verifying the installed signature under the PIN's key, which is why a
signer_mismatch row legitimately carries a signerDid that differs from
requiredSigner.

The dialog headline is unchanged: "published by a different signer" is the
right register for a user, and the mechanism belongs on the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(deps): re-capture the signer rows against real signatures, and say what a satisfied pin looks like

These rows are documented as verbatim wire payloads, and they were — of a rig
that no longer exists. They were captured by varying a `signer` sidecar, which
logos-package-manager has deleted; and the DID they pinned,
did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5In0, decodes to
{"kty":"OKP","crv":"Ed25519"} — no `x` member, so NO KEY AT ALL.

Against the sidecar that string was only ever COMPARED, so a keyless DID worked
fine. Against a signature it is an UNPARSEABLE PIN, which the library also
reports as signer_mismatch (fail-closed, with a warning naming the depending
manifest). So this suite would have stayed green while every signer row
documented the wrong cause: "this pin cannot be parsed" wearing the label
"a different key signed it".

Re-captured through the real stack — a real logoscore daemon, package_manager
built against the branch, depsvc INSTALLED FROM A REALLY-SIGNED .lgx so the
manifest.sig in its install directory is the one `lgx sign` produced. Nothing
is hand-planted; the only thing that varies is the `signer` pin in the
depending manifest, which is a developer declaration and is meant to vary. Both
DIDs are now real keypairs, and the mismatch row is a package really signed by
one key against a pin naming another.

Adds the row that was missing: a SATISFIED pin. It carries a signerDid, because
that is a property of the package rather than of the edge, and a gate that read
"signer information is present" as a signal in itself would refuse every
correctly-signed dependency in the fleet. Proven by mutation.

Also proven by mutation, and the reason NO comparison guard is added here:
replacing the status check with `signerDid != requiredSigner` — the inviting
shortcut, and the unsound one — turns a_signer_that_cannot_be_checked_does_not_block
red, because a signer_unknown row has a pin and no signerDid. The existing
suite already holds that line; the gate needs no new defence, it needs to keep
deciding from `status`.

The dialog now says what is actually known. "Published by a different signer"
reported a record the installer had written and could be no better than that
record. The scanner now extracts the key from the DID the module itself names
and checks the installed signature against it, so the claim is "not signed by
the key it requires" — which no relabelling on the package side can change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(deps): cut this PR's comments back to what a reader needs

608 added comment lines -> 351. Comments only; no code line moved or changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): relock onto the merged package-manager chain

Takes logos-package-manager-module 5feeb67 (#64) and logos-package-manager
1218d74 (#38), so the gate this PR adds is no longer inert.

Also adds logos-package-manager-ui.inputs.package_manager.follows: without it
the UI brought its own package_manager and the closure carried two, with the
UI that drives installs on the one that has no VersionMismatch. Same shape as
the package_downloader follows in #360.

Verified in the artifact, not the lock: the shipped
modules/package_manager/libpackage_manager_lib.so carries version_mismatch,
signer_mismatch and dependencyConstraints, and is md5-identical to the
relocked lib output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(deps): drive every dependency-gate verdict against the real binary

Adds basecamp-dependency-gate.test.yaml: one launch covering all five —
satisfied admits (exact and range), signer-satisfied admits, signer-unknown
admits, absent blocks, version blocks, signer blocks — each asserting its own
message, not just that something was refused.

Fixtures are real: two Ed25519 keys, both anchored, so the signer refusal is
identity and not trust. The spec fails if either DID carries no `x` member,
and if the app ever logs an unparseable pin — a keyless pin reports
signer_mismatch too, so without that check the suite stays green documenting
the wrong cause.

Also fixes a regression this PR introduced: an absent dependency with no
declared range added "— not installed" to its row, which broke
basecamp-missing-deps ("• demo_core_module" is an exact match) and changed the
text for every bare dependency in the fleet. The dialog heading already says
it; the detail is now empty unless there is a range to name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): one package_downloader in the closure too, not two

Folds in #360, which is closed. Same defect as the package_manager follows
directly above: logos-package-manager-ui declares neither, so the closure
carried two logos-package-downloader revisions with the UI that drives
installs on the older one.

That one matters because lgpd's resolver is the only component in the stack
that evaluates a version range or a signer pin at all. After: downloader
b6624a3, carrying both the empty-signer-pin fix and the signer binding that
never checked the signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): one liblgx in the closure, not two

b75bae8 moved the bundled package_manager onto logos-package 49151f00,
which adds lgx_get_manifest_sig_json and lgx_check_manifest_signature.
logos-liblogos still pinned its own older logos-package-manager, and
liblogos_core links libpackage_manager_lib, so liblogos put the older
liblgx into the bundle's flat lib/.

The two platforms then resolved that ambiguity in opposite directions:
bundle.sh rewrites a Mach-O @rpath dep to a flat @loader_path/../../lib
path, while its ELF arm leaves a bare NEEDED with $ORIGIN first. macOS
bound package_manager to the 34-export lib/ copy and Linux to the
36-export sibling — so the module crashed at its first signature check
on macOS with every Linux job green. Mach-O binds lazily, so it loaded
cleanly and died at the call, three hops from anything that named it.

Follows removes the second revision rather than adding one: no root
input rev changes, 18 nodes drop out of the lock, and lib/liblgx gains
the two symbols. Fixes the macOS package-lifecycle, dependency-gate and
persistence doctests, all 8 failures.

* test(deps): assert the plugin binary by the platform's own suffix

expect_contains matched "depsvc_plugin.so" verbatim — expand_vars runs
over run: but not over the expected strings, so {ext} cannot be used
there. Assert on a string the script emits instead.

The install itself was fine on macOS; the step listed depsvc_plugin.dylib
and exited 0.

* test(deps): gate a library staged twice against symbol skew

Where the bundle stages one library name in lib/ AND beside a module,
both copies must satisfy that module's imports — macOS binds to lib/,
Linux to the sibling, so a skew is fatal on exactly one platform. The
gate intersects each consumer's undefined symbols against both copies
and fails when the two answers differ.

Intersecting against bundled definers keeps host and system symbols out
of scope. Vacuity guards abort if no duplicated name is found or nm
reads nothing, so the gate cannot pass by measuring zero.

On the pre-fix bundle it fails on BOTH platforms, including the Linux
one CI called green. Wired into build-appimage and build-macos-app,
which already build this bundle; not on Windows, where a PE has no
nm -D symbol table.

* fix(ci): the link gate must not clobber ./result

nix build without --out-link writes ./result, so the gate replaced the
bundle symlink that "Rename AppImage with architecture" and "Package app
bundle as tarball" both find their artifact through. The gate itself
passed on aarch64-linux; the step after it failed.

--no-link: the gate's output is a marker, nothing reads it.

* fix(deps): the load gate must not read the cache before it is filled

blockingDepsOf returns an empty list for a cache MISS, and loadUiModule
read that as "nothing blocks this". PackageCoordinator.h states the
opposite contract — "Empty when the async refresh chain hasn't completed
yet; treat empty as not known — show safe defaults" — and the tiles are
published before the dependency fan-out is dispatched, so the window is
real: clicking inside it loads the plugin.

Pre-existing, but this PR is what makes it matter. The only verdict that
could slip through before was not_installed, which liblogos refuses on
its own. version_mismatch and signer_mismatch have no enforcement below
Basecamp at all — DependencyEntry carries {kind, name}, and the resolver
tests isKnown() — so losing the race now mounts a plugin against a
dependency of a rejected version.

Park the load on dependencyDataReadyChanged, mirroring
PackageCoordinator::uninstallApp: same disconnect-prior, same QPointer
guard, last click wins.

* chore(deps): take the staging install (lgpm d88abaa)

Install stages beside the destination and swaps it in rather than copying
over the existing tree, so a failed install leaves no merged directory,
an upgrade drops files the new package stopped shipping, and a module
dir left 0555 no longer wedges the package.

Both pins move together: basecamp declares logos-package-manager
directly AND gets one through the module, with no follows between them,
so moving one would leave two lgpm builds in one closure. Verified they
converge on d88abaa, and liblogos still follows the root pin.

Folded in here rather than raised separately because this branch already
touches flake.lock.

* fix(intents): the missing-deps lambda must match the widened signal

Master's intent broker connects to missingDepsPopupRequested with
(QString, QStringList) — the signature before this branch widened it to
(QString, QVariantList blockers, QString summary). Git merges both sides
cleanly and the result does not compile:

  qobject.h:244: no type named 'type' in FunctorReturnType<lambda, List<>>

The lambda only needs the name, so it takes the two extra parameters
unnamed. Also relocks onto master's flake.lock, which has moved
logos-liblogos to a2da65f; the follows here still resolves liblogos'
lgpm to the root pin (d88abaa) and keeps the lock at 3574 nodes against
master's 5152.

* chore(deps): logos-liblogos ca0d5bf — the runtime range gate

This PR gates a UI plugin at the Basecamp layer; liblogos#196 gates any
module at load, on the declared version range. Same feature, two layers,
and until now the app only had the upper one.

The follows still resolves liblogos' lgpm to the root pin (d88abaa) and
the lock grows by one node, the logos-package that logos-module now
carries.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:44:21 -03:00

2118 lines
92 KiB
C++

#include "PackageCoordinator.h"
#include "InstallRegistry.h"
#include "AppsModel.h"
#include "CoreModuleManager.h"
#include "UIPluginManager.h"
#include "LogosBasecampPaths.h"
#include "utils/DependencyBlocker.h"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QPointer>
#include <QTimer>
#include <memory>
#include "logos_sdk.h"
PackageCoordinator::PackageCoordinator(LogosAPI* logosAPI,
CoreModuleManager* coreModuleManager,
UIPluginManager* uiPluginManager,
AppsModel* appsModel,
QObject* parent)
: QObject(parent)
, m_logosAPI(logosAPI)
, m_coreModuleManager(coreModuleManager)
, m_uiPluginManager(uiPluginManager)
, m_appsModel(appsModel)
, m_installRegistry(new InstallRegistry(this))
{
subscribeToPackageInstallationEvents();
subscribeToPackageDownloaderEvents();
// A module absent at startup may be installed and loaded later in the
// session. Without this the guards above would turn a 6-minute stall into a
// silently non-functional Modules view, which is a worse bug. Both
// subscribe functions are idempotent, so re-running them is free once armed.
if (m_coreModuleManager) {
connect(m_coreModuleManager, &CoreModuleManager::coreModulesChanged,
this, [this]() {
subscribeToPackageInstallationEvents();
subscribeToPackageDownloaderEvents();
});
}
// NB: initial metadata fetch is deferred until MainUIBackend calls
// refresh() — the uiPluginsFetched signal would otherwise fire before
// UIPluginManager's setPackageCoordinator runs and the slot connection
// lands, causing the first-paint UI-plugin list to be empty until the
// next file-install event triggers a re-scan.
}
PackageCoordinator::~PackageCoordinator() = default;
namespace {
// Is a module actually loaded in THIS process?
//
// The obvious guard -- `client->isConnected()` -- was dead code: QtRO's
// connectToNode() only validates the URL scheme and never contacts a peer, so
// it reported "connected" for modules that were never loaded. Every call past
// it then blocked 20 s in waitForSource, twice over (the token handshake tries
// capability_module first). Measured cost with package_manager absent: ~417 s
// of blocked GUI thread on macOS and 361 s on Linux before Basecamp's window
// appeared, because all of this runs inside the Window constructor.
//
// logos_core_get_loaded_modules answers the same question in-process, with no
// IPC and no timeout. The same guard already ships in
// logos-logoscore-cli/src/daemon/daemon.cpp, which skips its identical
// setEmbeddedModulesDirectory block and reports that package commands are
// unavailable for the session.
bool moduleIsLoaded(CoreModuleManager* core, const QString& name)
{
if (!core) {
// No oracle available: keep the previous behaviour rather than silently
// disabling package management.
return true;
}
return core->loadedModules().contains(name);
}
} // namespace
void PackageCoordinator::subscribeToPackageInstallationEvents()
{
if (!m_logosAPI) {
return;
}
if (m_packageManagerSubscribed) {
return;
}
if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) {
if (!m_warnedPackageManagerMissing) {
m_warnedPackageManagerMissing = true;
qWarning() << "PackageCoordinator: package_manager is not loaded -- skipping its "
"directory setup and event subscriptions. Package management is "
"unavailable until it loads; this will be retried automatically.";
}
return;
}
m_packageManagerSubscribed = true;
LogosModules logos(m_logosAPI);
// Configure the package_manager module's directories so it knows where
// to install.
logos.package_manager.setEmbeddedModulesDirectory(LogosBasecampPaths::embeddedModulesDirectory());
logos.package_manager.setUserModulesDirectory(LogosBasecampPaths::modulesDirectory());
logos.package_manager.setEmbeddedUiPluginsDirectory(LogosBasecampPaths::embeddedPluginsDirectory());
logos.package_manager.setUserUiPluginsDirectory(LogosBasecampPaths::pluginsDirectory());
logos.package_manager.on("corePluginFileInstalled", [this](const QVariantList& data) {
if (data.isEmpty()) return;
qDebug() << "Core module file installed:" << data[0].toString();
QTimer::singleShot(100, this, [this]() {
if (m_coreModuleManager) m_coreModuleManager->refresh();
// Also rescan UI plugin metadata — a newly installed core module
// could satisfy a dependency that previously left a UI module
// marked with missing deps, so the sidebar red-cross needs to clear.
fetchUiPluginMetadata();
});
});
logos.package_manager.on("uiPluginFileInstalled", [this](const QVariantList& data) {
if (data.isEmpty()) return;
qDebug() << "UI plugin file installed:" << data[0].toString();
QTimer::singleShot(100, this, [this]() {
fetchUiPluginMetadata();
});
});
// Uninstall events — mirror the install handlers. We rescan UI plugin
// metadata in both cases because a core uninstall can make a previously
// satisfied UI dep go missing, and a UI uninstall flat-out removes the
// plugin from UIPluginManager's metadata. The 100ms settle matches
// install to absorb rapid batched events.
logos.package_manager.on("corePluginUninstalled", [this](const QVariantList& data) {
if (data.isEmpty()) return;
qDebug() << "Core module uninstalled:" << data[0].toString();
QTimer::singleShot(100, this, [this]() {
if (m_coreModuleManager) m_coreModuleManager->refresh();
fetchUiPluginMetadata();
});
});
logos.package_manager.on("uiPluginUninstalled", [this](const QVariantList& data) {
if (data.isEmpty()) return;
qDebug() << "UI plugin uninstalled:" << data[0].toString();
QTimer::singleShot(100, this, [this]() {
fetchUiPluginMetadata();
});
});
// Clear any pending action left over from a prior session that crashed
// mid-dialog. The module retains m_pendingAction across Basecamp restarts
// (it's non-persistent but survives our process death since it lives in
// package_manager's process); without this reset, the first request after
// a crash would get rejected with "another X is in progress".
logos.package_manager.resetPendingActionAsync([](QVariantMap){});
// Gated uninstall/upgrade events. package_manager emits these BEFORE any
// destructive work, with a 3s ack timer running; onBeforeUninstall /
// onBeforeUpgrade acks synchronously and — if the ack landed in time —
// drives the cascade confirmation dialog. See PackageCoordinator.h for the
// ack-gated protocol rationale.
logos.package_manager.on("beforeUninstall", [this](const QVariantList& data) {
if (data.isEmpty()) return;
const QByteArray payload = data.first().toString().toUtf8();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(payload, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "beforeUninstall payload parse error:" << err.errorString();
return;
}
const QJsonObject obj = doc.object();
const QString name = obj.value("name").toString();
QStringList installedDeps;
for (const QJsonValue& v : obj.value("installedDependents").toArray()) {
if (v.isString()) installedDeps.append(v.toString());
}
onBeforeUninstall(name, installedDeps);
});
logos.package_manager.on("beforeUpgrade", [this](const QVariantList& data) {
if (data.isEmpty()) return;
const QByteArray payload = data.first().toString().toUtf8();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(payload, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "beforeUpgrade payload parse error:" << err.errorString();
return;
}
const QJsonObject obj = doc.object();
const QString name = obj.value("name").toString();
const QString releaseTag = obj.value("releaseTag").toString();
const int mode = obj.value("mode").toInt();
QStringList installedDeps;
for (const QJsonValue& v : obj.value("installedDependents").toArray()) {
if (v.isString()) installedDeps.append(v.toString());
}
// Transitive dep changes the initiator (PMU) resolved for this swap —
// opaque display data the dialog lists. Absent/empty on a bare upgrade.
const QVariantList depChanges = obj.value("depChanges").toArray().toVariantList();
onBeforeUpgrade(name, releaseTag, mode, installedDeps, depChanges);
});
// beforeInstall — the catalog-install gate. Same ack-then-dialog shape as
// beforeUpgrade, but with no dependents (a fresh install unloads nothing).
logos.package_manager.on("beforeInstall", [this](const QVariantList& data) {
if (data.isEmpty()) return;
const QByteArray payload = data.first().toString().toUtf8();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(payload, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "beforeInstall payload parse error:" << err.errorString();
return;
}
const QJsonObject obj = doc.object();
const QString name = obj.value("name").toString();
const QString releaseTag = obj.value("releaseTag").toString();
const QVariantList depChanges = obj.value("depChanges").toArray().toVariantList();
onBeforeInstall(name, releaseTag, depChanges);
});
// Multi-uninstall is a separate event so existing single-uninstall handlers
// don't have to peek at the payload shape to disambiguate.
logos.package_manager.on("beforeMultiUninstall", [this](const QVariantList& data) {
if (data.isEmpty()) return;
const QByteArray payload = data.first().toString().toUtf8();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(payload, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "beforeMultiUninstall payload parse error:" << err.errorString();
return;
}
const QJsonObject obj = doc.object();
QStringList names;
for (const QJsonValue& v : obj.value("names").toArray()) {
if (v.isString()) names.append(v.toString());
}
QStringList installedDeps;
for (const QJsonValue& v : obj.value("installedDependents").toArray()) {
if (v.isString()) installedDeps.append(v.toString());
}
onBeforeMultiUninstall(names, installedDeps);
});
// The multi-uninstall cancellation counterpart. package_manager_ui
// subscribes to uninstallCancelled and toasts it, so the single-uninstall
// path is already covered on that surface — but nothing anywhere listens
// for the multi variant, and the App Manager now runs exclusively through
// it. Without this a 3s ack timeout (or a cancel that raced the dialog)
// is completely silent.
logos.package_manager.on("multiUninstallCancelled", [this](const QVariantList& data) {
if (data.isEmpty()) return;
const QJsonDocument doc =
QJsonDocument::fromJson(data.first().toString().toUtf8());
if (!doc.isObject()) return;
const QString reason = doc.object().value("reason").toString();
// A user-driven cancel already had a visible dialog; only surface the
// ones the user didn't ask for.
m_lastRequestedTargets.clear();
if (reason.contains(QStringLiteral("user cancelled"))) return;
qWarning() << "multiUninstallCancelled:" << reason;
});
}
void PackageCoordinator::subscribeToPackageDownloaderEvents()
{
if (!m_logosAPI) return;
if (m_packageDownloaderSubscribed) {
return;
}
if (!moduleIsLoaded(m_coreModuleManager, "package_downloader")) {
if (!m_warnedPackageDownloaderMissing) {
m_warnedPackageDownloaderMissing = true;
qWarning() << "PackageCoordinator: package_downloader is not loaded -- skipping its "
"event subscriptions; this will be retried automatically.";
}
return;
}
m_packageDownloaderSubscribed = true;
LogosModules logos(m_logosAPI);
logos.package_downloader.on("catalogChanged", [this](const QVariantList&) {
refreshRepositories();
refresh();
});
}
// ---------------------------------------------------------------------------
// Read-only accessors over the caches.
// ---------------------------------------------------------------------------
QString PackageCoordinator::installType(const QString& name) const
{
return m_installTypeByModule.value(name);
}
QStringList PackageCoordinator::missingDepsOf(const QString& name) const
{
return m_missingDepsByModule.value(name);
}
QVariantList PackageCoordinator::blockingDepsOf(const QString& name) const
{
return m_blockingDepsByModule.value(name);
}
QStringList PackageCoordinator::dependentsOf(const QString& name) const
{
return m_dependentsByModule.value(name);
}
QString PackageCoordinator::installedRootHash(const QString& name) const
{
return m_installedHashByName.value(name);
}
QString PackageCoordinator::displayNameFor(const QString& name) const
{
const QString dn = m_displayNameByModule.value(name);
if (!dn.isEmpty()) return dn;
return name;
}
// ---------------------------------------------------------------------------
// Gated uninstall — entry points
// ---------------------------------------------------------------------------
void PackageCoordinator::uninstallUiModule(const QString& moduleName)
{
qDebug() << "uninstallUiModule:" << moduleName;
// Main UI is protected — uninstalling it would brick Basecamp. Every
// other "is this uninstallable?" check (embedded-refusal, unknown-module)
// now lives in the package_manager module itself, so there's no duplicate
// gating here. This guard stays local because "don't kill your own UI"
// is a Basecamp concern, not a package-lifecycle concern.
if (moduleName == QStringLiteral("main_ui")) {
qWarning() << "Refusing to uninstall main_ui";
return;
}
// Kick off the gated request. The module:
// 1. Sets its pending slot, emits "beforeUninstall" with the installed-
// dependents list, and starts the 3s ack timer.
// 2. We catch the event in onBeforeUninstall, ack, and show the cascade
// dialog. Reentry protection lives in the module (global single-slot
// pending) so a concurrent second click gets rejected synchronously.
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.requestUninstallAsync(
moduleName, [self, moduleName](QVariantMap result) {
if (!self) return;
if (result.value("success", false).toBool()) return;
const QString error = result.value("error").toString();
qWarning() << "requestUninstall rejected for" << moduleName << ":" << error;
});
}
void PackageCoordinator::uninstallApp(const QString& name, const QString& repositoryUrl)
{
Q_UNUSED(repositoryUrl);
if (name.isEmpty()) return;
if (name == QStringLiteral("main_ui")) {
qWarning() << "Refusing to uninstall main_ui";
return;
}
if (m_dependencyDataReady) {
performUninstallApp(name);
return;
}
// Cache still populating — open the dialog in a loading state and defer
// the real dispatch until dependencyDataReadyChanged fires. Drop any
// prior deferred connection first so re-clicking during the cold-boot
// window doesn't stack callbacks.
QObject::disconnect(m_pendingUninstallAppConn);
m_pendingUninstallAppName = name;
emit uninstallPlanRequested(
buildLoadingPayload(name, QStringLiteral("app")));
QPointer<PackageCoordinator> self(this);
m_pendingUninstallAppConn = connect(
this, &PackageCoordinator::dependencyDataReadyChanged, this,
[self, name]() {
if (!self) return;
if (self->m_pendingUninstallAppName != name) return; // cancelled
self->m_pendingUninstallAppName.clear();
QObject::disconnect(self->m_pendingUninstallAppConn);
self->performUninstallApp(name);
});
}
void PackageCoordinator::performUninstallApp(const QString& name)
{
// Compose: target + orphaned deps. requestMultiUninstall refuses the
// whole batch if any member is embedded, so filter here.
const uninstallplan::Plan plan =
uninstallplan::composeFrom(planInput({name}));
if (plan.batch.isEmpty()) {
qWarning() << "performUninstallApp:" << name << "is not installed — ignoring";
return;
}
qDebug() << "performUninstallApp:" << name << "batch=" << plan.batch;
// Remembered only so the explain pass can flag which rows the user
// actually picked; the batch itself is recomputed on arrival.
m_lastRequestedTargets = {name};
m_lastRequestKind = QStringLiteral("app");
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
const QStringList batch = plan.batch;
logos.package_manager.requestMultiUninstallAsync(
batch, [self, batch](QVariantMap result) {
if (!self) return;
if (result.value("success", false).toBool()) return;
const QString error = result.value("error").toString();
qWarning() << "requestMultiUninstall rejected for" << batch << ":" << error;
self->m_lastRequestedTargets.clear();
});
}
void PackageCoordinator::cancelPendingUninstallApp(const QString& name)
{
if (m_pendingUninstallAppName != name) return;
m_pendingUninstallAppName.clear();
QObject::disconnect(m_pendingUninstallAppConn);
}
void PackageCoordinator::uninstallCoreModule(const QString& moduleName)
{
// Same flow as uninstallUiModule — requestUninstall is type-agnostic.
// The module's pending state is global so there's no type-specific
// bookkeeping to do here.
qDebug() << "uninstallCoreModule:" << moduleName;
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.requestUninstallAsync(
moduleName, [self, moduleName](QVariantMap result) {
if (!self) return;
if (result.value("success", false).toBool()) return;
const QString error = result.value("error").toString();
qWarning() << "requestUninstall rejected for" << moduleName << ":" << error;
});
}
// ---------------------------------------------------------------------------
// Uninstall plan — compose (what to remove) and explain (why the rest stays)
// ---------------------------------------------------------------------------
QSet<QString> PackageCoordinator::loadedNames() const
{
QSet<QString> loaded;
if (m_coreModuleManager) {
const QStringList core = m_coreModuleManager->loadedModules();
loaded = QSet<QString>(core.cbegin(), core.cend());
}
if (m_uiPluginManager) {
// intersectWithLoaded already merges core + in-process UI widgets, so
// asking it about every installed name yields the full loaded set —
// including ui_qml plugins, which never appear in loadedModules().
const QStringList all = m_installTypeByModule.keys();
for (const QString& n : m_uiPluginManager->intersectWithLoaded(all))
loaded.insert(n);
}
return loaded;
}
uninstallplan::Input PackageCoordinator::planInput(const QStringList& targets) const
{
uninstallplan::Input in;
in.targets = targets;
for (auto it = m_dependenciesByModule.cbegin();
it != m_dependenciesByModule.cend(); ++it) {
in.dependencies.insert(it.key(), it.value());
}
for (auto it = m_dependentsByModule.cbegin();
it != m_dependentsByModule.cend(); ++it) {
in.dependents.insert(it.key(), it.value());
}
// m_installTypeByModule is keyed on every installed package (UI + core),
// which makes its key list the installed set — and its values the
// embedded/user split the plan needs.
for (auto it = m_installTypeByModule.cbegin();
it != m_installTypeByModule.cend(); ++it) {
in.installed << it.key();
if (it.value() == QLatin1String("embedded")) in.embedded.insert(it.key());
}
in.protectedNames = {QStringLiteral("main_ui")};
for (auto it = m_displayNameByModule.cbegin();
it != m_displayNameByModule.cend(); ++it) {
in.displayNames.insert(it.key(), it.value());
}
in.versions = m_installedVersionByName;
in.loaded = loadedNames();
return in;
}
namespace {
QVariantMap removableRowToMap(const uninstallplan::Row& r)
{
return {
{QStringLiteral("name"), r.name},
{QStringLiteral("displayName"), r.displayName},
{QStringLiteral("version"), r.version},
{QStringLiteral("isTarget"), r.isTarget},
{QStringLiteral("isLoaded"), r.isLoaded},
};
}
} // namespace
QVariantMap PackageCoordinator::buildLoadingPayload(const QString& name,
const QString& kind) const
{
// Stub payload; UninstallDialog reads `loading` to swap in the spinner.
// `multi: false` sends cancel through cancelPendingUninstallApp (no
// module IPC to unwind).
QVariantMap payload;
payload.insert(QStringLiteral("loading"), true);
payload.insert(QStringLiteral("kind"), kind);
payload.insert(QStringLiteral("multi"), false);
payload.insert(QStringLiteral("batch"), QVariantList{name});
payload.insert(QStringLiteral("targetName"), name);
payload.insert(QStringLiteral("removable"), QVariantList{});
payload.insert(QStringLiteral("kept"), QVariantList{});
payload.insert(QStringLiteral("dependents"), QVariantList{});
return payload;
}
QVariantMap PackageCoordinator::buildPlanPayload(const QStringList& batch,
const QStringList& installedDependents,
const QString& kind,
bool multi) const
{
// Explain pass: the batch is already fixed, so it IS the target list and
// no orphan expansion runs. Everything left in the closure lands in
// `kept` with the reason it survived.
const uninstallplan::Plan plan =
uninstallplan::explainOf(planInput(batch));
// Which of those rows the user actually picked. Empty (PMUI / Settings)
// means "all of them", which is the honest reading there.
const QSet<QString> picked(m_lastRequestedTargets.cbegin(),
m_lastRequestedTargets.cend());
QVariantList removable;
removable.reserve(plan.removable.size());
for (const uninstallplan::Row& r : plan.removable) {
uninstallplan::Row row = r;
row.isTarget = picked.isEmpty() || picked.contains(r.name);
removable.append(removableRowToMap(row));
}
QVariantList kept;
kept.reserve(plan.kept.size());
for (const uninstallplan::KeptRow& k : plan.kept) {
kept.append(QVariantMap{
{QStringLiteral("name"), k.name},
{QStringLiteral("displayName"), k.displayName},
// Enum → string for the QML payload. See uninstallplan::reasonName.
{QStringLiteral("reason"), uninstallplan::reasonName(k.reason)},
{QStringLiteral("requiredBy"), k.requiredBy},
});
}
// The module's dependents list is authoritative (it walked the same graph
// at request time, under its own lock). Fall back to the locally computed
// one only when the gate didn't carry it.
QStringList dependentNames = installedDependents;
if (dependentNames.isEmpty()) {
for (const uninstallplan::Row& r : plan.dependents) dependentNames << r.name;
}
const QSet<QString> loaded = loadedNames();
const QSet<QString> batchSet(batch.cbegin(), batch.cend());
QVariantList dependents;
for (const QString& n : dependentNames) {
if (n.isEmpty() || batchSet.contains(n)) continue;
dependents.append(QVariantMap{
{QStringLiteral("name"), n},
{QStringLiteral("displayName"), displayNameFor(n)},
{QStringLiteral("isLoaded"), loaded.contains(n)},
});
}
return {
{QStringLiteral("kind"), kind},
{QStringLiteral("multi"), multi},
{QStringLiteral("batch"), batch},
{QStringLiteral("removable"), removable},
{QStringLiteral("kept"), kept},
{QStringLiteral("dependents"), dependents},
};
}
// ---------------------------------------------------------------------------
// Cascade confirmation — triggered from QML once the user OKs the dialog.
// ---------------------------------------------------------------------------
void PackageCoordinator::cascadeUnloadForPackage(const QString& moduleName)
{
// Snapshot the loaded-dependents list BEFORE the cascade — once
// unloadModuleWithDependents returns, the target is off the loaded-
// modules list and the filter would come up empty. UI-plugin dependents
// need teardown in-process because the core cascade only kills core
// modules (QProcess termination). Without this pass, e.g. accounts_ui
// stays wired to a now-dead accounts_module.
QStringList loadedDeps;
if (m_uiPluginManager) {
loadedDeps = m_uiPluginManager->intersectWithLoaded(
m_dependentsByModule.value(moduleName));
}
const QStringList loadedCore = m_coreModuleManager
? m_coreModuleManager->loadedModules()
: QStringList{};
// Core cascade: terminate the target process (if it's a loaded core
// module) plus any loaded core-module dependents. Local-mode / pure-UI
// targets aren't in loadedModules and the function will return
// non-zero; we tolerate that and proceed to the UI teardown and module-
// side confirm call — the user-visible action (deleting the package /
// swapping versions) is what we must preserve.
if (loadedCore.contains(moduleName) || !loadedDeps.isEmpty()) {
qDebug() << "Cascade-unloading before uninstall:" << moduleName;
bool ok = m_coreModuleManager
? m_coreModuleManager->unloadModuleWithDependents(moduleName)
: false;
if (!ok) {
qWarning() << "Cascade unload failed during uninstall of" << moduleName
<< "— proceeding with confirm anyway";
}
}
// UI plugins are in-process widgets managed by UIPluginManager, not core
// processes. teardownUiPluginWidget is idempotent, so calling it for
// names that aren't loaded UI plugins is harmless.
if (m_uiPluginManager) {
for (const QString& dep : loadedDeps) {
m_uiPluginManager->teardownUiPluginWidget(dep);
}
m_uiPluginManager->teardownUiPluginWidget(moduleName);
}
}
void PackageCoordinator::confirmUninstallCascade(const QString& moduleName)
{
if ((m_pendingAction.op != PendingOp::UninstallCascade &&
m_pendingAction.op != PendingOp::UpgradeCascade)
|| m_pendingAction.name != moduleName) {
qWarning() << "confirmUninstallCascade for" << moduleName
<< "but pending action is" << m_pendingAction.name;
return;
}
// Snapshot before clearing — the callbacks below capture by value.
const bool isUpgrade = (m_pendingAction.op == PendingOp::UpgradeCascade);
const QString releaseTag = m_pendingAction.releaseTag;
m_pendingAction = {};
QPointer<PackageCoordinator> selfDefer(this);
QMetaObject::invokeMethod(this,
[this, selfDefer, moduleName, isUpgrade, releaseTag]() {
if (!selfDefer) return;
cascadeUnloadForPackage(moduleName);
// Hand the actual package-lifecycle work back to the module.
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
if (isUpgrade) {
// Upgrade — the module does the uninstall step + emits
// upgradeUninstallDone for PMU to drive the install of the new
// version (a catalog download, or the local .lgx it stashed).
logos.package_manager.confirmUpgradeAsync(moduleName, releaseTag,
[moduleName](QVariantMap r) {
if (!r.value("success", false).toBool()) {
qWarning() << "confirmUpgrade rejected for" << moduleName
<< ":" << r.value("error").toString();
}
});
} else {
// Plain uninstall.
logos.package_manager.confirmUninstallAsync(moduleName,
[moduleName](QVariantMap r) {
if (!r.value("success", false).toBool()) {
qWarning() << "confirmUninstall rejected for" << moduleName
<< ":" << r.value("error").toString();
}
});
}
emit coreModulesChanged();
emit uiModulesChanged();
emit launcherAppsChanged();
}, Qt::QueuedConnection); // run the cascade off the click stack
}
void PackageCoordinator::refresh()
{
fetchUiPluginMetadata();
refreshRepositories();
}
void PackageCoordinator::remoteRefresh()
{
if (!m_appsLoading) {
m_appsLoading = true;
emit appsLoadingChanged();
}
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (!dlClient || !dlClient->isConnected()) {
// Downloader unreachable — fall back to a local re-sync
refresh();
return;
}
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_downloader.refreshCatalogAsync([self](QVariantMap r) {
if (!self) return;
const QString err = r.value(QStringLiteral("error")).toString();
if (!err.isEmpty())
qWarning() << "package_downloader.refreshCatalog reported:" << err;
self->refresh();
});
}
void PackageCoordinator::cancelPendingAction(const QString& moduleName)
{
if (m_pendingAction.op == PendingOp::None || m_pendingAction.name != moduleName) {
// MainUIBackend fans out cancelPendingAction to both managers so one
// of them is always a no-op — don't even warn here.
return;
}
qDebug() << "Cancelling pending package action for" << moduleName;
const PendingOp op = m_pendingAction.op;
const QString releaseTag = m_pendingAction.releaseTag;
m_pendingAction = {};
// Uninstall / Upgrade are gated by the module — tell it we bailed;
// otherwise its pending slot stays set and the next request is
// rejected with "another <op> is in progress".
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
if (op == PendingOp::UpgradeCascade) {
logos.package_manager.cancelUpgradeAsync(moduleName, releaseTag,
[](QVariantMap){});
} else {
logos.package_manager.cancelUninstallAsync(moduleName,
[](QVariantMap){});
}
}
// ---------------------------------------------------------------------------
// Ack-gated cascade event handlers
//
// package_manager emits beforeUninstall / beforeUpgrade BEFORE any destructive
// work, then starts a short (3s) ack timer. Our contract:
//
// 1. Call ackPendingActionAsync IMMEDIATELY — before any UI work — to
// cancel the module's ack timer and claim the pending slot.
// 2. Only emit the cascade dialog if the ack SUCCEEDED. If it failed, the
// module already cancelled the request (timer fired, or another listener
// got there first); emitting the dialog would let the user "Continue"
// into a dead request.
//
// Once we own the slot, the user has unlimited time to decide. confirm* /
// cancel* on the module ends the flow.
// ---------------------------------------------------------------------------
void PackageCoordinator::onBeforeUninstall(const QString& name, const QStringList& installedDeps)
{
if (!m_logosAPI) return;
// Last-line defence. The module now rejects empty names at requestUninstall
// (and PMU + QML filter them too), so this branch shouldn't fire in
// practice. Kept because an empty name here would open a cascade dialog
// titled "Uninstall ''?" — the user-reported symptom — and because it's
// cheaper than re-debugging it if a future caller bypasses the gate.
if (name.isEmpty()) {
qWarning() << "PackageCoordinator::onBeforeUninstall received empty name — ignoring";
return;
}
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.ackPendingActionAsync(name,
[self, name, installedDeps](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool()) {
// The module already rejected us (ack timer fired, or the
// request was cancelled by another path). Do NOT show a
// dialog — package_manager already emitted uninstallCancelled
// to its listeners.
qWarning() << "ackPendingAction rejected for" << name << ":"
<< result.value("error").toString();
return;
}
self->m_pendingAction = {PendingOp::UninstallCascade, name, QString{}, 0};
// A batch of one. The single-uninstall gate is what
// package_manager_ui's trash icon and Settings → Modules use, so
// confirm/cancel route to the single slots — hence multi=false.
// Everything else (the Kept section, the dependent warning) is
// identical to the multi path.
const QVariantMap payload = self->buildPlanPayload(
{name}, installedDeps, QStringLiteral("packages"), /*multi=*/false);
self->m_lastRequestedTargets.clear();
emit self->uninstallPlanRequested(payload);
});
}
void PackageCoordinator::onBeforeUpgrade(const QString& name, const QString& releaseTag,
int mode, const QStringList& installedDeps,
const QVariantList& depChanges)
{
if (!m_logosAPI) return;
// Mirror of onBeforeUninstall — see rationale there.
if (name.isEmpty()) {
qWarning() << "PackageCoordinator::onBeforeUpgrade received empty name — ignoring";
return;
}
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.ackPendingActionAsync(name,
[self, name, releaseTag, mode, installedDeps, depChanges](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool()) {
qWarning() << "ackPendingAction rejected for" << name << ":"
<< result.value("error").toString();
return;
}
const QStringList loadedDeps = self->m_uiPluginManager
? self->m_uiPluginManager->intersectWithLoaded(installedDeps)
: QStringList{};
self->m_pendingAction = {PendingOp::UpgradeCascade, name, releaseTag, mode, {}};
// Distinct cascade signal for upgrade/downgrade/reinstall: same
// dependent-impact lists as the uninstall variant (the
// package_manager performs an uninstall step first), but the
// dialog needs the target version + UpgradeMode so it can lead
// with "Upgrade to vX.Y.Z" / "Downgrade to vX.Y.Z" /
// "Reinstall vX.Y.Z" instead of bare "Uninstall and Unload
// Dependents?" — which previously caused user confusion on
// downgrades that looked like a pure uninstall.
emit self->upgradeCascadeConfirmationRequested(
name, releaseTag, mode, installedDeps, loadedDeps, depChanges);
});
}
void PackageCoordinator::onBeforeInstall(const QString& name, const QString& releaseTag,
const QVariantList& depChanges)
{
if (!m_logosAPI) return;
if (name.isEmpty()) {
qWarning() << "PackageCoordinator::onBeforeInstall received empty name — ignoring";
return;
}
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.ackPendingActionAsync(name,
[self, name, releaseTag, depChanges](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool()) {
qWarning() << "ackPendingAction rejected for" << name << ":"
<< result.value("error").toString();
return;
}
// No pending-slot / cascade work — a fresh install unloads nothing.
// The dialog's confirm/cancel forward straight to the module gate.
emit self->installGateConfirmationRequested(name, releaseTag, depChanges);
});
}
void PackageCoordinator::confirmInstallGate(const QString& name)
{
if (!m_logosAPI || name.isEmpty()) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.confirmInstallAsync(name, [self, name](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool())
qWarning() << "confirmInstallGate rejected for" << name << ":"
<< result.value("error").toString();
});
}
void PackageCoordinator::cancelInstallGate(const QString& name)
{
if (!m_logosAPI || name.isEmpty()) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.cancelInstallAsync(name, [self, name](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool())
qWarning() << "cancelInstallGate rejected for" << name << ":"
<< result.value("error").toString();
});
}
void PackageCoordinator::onBeforeMultiUninstall(const QStringList& names,
const QStringList& installedDeps)
{
if (!m_logosAPI) return;
if (names.isEmpty()) {
qWarning() << "PackageCoordinator::onBeforeMultiUninstall received empty name list — ignoring";
return;
}
// Ack with any name from the batch — the module's ackPendingAction accepts
// any member of the pending batch's names for MultiUninstall (single-op
// ack still requires exact-match against m_pendingAction.name). Picking
// names.first() is convention; one ack closes the 3s timer for the whole
// batch.
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
const QString ackName = names.first();
logos.package_manager.ackPendingActionAsync(ackName,
[self, names, installedDeps, ackName](QVariantMap result) {
if (!self) return;
if (!result.value("success", false).toBool()) {
qWarning() << "ackPendingAction (multi) rejected for" << ackName << ":"
<< result.value("error").toString();
// The module already cancelled; drop the remembered targets so
// they can't mislabel the next batch that comes through.
self->m_lastRequestedTargets.clear();
return;
}
self->m_pendingAction = {PendingOp::MultiUninstallCascade, QString{}, QString{}, 0, names};
// `kind` is "app" only when WE composed this batch from a single
// App Manager row; a batch we didn't originate (package_manager_ui
// bulk selection) is always "packages", and a stale target list
// that isn't part of the arriving batch is dropped rather than
// used to mislabel someone else's request.
QString kind = QStringLiteral("packages");
const QSet<QString> batchSet(names.cbegin(), names.cend());
bool targetsBelong = !self->m_lastRequestedTargets.isEmpty();
for (const QString& t : self->m_lastRequestedTargets)
if (!batchSet.contains(t)) targetsBelong = false;
if (targetsBelong) kind = self->m_lastRequestKind;
else self->m_lastRequestedTargets.clear();
const QVariantMap payload =
self->buildPlanPayload(names, installedDeps, kind, /*multi=*/true);
self->m_lastRequestedTargets.clear();
emit self->uninstallPlanRequested(payload);
});
}
void PackageCoordinator::confirmUninstallMultiCascade(const QStringList& moduleNames)
{
if (m_pendingAction.op != PendingOp::MultiUninstallCascade
|| m_pendingAction.names != moduleNames) {
qWarning() << "confirmUninstallMultiCascade: no matching pending action — ignoring";
return;
}
m_pendingAction = {};
// Mirror of confirmUninstallCascade's cascade-unload, looped over each
// batch member. Snapshot loaded UI dependents per name BEFORE the core
// unload so the UI teardown pass can find them. Per-name dependents are
// already deduped within each list, but a single dep could appear under
// multiple targets — that's harmless because both teardownUiPluginWidget
// and unloadModuleWithDependents are idempotent on already-gone modules.
QStringList loadedCore = m_coreModuleManager
? m_coreModuleManager->loadedModules()
: QStringList{};
for (const QString& moduleName : moduleNames) {
QStringList loadedDeps;
if (m_uiPluginManager) {
loadedDeps = m_uiPluginManager->intersectWithLoaded(
m_dependentsByModule.value(moduleName));
}
if (loadedCore.contains(moduleName) || !loadedDeps.isEmpty()) {
qDebug() << "Cascade-unloading before multi-uninstall:" << moduleName;
bool ok = m_coreModuleManager
? m_coreModuleManager->unloadModuleWithDependents(moduleName)
: false;
if (!ok) {
qWarning() << "Cascade unload failed during multi-uninstall of" << moduleName
<< "— proceeding";
}
// Refresh the loaded snapshot so the next iteration sees the
// post-cascade state (dependents that were also batch members
// are already gone after their own cascade ran).
loadedCore = m_coreModuleManager
? m_coreModuleManager->loadedModules()
: QStringList{};
}
if (m_uiPluginManager) {
for (const QString& dep : loadedDeps) {
m_uiPluginManager->teardownUiPluginWidget(dep);
}
m_uiPluginManager->teardownUiPluginWidget(moduleName);
}
}
// Hand the destructive work back to the module under one confirm call.
// No rollback path on rejection: at this point the cascade-unload above
// has already run, so a `success: false` here means the modules are
// unloaded but their packages remain on disk. Rejection is rare in
// normal flow (we just acked, the module's pending state is ours) — most
// commonly it'd indicate a name-list mismatch we should never construct.
// The user can re-load via the Modules tab if they hit this.
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
logos.package_manager.confirmMultiUninstallAsync(moduleNames,
[moduleNames](QVariantMap r) {
if (!r.value("success", false).toBool()) {
qWarning() << "confirmMultiUninstall rejected:"
<< r.value("error").toString();
}
});
emit coreModulesChanged();
emit uiModulesChanged();
emit launcherAppsChanged();
}
void PackageCoordinator::cancelMultiUninstall(const QStringList& moduleNames)
{
if (m_pendingAction.op != PendingOp::MultiUninstallCascade
|| m_pendingAction.names != moduleNames) {
// Cancel was fanned out from MainUIBackend or arrived after another
// path already cleared the slot — treat as no-op rather than warning.
return;
}
m_pendingAction = {};
if (!m_logosAPI) return;
LogosModules logos(m_logosAPI);
logos.package_manager.cancelMultiUninstallAsync(moduleNames,
[](QVariantMap){});
}
// ---------------------------------------------------------------------------
// Metadata refresh chain
// ---------------------------------------------------------------------------
void PackageCoordinator::fetchUiPluginMetadata()
{
// The !m_logosAPI branch below already does the right thing when package
// metadata is unavailable -- clear the loading state and tell the UI -- so
// an absent package_manager takes the same path rather than blocking 20 s
// acquiring a token for a module that is not there. Without this the app
// would show its window and then sit in a loading state for the timeout.
if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) {
if (m_appsLoading) {
m_appsLoading = false;
emit appsLoadingChanged();
}
emit uiModulesChanged();
return;
}
if (!m_logosAPI) {
if (m_appsLoading) {
m_appsLoading = false;
emit appsLoadingChanged();
}
emit uiModulesChanged();
return;
}
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.getInstalledUiPluginsAsync([self](QVariantList uiPlugins) {
if (!self) return;
// Seed installType for the UI-plugin subset. refreshDependencyInfo's
// full-scan pass will overwrite this with the core-inclusive version;
// we do this first so QML has a non-empty map to key on during the
// window between the two async calls.
self->m_installTypeByModule.clear();
QHash<QString, QString> installedByName; // name → installed version
for (const QVariant& item : uiPlugins) {
QVariantMap pluginInfo = item.toMap();
const QString name = pluginInfo.value("name").toString();
if (name.isEmpty()) continue;
self->m_installTypeByModule[name] = pluginInfo.value("installType").toString();
const QString version = pluginInfo.value("version").toString();
installedByName.insert(name, version);
const QString rootHash =
pluginInfo.value("hashes").toMap().value("root").toString();
self->m_installedHashByName.insert(name, rootHash);
}
// Push the raw UI-plugin list to UIPluginManager — that's where the
// UI-specific cache (used for widget loading) lives.
emit self->uiPluginsFetched(uiPlugins);
emit self->uiModulesChanged();
emit self->launcherAppsChanged();
self->refreshDependencyInfo();
// Kick off the App-Manager catalog fetch.
self->tryFetchCatalog(installedByName, /*retriesLeft=*/10);
});
}
void PackageCoordinator::tryFetchCatalog(const QHash<QString, QString>& installedByName, int retriesLeft)
{
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (dlClient && dlClient->isConnected()) {
QPointer<PackageCoordinator> self(this);
dlClient->invokeRemoteMethodAsync(
"package_downloader", "getCatalog", QVariantList{},
[self, installedByName](QVariant catalogVar) {
if (!self) return;
const QVariantList catalog = catalogVar.toList();
self->buildCatalogIndexes(catalog);
self->populateAppsModel(catalog, installedByName);
});
return;
}
if (retriesLeft <= 0) {
// Give up — drop the App Manager overlay so Reload doesn't stick.
if (m_appsLoading) {
m_appsLoading = false;
emit appsLoadingChanged();
}
return;
}
QPointer<PackageCoordinator> self(this);
QTimer::singleShot(200, this, [self, installedByName, retriesLeft]() {
if (!self) return;
self->tryFetchCatalog(installedByName, retriesLeft - 1);
});
}
void PackageCoordinator::buildCatalogIndexes(const QVariantList& catalog)
{
QHash<QString, QVariantList> versionsByRepoAndName;
QHash<QString, QString> repoByName;
versionsByRepoAndName.reserve(catalog.size());
repoByName.reserve(catalog.size());
for (const QVariant& v : catalog) {
const QVariantMap row = v.toMap();
const QString name = row.value("name").toString();
if (name.isEmpty()) continue;
const QString repo = row.value("repositoryUrl").toString();
versionsByRepoAndName.insert(catalogKey(repo, name),
row.value("versions").toList());
repoByName.insert(name, repo);
}
m_versionsByRepoAndName = std::move(versionsByRepoAndName);
m_repoByName = std::move(repoByName);
}
void PackageCoordinator::populateAppsModel(
const QVariantList& catalog,
const QHash<QString, QString>& installedByName)
{
if (!m_appsModel) return;
m_appsModel->replaceCatalog(catalog);
m_appsModel->mergeLocalOnlyInstalled(m_installedPackagesCache);
const QHash<QString, QString>& fullInstalled = m_installedVersionByName.isEmpty()
? installedByName
: m_installedVersionByName;
m_appsModel->beginBulkInstalledUpdate();
for (auto it = fullInstalled.cbegin(); it != fullInstalled.cend(); ++it) {
const QString& name = it.key();
const QString& ver = it.value();
const QString hash = m_installedHashByName.value(name);
m_appsModel->markInstalled(name, ver, hash);
const QString installType = m_installTypeByModule.value(name);
if (!installType.isEmpty())
m_appsModel->setInstallType(name, installType);
if (m_uiPluginManager) {
const QString iconUrl = m_uiPluginManager->pluginIconUrl(name);
if (!iconUrl.isEmpty())
m_appsModel->setIconUrl(
name, iconUrl,
m_uiPluginManager->pluginManifestVersion(name));
}
}
for (auto it = m_missingDepsByModule.cbegin();
it != m_missingDepsByModule.cend(); ++it) {
m_appsModel->setMissingDeps(it.key(), it.value());
}
m_appsModel->endBulkInstalledUpdate();
if (m_appsLoading) {
m_appsLoading = false;
emit appsLoadingChanged();
}
}
// ── Package repository management ──────────────────────────────────────────
void PackageCoordinator::refreshRepositories()
{
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (!dlClient || !dlClient->isConnected()) return;
const bool wasLoading = m_repositoriesLoadingCount > 0;
++m_repositoriesLoadingCount;
if (!wasLoading) emit repositoriesLoadingChanged();
QPointer<PackageCoordinator> self(this);
dlClient->invokeRemoteMethodAsync(
"package_downloader", "listRepositories", QVariantList{},
[self](QVariant result) {
if (!self) return;
self->m_repositories = result.toList();
const int remaining = --self->m_repositoriesLoadingCount;
emit self->repositoriesChanged();
if (remaining == 0) emit self->repositoriesLoadingChanged();
});
}
// add/remove/setEnabled share a {success, error} result shape. The
// post-success refresh happens via catalogChanged in
// subscribeToPackageDownloaderEvents, so this only forwards the outcome.
void invokeRepositoryMutation(PackageCoordinator* self,
LogosAPIClient* dlClient,
const QString& methodName,
const QString& operation,
const QString& url,
const QVariantList& args)
{
QPointer<PackageCoordinator> selfPtr(self);
dlClient->invokeRemoteMethodAsync(
"package_downloader", methodName, args,
[selfPtr, operation, url](QVariant result) {
if (!selfPtr) return;
const QVariantMap r = result.toMap();
const bool ok = r.value("success").toBool();
emit selfPtr->repositoryOperationCompleted(operation, url,
ok, r.value("error").toString());
if (ok) selfPtr->refreshRepositories();
});
}
void PackageCoordinator::addRepository(const QString& url)
{
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (!dlClient || !dlClient->isConnected()) {
emit repositoryOperationCompleted(QStringLiteral("add"), url, false,
QStringLiteral("package_downloader not connected"));
return;
}
invokeRepositoryMutation(this, dlClient, QStringLiteral("addRepository"),
QStringLiteral("add"), url, QVariantList{url});
}
void PackageCoordinator::removeRepository(const QString& url)
{
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (!dlClient || !dlClient->isConnected()) {
emit repositoryOperationCompleted(QStringLiteral("remove"), url, false,
QStringLiteral("package_downloader not connected"));
return;
}
invokeRepositoryMutation(this, dlClient, QStringLiteral("removeRepository"),
QStringLiteral("remove"), url, QVariantList{url});
}
void PackageCoordinator::setRepositoryEnabled(const QString& url, bool enabled)
{
LogosAPIClient* dlClient = m_logosAPI
? m_logosAPI->getClient("package_downloader")
: nullptr;
if (!dlClient || !dlClient->isConnected()) {
emit repositoryOperationCompleted(QStringLiteral("setEnabled"), url, false,
QStringLiteral("package_downloader not connected"));
return;
}
invokeRepositoryMutation(this, dlClient, QStringLiteral("setRepositoryEnabled"),
QStringLiteral("setEnabled"), url,
QVariantList{url, enabled});
}
void PackageCoordinator::refreshDependencyInfo()
{
if (!m_logosAPI) return;
// Same reasoning as fetchUiPluginMetadata: no package_manager, no
// dependency info to fetch, and no reason to block on discovering that.
if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) return;
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
// First: refresh installType for every installed package (UI + core).
// We want the Uninstall button on both tabs to gate correctly, so the
// map must cover everything — not just UI plugins. fetchUiPluginMetadata
// already filled installType for its subset; we overwrite with this
// full-scan so core-only modules pick up their installType too.
logos.package_manager.getInstalledPackagesAsync(
[self](QVariantList packages) {
if (!self) return;
self->m_installedPackagesCache = packages;
QMap<QString, QString> typeMap;
QSet<QString> nameSet;
QHash<QString, QString> versionByName;
QHash<QString, QString> hashByName;
nameSet.reserve(packages.size());
versionByName.reserve(packages.size());
hashByName.reserve(packages.size());
QMap<QString, QString> displayNameMap;
for (const QVariant& v : packages) {
const QVariantMap pkg = v.toMap();
const QString name = pkg.value("name").toString();
if (name.isEmpty()) continue;
typeMap[name] = pkg.value("installType").toString();
const QString dn = pkg.value("displayName").toString();
if (!dn.isEmpty()) displayNameMap[name] = dn;
// moduleName is the key openApp / runResolverAndOpenDialog
// use; fall back to name when the field is absent.
const QString lookupName = pkg.value("moduleName").toString().isEmpty()
? name
: pkg.value("moduleName").toString();
const QString version = pkg.value("version").toString();
const QString rootHash = pkg.value("hashes").toMap().value("root").toString();
nameSet.insert(lookupName);
if (!version.isEmpty()) versionByName.insert(lookupName, version);
if (!rootHash.isEmpty()) hashByName.insert(lookupName, rootHash);
}
self->m_installTypeByModule = std::move(typeMap);
self->m_displayNameByModule = std::move(displayNameMap);
self->m_installedNameSet = std::move(nameSet);
self->m_installedVersionByName = std::move(versionByName);
self->m_installedHashByName = std::move(hashByName);
if (self->m_appsModel) {
self->m_appsModel->mergeLocalOnlyInstalled(self->m_installedPackagesCache);
self->m_appsModel->replaceInstalledSet(
self->m_installedVersionByName, self->m_installedHashByName);
}
// Second pass — per-module missing/dependents queries. Dispatched
// for every entry in the full installed-packages list (both UI and
// core) so that QML lookups work uniformly regardless of which tab
// is surfacing the button. `typeMap` was moved into
// m_installTypeByModule above, so we read from the destination.
QStringList names = self->m_installTypeByModule.keys();
if (names.isEmpty()) {
self->m_missingDepsByModule.clear();
self->m_blockingDepsByModule.clear();
self->m_dependentsByModule.clear();
self->m_dependenciesByModule.clear();
// Nothing installed is a complete answer, not an unfinished one —
// flip the gate so the UI doesn't wait forever on an empty box.
if (!self->m_dependencyDataReady) {
self->m_dependencyDataReady = true;
emit self->dependencyDataReadyChanged();
}
emit self->uiModulesChanged();
emit self->coreModulesChanged();
// Sidebar reads from launcherApps (not uiModules) so it needs its
// own kick whenever hasMissingDeps may have flipped.
emit self->launcherAppsChanged();
return;
}
LogosModules inner(self->m_logosAPI);
auto remaining = std::make_shared<int>(names.size() * 2);
auto missingMap = std::make_shared<QMap<QString, QStringList>>();
auto blockingMap = std::make_shared<QMap<QString, QVariantList>>();
auto dependenciesMap = std::make_shared<QMap<QString, QStringList>>();
auto dependentsMap = std::make_shared<QMap<QString, QStringList>>();
QPointer<PackageCoordinator> selfCopy(self.data());
auto maybeFinish = [selfCopy, missingMap, blockingMap, dependenciesMap,
dependentsMap, remaining]() {
if (!selfCopy) return;
if (--(*remaining) > 0) return;
selfCopy->m_missingDepsByModule = *missingMap;
selfCopy->m_blockingDepsByModule = *blockingMap;
selfCopy->m_dependenciesByModule = *dependenciesMap;
selfCopy->m_dependentsByModule = *dependentsMap;
if (!selfCopy->m_dependencyDataReady) {
selfCopy->m_dependencyDataReady = true;
emit selfCopy->dependencyDataReadyChanged();
}
if (selfCopy->m_appsModel) {
for (auto it = missingMap->cbegin(); it != missingMap->cend(); ++it)
selfCopy->m_appsModel->setMissingDeps(it.key(), it.value());
}
emit selfCopy->uiModulesChanged();
emit selfCopy->coreModulesChanged();
// Critical for the sidebar red-cross marker — without this, the
// SidebarPanel's launcherApps binding doesn't re-evaluate and the
// marker only appears on the next side-effect that triggers a
// launcher refresh (e.g. clicking the plugin to load it).
emit selfCopy->launcherAppsChanged();
};
for (const QString& name : names) {
// One call, three caches: the names the load gate refuses on, the
// reason per name, and the on-disk closure the uninstall plan
// walks. Why the dependency cleanup needs no extra IPC.
inner.package_manager.resolveFlatDependenciesAsync(
name, true,
[missingMap, blockingMap, dependenciesMap, name,
maybeFinish](QVariantList deps) {
// Two questions, not one: which rows are on disk (the
// graph) and which refuse the load (the gate). The split
// lives in utils/DependencyBlocker.h so it is under test —
// this lambda is not reachable from one.
const logos::DependencyRowSplit split =
logos::splitDependencyRows(deps);
missingMap->insert(name, split.blocking);
blockingMap->insert(name, split.blockers);
dependenciesMap->insert(name, split.present);
maybeFinish();
});
inner.package_manager.resolveFlatDependentsAsync(
name, true, [dependentsMap, name, maybeFinish](QVariantList deps) {
QStringList out;
for (const QVariant& v : deps) {
const QVariantMap m = v.toMap();
const QString s = m.value("name").toString();
if (!s.isEmpty()) out << s;
}
dependentsMap->insert(name, out);
maybeFinish();
});
}
});
}
// ---------------------------------------------------------------------------
// App-Manager catalog install pipeline (ported from PMUI's
// PackageManagerBackend, adapted to PackageCoordinator's session model).
// ---------------------------------------------------------------------------
QString PackageCoordinator::buildResolverDepsJson(const QString& name,
const QString& repositoryUrl,
const QVariantMap& versionPins) const
{
QJsonArray arr;
QSet<QString> seenDeps;
auto append = [&arr, &seenDeps](const QString& n, const QString& repo, const QString& ver) {
if (n.isEmpty() || seenDeps.contains(n)) return;
seenDeps.insert(n);
QJsonObject obj;
obj.insert(QStringLiteral("name"), n);
if (!repo.isEmpty()) obj.insert(QStringLiteral("repositoryUrl"), repo);
if (!ver.isEmpty()) obj.insert(QStringLiteral("version"), ver);
arr.append(obj);
};
append(name, repositoryUrl, versionPins.value(name).toString());
if (!repositoryUrl.isEmpty() && m_appsModel) {
QStringList queue;
queue << name;
for (int head = 0; head < queue.size(); ++head) {
const QString cur = queue[head];
const QVariantMap row = m_appsModel->rowDataByName(cur, repositoryUrl);
if (row.isEmpty()) continue;
const QVariantList deps = row.value("dependencies").toList();
for (const QVariant& d : deps) {
const QString depName = d.toMap().value("name").toString();
if (depName.isEmpty() || seenDeps.contains(depName)) continue;
const QVariantMap depRow =
m_appsModel->rowDataByName(depName, repositoryUrl);
if (depRow.isEmpty()) continue; // not in this repo — leave unpinned
append(depName, repositoryUrl, versionPins.value(depName).toString());
queue << depName;
}
}
}
for (auto it = versionPins.cbegin(); it != versionPins.cend(); ++it) {
const QString pinName = it.key();
if (pinName == name || pinName.isEmpty()) continue;
if (seenDeps.contains(pinName)) continue;
const QString pinVersion = it.value().toString();
if (pinVersion.isEmpty()) continue;
append(pinName, m_repoByName.value(pinName), pinVersion);
}
return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact));
}
QString PackageCoordinator::buildInstalledPackagesJson() const
{
QJsonArray arr;
for (const QVariant& v : m_installedPackagesCache) {
const QVariantMap m = v.toMap();
// package_manager rows expose both `name` and `moduleName`; the
// resolver wants the module name. Fall back to `name` when the
// module-name field is empty (older index shape).
const QString name = m.value("moduleName").toString().isEmpty()
? m.value("name").toString()
: m.value("moduleName").toString();
const QString version = m.value("version").toString();
if (name.isEmpty() || version.isEmpty()) continue;
QJsonObject o;
o.insert(QStringLiteral("name"), name);
o.insert(QStringLiteral("version"), version);
const QString rootHash = m.value("hashes").toMap().value("root").toString();
if (!rootHash.isEmpty()) o.insert(QStringLiteral("rootHash"), rootHash);
arr.append(o);
}
return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact));
}
QVariantMap nameAndRepo(const QString& name, const QString& repo)
{
return {
{QStringLiteral("name"), name},
{QStringLiteral("repositoryUrl"), repo},
};
}
QVariantList PackageCoordinator::collectCatalogRequired(const QString& name,
const QString& repositoryUrl) const
{
QVariantList out;
QSet<QString> seen;
out.append(nameAndRepo(name, repositoryUrl));
seen.insert(name);
if (repositoryUrl.isEmpty() || !m_appsModel) return out;
QStringList queue;
queue << name;
for (int head = 0; head < queue.size(); ++head) {
const QVariantMap row = m_appsModel->rowDataByName(queue[head], repositoryUrl);
if (row.isEmpty()) continue;
const QVariantList deps = row.value("dependencies").toList();
for (const QVariant& d : deps) {
const QString depName = d.toMap().value("name").toString();
if (depName.isEmpty() || seen.contains(depName)) continue;
if (m_appsModel->rowDataByName(depName, repositoryUrl).isEmpty()) continue;
seen.insert(depName);
out.append(nameAndRepo(depName, repositoryUrl));
queue << depName;
}
}
return out;
}
QString PackageCoordinator::depAction(const QString& installedVersion,
const QString& resolvedVersion,
const QString& installedHash,
const QString& resolvedHash)
{
if (installedVersion.isEmpty()) return QStringLiteral("install");
if (installedVersion == resolvedVersion) {
const bool hashKnown = !installedHash.isEmpty() && !resolvedHash.isEmpty();
if (hashKnown && installedHash != resolvedHash)
return QStringLiteral("reinstall");
return QStringLiteral("installed");
}
return QStringLiteral("upgrade");
}
bool PackageCoordinator::installPluginSucceeded(const QVariantMap& installResult)
{
return installResult.value(QStringLiteral("error")).toString().isEmpty();
}
QVariantMap PackageCoordinator::changeFromResolverEntry(const QVariantMap& entry,
const QString& installedVersion,
const QString& installedHash)
{
if (entry.contains("error")) {
return {
{QStringLiteral("name"), entry.value("name")},
{QStringLiteral("action"), QStringLiteral("error")},
{QStringLiteral("error"), entry.value("error")},
};
}
const QString to = entry.value("version").toString();
const QString toH = entry.value("rootHash").toString();
return {
{QStringLiteral("name"), entry.value("name").toString()},
{QStringLiteral("toVersion"), to},
{QStringLiteral("fromVersion"), installedVersion},
{QStringLiteral("repositoryUrl"), entry.value("repositoryUrl").toString()},
{QStringLiteral("description"), entry.value("description").toString()},
{QStringLiteral("action"), depAction(installedVersion, to, installedHash, toH)},
{QStringLiteral("isTopLevel"), entry.value("topLevel").toBool()},
};
}
QVariantList PackageCoordinator::computeDepChanges(
const QVariantList& resolved,
const QHash<QString, QString>& installedByName) const
{
QVariantList out;
for (const QVariant& v : resolved) {
const QVariantMap m = v.toMap();
const QString name = m.value("name").toString();
QVariantMap c = changeFromResolverEntry(
m, installedByName.value(name), m_installedHashByName.value(name));
if (c.value("action").toString() == QStringLiteral("error")) {
out.append(c);
continue;
}
const QString repoUrl = c.value("repositoryUrl").toString();
c.insert(QStringLiteral("versions"),
m_versionsByRepoAndName.value(catalogKey(repoUrl, name)));
if (c.value("isTopLevel").toBool()) out.prepend(c);
else out.append(c);
}
return out;
}
void PackageCoordinator::setOpStage(const QString& name, InstallStage::Value stage)
{
if (!m_installRegistry->has(name)) return;
if (m_installRegistry->stage(name) == static_cast<int>(stage)) return;
m_installRegistry->setStage(name, stage);
emit catalogInstallStageChanged(name, stage);
}
void PackageCoordinator::openApp(const QString& name,
const QString& repositoryUrl,
const QVariantMap& versionPins,
bool allowFastLaunch)
{
if (!m_logosAPI || name.isEmpty()) return;
// Fast-launch only for the tile whose repo's rootHash matches what's
// on disk. installStatus is already missing-deps-aware: AppsModel's
// recomputeInstallStatus demotes a row with non-empty missingDeps to
// NotInstalled, so tileStatus == Installed implies healthy deps.
int tileStatus = InstallStatus::NotInstalled;
if (m_appsModel) {
const QVariantMap row =
m_appsModel->rowDataByName(name, repositoryUrl);
tileStatus = row.value("installStatus").toInt();
}
if (allowFastLaunch && tileStatus == InstallStatus::Installed) {
qDebug() << "openApp fast-path: installed (v="
<< m_installedVersionByName.value(name)
<< "), emitting launchAppRequested";
emit launchAppRequested(name);
return;
}
runResolverAndOpenDialog(name, repositoryUrl, versionPins);
}
void PackageCoordinator::notifyAddApplicationDialogClosed()
{
if (m_activeAddDialogName.isEmpty()) return;
++m_dialogResolveEpoch[m_activeAddDialogName];
m_activeAddDialogName.clear();
}
void PackageCoordinator::runResolverAndOpenDialog(const QString& name,
const QString& repositoryUrl,
const QVariantMap& versionPins)
{
QVariantMap catalogRow =
m_appsModel ? m_appsModel->rowDataByName(name, repositoryUrl) : QVariantMap{};
const QString targetVersion = versionPins.value(name).toString();
const int epoch = ++m_dialogResolveEpoch[name];
m_activeAddDialogName = name;
const QString depsJson = buildResolverDepsJson(name, repositoryUrl, versionPins);
qDebug() << "PackageCoordinator::runResolverAndOpenDialog" << name
<< "repo=" << repositoryUrl << "targetVersion=" << targetVersion
<< "pins=" << versionPins.size() << "epoch=" << epoch;
QVariantList initialChanges;
if (m_installRegistry->isInFlight(name))
initialChanges = m_lastResolvedChangesByName.value(name);
// Sync stack frame only — QML may open the modal from this signal.
emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, initialChanges,
/*requestOpen=*/true);
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_downloader.resolveDependenciesAsync(depsJson, QString(),
[self, name, repositoryUrl, targetVersion, catalogRow, epoch]
(QVariantList resolved) {
if (!self) return;
if (self->m_dialogResolveEpoch.value(name) != epoch) {
qDebug() << "runResolverAndOpenDialog: dropping superseded epoch"
<< epoch << "for" << name;
return;
}
const QVariantList changes =
self->computeDepChanges(resolved, self->m_installedVersionByName);
if (!resolved.isEmpty())
self->m_lastResolvedRawByName.insert(name, resolved);
if (!changes.isEmpty())
self->m_lastResolvedChangesByName.insert(name, changes);
// Async refresh only — never reopens the modal.
self->emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, changes,
/*requestOpen=*/false);
});
}
void PackageCoordinator::emitDialogMetadata(const QString& name,
const QString& repositoryUrl,
const QString& targetVersion,
const QVariantMap& catalogRow,
const QVariantList& changes,
bool requestOpen)
{
if (name != m_activeAddDialogName)
return;
QVariantMap metadata;
metadata["name"] = name;
metadata["repositoryUrl"] = repositoryUrl;
metadata["selectedVersion"] = targetVersion;
metadata["displayName"] = catalogRow.value("displayName").toString().isEmpty()
? name
: catalogRow.value("displayName");
metadata["description"] = catalogRow.value("description");
metadata["icon"] = catalogRow.value("iconUrl");
metadata["category"] = catalogRow.value("category");
metadata["versions"] = catalogRow.value("versions").toList();
const QString installedVersion = m_installedVersionByName.value(name);
// Per-tile installStatus from the CLICKED row. Already missing-deps-aware
// (AppsModel::recomputeInstallStatus returns NotInstalled when missingDeps
// is non-empty), so the dialog reads Install instead of Launch for a
// partial install.
int tileStatus = InstallStatus::NotInstalled;
if (m_appsModel) {
const QVariantMap clickedRow = m_appsModel->rowDataByName(name, repositoryUrl);
tileStatus = clickedRow.value("installStatus").toInt();
}
metadata["installStatus"] = tileStatus;
metadata["isInstalled"] = tileStatus == InstallStatus::Installed;
metadata["installedVersion"] = installedVersion;
// Needed by the dialog's Uninstall affordance: "embedded" packages ship
// inside the bundle and package_manager refuses to remove them, so the
// button has to hide rather than offer a call that always fails.
metadata["installType"] = m_installTypeByModule.value(name);
const QVariantList versionsList = catalogRow.value("versions").toList();
metadata["latestVersion"] = versionsList.isEmpty()
? QString()
: versionsList.first().toMap().value("manifest").toMap().value("version").toString();
metadata["installStage"] = m_installRegistry->stage(name);
// {name, repo} entries so the filter pins each row to the resolver's
// chosen repo and multi-repo names don't duplicate. Always at least the
// top-level entry so the dialog has something to render before the
// async resolver callback arrives.
QVariantList requiredEntries;
QSet<QString> seen;
requiredEntries.reserve(changes.size() + 1);
requiredEntries.append(nameAndRepo(name, repositoryUrl));
seen.insert(name);
if (m_appsModel) {
QList<AppsModel::ResolverRow> overlay;
overlay.reserve(changes.size());
for (const QVariant& v : changes) {
const QVariantMap c = v.toMap();
AppsModel::ResolverRow rr;
rr.name = c.value("name").toString();
rr.repositoryUrl = c.value("repositoryUrl").toString();
rr.action = c.value("action").toString();
rr.toVersion = c.value("toVersion").toString();
rr.isTopLevel = c.value("isTopLevel").toBool();
rr.resolverError = c.value("error").toString();
overlay.append(rr);
if (!rr.name.isEmpty() && !seen.contains(rr.name)) {
seen.insert(rr.name);
requiredEntries.append(
nameAndRepo(rr.name, c.value("repositoryUrl").toString()));
}
}
m_appsModel->setResolverOverlay(overlay);
}
// Union in the catalog-derived dependency set.
for (const QVariant& v : collectCatalogRequired(name, repositoryUrl)) {
const QString depName = v.toMap().value("name").toString();
if (depName.isEmpty() || seen.contains(depName)) continue;
seen.insert(depName);
requiredEntries.append(v);
}
emit requiredPackagesResolved(requiredEntries);
if (requestOpen)
emit requestOpenAddApplicationDialog(metadata);
else
emit addApplicationDataUpdated(metadata);
}
void PackageCoordinator::refreshOverlayAfterInstall(const QString& topLevelName)
{
if (!m_appsModel || topLevelName.isEmpty()) return;
const QVariantList raw = m_lastResolvedRawByName.value(topLevelName);
if (raw.isEmpty()) return;
const QVariantList changes =
computeDepChanges(raw, m_installedVersionByName);
if (!changes.isEmpty())
m_lastResolvedChangesByName.insert(topLevelName, changes);
// Only push UI updates while this app's dialog is still the active session.
if (topLevelName != m_activeAddDialogName) return;
const QString repositoryUrl = m_repoByName.value(topLevelName);
const QVariantMap catalogRow =
m_appsModel->rowDataByName(topLevelName, repositoryUrl);
emitDialogMetadata(topLevelName, repositoryUrl, QString(), catalogRow, changes,
/*requestOpen=*/false);
}
void PackageCoordinator::confirmCatalogInstall(const QString& name,
const QString& repositoryUrl,
const QVariantMap& versionPins)
{
if (!m_logosAPI || name.isEmpty()) return;
if (m_installRegistry->has(name)) {
qDebug() << "confirmCatalogInstall: session for" << name
<< "already in progress, ignoring";
return;
}
m_installRegistry->begin(name, /*targetVersion=*/{}, /*targetHash=*/{},
/*startedByTopLevel=*/name);
emit catalogInstallStageChanged(name, InstallStage::Downloading);
const QString depsJson = buildResolverDepsJson(name, repositoryUrl, versionPins);
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
// Default IPC deadline (20s) is too tight when the catalog blob is many
// MB or the user is on a slow connection
constexpr int kDownloadIpcDeadlineMs = 5 * 60 * 1000;
logos.package_downloader.downloadResolvedDependenciesAsync(depsJson, QString(),
[self, name](QVariantList results) {
if (!self) return;
if (!results.isEmpty())
self->m_lastResolvedRawByName.insert(name, results);
QVariantList toInstall;
for (const QVariant& v : results) {
const QVariantMap m = v.toMap();
const QString rowName = m.value("name").toString();
if (!m.value("error").toString().isEmpty()) {
toInstall.append(v);
continue;
}
const QString resolvedVersion = m.value("version").toString();
const QString resolvedHash = m.value("rootHash").toString();
const QString installedVersion = self->m_installedVersionByName.value(rowName);
const QString installedHash = self->m_installedHashByName.value(rowName);
const bool versionMatches =
!installedVersion.isEmpty()
&& (resolvedVersion.isEmpty()
|| resolvedVersion == installedVersion);
const bool hashMatches =
resolvedHash.isEmpty()
|| installedHash.isEmpty()
|| resolvedHash == installedHash;
if (versionMatches && hashMatches) {
self->m_installRegistry->beginOrTrack(rowName, resolvedVersion,
resolvedHash, name);
self->m_installRegistry->setStage(rowName, InstallStage::Installed);
continue;
}
toInstall.append(v);
}
if (toInstall.isEmpty()) {
// Nothing left to do after the skip-already-installed
// filter; treat as a successful no-op rather than Failed.
self->setOpStage(name, InstallStage::Installed);
emit self->catalogInstallFinished(name);
self->refreshOverlayAfterInstall(name);
QTimer::singleShot(1500, self.data(), [self, name]() {
if (!self) return;
self->m_installRegistry->clearByTopLevel(name);
});
return;
}
for (const QVariant& v : toInstall) {
const QVariantMap m = v.toMap();
const QString rowName = m.value("name").toString();
if (rowName.isEmpty()) continue;
self->m_installRegistry->beginOrTrack(rowName,
m.value("version").toString(),
m.value("rootHash").toString(),
name);
self->m_installRegistry->setStage(rowName, InstallStage::Queued);
}
self->setOpStage(name, InstallStage::Installing);
self->installResultsSequential(toInstall, name, 0);
},
Timeout(kDownloadIpcDeadlineMs));
}
void PackageCoordinator::installOnePackage(const QVariantMap& dl,
std::function<void(bool, const QString&)> onDone)
{
const QString packageName = dl.value("name").toString();
const QString filePath = dl.value("path").toString();
const QString downloadError = dl.value("error").toString();
if (filePath.isEmpty()) {
if (onDone) onDone(false, downloadError.isEmpty()
? QStringLiteral("Download failed")
: downloadError);
return;
}
if (!m_logosAPI) {
if (onDone) onDone(false, QStringLiteral("package_manager not connected"));
return;
}
const bool alreadyInstalled = m_installedNameSet.contains(packageName);
const bool isEmbedded =
m_installTypeByModule.value(packageName) == QLatin1String("embedded");
// Never tear down or remove our own UI — same guard uninstallUiModule
// carries, for the same reason: it would brick Basecamp mid-install.
// AppsFilterProxy::excludeMainUi only hides it from the list; it is not a
// safety gate, and a resolver result can name it as a transitive entry.
// Falling through installs over it, which is the old merge behaviour —
// strictly better than deleting the running UI.
const bool isSelf = (packageName == QStringLiteral("main_ui"));
if (isSelf && alreadyInstalled) {
qWarning() << "Refusing to remove main_ui before install; "
"installing over it instead";
}
if (alreadyInstalled && !isEmbedded && !isSelf) {
cascadeUnloadForPackage(packageName);
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
logos.package_manager.uninstallPackageAsync(packageName,
[self, dl, packageName, onDone](QVariantMap uninstallResult) {
if (!self) return;
if (!uninstallResult.value("success", false).toBool()) {
const QString err = uninstallResult.value("error").toString();
qWarning() << "Pre-install removal of" << packageName
<< "failed, aborting install:" << err;
if (onDone)
onDone(false, err.isEmpty()
? QStringLiteral("Could not remove the installed version")
: err);
return;
}
self->installDownloadedFile(dl, onDone);
});
return;
}
installDownloadedFile(dl, onDone);
}
void PackageCoordinator::installDownloadedFile(const QVariantMap& dl,
std::function<void(bool, const QString&)> onDone)
{
const QString packageName = dl.value("name").toString();
const QString filePath = dl.value("path").toString();
LogosModules logos(m_logosAPI);
QPointer<PackageCoordinator> self(this);
// Installing was left on the default 20 s IPC deadline while DOWNLOADING
// got five minutes -- backwards. Downloading is network-bound and can be
// retried; installing is disk-bound over a payload that package_manager
// reads, gunzips and tar-parses three times and Merkle-hashes twice, then
// extracts and copies. It is ~1 s on a warm dev box and unbounded on a slow
// disk, a large package, or a machine where the antivirus scans every DLL
// as it lands. Blowing the deadline does not cancel any of that work: the
// files still install and the reply is simply abandoned, so the user is
// told the install failed when it did not. Match the download budget.
constexpr int kInstallIpcDeadlineMs = 5 * 60 * 1000;
// `installPluginAsyncResult`, not `installPluginAsync`: the plain async
// wrapper hands the callback a bare QVariantMap, so a transport failure is
// indistinguishable from a provider that legitimately returned an empty
// one. AsyncResult<T> carries the value and the error together, which is
// the whole reason it exists.
logos.package_manager.installPluginAsyncResult(filePath, false,
[self, packageName, onDone](logos::AsyncResult<QVariantMap> r) {
if (!self) return;
// Transport-level failure FIRST -- a timeout leaves `value`
// default-constructed, and reading it as an install verdict is
// exactly the mistake this channel removes. The message names the
// module and the deadline (logos::callErrorTimeout builds it), and
// says the package may in fact be installed: blowing the deadline
// cancels nothing, so the files may well be on disk.
if (!r.ok()) {
const QString detail = QString::fromStdString(r.error.message);
if (onDone) onDone(false,
QStringLiteral("%1 — the package may in fact be installed; check before retrying")
.arg(detail.isEmpty()
? QStringLiteral("package_manager did not reply")
: detail));
return;
}
const bool success = installPluginSucceeded(r.value);
const QString err = r.value.value("error").toString();
if (onDone) onDone(success, success ? QString() : err);
},
Timeout(kInstallIpcDeadlineMs));
}
void PackageCoordinator::installResultsSequential(const QVariantList& results,
const QString& topLevelName,
int index,
QStringList failures)
{
if (index >= results.size()) return;
const QVariantMap dl = results[index].toMap();
const QString rowName = dl.value("name").toString();
qDebug() << "installResultsSequential index=" << index
<< "of" << results.size()
<< "rowName=" << rowName
<< "topLevel=" << topLevelName;
if (!rowName.isEmpty()) {
m_installRegistry->beginOrTrack(rowName, dl.value("version").toString(),
dl.value("rootHash").toString(), topLevelName);
m_installRegistry->setStage(rowName, InstallStage::Installing);
}
QPointer<PackageCoordinator> self(this);
installOnePackage(dl,
[self, results, topLevelName, rowName, index, failures, dl]
(bool success, const QString& err) mutable {
qDebug() << "installOnePackage callback rowName=" << rowName
<< "success=" << success << "err=" << err;
if (!self) return;
if (!rowName.isEmpty()) {
if (success) {
const QString ver = dl.value("version").toString();
const QString hash = dl.value("rootHash").toString();
if (!ver.isEmpty())
self->m_installedVersionByName.insert(rowName, ver);
if (!hash.isEmpty())
self->m_installedHashByName.insert(rowName, hash);
if (self->m_appsModel)
self->m_appsModel->markInstalled(rowName, ver, hash);
self->m_installRegistry->finish(rowName);
} else {
self->m_installRegistry->fail(rowName, err);
}
}
if (!success) {
failures.append(rowName.isEmpty()
? err
: (rowName + ": " + err));
}
// Stop on the first failure rather than growing a half-installed
// set; report it now. (Rollback of installed packages is follow-up.)
const bool isLast = (index + 1) >= results.size();
if (!isLast && success) {
self->installResultsSequential(
results, topLevelName, index + 1, failures);
return;
}
if (!failures.isEmpty()) {
qDebug() << " install loop complete with failures for"
<< topLevelName << ":" << failures.size();
self->setOpStage(topLevelName, InstallStage::Failed);
emit self->catalogInstallFailed(
topLevelName, failures.join(QStringLiteral("; ")));
QTimer::singleShot(2500, self.data(), [self, topLevelName]() {
if (!self) return;
self->m_installRegistry->clearByTopLevel(topLevelName);
});
return;
}
self->setOpStage(topLevelName, InstallStage::Installed);
emit self->catalogInstallFinished(topLevelName);
self->refreshOverlayAfterInstall(topLevelName);
QTimer::singleShot(1500, self.data(), [self, topLevelName]() {
if (!self) return;
self->m_installRegistry->clearByTopLevel(topLevelName);
});
});
}