Commit Graph
3 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 e5a4be41a4 feat(ui): let an in-process UI plugin finish before its widget is destroyed
teardownUiPluginWidget destroyed the widget immediately. Core modules and
views (ui-host) both got a grace period when the teardown hook shipped;
in-process `type: ui` plugins did not.

WHY THIS PATH IS NOT THE OTHER TWO. The shipped helper runs a nested
QEventLoop, which is correct where it is used: logos_host calls it after
QtApp::exec() has returned and ui-host after app.exec() has, so in both
cases there is no outer loop left. Here there is. This runs on the LIVE UI
thread, from a user action, in the middle of widget destruction —
pluginWindowRemoveRequested, component->destroyWidget, deleteLater. Spinning
a nested loop there is the re-entrancy hazard that already cost this
codebase a SIGSEGV in the wallet's QtRO read stack. So Asynchronous DEFERS
rather than blocks: connect unloadFinished(), arm a deadline, and run the
existing teardown body from whichever fires first.

WHAT ui_qml CANNOT DO, and why that is not a gap. For ui_qml there is no
in-process plugin object to ask: PluginLoader emits pluginLoaded(...,
nullptr, UiQml, viewHost) — the component is null because the plugin is
QPluginLoader-loaded inside the ui-host CHILD PROCESS. Basecamp holds a
QQuickWidget and a QProcess wrapper. No host-side call from here could ever
reach it, which is exactly why that path's grace period lives in ui-host
instead (logos-view-module-runtime#26). unloadHookTarget() encodes that:
legacy plugins have a target, ui_qml returns nullptr and the teardown
proceeds as before.

Consequences handled rather than hoped for:
  * Idempotence, which this function documents and callers rely on: a
    second call while a deferral is in flight neither starts a second
    teardown nor tears down underneath the first.
  * The widget is held by QPointer across the deferral, not raw — anything
    else may destroy it meanwhile, and a stale raw pointer would be a
    use-after-free rather than a skipped teardown.
  * Both the signal and the deadline are disconnected on whichever arrives
    first, so the body cannot run twice.
  * The synchronous path — no hook, or Synchronous — is byte-for-byte what
    it was. That is the common case and it stays free.

The trade-off is stated on the declaration: a caller that needs the widget
gone before it proceeds cannot get that guarantee from this function any
more. Blocking to restore it is the thing that is not available here.

unit-tests and shutdown-test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:00:41 -03:00
Dario Gabriel Lipicar 9c3d023062 feat(shell): put the UI shell behind IShellHost, and order its shutdown
The shell held the host's objects: MainContainer owned MainUIBackend, took a
LogosAPI* and a QtLogosCore*, and connected to backend signals by concrete
type. Nothing stopped it minting identities or reading the token store.

Three headers in app/interfaces/, on the include path both targets share, so
there is exactly one copy and source drift is impossible:

  * IShellHost     -- 8 operations the shell may perform
  * IShellObserver -- 5 notifications the host may deliver
  * IShellView     -- how the host builds and tears down the shell

Only QObject*, QWidget* and Qt value types cross. MainContainer holds one
IShellHost* and nothing else; QML reaches the backend as an opaque QObject*
via backendObject(), resolved through the metaobject, so no host C++ type has
to be nameable by the shell.

Ownership inverts: Window owns MainUIBackend, ShellHostAdapter and
MainShellView; the shell borrows. Teardown stops being a consequence of
construction order and becomes a stated contract:

  1. beginShutdown() unmounts in-process UI plugin widgets WHILE the shell's
     tree is intact -- they are docked inside it
  2. destroyShell() detaches the observer, then deletes the shell
  3. the backend goes, tearing down Package -> UIPlugin -> Core
  4. main() destroys the core facade

Every observer forward is null-guarded: PluginLoader dispatches through
QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout, so a callback can
land after teardown starts, and QPointer cannot help -- IShellObserver is not
a QObject. UIPluginManager's plugin-widget maps become QPointer for the
mirror-image reason: those widgets are docked inside the shell, so its Qt
parent can destroy them without going through unloadUiModule.

IComponent is untouched and still serves the third-party legacy widget
plugins PluginLoader loads.
2026-08-22 16:42:13 -03:00
Dario Gabriel Lipicar 7851bf9f46 feat(host): per-plugin identities, an opt-in access policy, and the source split
Each loaded plugin -- including pure-QML ones -- gets its own LogosAPI identity
rather than sharing the host's, so a plugin's calls are attributable and can be
refused independently. The host-services grant is wired through to
capability_module, and its trust root is guarded on an OUTCOME rather than a
log line.

Inter-module access policy stays OFF by default: enforce mode's derived
deny-by-default gates every ui_qml app's calls to its own backend module,
because UI plugins load out-of-process and are not tracked as dependents in the
core ModuleRegistry. Operators opt in per launch with --access-policy enforce
or LOGOS_ACCESS_POLICY.

Takes the Qt host runtime from logos-plugin-qt rather than logos-qt-sdk, which
keeps only the Qt<->lp seam headers, and moves logos-protocol onto the rev that
split host needs. On Windows logos_core must come LAST on the link line: GNU ld
resolves an archive left to right, so the view runtime's references have to be
undefined already when it reaches the import library.

Separates the two source trees -- app/ is the host, src/ is the UI shell -- and
brings the CI onto setup-nix-cache-action. Merges master.
2026-08-22 16:42:13 -03:00