Files
logos-basecamp/app/main.cpp
T
Dario Gabriel Lipicar 13665a5e34 feat(host-core): adopt the SDK core facade, and delete both C ABI mirrors
CoreModuleManager and main.cpp each carried a hand-written `extern "C"` mirror
of liblogos' core-management ABI -- two blocks declaring the same symbols in
one image, an ODR hazard with no diagnostic. Both are replaced by
logos::qt::QtLogosCore over logos::host::LogosCore, which owns the
char*/char** marshalling, the `delete[]`-not-`free()` rule, and the pre-start
ordering constraint -- the last as constructor arguments, so the illegal order
stops being expressible.

Four things about the stats path that fail INVISIBLY, all preserved:
  * "cpu"/"memory" stay 1-decimal STRINGS -- QML and ModuleInstanceModel bind
    those names, so the facade's cpuPercent/memoryMb spelling stops here
  * allStats() is called ONCE per tick; each moduleStats(name) repeats the
    full C call and parse, and the snapshot walks every known module
  * the three-way back-compat fallbacks are kept for older runtimes
  * memoryMb (double), never memoryBytes

The cpp-sdk lock moves for that last point: the pin predated the rename, and
that pair does not compile -- qt-sdk's header reads s.memoryMb while the
pinned cpp-sdk struct still called it memoryBytes.

Also drops a QTimer in main.cpp that was started and stopped with no
connect() at all.
2026-08-22 16:42:13 -03:00

347 lines
15 KiB
C++

#include "window.h"
#include "logos_api.h"
#include "logos_mode.h"
#include "LogosBasecampPaths.h"
#include "LogRedirector.h"
#include "AccessPolicyOption.h"
#ifdef ENABLE_QML_INSPECTOR
#include "inspectorserver.h"
#endif
#include <QAccessible>
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QEvent>
#include <QFileInfo>
#include <QIcon>
#include <QDir>
#include <QStyleHints>
#include <QStandardPaths>
#include <iostream>
#include <memory>
#include <QStringList>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QFile>
#include "logos_qt_host_core.h"
#include "logos_provider_object.h"
#include "qt_provider_object.h"
#include "BuildInfo.h"
#ifdef Q_OS_UNIX
#include <QSocketNotifier>
#include <signal.h>
#include <sys/socket.h>
#include <unistd.h>
#endif
#ifdef Q_OS_UNIX
// Self-pipe pattern for SIGTERM/SIGINT: the signal handler writes one byte
// to a socketpair; a QSocketNotifier on the main thread wakes the event loop
// and calls QApplication::quit(), which lets the orderly teardown below
// (~Window, logos_core_cleanup, log flush) run. Doing anything Qt-related
// directly from a signal handler is undefined behaviour.
static int gSignalFd[2] = {-1, -1};
static void unixSignalHandler(int)
{
char a = 1;
::write(gSignalFd[0], &a, sizeof(a));
}
static void installUnixSignalHandlers(QApplication& app)
{
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, gSignalFd) != 0) {
qWarning() << "Failed to create signal socketpair; SIGTERM/SIGINT will not trigger graceful shutdown";
return;
}
auto* notifier = new QSocketNotifier(gSignalFd[1], QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [notifier]() {
notifier->setEnabled(false);
char tmp;
::read(gSignalFd[1], &tmp, sizeof(tmp));
QApplication::quit();
});
struct sigaction sa {};
sa.sa_handler = unixSignalHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
::sigaction(SIGTERM, &sa, nullptr);
::sigaction(SIGINT, &sa, nullptr);
}
#endif
int main(int argc, char *argv[])
{
// Set logos mode to Local for testing
//LogosModeConfig::setMode(LogosMode::Local);
// Kill the per-file .qmlc disk cache under QStandardPaths::CacheLocation
// for every QQmlEngine in this process. It must be set before Qt is up:
// Qt reads the env var when the first engine is constructed, and no later.
//
// Rationale: basecamp's own QML modules and the design system are STATIC-
// embedded via qt_add_qml_module — nothing on disk to cache, so this flag
// is a no-op for them. The load-bearing effect is on plugin QML under
// Contents/plugins/<name>/qml/, which ships with nix-frozen mtimes; Qt's
// (path, mtime + content-hash) cache key can reuse stale .qmlc across app
// upgrades when a bundled plugin's Q_PROPERTY / signal signatures change
// between releases. Disabling disk cache costs ~30-100ms of QML parse on
// the first activation of each plugin per session and makes cross-version
// plugin upgrades physically immune to that class of staleness
qputenv("QML_DISABLE_DISK_CACHE", "1");
// Create QApplication first
QApplication app(argc, argv);
app.setOrganizationName("Logos");
app.setApplicationName("LogosBasecamp");
app.styleHints()->setTabFocusBehavior(Qt::TabFocusAllControls);
// Inter-module access policy, resolved from the CLI below. Empty ⇒ install
// nothing (enforcement off) — Basecamp's default, unchanged. See the
// logos_core_set_access_policy call further down.
QByteArray accessPolicyJson;
// Parse --user-dir / -u and set LOGOS_USER_DIR before anything else resolves
// a path. This lets multiple Basecamp instances run side-by-side against
// isolated data trees (plugins, modules, module_data, logs). LOGOS_USER_DIR
// overrides baseDirectory() as-is (no "Dev" suffix), so the user gets the
// exact path they asked for. parse() rather than process() so unrecognised
// flags (e.g. Qt's own -platform, -style) don't abort startup.
{
QCommandLineParser parser;
QCommandLineOption userDirOption({"u", "user-dir"},
QStringLiteral("Override the data directory (isolates plugins, "
"modules, module_data, logs for this instance)."),
QStringLiteral("path"));
parser.addOption(userDirOption);
QCommandLineOption accessPolicyOption(QStringLiteral("access-policy"),
QStringLiteral("Inter-module access policy (default: none, no "
"enforcement). 'enforce' turns on deny-by-default: a "
"module may only call the modules it declares as "
"dependencies. Also accepts a path to a JSON policy "
"file, or inline JSON."),
QStringLiteral("enforce|path|json"));
parser.addOption(accessPolicyOption);
if (!parser.parse(app.arguments())) {
std::cerr << parser.errorText().toStdString() << std::endl;
return 1;
}
// The flag wins; LOGOS_ACCESS_POLICY is the way in for a launch that
// has no argv to speak of (double-clicked bundle, desktop entry).
// Neither present ⇒ stays empty ⇒ enforcement off.
const QString accessPolicyArg = parser.isSet(accessPolicyOption)
? parser.value(accessPolicyOption)
: QString::fromUtf8(qgetenv("LOGOS_ACCESS_POLICY"));
if (!accessPolicyArg.trimmed().isEmpty()) {
const auto resolved = LogosBasecamp::resolveAccessPolicy(accessPolicyArg);
if (!resolved.ok) {
// Abort rather than boot: the operator explicitly asked to lock
// this runtime down, and starting anyway would hand them a
// wide-open one that looks like it obeyed.
std::cerr << resolved.error.toStdString() << std::endl;
return 1;
}
accessPolicyJson = resolved.policyJson.toUtf8();
}
if (parser.isSet(userDirOption)) {
const QString absUserDir =
QFileInfo(parser.value(userDirOption)).absoluteFilePath();
QFileInfo userDirInfo(absUserDir);
if (userDirInfo.exists() && !userDirInfo.isDir()) {
qCritical() << "The --user-dir path exists but is not a directory:"
<< absUserDir;
return 1;
}
if (!userDirInfo.exists() && !QDir().mkpath(absUserDir)) {
qCritical() << "Failed to create --user-dir directory:"
<< absUserDir;
return 1;
}
qputenv("LOGOS_USER_DIR", absUserDir.toUtf8());
}
}
// Redirect stdout/stderr to a rotating per-session log file under
// <baseDirectory>/logs. Must happen after setOrganizationName/setApplicationName
// and after the --user-dir override is applied so baseDirectory() resolves
// to the right location. Terminal output is preserved by mirroring to the
// original stdout.
const QString logsDir = LogosBasecampPaths::logsDirectory();
if (!LogosBasecampLog::LogRedirector::instance().start(logsDir)) {
qWarning() << "Failed to start log redirection; continuing without file logs."
<< "Logs directory:" << logsDir;
}
// Print build metadata (version, dev/portable, commit hashes) so the
// per-session log captures exactly which sources produced this binary.
LogosBasecampBuildInfo::logStartupBanner();
qInfo().noquote() << "Base data directory:" << LogosBasecampPaths::baseDirectory();
// Everything liblogos requires BEFORE start() is a constructor argument of
// the facade, so the ordering constraint below is enforced by the shape of
// the type rather than by this comment. Applied in the order set here.
logos::host::LogosCore::Config coreConfig;
// Set up module directories for logos core.
// 1. Embedded modules directory (pre-installed at build time, read-only)
QString embeddedModulesDir = QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../modules");
coreConfig.modulesDirs.push_back(embeddedModulesDir.toStdString());
// 2. User-writable modules directory (for runtime installs via the package store)
QString userModulesDir = LogosBasecampPaths::modulesDirectory();
coreConfig.modulesDirs.push_back(userModulesDir.toStdString());
// Set persistence base path for core modules
coreConfig.persistenceBasePath =
LogosBasecampPaths::moduleDataDirectory().toStdString();
// Inter-module access policy. DEFAULT: none — passing NULL clears any
// policy so no enforcement runs, and any loaded module may call any other.
//
// Why 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 are
// loaded out-of-process and aren't tracked as dependents in the core
// ModuleRegistry — so they're never in a module's derived allowed-caller
// set and get denied (e.g. accounts_ui -> accounts_module). Until the
// derivation accounts for ui_qml callers, turning this on by default would
// break the app.
//
// Operators can still opt IN per launch with `--access-policy enforce`
// (or LOGOS_ACCESS_POLICY), and name the ui_qml callers explicitly via a
// policy document's `restrictions` — see the README.
// (Must be set before logos_core_start() — see the Config note above.)
if (accessPolicyJson.isEmpty()) {
// nullopt, NOT an empty string. The old call passed NULL explicitly,
// which liblogos turns into "clear the policy"; at startup there is no
// policy to clear, so declining to set one is the same thing and says
// what is meant. An empty std::string would take the clearing path.
coreConfig.accessPolicyJson = std::nullopt;
} else {
qInfo().noquote() << "Installing inter-module access policy:" << accessPolicyJson;
coreConfig.accessPolicyJson = std::string(accessPolicyJson.constData(),
accessPolicyJson.size());
}
// Heap-allocated deliberately. ~LogosCore is what calls
// logos_core_cleanup(), and it has to run at the explicit reset() during
// teardown below. A stack object declared here would instead run at the
// closing brace of main() — silently moving cleanup to AFTER ~LogosAPI,
// which is destroyed on the same unwind.
auto core = std::make_unique<logos::qt::QtLogosCore>(argc, argv, std::move(coreConfig));
// Start the core
core->start();
std::cout << "Logos Core started successfully!" << std::endl;
bool loaded = core->loadModule(QStringLiteral("package_manager"));
if (loaded) {
qInfo() << "package_manager module loaded by default.";
} else {
qWarning() << "Failed to load package_manager module by default.";
}
bool downloaderLoaded = core->loadModule(QStringLiteral("package_downloader"));
if (downloaderLoaded) {
qInfo() << "package_downloader module loaded by default.";
} else {
qWarning() << "Failed to load package_downloader module by default.";
}
// Log the initial loaded-module list.
const QStringList modules = core->loadedModules();
if (modules.isEmpty()) {
qInfo() << "No modules loaded.";
} else {
qInfo() << "Currently loaded modules:";
for (const QString& name : modules) {
qInfo() << " -" << name;
}
qInfo() << "Total modules:" << modules.size();
}
LogosAPI logosAPI("core", nullptr);
// Set application icon.
#ifdef Q_OS_LINUX
// setDesktopFileName is required for Wayland compositors, which look up the
// icon via the .desktop file name rather than honouring setWindowIcon().
app.setDesktopFileName("logos-basecamp");
#endif
app.setWindowIcon(QIcon(":/icons/logos.png"));
// Don't quit when last window is closed (for system tray support)
app.setQuitOnLastWindowClosed(false);
#ifdef Q_OS_UNIX
installUnixSignalHandlers(app);
#endif
// Create and show the main window. Heap-allocated so we can control
// destruction ordering explicitly during shutdown (see below).
auto mainWindow = std::make_unique<Window>(&logosAPI, core.get());
mainWindow->show();
#ifdef ENABLE_QML_INSPECTOR
// Start QML Inspector server (controlled by QML_INSPECTOR_PORT env var, default 3768)
InspectorServer::attach(mainWindow.get());
#endif
// Run the application
int result = app.exec();
// Graceful teardown of the UI before QApplication is destroyed.
//
// On macOS, tearing down a QQuickWidget hierarchy crashes inside
// QCocoaAccessibility::notifyAccessibilityUpdate: QQuickItem destructors
// call setParentItem(nullptr) which triggers setEffectiveVisibleRecur(false),
// which notifies the accessibility bridge about items whose backing
// QObjects are already half-destroyed (null d_ptr → SIGSEGV).
//
// Hiding the window alone is insufficient — ~QQuickItem() unconditionally
// calls setParentItem(nullptr), bypassing the widget visibility state.
// The fix is to install a no-op accessibility update handler before
// destroying the widget hierarchy, so the platform bridge is never invoked
// on partially-destroyed objects.
if (mainWindow) {
mainWindow->hide();
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QCoreApplication::processEvents();
// Suppress accessibility notifications during destruction and the
// subsequent deferred-delete drain. QQuickItem::~QQuickItem() →
// setParentItem(nullptr) → setEffectiveVisibleRecur →
// notifyAccessibilityUpdate will hit this no-op instead of the
// Cocoa bridge. The handler stays suppressed through processEvents()
// because deleteLater() work queued during destruction can also
// trigger the same crash path.
auto previousHandler = QAccessible::installUpdateHandler(
[](QAccessibleEvent*) {});
mainWindow.reset();
// Drain remaining deferred work while the no-op handler is still active.
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QCoreApplication::processEvents();
// Restore the original handler now that all deferred work is done.
QAccessible::installUpdateHandler(previousHandler);
}
// Cleanup logos core (plugins, modules, etc.). ~QtLogosCore calls
// logos_core_cleanup(); this reset() is what pins it to exactly here,
// before logosAPI is destroyed on the stack unwind.
core.reset();
// Flush final output, restore original stdout/stderr, and close the log file.
LogosBasecampLog::LogRedirector::instance().stop();
return result;
}