Files
logos-view-module-runtime/ui-host/main.cpp
T
Dario LipicarandClaude Opus 5 ba56a70b04 feat(ui-host): adopt the parent's credential through the shared verb (#27)
* feat(ui-host): adopt the parent's credential through the shared verb

ui-host is handed its parent's per-spawn credential on stdin and installs
it under both bootstrap keys. That was already the right shape — it is the
one place in the system that seeded a store with its OWN identity's
credential rather than the host's — but it spelled the bootstrap key set
out itself, making it the fifth such site.

It now goes through logos::adoptConsumerCredential, so
TokenManager::bootstrapKeys() owns the set. No behaviour change here: this
image's store IS the process ring, which is exactly the case that verb is
narrowed to.

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

* chore(lock): logos-plugin-qt -> master for logos_consumer.h, protocol to the rev master implements

WHAT MOVED

  logos-plugin-qt  ef11c210 -> 7cad5ec3  (master, #27 merged)
  logos-protocol   79894727 -> b37a2e9f  (0.5.0 -> 0.7.0)

WHY plugin-qt HAD TO MOVE

This branch's ui-host calls logos::adoptConsumerCredential and the bridge
takes a logos::ConsumerIdentity, both declared in cpp/logos_consumer.h. That
header did not exist at ef11c210, so every build here died at

  LogosQmlBridge.h:17:10: fatal error: logos_consumer.h: No such file or directory

logos-plugin-qt#27 ("logos::admitConsumer -- one home for admitting a
non-module") merged as 7cad5ec3 and ships logos_consumer.h / .cpp. Relocking
that input is the whole fix for the reported red.

WHY protocol MOVED TO 0.7.0 AND NOT TO MASTER

logos-plugin-qt's logos-protocol input `follows` OURS, so this repo -- not
plugin-qt's own lock -- decides which protocol logos-qt-host is compiled
against. The first attempt took protocol to master (42460e5b, 0.8.0). It does
not build, and it does not fail in this repo:

  logos-plugin-qt/cpp/logos_consumer.h:68:4: error: #error "logos-protocol is
  newer than the consumer-admission contract this file implements. A private
  token store is created empty; if the protocol changed how a consumer is
  seeded, this file and the hosts calling logos::admitConsumer must move in the
  SAME wave. Review adoptCredentialFor / bootstrapKeys, then raise this bound."

That is a deliberate upper bound in plugin-qt master:

  #if defined(LOGOS_PROTOCOL_VERSION_MINOR) \
      && (LOGOS_PROTOCOL_VERSION_MAJOR > 0 \
          || (LOGOS_PROTOCOL_VERSION_MAJOR == 0 && LOGOS_PROTOCOL_VERSION_MINOR > 7))

Every other version guard in the fleet is a `>=` floor; this one is a ceiling,
written precisely so a protocol bump cannot outrun the consumer-admission
contract silently. protocol 42460e5b (0.8.0, the INBOUND/OUTBOUND direction
split) merged at 14:16:33Z and logos-plugin-qt#27 merged at 14:16:41Z, eight
seconds later, on top of 0.7 -- plugin-qt master's own flake.lock pins
logos-protocol b37a2e9f. So plugin-qt master implements the <= 0.7 contract and
no branch anywhere raises the bound yet.

b37a2e9f is therefore not a compromise, it is the rev plugin-qt master itself
locks: this closure is the one plugin-qt's own CI validates, and there is no
protocol split. Verified in the built closure:

  logos-view-module-runtime  compiles against  logos-protocol 0.7.0
  logos-qt-host-0.1.0        links             logos-protocol-lib-0.7.0
  logos-cpp-sdk              contributes no protocol path at all
                             (header-only Qt-free types; its own 0.2.0
                              logos-protocol lock node never reaches the build)

Nothing in this repo references a 0.8-only symbol (no saveInboundToken, no
adoptCredential/adoptCredentialFor, no LOGOS_PROTOCOL_VERSION guard), so 0.8
buys this branch nothing today. flake.nix is untouched and the lock's
`original` for logos-protocol stays master-tracking -- once plugin-qt raises
the bound, a plain `nix flake lock --update-input logos-protocol` carries this
repo to 0.8 with no edit.

CHECKS -- every target the flake exposes for this builder, built individually
with --print-out-paths, upstream cache only (cache.nix.logos.co is 502), so
these are real from-source builds:

  packages.x86_64-linux.default    OK
    /nix/store/gajjq3050m46sprbiqqz0c32mhi95vb7-logos-view-module-runtime-1.0.0
  checks.x86_64-linux.default      OK
    /nix/store/9a4wvnp6b226s2lam54dgxmzm86r9j58-logos-view-module-runtime-check-1.0.0
  packages.x86_64-linux.tests      OK  (same derivation as the check)
    /nix/store/9a4wvnp6b226s2lam54dgxmzm86r9j58-logos-view-module-runtime-check-1.0.0
  checks.x86_64-windows.default    OK  (cross to mingw; compile only, nix
                                        forces doCheck off for a cross build,
                                        so no ctest ran there)
    /nix/store/csp4n8hn0xrf2j2lxflv6k8affbn180v-logos-view-module-runtime-check-x86_64-w64-mingw32-1.0.0
  packages.x86_64-windows.default  OK  (bin/ui-host.exe is a real PE32+)
    /nix/store/s8y4s43nfl8932hnffg645bzn9dqy5fs-logos-view-module-runtime-x86_64-w64-mingw32-1.0.0

checks.x86_64-linux.default runs the suite in its checkPhase:

  Start  1: LogosQmlBridgeUnitTests        Start  6: BridgeIdentityTests
  Start  2: LogosQmlBridgeE2ETests         Start  7: LogosQmlBridgeDeferredTests
  Start  3: LogosQmlBridgeGuiTests         Start  8: LogosQmlBridgeCallsTests
  Start  4: LogosQmlBridgeResultTests      Start  9: LogosQmlBridgeReplayTests
  Start  5: LogosQmlBridgeHandshakeTests   Start 10: UiHostUnloadTests
  100% tests passed, 0 tests failed out of 10
  Total Test time (real) =  39.36 sec

BridgeIdentityTests is the suite this branch adds, so admitConsumer /
adoptConsumerCredential are exercised, not merely linked.

NOT VERIFIED: the aarch64-* and *-darwin outputs. This builder is x86_64-linux
and there is no darwin remote; CI's macos-latest leg covers them.

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

* chore(lock): protocol 0.8 + plugin-qt master, and un-collapse the fixture's two token stores

WHAT MOVED

  logos-protocol   b37a2e9f (0.7.0) -> 42460e5b (0.8.0, "separate INBOUND from OUTBOUND")
  logos-plugin-qt  7cad5ec3          -> 048152f2 (plugin-qt#26)

Six lines of flake.lock; the lock stays at 14 nodes. logos-plugin-qt's
logos-protocol input `follows` OURS, so this repo decides which protocol
logos-qt-host is compiled against, and both halves have to move together.

THIS SUPERSEDES e370ce5's REASONING, WHICH WAS CORRECT WHEN WRITTEN

e370ce5 took protocol to 0.7 rather than master and gave the reason: plugin-qt
master carried a CEILING, not a floor, in cpp/logos_consumer.h --

  #if ... || (LOGOS_PROTOCOL_VERSION_MAJOR == 0 && LOGOS_PROTOCOL_VERSION_MINOR > 7))
  #error "logos-protocol is newer than the consumer-admission contract this
          file implements ... Review adoptCredentialFor / bootstrapKeys, then
          raise this bound."

-- and no branch anywhere had raised it. logos-plugin-qt#26 has since merged as
048152f2 and performed exactly the review that text demands. Read from GitHub at
both revs rather than inferred:

  7cad5ec3  cpp/logos_consumer.h:67   ... VERSION_MINOR > 7))
  048152f2  cpp/logos_consumer.h:90   ... VERSION_MINOR > 8))

#26 also makes 0.8 mandatory rather than merely permitted: cpp/logos_provider_
object.cpp and cpp/qt_provider_object.cpp call TokenManager::saveInboundToken
unguarded, which does not exist before 0.8. plugin-qt master and protocol 0.8
are now one step, and 0.7 is no longer reachable from this branch.

Verified in the built closure rather than in the lock -- the version literal in
the header that actually reached the compiler, on both targets:

  LOGOS_PROTOCOL_VERSION_MAJOR 0 / LOGOS_PROTOCOL_VERSION_MINOR 8

and exactly ONE logos-qt-host and ONE logos-protocol derivation in the .drv
graph of every output (the runtime closure cannot see either -- logos_qt_host is
linked statically -- so this was read from `nix-store -qR` over the DERIVATION,
which is the instrument that finds a split host):

  x86_64-linux      logos-qt-host-0.1.0.drv                  x1
                    logos-protocol{,-lib,-headers}-0.8.0.drv  one derivation
  x86_64-windows    logos-qt-host-x86_64-w64-mingw32-0.1.0.drv x1
                    logos-protocol-*-x86_64-w64-mingw32-0.8.0.drv one derivation

── THE ONE TEST THAT MOVED, AND WHY IT IS THE FIXTURE AND NOT THE PRODUCT ─────

At 0.8 this branch built clean and ran 10/11, with case 1 red:

  FAIL!  : TestBridgeIdentity::aHostIdentityBridgeReachesAnUndeclaredBackend
           WithNoHandshake() '!payloadIsError(payload)' returned FALSE.
  ({"error":"Module source unavailable",
    "message":"call to 'backend_module' rejected: token not recognized
               (re-exchange failed)"})

Case 1 is the deliberately-GREEN ambient-ring escalation control, and its own
comment says that if it goes red the ambient ring changed shape and every
conclusion in the file needs re-deriving. So this had to be decided, not
silenced. Two readings were live:

  (A) the fixture seeds the ring with saveToken -- the OUTBOUND door -- and 0.8
      authorizes against the INBOUND view, so the fixture stopped modelling
      production and the TEST should change;
  (B) any host that seeds a peer's token with saveToken loses in-process
      authorization at 0.8, and the PRODUCT should change.

It is (A), but NOT for the reason (A) states, and the difference decides what
the replacement asserts. The fixture's ambient seed is still a faithful model of
logos-liblogos/src/logos_core/module_manager.cpp:336. What the fixture got wrong
is that it COLLAPSED TWO STORES INTO ONE: both ModuleProxys took the defaulted
store, so the "module's" credential was whatever the host's ambient ring held --
the capability bootstrap kCapToken -- instead of the module's own root token.

In production those are two objects in two images, holding ONE value:

  logos-liblogos/src/logos_core/module_manager.cpp:329-336
      the HOST mints authToken, sendToken()s it to the module, and caches it
      OUTBOUND under the module's name.
  logos-module-loader-qt/src/host/module_initializer.cpp:169-170
      the MODULE's image writes that same value under both bootstrapKeys(),
      which is what TokenManager::credential() is derived from.

0.8 closed the OUTBOUND-MAP route into a provider. It left the ANCHOR route
open, deliberately, and logos-protocol pins that at master in
tests/protocol/test_token_direction.cpp:221
(TokenDirection.TheOwnCredentialStillAuthorizesAndStillGatesPushes): a module
authorizes anyone presenting ITS OWN credential. Since the ambient ring's entry
for a module IS that module's credential, the escalation case 1 records is STILL
LIVE at 0.8 -- which is why the fix is to give the backend the store a real
module has, and emphatically NOT to make the call fail.

THE REMEDY NOT TAKEN. Routing the seed through informModuleToken /
saveInboundToken -- reading (A) as literally stated -- files a caller GRANT.
Case 1 would then assert "a caller that was granted may call": trivially true,
green forever, and blind to the escalation the file exists to record. That is
worse than red, and it is the trap this fleet keeps hitting.

WHAT CHANGED IN tests/test_bridge_identity.cpp

  * backendImageStore() -- the backend module gets its OWN TokenManager
    (isolateIdentity + forIdentity + adoptCredential(kBackendRootToken)), the
    same three steps logos-protocol's own ModuleImage helper uses, and
    backendProxy is constructed with the 3-arg ModuleProxy ctor against it.
  * the ambient-ring seed is UNCHANGED -- it was never the thing that was wrong.
  * capability_module DELIBERATELY stays on the ambient ring: its credential is
    the host's kCapToken, which is what lets logos::admitConsumer's
    informModuleToken push clear the trusted-channel gate. Isolating it too
    breaks cases 3-8 for reasons unrelated to any of them.
  * case 1 now pins the MECHANISM, not only the outcome:
        QCOMPARE(TokenManager::instance().getToken("backend_module"),
                 fx.backendStore->credential());     // one secret, two stores
        QVERIFY(fx.backendStore->inbound().keys().isEmpty());  // nothing granted
    plus a null/identity check on the store, because a null would have silently
    defaulted the proxy back onto the ambient ring and made the whole case
    theatre.

── THE CONTROLS. A CONTROL THAT CANNOT FAIL IS NOT A CONTROL ─────────────────

Case 1 asserts that an undeclared backend IS reachable, so "reachable" is its
GREEN state and it cannot be made red by making the backend reachable. What CAN
be shown -- and was, by mutating one thing at a time in the built tree and
re-running -- is that it goes red on every way the ambient ring's shape can
change, and that the tautology substitution is blocked. Five mutations, five
reds, each in a different assertion:

  NULL  unmutated                                   11 passed, 0 failed
  A     ring caches a value no image holds          9/2  -- QCOMPARE (mechanism 1)
        (module_manager.cpp:336 changes shape)              + case 5's ring assert
  B     the two stores collapsed back into one       10/1 -- '!payloadIsError'
        (literally today's fixture)                         with the exact 0.8
                                                            "token not recognized
                                                            (re-exchange failed)"
  C     target INFORMED of the caller instead        10/1 -- inbound().keys()
        (the tautology substitution; the call                .isEmpty()
        still succeeds, and the test refuses
        to call that a pass)
  D     module image never adopts a credential       10/1 -- QCOMPARE (mechanism 1)
        (module_initializer.cpp:169-170 changes)
  E     case 5's admitted consumer is HANDED the     10/1 -- 'payloadIsError'
        ambient entry, so an undeclared backend             ("undeclared backend
        IS reachable with no handshake                       was REACHED: hello")

E is the literal "make it reachable, show RED", answered by the assertion that
owns that claim: case 5 is the polarity case 1 is not. The file carries both, so
it cannot pass vacuously in either direction.

── PRODUCT: NOTHING TO CHANGE HERE ───────────────────────────────────────────

This repo has no saveToken call in production source at all. ui-host/main.cpp:221
adopts its OWN per-spawn credential through logos::adoptConsumerCredential -- the
value the parent minted FOR IT, not a peer's token through the outbound door --
which is the correct direction and is what this branch already did. The one
shipped (B) in the fleet is logos-logoscore-cli's daemon
(src/daemon/daemon.cpp:629, saveToken("cli_client", ...)), fixed separately in
logoscore-cli#104.

── CHECKS, EVERY OUTPUT INDIVIDUALLY, --print-out-paths ──────────────────────

  packages.x86_64-linux.default      34vfy1yshm3hqk06ngsl3dycfwqlqssj-…-1.0.0
  packages.x86_64-linux.tests        38md8bjl93ga42z7d94nxrqjqq8qldx6-…-check-1.0.0
  checks.x86_64-linux.default        38md8bjl93ga42z7d94nxrqjqq8qldx6-…-check-1.0.0
  packages.x86_64-windows.default    kyncplgbrryb40sa8p8dg5037k3qrslq-…-mingw32-1.0.0
  packages.x86_64-windows.tests      jdpgmgi4w7qaxhxzhl5s0kpixbncj7j6-…-check-mingw32
  checks.x86_64-windows.default      jdpgmgi4w7qaxhxzhl5s0kpixbncj7j6-…-check-mingw32

checks.x86_64-linux.default runs the suite in its checkPhase:

  1/10 LogosQmlBridgeUnitTests        6/10 BridgeIdentityTests      Passed
  2/10 LogosQmlBridgeResultTests      7/10 LogosQmlBridgeGuiTests
  3/10 LogosQmlBridgeE2ETests         8/10 LogosQmlBridgeDeferredTests
  4/10 LogosQmlBridgeReplayTests      9/10 UiHostUnloadTests
  5/10 LogosQmlBridgeHandshakeTests  10/10 LogosQmlBridgeCallsTests
  100% tests passed, 0 tests failed out of 10   (38.96 sec)

BridgeIdentityTests itself: 11 passed, 0 failed, 0 skipped. The cross target
compiles tests/test_bridge_identity.exe but nix forces doCheck off for a cross
build, so no ctest ran there.

NOT BUILT: aarch64-linux and both darwin. This builder is x86_64-linux and there
is no darwin remote; all nine of those outputs EVALUATE (drvPath resolved for
packages.default, packages.tests and checks.default on each), which is the part
that catches the pseudo-system eval traps. CI's macos-latest leg covers the rest.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:05:23 -03:00

305 lines
13 KiB
C++

// ui-host: Out-of-process host for view module plugins.
// Loads a Qt plugin and exposes it directly via a private QRemoteObjectHost
// socket. The plugin's Q_INVOKABLE methods, signals, Q_PROPERTYs and enums
// become available to the parent process via QRemoteObjectDynamicReplica.
// Any Q_PROPERTY of type QAbstractItemModel* is additionally remoted as a
// child source so QML can use it as a model directly (see Gap 2 design).
//
// Only the parent process (via LogosQmlBridge) knows the socket name, so the
// plugin is NOT discoverable by other modules.
#include <QCoreApplication>
#include <QCommandLineParser>
#include <QLocalSocket>
#include <QPluginLoader>
#include <QRemoteObjectHost>
#include <QAbstractItemModel>
#include <QMetaProperty>
#include <QSocketNotifier>
#include <QTextStream>
#include <QDebug>
#include <cerrno>
#include <csignal>
#include <cstring>
#include <thread>
#ifndef _WIN32
#include <fcntl.h>
#include <unistd.h>
#endif
#ifdef __linux__
#include <sys/prctl.h>
#endif
#include "logos_api.h"
#include "logos_consumer.h"
#include "logos_plugin_unload.h"
#include "LogosViewPlugin.h"
// The view's chance to finish, between "stop" and teardown.
//
// Budget. ViewModuleHost::stop() gives this process terminate() plus
// waitForFinished(3000) before it resorts to kill(), and EVERYTHING after the
// signal has to fit inside that 3s: unwinding app.exec() through the self-pipe
// notifier, this grace period, then `delete pluginObject`, the
// QRemoteObjectHost destructor that unlinks the QtRO socket, and process exit.
// 2s spends most of the window on the view while keeping a margin that is
// comfortably more than the teardown it precedes: the whole SIGTERM-to-exit
// sequence for a plugin with no hook at all measures 1ms
// (tests/test_ui_host_unload.cpp, noHookIsSilentAndImmediate), so the ~1s left
// over is three orders of magnitude more than it has to cover.
//
// Note that the deadline is a default (coarse) QTimer, which Qt may fire up to
// 5% early -- measured 1903ms for a nominal 2000ms. That errs toward giving up
// sooner, which is the safe direction for a budget carve-out.
//
// This is deliberately SMALLER than logos_host's 3000ms. The grace period is
// carved out of the caller's hard-kill budget, and ui-host's caller is less
// patient than the module container's (3s here, 5s there). Sharing the helper
// but not the constant is exactly why runPluginAboutToUnload takes the grace
// period as a parameter. Raising this to 3000 would consume the whole budget
// and leave the view hard-killed mid-teardown -- the precise failure the hook
// exists to prevent.
constexpr int kUnloadGraceMs = 2000;
int main(int argc, char* argv[])
{
#ifndef _WIN32
// Out-of-process view-module hosts are spawned by a parent (Basecamp, or any
// app embedding the view runtime). Mirror logos_host: (1) detach into our own
// session/process group so tearing the module tree down can't leak a signal
// into the parent's process group; (2) tie our lifetime to the parent so we
// never linger as an orphan if the parent *crashes* (it normally kills us
// explicitly). setsid() removes the controlling-terminal SIGHUP that used to
// reap orphans, so we replace it with PR_SET_PDEATHSIG (Linux) plus a
// portable getppid() watchdog. Compare against the parent's actual pid (not
// pid 1) so a parent that is itself PID 1 (a container) is handled correctly.
if (::setsid() == -1 && errno != EPERM) {
::setpgid(0, 0);
}
{
const pid_t parent_pid = ::getppid();
#ifdef __linux__
::prctl(PR_SET_PDEATHSIG, SIGKILL);
#endif
if (::getppid() != parent_pid) {
_exit(0);
}
std::thread([parent_pid] {
while (::getppid() == parent_pid) {
::sleep(1);
}
_exit(0);
}).detach();
}
#endif
QCoreApplication app(argc, argv);
app.setOrganizationName("Logos");
app.setOrganizationDomain("logos.co");
app.setApplicationName("ui-host");
// Catch SIGTERM/SIGINT so the parent's QProcess::terminate() (and a
// user-issued Ctrl-C) unwinds app.exec() cleanly. Without this the
// default handler kills the process outright, skipping plugin
// destructors. SIGKILL stays uncatchable and remains the force-kill
// fallback in ViewModuleHost::stop().
//
// Self-pipe trick: the POSIX handler may only call async-signal-safe
// functions, and QCoreApplication::quit() is not (it grabs Qt-internal
// mutexes). Write a byte to a pipe from the handler — write(2) IS
// async-signal-safe — and let a QSocketNotifier on the main thread
// pick it up and call quit() from normal Qt context.
#ifndef _WIN32
static int sigPipe[2] = {-1, -1};
if (::pipe(sigPipe) != 0) {
qWarning() << "ui-host: pipe() for signal handler failed:"
<< ::strerror(errno);
} else {
// Non-blocking read end so the drain loop in the notifier callback
// returns instead of blocking once the pipe is empty. The write end
// stays blocking (write of 1 byte never blocks in practice since
// the pipe buffer is 4 KiB+) so the handler stays trivial.
::fcntl(sigPipe[0], F_SETFL, ::fcntl(sigPipe[0], F_GETFL, 0) | O_NONBLOCK);
auto* notifier = new QSocketNotifier(sigPipe[0], QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [&app]() {
char buf[8];
while (::read(sigPipe[0], buf, sizeof(buf)) > 0) {}
qDebug() << "ui-host: received termination signal, quitting";
app.quit();
});
struct sigaction sa{};
sa.sa_handler = [](int) {
const char c = 1;
// Drop EINTR/EAGAIN silently — a missed wake-up just delays
// shutdown until the next signal or socket activity.
(void)::write(sigPipe[1], &c, 1);
};
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
::sigaction(SIGTERM, &sa, nullptr);
::sigaction(SIGINT, &sa, nullptr);
}
#endif
QCommandLineParser parser;
parser.setApplicationDescription("Logos UI module host process");
parser.addHelpOption();
QCommandLineOption nameOpt(QStringList() << "name",
"Module name", "module_name");
QCommandLineOption pathOpt(QStringList() << "path",
"Path to the plugin .so/.dylib", "plugin_path");
QCommandLineOption socketOpt(QStringList() << "socket",
"Local socket name for QRemoteObjectHost", "socket_name");
parser.addOption(nameOpt);
parser.addOption(pathOpt);
parser.addOption(socketOpt);
parser.process(app);
if (!parser.isSet(nameOpt) || !parser.isSet(pathOpt) || !parser.isSet(socketOpt)) {
qCritical() << "Usage: ui-host --name <module_name> --path <plugin.so> --socket <socket_name>";
return 1;
}
const QString moduleName = parser.value(nameOpt);
const QString pluginPath = parser.value(pathOpt);
const QString socketName = parser.value(socketOpt);
// Receive the per-spawn auth token from the parent (ViewModuleHost)
QString authToken;
{
QLocalSocket client;
client.connectToServer(socketName + QStringLiteral("_token"));
if (!client.waitForConnected(10000)) {
qCritical() << "ui-host: failed to connect to parent token socket for"
<< moduleName << ":" << client.errorString();
return 1;
}
if (!client.waitForReadyRead(5000)) {
qCritical() << "ui-host: timeout waiting for auth token from parent for"
<< moduleName;
return 1;
}
authToken = QString::fromUtf8(client.readAll());
client.disconnectFromServer();
}
if (authToken.isEmpty()) {
qCritical() << "ui-host: parent sent empty auth token for" << moduleName;
return 1;
}
QPluginLoader loader(pluginPath);
if (!loader.load()) {
qCritical() << "Failed to load plugin:" << loader.errorString();
return 1;
}
QObject* pluginObject = loader.instance();
if (!pluginObject) {
qCritical() << "Failed to get plugin instance:" << loader.errorString();
return 1;
}
qDebug() << "ui-host: loaded plugin" << moduleName << "from" << pluginPath;
LogosAPI* logosAPI = new LogosAPI(moduleName);
logosAPI->setParent(&app);
// Adopt the per-spawn credential the PARENT minted and already registered
// with capability_module (basecamp's PluginLoader / standalone's MainWindow,
// both through logos::admitConsumer). This process only installs it — no
// isolation and no second registration, which would invalidate the very
// credential the parent is still holding.
//
// Was two saveToken() calls spelling "core" and "capability_module" by hand.
// Those keys are TokenManager::bootstrapKeys(), and this was the fifth place
// in the workspace that spelled them out; adoptConsumerCredential is the one
// that owns the set.
logos::adoptConsumerCredential(logosAPI, authToken);
int methodIndex = pluginObject->metaObject()->indexOfMethod("initLogos(LogosAPI*)");
if (methodIndex != -1) {
QMetaObject::invokeMethod(pluginObject, "initLogos",
Qt::DirectConnection,
Q_ARG(LogosAPI*, logosAPI));
qDebug() << "ui-host: called initLogos on plugin" << moduleName;
}
QRemoteObjectHost host(QUrl(QStringLiteral("local:") + socketName));
// Prefer typed remoting via the LogosViewPlugin interface. The generated
// <Foo>ViewPluginBase (from logos_module(REP_FILE ...)) performs
// host->enableRemoting<FooSourceAPI>(backend), which publishes the typed
// source signature so typed replicas on the client side reach the Valid
// state. Without this, dynamic (name-based) remoting would use a
// different signature hash and typed replicas would stall in Default.
auto* viewPlugin = qobject_cast<LogosViewPlugin*>(pluginObject);
if (!viewPlugin) {
viewPlugin = dynamic_cast<LogosViewPlugin*>(pluginObject);
}
QObject* remoteTarget = viewPlugin ? viewPlugin->viewObject() : pluginObject;
if (!remoteTarget) remoteTarget = pluginObject;
bool remotingEnabled = false;
if (viewPlugin) {
remotingEnabled = viewPlugin->enableRemoting(&host);
if (!remotingEnabled) {
qWarning() << "ui-host: LogosViewPlugin::enableRemoting() returned "
"false, falling back to dynamic remoting";
}
}
// Fallback: dynamic remoting. All Q_INVOKABLEs, slots, signals, and
// Q_PROPERTYs (with NOTIFY) on the remote target propagate to a
// QRemoteObjectDynamicReplica on the client side. Used for plugins
// without a .rep / LogosViewPlugin implementation.
if (!remotingEnabled) {
if (!host.enableRemoting(remoteTarget, moduleName)) {
qCritical() << "Failed to enable remoting for" << moduleName;
return 1;
}
}
qDebug() << "ui-host: remoting enabled for" << moduleName;
// Gap 2: scan the remote target for Q_PROPERTYs whose value is a
// QAbstractItemModel* and remote each as a child source named
// "<moduleName>/<propertyName>". The parent side acquires these via
// QRemoteObjectNode::acquireModel().
const QMetaObject* mo = remoteTarget->metaObject();
for (int i = 0; i < mo->propertyCount(); ++i) {
QMetaProperty prop = mo->property(i);
if (!prop.isReadable()) continue;
QVariant value = prop.read(remoteTarget);
auto* model = qvariant_cast<QAbstractItemModel*>(value);
if (!model) continue;
QList<int> roles = model->roleNames().keys();
QString childName = QStringLiteral("%1/%2").arg(moduleName, QString::fromUtf8(prop.name()));
if (!host.enableRemoting(model, childName, roles)) {
qWarning() << "ui-host: failed to enable remoting for model" << childName;
continue;
}
qDebug() << "ui-host: remoted model property" << prop.name()
<< "as" << childName << "with roles" << roles;
}
QTextStream out(stdout);
out << "READY" << Qt::endl;
out.flush();
const int rc = app.exec();
// Safe to run a nested event loop here: the application loop has already
// returned, so this is the same shape as logos_host's call site rather
// than a re-entrant exec(). Must come BEFORE `delete pluginObject` -- it is
// the plugin it asks, and the plugin has to still be alive to answer.
logos::runPluginAboutToUnload(pluginObject, kUnloadGraceMs);
delete pluginObject;
return rc;
}