feat(shell): ship main_ui as a plugin that links no logos runtime

Measured on the built artefact: main_ui.dylib DEFINES zero and IMPORTS zero of
TokenManager, StoreRegistry, LogosAPI, LogosAPIClient and logos_core_*, out of
3410 symbols read. It links Qt and nothing else from this workspace, which
nix/symbol-gate.nix enforces across the in-process image set.

Getting there needed the last non-Qt types off the boundary: the model
properties cross as QAbstractItemModel*, catalogInstallStageChanged carries an
int rather than InstallStage::Value, and the two prebuilt AppsFilterProxy
instances are declared in QML instead of owned by MainUIBackend. That last one
removes a real inversion -- PackageCoordinator called setRequiredPackages() on
a proxy the host held a pointer to; it now emits requiredPackagesResolved() and
QML binds to the republished property.

Window resolves the plugin, qobject_casts it to IShellView, checks
hostAbiVersion() against IShellHost_abi, and calls createShell(IShellHost*).
No error-label fallback widget: that degraded to something that looked like a
working app with an empty window.

Filter proxies read role constants off host-side models and InstallEnums is
used by nine host files, so app/interfaces/ gains the contract headers both
sides compile against -- the models inherit the role structs, leaving every
AppsModel::NameRole call site unchanged. The plugin's include path is
app/interfaces only, so including a host header does not compile.

Four things only running it finds:
  * Logos::DesignSystem may be linked by exactly ONE image -- both linked it
    and the app aborted with "Cannot add multiple registrations for
    Logos.Icons"; QML module registration is process-global
  * qmltyperegistrar emits no #include for a SOURCES header given as an
    absolute path outside the project
  * each qt_add_qml_module is its own target and inherits no include dirs
  * AUTOMOC pairs header<->cpp by same-basename-same-DIRECTORY, which the
    split breaks

tst_AppManagerView.qml grows five tests for the QML binding, checked with a
negative control: breaking one assertion fails qml-tests, so they run.
This commit is contained in:
Dario Gabriel Lipicar
2026-08-22 16:42:13 -03:00
committed by Dario Lipicar
parent 9c3d023062
commit 9b8cf6e47e
85 changed files with 1039 additions and 706 deletions
+11 -11
View File
@@ -12,18 +12,18 @@ nix build
nix build && ./result/bin/LogosBasecamp
# Iterate on QML without rebuilding — relaunch to pick up edits.
DEV_QML_PATH=$PWD/app nix build && DEV_QML_PATH=$PWD/app ./result/bin/LogosBasecamp
DEV_QML_PATH=$PWD/src nix build && DEV_QML_PATH=$PWD/src ./result/bin/LogosBasecamp
```
QML lives in feature-axis qt_add_qml_module modules (Basecamp.Sidebar,
.AppManager, .Settings, .Shell, plus .Backend for C++ types) — bytecode is
embedded in the LogosBasecamp binary. No runtime QML disk cache, so the qrc-keyed cache
embedded in the main_ui plugin. No runtime QML disk cache, so the qrc-keyed cache
staleness bug doesn't apply.
### `DEV_QML_PATH` — iterate on view layouts without rebuilding
Point `DEV_QML_PATH` at a directory whose layout mirrors the QML URI hierarchy
(typically `<repo>/app`, which contains `Basecamp/Sidebar/`,
(typically `<repo>/src`, which contains `Basecamp/Sidebar/`,
`Basecamp/Shell/`, etc.). MainContainer's three view-entry `setSource` calls
will read from `$DEV_QML_PATH/Basecamp/<Feature>/<Entry>.qml` instead of the
embedded qrc resource. Relaunch the app to pick up edits.
@@ -66,7 +66,7 @@ nix build .#integration-test -L
- **Sidebar** (left): Contains app plugin icons (top/middle) and system buttons at the bottom (Dashboard, Modules, Settings)
- **Plugins** appear as sidebar icons: `package_manager_ui`
- Plugins are loaded from `~/Library/Application Support/Logos/LogosBasecampDev/plugins/`
- Main UI is in `app/Basecamp/`, organised by feature: `Sidebar/`, `AppManager/`, `Settings/`, `Shell/`, `Icons/`
- Main UI is in `src/Basecamp/` (the main_ui plugin), organised by feature: `Sidebar/`, `AppManager/`, `Settings/`, `Shell/`, `Icons/`
## C++ Architecture
@@ -114,12 +114,12 @@ CoreModuleManager is constructed first, UIPluginManager second (receives CoreMod
| File | Purpose |
|------|---------|
| `app/Basecamp/Shell/OverlayDialogs.qml` | Global dialog layer (missing deps, cascade confirm, install gate, install failure) — hosted in a transparent top-level QQuickWidget |
| `app/Basecamp/Shell/ConfirmationDialog.qml` | Multi-mode dialog: `missingDeps`, `unloadCascade`, `upgradeCascade`, `installGate`, `installError` — uninstall confirmation lives in `UninstallDialog.qml` |
| `app/Basecamp/Sidebar/SidebarPanel.qml` | App icons + system nav buttons |
| `app/Basecamp/Settings/AppsInspectorView.qml` | Apps Inspector (UI plugins) — view-only, load/unload; uninstall lives in PMUI |
| `app/Basecamp/Settings/ModuleInspectorView.qml` | Module Inspector (core modules) — view-only, load/unload + stats; uninstall lives in PMUI |
| `app/Basecamp/Shell/ContentViews.qml` | StackLayout switching between Dashboard, Repositories, Apps/Module Inspector |
| `src/Basecamp/Shell/OverlayDialogs.qml` | Global dialog layer (missing deps, cascade confirm, install gate, install failure) — hosted in a transparent top-level QQuickWidget |
| `src/Basecamp/Shell/ConfirmationDialog.qml` | Multi-mode dialog: `missingDeps`, `unloadCascade`, `upgradeCascade`, `installGate`, `installError` — uninstall confirmation lives in `UninstallDialog.qml` |
| `src/Basecamp/Sidebar/SidebarPanel.qml` | App icons + system nav buttons |
| `src/Basecamp/Settings/AppsInspectorView.qml` | Apps Inspector (UI plugins) — view-only, load/unload; uninstall lives in PMUI |
| `src/Basecamp/Settings/ModuleInspectorView.qml` | Module Inspector (core modules) — view-only, load/unload + stats; uninstall lives in PMUI |
| `src/Basecamp/Shell/ContentViews.qml` | StackLayout switching between Dashboard, Repositories, Apps/Module Inspector |
## QML Inspector (MCP)
@@ -139,7 +139,7 @@ The app runs an inspector server (default: localhost:3768) that the `qml-inspect
## Key Directories
- `app/Basecamp/` - QML UI source files, organised by feature (Sidebar/AppManager/Settings/Shell/Icons)
- `src/Basecamp/` - QML UI source files, organised by feature (Sidebar/AppManager/Settings/Shell/Icons)
- `nix/` - Nix build configurations (app.nix, smoke-test.nix, integration-test.nix)
- `logos-qt-mcp` - QML Inspector: MCP server, test framework, Qt plugin (separate repo, flake input)
- `tests/` - UI integration tests
+1 -1
View File
@@ -87,7 +87,7 @@ Releases are cut from `release/**` branches by the maintainers — see [`docs/RE
## Style and conventions
- **C++**: C++17. No formatter enforced yet (`.clang-format` planned) — match the style of files you touch.
- **QML**: Feature-axis `qt_add_qml_module` modules under `app/Basecamp/<Feature>/`. Follow the pattern: view files under a feature emit signals; backend calls belong in `Basecamp/Shell/ContentViews.qml`. See `CLAUDE.md` for the C++ backend split (`MainUIBackend` / `CoreModuleManager` / `UIPluginManager` / `PackageCoordinator`) — respect the dependency direction.
- **QML**: Feature-axis `qt_add_qml_module` modules under `src/Basecamp/<Feature>/`. Follow the pattern: view files under a feature emit signals; backend calls belong in `Basecamp/Shell/ContentViews.qml`. See `CLAUDE.md` for the C++ backend split (`MainUIBackend` / `CoreModuleManager` / `UIPluginManager` / `PackageCoordinator`) — respect the dependency direction.
- **Nix**: modular files under `nix/`. `flake.nix` inputs follow `logos-cpp-sdk`'s `nixpkgs` — never pin a separate one.
## Licensing
+2 -1
View File
@@ -144,7 +144,8 @@ nix build --extra-experimental-features 'nix-command flakes'
The nix build system is organized into modular files in the `/nix` directory:
- `nix/default.nix` - Common configuration shared by every derivation
- `nix/app.nix` - The application build (the UI shell compiles into it)
- `nix/app.nix` - The application build
- `nix/main-ui.nix` - The `main_ui` UI shell plugin
## Modules
+2 -33
View File
@@ -1,5 +1,6 @@
#pragma once
#include "BasecampModelRoles.h"
#include "InstallEnums.h"
#include <QAbstractListModel>
@@ -13,42 +14,10 @@ class InstallRegistry;
// AppsModel — the single source of truth for every package the App Manager
// (and Modules tab) cares about.
class AppsModel : public QAbstractListModel {
class AppsModel : public QAbstractListModel, public AppsModelRoles {
Q_OBJECT
Q_PROPERTY(QStringList categories READ categories NOTIFY categoriesChanged)
public:
enum Roles {
NameRole = Qt::UserRole + 1,
RepositoryUrlRole,
DisplayNameRole,
DescriptionRole,
CategoryRole,
TypeRole, // "ui_qml" | "core"
IconUrlRole, // resolved icon URL; "" → monogram fallback
SupportsFullBleedIconRole, // manifest >= 0.4.0: icon is a validated
// 256x256 asset, safe to render edge-to-edge
VersionsRole, // QVariantList — all known catalog versions
DependenciesRole, // QVariantList — direct deps of versions[0]'s manifest,
// normalized to [{name, version}, ...]. version "" = no constraint.
InstalledVersionRole, // "" when not installed
LatestVersionRole, // versions[0].version
HasUpdateRole, // installedVersion != "" && installed != latest
IsInstalledRole, // installedVersion != ""
MissingDepsRole, // QStringList of dep names whose LGX isn't on
// disk (sourced from
// PackageCoordinator::m_missingDepsByModule).
// Empty when the install is complete.
InstallStatusRole, // InstallStatus enum — per-row catalog-vs-disk
// state (Install/Launch/Upgrade/Downgrade/
// Reinstall in QML terms). PMUI mirror.
InstallTypeRole, // "embedded" | "user" | ""
ActionRole,
ToVersionRole,
IsTopLevelRole,
ResolverErrorRole,
InstallStageRole, // InstallStage::Value (int) — see InstallEnums.h
InstallErrorRole, // failure message when InstallStage == Failed
};
Q_ENUM(Roles)
explicit AppsModel(QObject* parent = nullptr);
+26 -183
View File
@@ -9,7 +9,7 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # Enable Qt's Meta-Object Compiler
set(CMAKE_AUTORCC ON) # Enable automatic compilation of resource files
set(CMAKE_AUTOUIC ON) # carried over from the folded main_ui plugin
set(CMAKE_AUTOUIC ON) # Enable Qt's UI compiler
set(CMAKE_INCLUDE_CURRENT_DIR ON)
# RPATH settings for macOS
@@ -57,9 +57,8 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/macos)
# semver headers and logos-cpp-generator's --general-only output. Absent in
# non-nix builds — BuildInfo.h guards its include with __has_include.
#
# Deliberately a single directory. The plugin build had its own
# src/generated_code alongside this one, so once both targets are the same
# target BuildInfo.h's __has_include would be decided by -I ORDER, silently.
# Keep it a SINGLE directory: a second generated dir on the include path would
# make that __has_include depend on -I ORDER, silently.
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/generated)
# Allow override from environment or command line (for nix builds)
@@ -156,9 +155,7 @@ else()
endif()
# ── logos-view-module-runtime (provides ViewModuleHost.h + the ui-host lib) ───
# Folded in from the former src/CMakeLists.txt. -DLOGOS_VIEW_MODULE_RUNTIME_ROOT
# was already being passed to this target by nix/app.nix and was a NO-OP; it is
# load-bearing now that UIPluginManager/PluginLoader compile into the exe.
# Load-bearing: UIPluginManager/PluginLoader compile into the exe.
if(NOT DEFINED LOGOS_VIEW_MODULE_RUNTIME_ROOT)
set(_candidate "${REAL_SOURCE_DIR}/../../logos-view-module-runtime")
if(EXISTS "${_candidate}/include/ViewModuleHost.h")
@@ -244,20 +241,14 @@ set(PROJECT_SOURCES
interfaces/IComponent.h
interfaces/IShellHost.h
interfaces/IShellView.h
interfaces/InstallEnums.h
interfaces/BasecampModelRoles.h
resources.qrc
# The host side of the shell boundary. ShellHostAdapter implements
# IShellHost over MainUIBackend; MainShellView is the IShellView
# implementation that will move into the main_ui plugin when the shell is
# split back out.
# The host side of the shell boundary. The shell itself lives in ../src and
# builds as the main_ui plugin; this image never compiles a line of it.
ShellHostAdapter.cpp
MainShellView.cpp
# The UI shell, still compiled in. Everything from MainContainer down
# reaches the host only through IShellHost — no LogosAPI, no QtLogosCore,
# no MainUIBackend — which is what makes splitting it back into a plugin a
# packaging change rather than a privilege change.
MainContainer.cpp
MainUIBackend.cpp
CoreModuleManager.cpp
UIPluginManager.cpp
@@ -267,8 +258,6 @@ set(PROJECT_SOURCES
AppsModel.cpp
ModuleInstanceModel.cpp
PluginLoader.cpp
WorkspaceArea.cpp
ShortcutBridge.cpp
restricted/DenyAllReply.cpp
restricted/DenyAllNetworkAccessManager.cpp
restricted/DenyAllNAMFactory.cpp
@@ -297,125 +286,9 @@ qt_add_executable(LogosBasecamp
${PROJECT_SOURCES}
)
# ── QML modules (folded in verbatim from the former src/CMakeLists.txt) ──────
# These paths are relative to CMAKE_CURRENT_SOURCE_DIR, which is why Basecamp/
# was moved PHYSICALLY into app/ rather than referenced as ../src/Basecamp:
# qt_add_qml_module's QML_FILES/RESOURCES are resolved against this directory
# and a `../` prefix changes where each file lands in the resource tree.
set_source_files_properties(Basecamp/Icons/BasecampIcons.qml
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
set_source_files_properties(Basecamp/AppManager/AppColors.qml
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(basecamp_backend_qml
URI Basecamp.Backend
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Backend
SOURCES
InstallEnums.h
InstallEnums.cpp
AppsFilterProxy.h
AppsFilterProxy.cpp
ModulesFilterProxy.h
ModulesFilterProxy.cpp
)
qt_add_qml_module(basecamp_common_qml
URI Basecamp.Common
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Common
QML_FILES
Basecamp/Common/LoadingOverlay.qml
Basecamp/Common/EmptyView.qml
)
qt_add_qml_module(basecamp_sidebar_qml
URI Basecamp.Sidebar
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Sidebar
QML_FILES
Basecamp/Sidebar/SidebarPanel.qml
Basecamp/Sidebar/SidebarIconButton.qml
Basecamp/Sidebar/SidebarCircleButton.qml
Basecamp/Sidebar/SidebarAppDelegate.qml
)
qt_add_qml_module(basecamp_appmanager_qml
URI Basecamp.AppManager
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/AppManager
QML_FILES
Basecamp/AppManager/AppManagerView.qml
Basecamp/AppManager/AppManagerPanelHeader.qml
Basecamp/AppManager/AppRepoSection.qml
Basecamp/AppManager/AppContextMenu.qml
Basecamp/AppManager/AppGrid.qml
Basecamp/AppManager/AppGridDelegate.qml
Basecamp/AppManager/AppListDelegate.qml
Basecamp/AppManager/PackageRowDelegate.qml
Basecamp/AppManager/AddApplicationDialog.qml
Basecamp/AppManager/AppColors.qml
)
qt_add_qml_module(basecamp_settings_qml
URI Basecamp.Settings
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Settings
QML_FILES
Basecamp/Settings/SettingsView.qml
Basecamp/Settings/DashboardView.qml
Basecamp/Settings/AppsInspectorView.qml
Basecamp/Settings/ModuleInspectorView.qml
Basecamp/Settings/InspectorPanelHeader.qml
Basecamp/Settings/ModuleStatusBadge.qml
Basecamp/Settings/ModuleRowActions.qml
Basecamp/Settings/PluginInterfaceView.qml
Basecamp/Settings/RepositoriesView.qml
)
qt_add_qml_module(basecamp_shell_qml
URI Basecamp.Shell
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Shell
QML_FILES
Basecamp/Shell/ContentViews.qml
Basecamp/Shell/OverlayDialogs.qml
Basecamp/Shell/ConfirmationDialog.qml
Basecamp/Shell/UninstallDialog.qml
Basecamp/Shell/WelcomePage.qml
)
qt_add_qml_module(basecamp_icons_qml
URI Basecamp.Icons
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Icons
QML_FILES
Basecamp/Icons/BasecampIcons.qml
RESOURCES
Basecamp/Icons/basecamp.svg
Basecamp/Icons/dashboard.svg
Basecamp/Icons/module.svg
Basecamp/Icons/settings.svg
Basecamp/Icons/tent.png
Basecamp/Icons/workspace.svg
)
qt_import_qml_plugins(LogosBasecamp)
# The UI shell's seven qt_add_qml_module targets live in ../src. They are STATIC
# modules registering their types from a static initializer, so they link into
# the main_ui plugin — the image that loads the QML — not this executable.
target_include_directories(LogosBasecamp PRIVATE
${LOGOS_VIEW_MODULE_RUNTIME_ROOT}/include
${GENERATED_LOGOS_SDK_DIR}
@@ -445,14 +318,13 @@ endif()
# logos-qt-sdk and the host split moved to logos-plugin-qt; it carries the
# logos-protocol library transitively.
#
# Qt6::Network is named EXPLICITLY. find_package() has always listed it, but
# nothing linked it: it arrived transitively through QuickWidgets/Quick, and
# restricted/DenyAllNetworkAccessManager.cpp subclasses QNetworkAccessManager.
# Qt6::Network is named EXPLICITLY rather than left to arrive transitively
# through Quick/QuickWidgets: restricted/DenyAllNetworkAccessManager.cpp
# subclasses QNetworkAccessManager.
#
# The seven $<LINK_LIBRARY:WHOLE_ARCHIVE,...qmlplugin> entries are load-bearing
# and must not be trimmed: a STATIC qt_add_qml_module registers its types from a
# static initializer inside the *_qmlplugin archive, so dropping one lets the
# linker garbage-collect it — no build error, blank pane at runtime.
# This executable owns no QML. Qt6::Quick / Qml / QuickWidgets / QuickControls2
# are linked for the out-of-process UI plugins, and so the portable bundler
# picks them up.
target_link_libraries(LogosBasecamp PRIVATE
Qt6::Widgets
Qt6::RemoteObjects
@@ -463,45 +335,16 @@ target_link_libraries(LogosBasecamp PRIVATE
Qt6::Network
logos-qt-host::logos_qt_host_shared
${LOGOS_VIEW_MODULE_RUNTIME_LIB}
Logos::DesignSystem
basecamp_backend_qml
basecamp_common_qml
basecamp_sidebar_qml
basecamp_appmanager_qml
basecamp_settings_qml
basecamp_shell_qml
basecamp_icons_qml
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_backend_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_common_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_sidebar_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_appmanager_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_settings_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_shell_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_icons_qmlplugin>
$<$<BOOL:${ENABLE_QML_INSPECTOR}>:qml-inspector>
# logos_core is LAST, and that position is load-bearing on Windows.
#
# On Windows liblogos_core.dll is the single provider of the shared C++
# runtime, so the static archives that define those types are emptied out
# (LogosSharedFromDll.cmake) and every reference to them has to be satisfied
# by liblogos_core's IMPORT LIBRARY. GNU ld resolves an archive left to
# right: it only takes members that satisfy references that are already
# undefined when it reaches them. With logos_core listed before
# LOGOS_VIEW_MODULE_RUNTIME_LIB, the view runtime's references to
# LogosAPIClient::whenObjectAvailable / ::eventSubscriptionState /
# ::pendingEventSubscriptions and logos::qvariantToNlohmann came up AFTER
# the import library had already been passed, and the mingw link failed with
# four undefined references — even though liblogos_core.dll exports all four
# (measured: they are in its export table and its .dll.a).
#
# This did not bite before the main_ui fold only because of where the two
# sat: the plugin's CMakeLists linked LOGOS_VIEW_MODULE_RUNTIME_LIB in its
# main target_link_libraries() and logos_core in a separate, LATER call, so
# the generated order was already correct. Folding the UI shell into this
# executable brought the view runtime onto a link line whose logos_core came
# eighth. Nothing else on this list defines symbols logos_core needs — it is
# an import library for a DLL — so last is both correct and the position
# that cannot be broken by adding another archive above it.
# logos_core is LAST, and that position is load-bearing on Windows: it is the
# IMPORT LIBRARY for liblogos_core.dll, the single provider of the shared C++
# runtime (LogosSharedFromDll.cmake empties the static archives defining those
# types). GNU ld takes only archive members satisfying references already
# undefined when it reaches them, so placing it ahead of
# LOGOS_VIEW_MODULE_RUNTIME_LIB fails the mingw link with four undefined
# references — LogosAPIClient::whenObjectAvailable / ::eventSubscriptionState
# / ::pendingEventSubscriptions and logos::qvariantToNlohmann — despite all
# four being in liblogos_core.dll's export table and its .dll.a.
logos_core
)
+34 -44
View File
@@ -9,73 +9,64 @@
class QTimer;
// Forward-declared rather than included: logos_qt_host_core.h pulls in
// nlohmann/json, and this header is included by MainUIBackend, UIPluginManager,
// PackageCoordinator and PluginLoader, none of which touch the facade.
// Forward-declared, not included: logos_qt_host_core.h pulls in nlohmann/json,
// and MainUIBackend, UIPluginManager, PackageCoordinator and PluginLoader all
// include this header without touching the facade.
namespace logos { namespace qt { class QtLogosCore; } }
// CoreModuleManager — the app's module-management surface, over the SDK facade.
// CoreModuleManager — the app's module-management surface over the SDK facade.
//
// Every call into liblogos (known/loaded module lists, load/unload, cascade
// unload, stats) funnels through this class. Everything else in the app
// (UIPluginManager, PackageManager, MainUIBackend) uses these thin wrappers and never touches
// the C API directly.
// unload, stats) funnels through this class; UIPluginManager, PackageManager
// and MainUIBackend never touch the C API directly.
//
// The char*/char** marshalling and the `delete[]`-not-`free()` ownership rule
// used to live here as a hand-written `extern "C"` mirror of the C ABI. They
// now live once, in logos-qt-sdk's `logos::qt::QtLogosCore`
// (`logos_qt_host_core.h`) over logos-cpp-sdk's `logos::host::LogosCore`, and
// this class holds one rather than declaring the ABI a second time. The
// second mirror was in main.cpp; two blocks declaring the same symbols in one
// image is an ODR hazard with no diagnostic.
// live once, in logos-qt-sdk's `logos::qt::QtLogosCore`
// (`logos_qt_host_core.h`) over logos-cpp-sdk's `logos::host::LogosCore`.
// Declaring that ABI a second time in the same image is an ODR hazard with no
// diagnostic.
//
// This class deliberately keeps the parts the SDK facade has no basis to
// decide: the poll interval, which thread the timer lives on, when
// "the module set changed" is announced, and the QML-facing key names.
//
// Also owns the periodic stats poller — a single 2s QTimer that asks
// liblogos for per-module CPU/memory and emits coreModulesChanged() so the
// Modules tab re-reads via Q_PROPERTY.
// What stays here is what the facade has no basis to decide: the poll interval,
// which thread the timer lives on, when "the module set changed" is announced,
// and the QML-facing key names. The poller is a single 2s QTimer that reads
// per-module CPU/memory and emits coreModulesChanged() so the Modules tab
// re-reads via Q_PROPERTY.
class CoreModuleManager : public QObject {
Q_OBJECT
public:
// `core` is the process-wide core facade, owned by main() and outliving
// this object. Must not be null.
// `core` is the process-wide facade, owned by main() and outliving this
// object. Must not be null.
explicit CoreModuleManager(LogosAPI* logosAPI,
logos::qt::QtLogosCore* core,
QObject* parent = nullptr);
~CoreModuleManager() override;
// Thin wrappers — each is a one-liner over the corresponding QtLogosCore
// call. Callers get cooked Qt types; they never see a raw C string.
// Thin wrappers over QtLogosCore. Callers never see a raw C string.
QStringList knownModules() const;
QStringList loadedModules() const;
// Returns true on success. Loads with dependencies — the facade call
// that also resolves forward deps before loading.
// Loads with forward dependencies resolved. Returns true on success.
bool loadModule(const QString& name);
// Returns true on success. This does NOT cascade. Caller is responsible
// for cascade semantics (see unloadModuleWithDependents).
// Returns true on success. Does NOT cascade — see
// unloadModuleWithDependents.
bool unloadModule(const QString& name);
// Cascade variant — tears down `name` and all currently-loaded modules
// that depend on it, leaves-first. Returns true on full success; false
// if any individual unload step failed (the cascade may have made
// progress — callers should still refresh their UI state).
// Tears down `name` and every currently-loaded module that depends on it,
// leaves-first. False if any step failed — the cascade may still have made
// progress, so callers should refresh their UI state.
bool unloadModuleWithDependents(const QString& name);
// Cached stats as of the last timer tick (may be up to ~2s stale). Empty
// entries for modules the poller hasn't seen yet. QML renders "0.0" via
// the caller's compose layer when absent — we return the raw map here.
// Cached as of the last timer tick, so up to ~2s stale; empty for modules
// the poller hasn't seen yet.
QVariantMap moduleStats(const QString& name) const;
// Re-scan every plugin directory via the lib, then emit
// coreModulesChanged(). Used by the Modules tab's Reload button and by
// PackageManager after install/uninstall events reshape the known set.
// Re-scans every plugin directory, then emits coreModulesChanged(). Called
// by the Modules tab's Reload button and after install/uninstall reshapes
// the known set.
Q_INVOKABLE void refresh();
// Introspection serialised to JSON for QML. getMethods/getEvents return
// "[]" and callMethod returns error JSON on failure rather than throwing.
// Module not being connected is a normal transient state, not an error.
// Introspection, serialised to JSON for QML. On failure getMethods/
// getEvents return "[]" and callMethod returns error JSON; a disconnected
// module is a normal transient state, not an error.
Q_INVOKABLE QString getMethods(const QString& moduleName);
Q_INVOKABLE QString getEvents(const QString& moduleName);
Q_INVOKABLE QString callMethod(const QString& moduleName,
@@ -83,9 +74,8 @@ public:
const QString& argsJson);
signals:
// Emitted by refresh() and after every stats-timer tick. MainUIBackend
// forwards this into its own signal of the same name via a signal-to-
// signal connect — QML binds to that forwarder.
// Emitted by refresh() and after every stats tick. MainUIBackend forwards
// it into its own same-named signal, which is what QML binds to.
void coreModulesChanged();
private slots:
+21 -13
View File
@@ -1,5 +1,4 @@
#include "MainUIBackend.h"
#include "AppsFilterProxy.h"
#include "AppsModel.h"
#include "CoreModuleManager.h"
#include "ModuleInstanceModel.h"
@@ -37,20 +36,9 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
m_appsModel = new AppsModel(this);
m_uiAppsProxy = new AppsFilterProxy(this);
m_uiAppsProxy->setSourceModel(m_appsModel);
m_uiAppsProxy->setTypeFilter(QStringLiteral("ui_qml"));
m_uiAppsProxy->setExcludeMainUi(true);
m_requiredPackagesModel = new AppsFilterProxy(this);
m_requiredPackagesModel->setSourceModel(m_appsModel);
m_requiredPackagesModel->setExcludeMainUi(false);
m_requiredPackagesModel->setInstallStateFilter(QString());
m_coreModuleManager = new CoreModuleManager(m_logosAPI, m_core, this);
m_uiPluginManager = new UIPluginManager(m_logosAPI, m_coreModuleManager, this);
m_packageCoordinator = new PackageCoordinator(m_logosAPI, m_coreModuleManager, m_uiPluginManager, m_appsModel, this);
m_packageCoordinator->setRequiredPackagesModel(m_requiredPackagesModel);
m_appsModel->setInstallRegistry(m_packageCoordinator->installRegistry());
// Setter-injection closes the cycle — UIPluginManager queries
@@ -125,8 +113,23 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
this, &MainUIBackend::addApplicationDataUpdated);
connect(m_packageCoordinator, &PackageCoordinator::launchAppRequested,
this, &MainUIBackend::launchAppRequested);
// The resolver's required packages are cached here and published as a
// property; QML binds a shell-declared AppsFilterProxy to it. Nothing on
// this side holds a pointer to that proxy.
connect(m_packageCoordinator, &PackageCoordinator::requiredPackagesResolved,
this, [this](const QVariantList& entries) {
if (m_requiredPackages == entries) return;
m_requiredPackages = entries;
emit requiredPackagesChanged();
});
// Widened to int at this hop rather than connected signal-to-signal, so
// the conversion is written down instead of relying on the implicit
// enum-to-int the connect check would otherwise permit silently.
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallStageChanged,
this, &MainUIBackend::catalogInstallStageChanged);
this, [this](const QString& name, InstallStage::Value stage) {
emit catalogInstallStageChanged(name, static_cast<int>(stage));
});
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallFinished,
this, &MainUIBackend::catalogInstallFinished);
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallFailed,
@@ -166,6 +169,11 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
MainUIBackend::~MainUIBackend() = default;
QAbstractItemModel* MainUIBackend::appsModel() const
{
return m_appsModel;
}
void MainUIBackend::beginShutdown()
{
if (m_uiPluginManager) {
+34 -20
View File
@@ -1,9 +1,9 @@
#pragma once
#include "InstallEnums.h"
#include "AppsFilterProxy.h"
#include "ModuleInstanceModel.h"
#include <QAbstractItemModel>
#include <QObject>
#include <QVariantList>
#include <QVariantMap>
@@ -63,20 +63,24 @@ class MainUIBackend : public QObject {
// ModulesFilterProxy. The QVariantList uiModules()/coreModules() shape
// used to be a Q_PROPERTY too; it now lives on this class only as a
// private helper feeding the models on each *Changed tick.
Q_PROPERTY(ModuleInstanceModel* uiModulesModel READ uiModulesModel CONSTANT)
Q_PROPERTY(ModuleInstanceModel* coreModulesModel READ coreModulesModel CONSTANT)
//
// Declared as QAbstractItemModel*, not ModuleInstanceModel*. The shell's
// QML only ever uses them as models, and typing them as the Qt base is
// what keeps the concrete class from having to be nameable — and
// therefore present — on the shell's side of the boundary.
Q_PROPERTY(QAbstractItemModel* uiModulesModel READ uiModulesModel CONSTANT)
Q_PROPERTY(QAbstractItemModel* coreModulesModel READ coreModulesModel CONSTANT)
// AppsModel — the single source of truth for catalog rows + installed
// state + live install pipeline. QML views bind directly. Lifetime is
// tied to MainUIBackend; the pointer is stable across the app's life.
Q_PROPERTY(AppsModel* appsModel READ appsModel CONSTANT)
// Prebuilt filter proxy over appsModel, pre-configured with
// type="ui_qml" and excludeMainUi=true
Q_PROPERTY(AppsFilterProxy* uiAppsProxy READ uiAppsProxy CONSTANT)
// Prebuilt filter proxy used exclusively by AddApplicationDialog's
// "Required Packages" list. PackageCoordinator sets its
// requiredPackages per resolver call so the ListView shows only the
// resolver's tree in install order.
Q_PROPERTY(AppsFilterProxy* requiredPackagesModel READ requiredPackagesModel CONSTANT)
// Same reasoning as the two above: crosses as QAbstractItemModel*.
Q_PROPERTY(QAbstractItemModel* appsModel READ appsModel CONSTANT)
// The resolver's required-package entries, in install order. QML binds
// them into a shell-declared AppsFilterProxy as a filter input
// (requiredPackageEntries) for AddApplicationDialog's "Required Packages"
// list. Only the data crosses: a proxy is view configuration, and the host
// must not own — or be able to name — a shell-side type.
Q_PROPERTY(QVariantList requiredPackages READ requiredPackages NOTIFY requiredPackagesChanged)
// App Launcher
Q_PROPERTY(QVariantList launcherApps READ launcherApps NOTIFY launcherAppsChanged)
@@ -144,11 +148,16 @@ public:
CoreModuleManager* coreModuleManager() const { return m_coreModuleManager; }
UIPluginManager* uiPluginManager() const { return m_uiPluginManager; }
PackageCoordinator* packageCoordinator() const { return m_packageCoordinator; }
AppsModel* appsModel() const { return m_appsModel; }
AppsFilterProxy* uiAppsProxy() const { return m_uiAppsProxy; }
AppsFilterProxy* requiredPackagesModel() const { return m_requiredPackagesModel; }
ModuleInstanceModel* uiModulesModel() const { return m_uiModulesModel; }
ModuleInstanceModel* coreModulesModel() const { return m_coreModulesModel; }
// Base-typed on purpose — see the Q_PROPERTY comments above. Internal
// code uses the concrete members directly and does not go through these.
//
// Defined out of line: AppsModel is only forward-declared here, so the
// derived-to-base conversion needs the definition, and pulling AppsModel.h
// into this header would push it into every TU that includes it.
QAbstractItemModel* appsModel() const;
QVariantList requiredPackages() const { return m_requiredPackages; }
QAbstractItemModel* uiModulesModel() const { return m_uiModulesModel; }
QAbstractItemModel* coreModulesModel() const { return m_coreModulesModel; }
public slots:
// Navigation
@@ -233,7 +242,12 @@ signals:
void requestOpenAddApplicationDialog(const QVariantMap& metadata);
void addApplicationDataUpdated(const QVariantMap& metadata);
void launchAppRequested(const QString& name);
void catalogInstallStageChanged(const QString& name, InstallStage::Value stage);
// `stage` is an int, not InstallStage::Value. QML already declares
// installStage as an int and compares against the QML_ELEMENT-registered
// InstallStage.* values, so nothing changes for the consumer — but the
// signal signature stops naming a type that would otherwise have to exist
// in both images once the shell splits out.
void catalogInstallStageChanged(const QString& name, int stage);
void catalogInstallFinished(const QString& name);
void catalogInstallFailed(const QString& name, const QString& error);
void launcherAppsChanged();
@@ -257,6 +271,7 @@ signals:
void uninstallPlanRequested(const QVariantMap& plan);
void dependencyDataReadyChanged();
void requiredPackagesChanged();
// Distinct signal for upgrade/downgrade/reinstall — see
// PackageCoordinator::upgradeCascadeConfirmationRequested for why we
// can't reuse the uninstall variant (the dialog needs the target
@@ -321,8 +336,7 @@ private:
// uiPluginManager second, packageCoordinator third. See class comment for
// lifetime reasoning.
AppsModel* m_appsModel;
AppsFilterProxy* m_uiAppsProxy;
AppsFilterProxy* m_requiredPackagesModel;
QVariantList m_requiredPackages;
CoreModuleManager* m_coreModuleManager;
UIPluginManager* m_uiPluginManager;
PackageCoordinator* m_packageCoordinator;
+2 -17
View File
@@ -1,5 +1,6 @@
#pragma once
#include "BasecampModelRoles.h"
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
@@ -22,25 +23,9 @@
// `replaceRows` patches in place when the row identities are unchanged, so the
// 2-second core-stats poll doesn't reset the model (which would drop the
// selection + flicker every delegate).
class ModuleInstanceModel : public QAbstractListModel {
class ModuleInstanceModel : public QAbstractListModel, public ModuleInstanceRoles {
Q_OBJECT
public:
enum Roles {
NameRole = Qt::UserRole + 1,
LabelRole, // displayName if set, else name
DescriptionRole,
CategoryRole,
TypeRole, // "ui_qml" | "core" | ""
VersionRole,
IconPathRole,
InstallTypeRole, // "user" | "embedded" | ""
IsLoadedRole,
IsMainUiRole,
HasMissingDepsRole,
StatusTextRole, // derived: Main UI / Missing deps / Loaded / Not loaded
CpuRole, // core modules only; 0 when unknown
MemoryRole, // core modules only; 0 when unknown
};
Q_ENUM(Roles)
explicit ModuleInstanceModel(QObject* parent = nullptr);
+1 -3
View File
@@ -1,6 +1,5 @@
#include "PackageCoordinator.h"
#include "InstallRegistry.h"
#include "AppsFilterProxy.h"
#include "AppsModel.h"
#include "CoreModuleManager.h"
#include "UIPluginManager.h"
@@ -1796,8 +1795,7 @@ void PackageCoordinator::emitDialogMetadata(const QString& name,
requiredEntries.append(v);
}
if (m_requiredPackagesModel)
m_requiredPackagesModel->setRequiredPackages(requiredEntries);
emit requiredPackagesResolved(requiredEntries);
if (requestOpen)
emit requestOpenAddApplicationDialog(metadata);
+7 -3
View File
@@ -11,7 +11,6 @@
#include <QSet>
#include "logos_api.h"
class AppsFilterProxy;
class AppsModel;
class InstallRegistry;
class CoreModuleManager;
@@ -57,7 +56,7 @@ public:
QObject* parent = nullptr);
~PackageCoordinator() override;
void setRequiredPackagesModel(AppsFilterProxy* proxy) { m_requiredPackagesModel = proxy; }
// Read-only accessors over the package-state caches. Empty when the
// async refresh chain hasn't completed yet; QML and UIPluginManager
@@ -185,6 +184,12 @@ signals:
void catalogInstallFailed(const QString& name, const QString& error);
void launchAppRequested(const QString& name);
void uninstallPlanRequested(const QVariantMap& plan);
// The resolver's required-package entries, in install order. Published
// rather than written: this used to call setRequiredPackages() on an
// AppsFilterProxy the host held a pointer to, which is a host reaching
// across into a shell-owned object. QML binds to it now.
void requiredPackagesResolved(const QVariantList& entries);
void dependencyDataReadyChanged();
// Upgrade/Downgrade/Reinstall cascade dialog trigger. Same dependent-
@@ -402,7 +407,6 @@ private:
bool m_warnedPackageDownloaderMissing = false;
UIPluginManager* m_uiPluginManager;
AppsModel* m_appsModel;
AppsFilterProxy* m_requiredPackagesModel = nullptr;
// Package-state caches sourced from the package_manager module.
QMap<QString, QString> m_installTypeByModule;
+3 -5
View File
@@ -12,10 +12,9 @@ ShellHostAdapter::ShellHostAdapter(MainUIBackend* backend, QObject* parent)
qFatal("ShellHostAdapter requires a MainUIBackend instance");
}
// Signal → observer translation. Every forward is guarded on m_observer:
// the shell detaches during destroyShell(), but PluginLoader dispatches
// through QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout, so
// a backend signal can still arrive afterwards.
// Signal → observer translation, guarded on m_observer: the shell detaches
// in destroyShell(), but PluginLoader's QTimer::singleShot(0, ...) and a 30s
// ViewModuleHost timeout can still deliver a backend signal afterwards.
connect(m_backend, &MainUIBackend::currentActiveSectionIndexChanged, this, [this]() {
if (!m_observer || !m_backend) return;
m_observer->onSectionIndexChanged(m_backend->currentActiveSectionIndex());
@@ -79,7 +78,6 @@ void ShellHostAdapter::setCurrentVisibleApp(const QString& name)
QString ShellHostAdapter::displayNameFor(const QString& name) const
{
// Same fallback the shell used to apply inline when the backend was null.
return m_backend ? m_backend->displayNameFor(name) : name;
}
+14 -20
View File
@@ -9,26 +9,21 @@
class MainUIBackend;
// ─────────────────────────────────────────────────────────────────────────────
// ShellHostAdapter — the host's side of the shell boundary.
// ShellHostAdapter — the host's side of the shell boundary. Implements
// IShellHost over MainUIBackend and translates its five signals into
// IShellObserver calls; every operation is a one-line delegation.
//
// Implements IShellHost over MainUIBackend and translates the five backend
// signals the shell cares about into IShellObserver virtual calls. It adds no
// policy of its own: every operation is a one-line delegation.
// It exists so the shell never names MainUIBackend, whose header pulls in
// InstallEnums, the models and logos_api.h and whose signals carry
// InstallStage::Value — none of which a Qt-only shell could compile against.
//
// It exists so the shell never names MainUIBackend. That matters twice over:
// MainUIBackend's header pulls in InstallEnums, the models and logos_api.h, and
// its signal signatures carry InstallStage::Value — none of which a Qt-only
// shell could compile against.
//
// Owned by Window, alongside the MainUIBackend it wraps. Neither is owned by
// the shell.
// Owned by Window, alongside the MainUIBackend it wraps.
// ─────────────────────────────────────────────────────────────────────────────
class ShellHostAdapter : public QObject, public IShellHost {
Q_OBJECT
public:
// `backend` is borrowed and must outlive this adapter. Both are owned by
// Window, which destroys the adapter first.
// `backend` is borrowed; Window owns both and destroys this adapter first.
explicit ShellHostAdapter(MainUIBackend* backend, QObject* parent = nullptr);
~ShellHostAdapter() override;
@@ -43,14 +38,13 @@ public:
void setObserver(IShellObserver* observer) override;
private:
// QPointer, not a raw pointer: Window deletes the backend during teardown,
// and a queued backend signal can still be in flight when it does.
// QPointer: Window deletes the backend during teardown, and a queued
// backend signal can still be in flight when it does.
QPointer<MainUIBackend> m_backend;
// Raw by necessity — IShellObserver is not a QObject, so QPointer cannot
// track it. The shell's destroyShell() contract is what nulls this; every
// forward below is guarded anyway, because the host dispatches through
// QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout, both of
// which can fire after teardown has begun.
// Raw: IShellObserver is not a QObject, so QPointer cannot track it.
// destroyShell() nulls it; forwards are guarded anyway, since
// QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout can both
// fire after teardown has begun.
IShellObserver* m_observer = nullptr;
};
+4 -7
View File
@@ -218,13 +218,10 @@ private:
// Loaded-plugin state
QMap<QString, IComponent*> m_loadedUiModules;
// QPointer, not raw. These widgets are docked inside the shell, so the
// shell's Qt parent can destroy them without going through unloadUiModule
// -- and every read below then hands out a dangling pointer. Window's
// ordered teardown calls beginShutdown() before the shell dies, which
// closes that window; QPointer is what keeps it closed if some future path
// does not. The pre-fold MainUIPlugin carried the same guard, and the fold
// deleted it.
// QPointer, not raw: these widgets are docked inside the shell, so the
// shell's Qt parent can destroy them without going through unloadUiModule,
// leaving every read below dangling. Window's ordered teardown calls
// beginShutdown() first; QPointer is the backstop if some path does not.
QMap<QString, QPointer<QWidget>> m_uiModuleWidgets;
QMap<QString, QPointer<QQuickWidget>> m_qmlPluginWidgets;
QMap<QString, ViewModuleHost*> m_viewModuleHosts;
+74
View File
@@ -0,0 +1,74 @@
#pragma once
// qnamespace.h, not QtGlobal: the role bases are Qt::UserRole, and QtGlobal
// does not declare the Qt namespace. This header is included FIRST by
// AppsModel.h, so there is nothing ahead of it to pull the declaration in.
#include <QtCore/qnamespace.h>
// ─────────────────────────────────────────────────────────────────────────────
// BasecampModelRoles.h — the model role contract, shared across the boundary.
//
// AppsModel and ModuleInstanceModel live host-side; the filter proxies over
// them live in the shell and need the role NUMBERS and no symbol of any kind.
// The models inherit these structs rather than redeclaring the enum, so
// `AppsModel::NameRole` resolves host-side and the shell spells the same
// constant `AppsModelRoles::NameRole` — one definition either way.
//
// Adding a role is a boundary change: append at the END. Renumbering shifts
// values the shell was compiled against, and the failure is a proxy silently
// reading the wrong role rather than anything that fails to build.
// ─────────────────────────────────────────────────────────────────────────────
struct AppsModelRoles {
enum Roles {
NameRole = Qt::UserRole + 1,
RepositoryUrlRole,
DisplayNameRole,
DescriptionRole,
CategoryRole,
TypeRole, // "ui_qml" | "core"
IconUrlRole, // resolved icon URL; "" → monogram fallback
SupportsFullBleedIconRole, // manifest >= 0.4.0: icon is a validated
// 256x256 asset, safe to render edge-to-edge
VersionsRole, // QVariantList — all known catalog versions
DependenciesRole, // QVariantList — direct deps of versions[0]'s manifest,
// normalized to [{name, version}, ...]. version "" = no constraint.
InstalledVersionRole, // "" when not installed
LatestVersionRole, // versions[0].version
HasUpdateRole, // installedVersion != "" && installed != latest
IsInstalledRole, // installedVersion != ""
MissingDepsRole, // QStringList of dep names whose LGX isn't on
// disk (sourced from
// PackageCoordinator::m_missingDepsByModule).
// Empty when the install is complete.
InstallStatusRole, // InstallStatus enum — per-row catalog-vs-disk
// state (Install/Launch/Upgrade/Downgrade/
// Reinstall in QML terms). PMUI mirror.
InstallTypeRole, // "embedded" | "user" | ""
ActionRole,
ToVersionRole,
IsTopLevelRole,
ResolverErrorRole,
InstallStageRole, // InstallStage::Value (int) — see InstallEnums.h
InstallErrorRole, // failure message when InstallStage == Failed
};
};
struct ModuleInstanceRoles {
enum Roles {
NameRole = Qt::UserRole + 1,
LabelRole, // displayName if set, else name
DescriptionRole,
CategoryRole,
TypeRole, // "ui_qml" | "core" | ""
VersionRole,
IconPathRole,
InstallTypeRole, // "user" | "embedded" | ""
IsLoadedRole,
IsMainUiRole,
HasMissingDepsRole,
StatusTextRole, // derived: Main UI / Missing deps / Loaded / Not loaded
CpuRole, // core modules only; 0 when unknown
MemoryRole, // core modules only; 0 when unknown
};
};
+28 -37
View File
@@ -7,39 +7,30 @@ class QWidget;
// ─────────────────────────────────────────────────────────────────────────────
// IShellHost / IShellObserver — the whole surface between the Basecamp host and
// its UI shell.
// its UI shell plugin.
//
// The shell is handed one IShellHost* and can express nothing beyond the eight
// operations below. That is the point: it holds no LogosAPI*, no QtLogosCore*,
// and no TokenManager access, so it cannot mint identities, read the token
// store, or drive the core lifecycle even by accident. The host keeps those and
// exposes only what a shell legitimately needs.
// The shell is handed one IShellHost* and nothing else: no LogosAPI*, no
// QtLogosCore*, no TokenManager, so it cannot mint identities, read the token
// store or drive the core lifecycle even by accident.
//
// ── Why these types and no others ───────────────────────────────────────────
// Only QObject*, QWidget* and Qt value types cross. Once the shell becomes a
// separate image again, that is what lets it link Qt and nothing else — no
// logos runtime, and therefore no second copy of any runtime singleton. The
// symbol gate enforces exactly that, so widening this surface with a logos type
// is not a style question: it will fail the build.
// Only QObject*, QWidget* and Qt value types cross, which is what lets the
// shell link Qt and nothing else — no logos runtime, hence no second copy of
// any runtime singleton. The symbol gate enforces that: widening this surface
// with a logos type fails the build. backendObject() is the escape hatch and is
// safe for the same reason — QML resolves it through the metaobject, so the
// shell never has to name a host C++ type.
//
// backendObject() is the deliberate escape hatch, and it is safe for the same
// reason: QML resolves everything on it dynamically through the metaobject, so
// no C++ type from the host's side has to be nameable by the shell.
// IShellHost_abi is compiled into BOTH sides; bump it on ANY vtable change —
// added, removed, reordered or re-signatured methods. A stale plugin is a
// silent vtable mismatch rather than a link error, and the version check is the
// only thing standing between that and a jump through a garbage slot.
//
// ── Versioning ──────────────────────────────────────────────────────────────
// IShellHost_abi below is compiled into BOTH sides. Bump it on ANY change to
// either vtable — added, removed, reordered or re-signatured methods. Once the
// shell is a plugin, a stale one is a silent vtable mismatch rather than a link
// error, and the version check is the only thing standing between that and a
// jump through a garbage slot.
//
// There is deliberately no source-drift guard script here (contrast
// logos-module-builder/tests/view-interface-abi.py, which exists because
// LogosViewReplicaFactory has two independently maintained copies in two repos
// with no dependency edge). This header is carried by the `component-interfaces`
// INTERFACE library and included by both targets, so there is exactly one copy
// and source drift is impossible by construction. Binary drift is the real
// risk, and that is what IShellHost_abi covers.
// The `component-interfaces` INTERFACE library carries this header to both
// targets, so there is exactly one copy and source drift is impossible by
// construction — no guard script needed here, unlike
// logos-module-builder/tests/view-interface-abi.py, which covers two
// independently maintained copies. Binary drift is the real risk, and that is
// what IShellHost_abi covers.
// ─────────────────────────────────────────────────────────────────────────────
// Bump on ANY vtable change to IShellHost or IShellObserver.
@@ -47,9 +38,9 @@ constexpr int IShellHost_abi = 1;
// Host → shell notifications. Implemented shell-side by MainContainer.
//
// Plain virtuals rather than signals on purpose: a signal/slot connection would
// make the two sides agree on a metaobject, which is exactly the coupling the
// boundary exists to avoid. These are called synchronously on the GUI thread.
// Plain virtuals rather than signals: a signal/slot connection would make the
// two sides agree on a metaobject, exactly the coupling this boundary avoids.
// Called synchronously on the GUI thread.
class IShellObserver {
public:
virtual ~IShellObserver() = default;
@@ -69,9 +60,9 @@ class IShellHost {
public:
virtual ~IShellHost() = default;
// The object QML binds as the `backend` context property. Returned as a
// bare QObject* so the shell never names a host C++ type; QML resolves
// properties, signals and slots on it through the metaobject.
// The object QML binds as the `backend` context property. Bare QObject* so
// the shell never names a host C++ type; QML resolves properties, signals
// and slots on it through the metaobject.
virtual QObject* backendObject() = 0;
virtual int currentSectionIndex() const = 0;
@@ -87,7 +78,7 @@ public:
// nullptr detaches. The shell MUST call setObserver(nullptr) before it is
// destroyed: host-side callbacks are dispatched from a
// QTimer::singleShot(0, ...) and from a 30s ViewModuleHost timeout, so they
// can land after teardown has begun. QPointer cannot help here
// IShellObserver is not a QObject.
// can land after teardown begins. QPointer cannot help — IShellObserver is
// not a QObject.
virtual void setObserver(IShellObserver* observer) = 0;
};
+11 -15
View File
@@ -8,31 +8,27 @@ class IShellHost;
// ─────────────────────────────────────────────────────────────────────────────
// IShellView — the UI shell, from the host's side.
//
// This is the seam that replaces IComponent for the main UI. IComponent stays
// exactly as it is, for the third-party legacy widget plugins PluginLoader
// still loads; it is not reused here because its signature
// The seam for the main UI. IComponent is not reused here: its signature
// (`createWidget(LogosAPI*)`) names the very type this boundary exists to keep
// out of the shell.
// out of the shell. It stays exactly as it is for the third-party legacy widget
// plugins PluginLoader still loads.
//
// Today the implementation is compiled into the executable and Window
// constructs it directly. Once the shell is a plugin again, Window will resolve
// it with a real qobject_cast<IShellView*> on the QPluginLoader instance —
// NOT a QMetaObject::invokeMethod("createWidget") string call, which is what
// the pre-fold code did and is a silent-failure path: change an argument type
// and both sides still compile, then miss at runtime.
// Window resolves the shell with qobject_cast<IShellView*> on the QPluginLoader
// instance — NOT a QMetaObject::invokeMethod("createWidget") string call, which
// is a silent-failure path: change an argument type and both sides still
// compile, then miss at runtime.
// ─────────────────────────────────────────────────────────────────────────────
class IShellView {
public:
virtual ~IShellView() = default;
// Builds the shell widget. `host` is borrowed and outlives the shell; a
// null host is FATAL, not a degraded mode — there is no meaningful shell
// without one, and failing here beats a null dereference later.
// null host is FATAL, not a degraded mode — failing here beats a null
// dereference later.
virtual QWidget* createShell(IShellHost* host) = 0;
// Tears the shell down. Implementations must detach from the host
// (setObserver(nullptr)) before returning, so no late callback can reach a
// destroyed observer.
// Tears the shell down. Must detach from the host (setObserver(nullptr))
// before returning, so no late callback reaches a destroyed observer.
virtual void destroyShell(QWidget* widget) = 0;
// Must return IShellHost_abi as compiled into the shell. The host compares
+3 -8
View File
@@ -1,13 +1,8 @@
#pragma once
// Single place that pulls the nix-generated logos_build_info.h and exposes
// the values to the two consumers:
// * app/main.cpp — logs a banner to the per-session log on startup.
// * app/MainUIBackend — exposes Q_PROPERTYs for the Dashboard view.
//
// Header-only: both consumers are translation units of the same target now
// (they were two targets before the main_ui fold), and keeping everything
// inline means there is no separate .cpp to wire in.
// Pulls the nix-generated logos_build_info.h for its two consumers:
// * app/main.cpp — startup banner in the per-session log.
// * app/MainUIBackend — Q_PROPERTYs for the Dashboard view.
//
// Non-nix builds see no logos_build_info.h; accessors return empty values
// so callers can still render / log something sane.
+112 -105
View File
@@ -19,11 +19,13 @@
#include <QPointer>
#include <QScopedValueRollback>
#include "LogosBasecampPaths.h"
#include "MainShellView.h"
#include "MainUIBackend.h"
#include "ShellHostAdapter.h"
#include "IShellHost.h"
#include "IShellView.h"
#include "logos_api.h"
#include "win_dll_search.h"
#include <QPluginLoader>
#ifdef Q_OS_MAC
#include "trafficLightsTitleBar.h"
#include "macWindowStyle.h"
@@ -55,28 +57,27 @@ Window::Window(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, QWidget *parent
Window::~Window()
{
// Explicit, ordered teardown. Qt's reverse child destruction would get
// most of this right by accident; doing it here makes the ordering a
// stated contract rather than a consequence of construction order.
//
// 1. beginShutdown() unmounts every in-process UI plugin widget WHILE
// the shell's widget tree is still intact. Those widgets are docked
// inside the shell, so the shell must outlive this step.
// 2. destroyShell() detaches the observer, then deletes the shell — so
// no late host callback can reach a destroyed observer.
// 3. only then does the backend go, tearing down
// PackageCoordinator -> UIPluginManager -> CoreModuleManager in the
// order MainUIBackend.h documents.
//
// main() then destroys the core facade, which is step 4.
// Ordered teardown, stated as a contract instead of left to Qt's reverse
// child destruction:
// 1. beginShutdown() unmounts every in-process UI plugin widget while the
// shell's widget tree is still intact — those widgets are docked in
// the shell, so the shell must outlive this step.
// 2. destroyShell() detaches the observer before deleting the shell, so
// no late host callback reaches a destroyed observer.
// 3. only then the backend: PackageCoordinator -> UIPluginManager ->
// CoreModuleManager, the order MainUIBackend.h documents.
if (m_backend) {
m_backend->beginShutdown();
}
if (m_shellView) {
// Out of the central-widget slot before handing it back, or QMainWindow
// deletes it too.
QWidget* shell = takeCentralWidget();
m_shellView->destroyShell(shell);
delete m_shellView;
// NOT deleted: m_shellView is QPluginLoader's root instance, destroyed
// by Qt in QLibraryStore::cleanup() at process exit — deleting it here
// double-frees.
m_shellView = nullptr;
}
@@ -93,69 +94,86 @@ Window::~Window()
void Window::setupUi()
{
// The UI shell is compiled into this executable, and reached through
// IShellView / IShellHost.
// The UI shell is a Qt plugin — plugins/main_ui/main_ui.{so,dylib,dll} —
// reached through IShellView / IShellHost. It gets a QWidget* out and eight
// named operations in, holds no host privilege, and links no logos runtime;
// nix/symbol-gate.nix enforces that rather than trusting it.
//
// It used to be a Qt plugin, folded in here because the boundary "bought
// nothing": the shell needed a QWidget* handed back, the logos_core_*
// lifecycle, and TokenManager access, and two of those three are host
// privileges. That argument is what this seam dismantles — the shell now
// gets a QWidget* out and eight named operations in, and holds no host
// privilege at all. What is left to do is packaging, not privilege.
//
// Note the shape of the resolution below: a typed hostAbiVersion() check
// and a direct call, NOT the QMetaObject::invokeMethod("createWidget")
// string dispatch the pre-fold code used. That was a silent-failure path —
// change an argument type and both sides still compile, then miss at
// runtime. When this becomes a plugin again the only change here is
// qobject_cast<IShellView*> on the QPluginLoader instance.
//
// There is deliberately no "No main UI module found" fallback: failure to
// construct the shell is not a runtime resolution that can miss.
//
// Ownership inverts here: the backend and its host adapter belong to the
// Window, and the shell only borrows them through IShellHost. The shell
// holds no LogosAPI*, no QtLogosCore* and no MainUIBackend* — which is
// what will let it link Qt and nothing else once it is a plugin again.
//
// Deliberately NOT parented to `this`: ~Window destroys these explicitly
// and in order, and a Qt parent would delete them a second time.
// Ownership stays HERE: the backend and its adapter belong to the Window and
// the shell only borrows them. Deliberately not parented to `this`; ~Window
// destroys them explicitly and in order.
m_backend = new MainUIBackend(m_logosAPI, m_core, nullptr);
m_hostAdapter = new ShellHostAdapter(m_backend, nullptr);
m_shellView = new MainShellView(nullptr);
// A real ABI check, even though both sides are in this image today. Once
// the shell is loaded from a plugin this is the only thing between a stale
// build and a jump through a mismatched vtable slot.
QString pluginExtension;
#if defined(Q_OS_WIN)
pluginExtension = ".dll";
#elif defined(Q_OS_MAC)
pluginExtension = ".dylib";
#else // Linux and other Unix-like systems
pluginExtension = ".so";
#endif
// Embedded (pre-installed at build time) first, then the user-writable dir.
const QString embeddedPath = LogosBasecampPaths::embeddedPluginsDirectory()
+ "/main_ui/main_ui" + pluginExtension;
const QString mainUiPluginPath =
QFile::exists(embeddedPath)
? embeddedPath
: LogosBasecampPaths::pluginsDirectory() + "/main_ui/main_ui" + pluginExtension;
// main_ui lives in its own plugins/main_ui/ directory, so anything vendored
// beside it is invisible to Windows' loader without this. No-op elsewhere;
// the reference is intentionally held for the process lifetime.
ModuleLib::preloadPluginWithOwnDirSearch(mainUiPluginPath);
QPluginLoader loader(mainUiPluginPath);
if (!loader.load()) {
qFatal("Failed to load the main UI plugin from %s: %s",
qUtf8Printable(mainUiPluginPath), qUtf8Printable(loader.errorString()));
}
// A real cast, not QMetaObject::invokeMethod("createWidget"): the string
// form is a silent-failure path — change an argument type and both sides
// still compile, then miss at runtime.
m_shellView = qobject_cast<IShellView*>(loader.instance());
if (!m_shellView) {
qFatal("main_ui at %s does not implement IShellView",
qUtf8Printable(mainUiPluginPath));
}
// All that stands between a stale plugin build and a jump through a
// mismatched vtable slot. Both sides take IShellHost_abi from the one copy
// in app/interfaces/, so a difference means the plugin was built against a
// different revision of that header.
if (m_shellView->hostAbiVersion() != IShellHost_abi) {
qFatal("main_ui shell was built against IShellHost ABI %d, host is %d",
qFatal("main_ui was built against IShellHost ABI %d, host is %d",
m_shellView->hostAbiVersion(), IShellHost_abi);
}
// Deliberately no "No main UI module found" fallback widget: an error-label
// widget reads as a working app with an empty window. A missing or
// mismatched shell is a broken install, and the qFatal paths above say
// which.
setCentralWidget(m_shellView->createShell(m_hostAdapter));
// Set window title and size. The default launch width is wide enough for
// the Package Manager's full table (category sidebar + columns through
// Action and Description) to be visible without horizontal scrolling; at
// the old 1024px those rightmost columns were clipped off-screen. The
// window can still be resized down to MainContainer's 800x600 minimum.
// The launch width is sized for the Package Manager's full table (category
// sidebar through the Action and Description columns) without horizontal
// scrolling; below it those rightmost columns clip off-screen. Still
// resizable down to MainContainer's 800x600 minimum.
setWindowTitle("Logos Basecamp");
{
// ...but never larger than the screen actually offers. A window bigger
// than the work area opens with its right and bottom edges off-screen,
// and on Windows it cannot be dragged back into view, so whatever hangs
// off is simply unreachable -- here, the sidebar's bottom-anchored
// system buttons. Measured on a 1271x839-logical RDP session: the
// 1600x900 request overflowed right by ~342 and bottom by ~97 logical
// px. Nothing else in this repo consults the screen; the QScreen
// include at the top of this file has been unused until now.
// ...but never larger than the screen offers: an oversized window opens
// with its right and bottom edges off-screen and, on Windows, cannot be
// dragged back, so whatever hangs off -- here the sidebar's
// bottom-anchored system buttons -- is unreachable. Measured on a
// 1271x839-logical RDP session: 1600x900 overflowed right by ~342 and
// bottom by ~97 logical px.
//
// Clamp to availableGeometry EXACTLY, not to a fraction of it. This has
// to be a no-op on every display big enough to hold the design size --
// which is all of them in normal use -- or it silently undoes the
// widening documented above. Qt raises the result back to
// MainContainer's 800x600 minimum on its own, so there is no second
// floor to keep in sync here.
// Clamp to availableGeometry EXACTLY, not to a fraction of it, so this
// is a no-op on any display big enough for the design size; otherwise it
// silently undoes the width above. Qt raises the result back to
// MainContainer's 800x600 minimum on its own.
//
// Skipped under the offscreen platform: QOffscreenScreen hardcodes its
// geometry to 800x600, so clamping to it would shrink every headless UI
@@ -163,9 +181,8 @@ void Window::setupUi()
//
// A screen that shrinks AFTER launch -- an RDP session with
// /dynamic-resolution -- is handled by rebindScreenWatch() +
// scheduleFitToScreen(), wired from showEvent once the platform window
// exists. It cannot be done here: the window has no screen it has
// actually landed on yet, and its frame margins are not yet knowable.
// scheduleFitToScreen() from showEvent: here the window has not landed
// on a screen yet and its frame margins are not knowable.
QSize target(1600, 900);
m_desiredSize = target; // what to grow back to when room returns
const QScreen* windowScreen = screen();
@@ -228,14 +245,12 @@ void Window::changeEvent(QEvent* event)
void Window::resizeEvent(QResizeEvent* event)
{
QMainWindow::resizeEvent(event);
// A resize the user performed redefines what the window is entitled to when
// A resize the USER performed redefines what the window is entitled to when
// room returns; one WE performed must not, or the fit becomes
// min(previous, available) -- monotone non-increasing, so the window would
// converge on the smallest work area seen all session and never grow back.
// Guarded on isVisible() so Qt's own pre-show sizing does not count.
// Also skipped while maximized/fullscreen: that size belongs to the window
// manager, not to the user's intent, and recording it would make a later
// un-maximize try to grow the window back to full-screen size.
// min(previous, available) and converges on the smallest work area seen all
// session. isVisible() keeps Qt's own pre-show sizing out. Maximized and
// fullscreen sizes belong to the window manager, not to the user's intent:
// recording one would grow the window to full screen on un-maximize.
if (!m_applyingFit && isVisible()
&& !(windowState() & (Qt::WindowMaximized | Qt::WindowFullScreen)))
m_desiredSize = size();
@@ -248,12 +263,10 @@ void Window::resizeEvent(QResizeEvent* event)
void Window::showEvent(QShowEvent* event)
{
QMainWindow::showEvent(event);
// Both idempotent, so this runs unconditionally rather than behind a
// one-shot flag. That is what makes a tray restore re-fit if -- and only if
// -- the screen shrank while the window was hidden: the fit is skipped
// while !isVisible(), so a screen change during that time is picked up here
// instead. A process-wide `static` would also have been wrong outright,
// gating every Window ever constructed on the first one.
// Both idempotent, so no one-shot flag: that is what makes a tray restore
// re-fit if -- and only if -- the screen shrank while the window was hidden,
// since the fit is skipped while !isVisible(). A process-wide `static` would
// gate every Window ever constructed on the first one.
rebindScreenWatch();
fitFrameToAvailableGeometry();
#ifdef Q_OS_MAC
@@ -332,38 +345,32 @@ void Window::fitFrameToAvailableGeometry()
return;
// Compare SIZES, not rectangles, and never touch the position.
//
// QRect::contains() is the obvious formulation and is wrong here on two
// counts. On Windows, frameGeometry() comes from GetWindowRect, which
// includes DWM's INVISIBLE resize border (~13px per side at 192dpi), so a
// perfectly placed window never reports as contained and this would run on
// every single launch. On macOS, Qt hands back a frame whose title bar sits
// above availableGeometry's origin, so containment fails there too -- and
// acting on it moved the window 66px down at launch, a visible change on a
// platform where nothing is broken. Measured both.
// QRect::contains() is wrong here twice over: on Windows frameGeometry()
// comes from GetWindowRect, which includes DWM's INVISIBLE resize border
// (~13px per side at 192dpi), so a perfectly placed window never reports as
// contained and this would run on every launch; on macOS Qt's frame title
// bar sits above availableGeometry's origin, and acting on that moved the
// window 66px down at launch. Measured both.
const QSize avail = windowScreen->availableGeometry().size();
// resize() sets the CLIENT size while availableGeometry() bounds the FRAME,
// so the constructor's clamp is short by the decoration margins -- and those
// are only knowable once the platform window exists, which is why this runs
// here and not there. Measured on a 3456x1826 work area: the clamped client
// filled the work area exactly and the frame still hung 72px below it,
// so the constructor's clamp is short by the decoration margins, knowable
// only once the platform window exists. Measured on a 3456x1826 work area:
// the clamped client filled it exactly and the frame still hung 72px below,
// putting the sidebar's bottom-anchored system buttons under the taskbar.
const QSize decoration = frameGeometry().size() - size();
// Bounded by what the window is ENTITLED to, not by its current size. Using
// the current size makes this min(previous, available) on every event, a
// monotone non-increasing map: the window would ratchet down to the smallest
// work area seen all session and never grow back -- so a taskbar that
// auto-hides and reappears, or an RDP window dragged smaller and back, would
// permanently shrink Basecamp. m_desiredSize is the launch design size,
// replaced by any resize the USER performs (see resizeEvent), so growing
// back can never exceed what was actually asked for.
// Bounded by what the window is ENTITLED to, not by its current size: the
// latter makes this min(previous, available) on every event, ratcheting down
// to the smallest work area seen all session -- an auto-hiding taskbar, or
// an RDP window dragged smaller and back, would shrink Basecamp for good.
// m_desiredSize is the launch design size, replaced by any resize the USER
// performs (see resizeEvent), so growing back never exceeds what was asked.
const QSize want = m_desiredSize.isValid() ? m_desiredSize : size();
const QSize target = (avail - decoration).boundedTo(want).expandedTo(minimumSize());
// The whole idempotence of this function. It is also why no re-entrancy flag
// is needed: our own resize lands here again with the same inputs and stops.
// The whole idempotence of this function, and why no re-entrancy flag is
// needed: our own resize re-enters with the same inputs and stops here.
if (target == size())
return;
+4 -2
View File
@@ -13,7 +13,7 @@
class LogosAPI;
class MainUIBackend;
class ShellHostAdapter;
class MainShellView;
class IShellView;
class QMenu;
class QAction;
class QCloseEvent;
@@ -86,7 +86,9 @@ private:
// explicitly and in order by ~Window; see the comment there.
MainUIBackend* m_backend = nullptr;
ShellHostAdapter* m_hostAdapter = nullptr;
MainShellView* m_shellView = nullptr;
// The plugin's root instance, reached only through the interface — this
// image never names MainShellView. Owned by QPluginLoader, not by us.
IShellView* m_shellView = nullptr;
QSystemTrayIcon* m_trayIcon;
QMenu* m_trayIconMenu;
QAction* m_showHideAction;
+19 -9
View File
@@ -14,7 +14,7 @@ logos-basecamp/
│ ├── index.md # Documentation index
│ ├── spec.md # High-level specification
│ └── project.md # This document
├── app/ # Main application executable (incl. the UI shell)
├── app/ # Main application executable (the host)
│ ├── CMakeLists.txt # App build configuration
│ ├── main.cpp # Entry point
│ ├── window.h/cpp # Main window (QMainWindow)
@@ -22,12 +22,11 @@ logos-basecamp/
│ ├── utils/ # Utility classes (paths, file helpers)
│ ├── macos/ # macOS-specific code (titlebar styling)
│ ├── icons/ # Application icons
│ ├── MainContainer.h/cpp # UI coordinator (sidebar + content)
│ ├── MainUIBackend.h/cpp # Core logic (module state, stats, navigation)
│ ├── ShellHostAdapter.h/cpp # IShellHost over MainUIBackend
│ ├── LogosQmlBridge.h/cpp # QML-to-C++ module call bridge
│ ├── mdiview.h/cpp # MDI tab workspace
│ ├── mdichild.h/cpp # Individual plugin tab window
│ ├── Basecamp/ # QML UI files, by feature
│ ├── qml/ # QML UI files
│ │ ├── panels/
│ │ │ ├── SidebarPanel.qml # Sidebar navigation
@@ -53,9 +52,16 @@ logos-basecamp/
│ ├── ui-tests.mjs # Node.js test suite (logos-qt-mcp)
│ ├── host-services-tests.mjs # Capability trust-root guard (spec)
│ └── host-services-assert.mjs # ...its assertion, shared with ui-tests
├── src/ # The main_ui UI shell plugin
│ ├── CMakeLists.txt # Plugin build (Qt only, no logos runtime)
│ ├── MainShellView.h/cpp # IShellView entry point
│ ├── MainContainer.h/cpp # UI coordinator (sidebar + content)
│ ├── WorkspaceArea.h/cpp # Dock-based app workspace
│ ├── Basecamp/ # QML UI files, by feature
├── nix/ # Nix build modules
│ ├── default.nix # Common build settings
│ ├── app.nix # Application package (UI shell included)
│ ├── app.nix # Application package
│ ├── main-ui.nix # main_ui UI shell plugin
│ ├── smoke-test.nix # Smoke test derivation
│ ├── integration-test.nix # UI integration test harness
│ ├── host-services-test.nix # Host-services grant guard
@@ -182,7 +188,11 @@ logos.package_manager.on("corePluginFileInstalled", [](const QVariantList& data)
**Files:** `app/window.h`, `app/window.cpp`
**Purpose:** Main `QMainWindow` derivative. Constructs the UI shell directly — `setCentralWidget(new MainContainer(m_logosAPI))` — which is compiled into this binary. (It used to be the `main_ui` Qt plugin, loaded with `QPluginLoader` and reached through `QMetaObject::invokeMethod("createWidget")`; that boundary was removed because the shell needs host privileges, not module isolation.) Manages system tray integration (minimize/restore) and applies platform-specific window styling (macOS native titlebar).
**Purpose:** Main `QMainWindow` derivative. Loads the UI shell from the `main_ui` Qt plugin with `QPluginLoader`, casts it to `IShellView`, checks `hostAbiVersion()` against `IShellHost_abi`, and calls `createShell(IShellHost*)`.
The shell's entire contract is `IShellHost`: a `QWidget*` out, eight named operations in. It holds no `LogosAPI*`, no `QtLogosCore*` and no `TokenManager` access, and links no logos runtime — `nix/symbol-gate.nix` enforces that across the in-process image set rather than trusting it.
`Window` also owns `MainUIBackend` and `ShellHostAdapter` (the shell only borrows them) and drives the ordered teardown described in `~Window`. It manages system tray integration (minimize/restore) and applies platform-specific window styling (macOS native titlebar).
### MainUIBackend
@@ -271,25 +281,25 @@ The escape and its fix are covered by the `sandbox-test` check (`tests/sandbox/`
### SidebarPanel
**File:** `app/Basecamp/Sidebar/SidebarPanel.qml`
**File:** `src/Basecamp/Sidebar/SidebarPanel.qml`
**Purpose:** Left-hand navigation panel. Sections are filtered by type — "workspace" entries appear at the top, "view" entries at the bottom. Loaded apps appear in the middle with close/activate interactions.
### ContentViews
**File:** `app/Basecamp/Shell/ContentViews.qml`
**File:** `src/Basecamp/Shell/ContentViews.qml`
**Purpose:** Content area using `StackLayout` with four indices: MDI area (index 0), Dashboard (1), Modules (2), Settings (3). The active index is controlled by the sidebar selection.
### ModulesView
**File:** `app/Basecamp/Settings/ModulesView.qml`
**File:** `src/Basecamp/Settings/ModulesView.qml`
**Purpose:** Component management screen with two tabs: **UI Apps** (Qt plugins managed by Basecamp) and **Logos Modules** (process-isolated modules managed by liblogos). Lists available/loaded components with load/unload buttons, icons, and status indicators. The Logos Modules tab also shows CPU/memory stats for running modules. Includes "Install LGX Package" action.
### DashboardView / SettingsView
**Files:** `app/Basecamp/Settings/DashboardView.qml`, `app/Basecamp/Settings/SettingsView.qml`
**Files:** `src/Basecamp/Settings/DashboardView.qml`, `src/Basecamp/Settings/SettingsView.qml`
**Purpose:** System views for overview information and application configuration.
+4 -4
View File
@@ -208,15 +208,15 @@ sections:
The UI plugins **Settings → Apps Inspector** will list are carried
under `plugins/` — confirm the package manager UI shipped in the
bundle. Basecamp's *own* shell is deliberately NOT here: it is
compiled into `bin/LogosBasecamp`, not carried as a `main_ui` plugin,
so the assertion below is that `main_ui` is ABSENT.
carried as a `main_ui` plugin again — one that links Qt and nothing
else, so the assertion below is that `main_ui` is PRESENT.
run: "ls result-bundle/plugins"
expect_contains:
- "package_manager_ui"
extra_run:
run: "ls result-bundle/plugins | grep -qx main_ui && echo 'main_ui STILL SHIPPED' || echo 'main_ui absent (folded into the executable)'"
run: "ls result-bundle/plugins | grep -qx main_ui && echo 'main_ui present' || echo 'main_ui MISSING'"
expect_contains:
- "main_ui absent (folded into the executable)"
- "main_ui present"
- title: "Confirm the bundled core modules"
text: |
+4 -9
View File
@@ -133,18 +133,13 @@ sections:
- title: "Confirm the baked-in UI plugins"
text: |
The UI plugins **Settings → Apps Inspector** will list are
installed under `plugins/` — confirm the package manager UI shipped
in the build. Basecamp's *own* shell is deliberately NOT here: it is
compiled into `bin/LogosBasecamp`, not shipped as a `main_ui` plugin,
so the assertion below is that `main_ui` is ABSENT.
The UI plugins ship under `plugins/`. Basecamp's own shell is one of
them again — `main_ui` — but a privilege-free one: it links Qt and
nothing else, and reaches the host only through `IShellHost`.
run: "ls result-app/plugins"
expect_contains:
- "main_ui"
- "package_manager_ui"
extra_run:
run: "ls result-app/plugins | grep -qx main_ui && echo 'main_ui STILL SHIPPED' || echo 'main_ui absent (folded into the executable)'"
expect_contains:
- "main_ui absent (folded into the executable)"
- title: "Confirm the baked-in core modules"
text: |
+1 -1
View File
@@ -76,7 +76,7 @@ sections:
is that the plugin's `Shortcut { sequence: "Ctrl+K" }` silently fails
to fire on the first press; on other platforms it may not fire at all.
`ShortcutBridge` (see `app/ShortcutBridge.h`) fixes this without asking
`ShortcutBridge` (see `src/ShortcutBridge.h`, in the main_ui plugin) fixes this without asking
plugin authors to change anything. It scans the current pane's QML tree
for `QQuickShortcut` objects and, for each one, installs a matching C++
`QShortcut` on the host `MainContainer` with `Qt::ApplicationShortcut`
+14 -14
View File
@@ -302,16 +302,15 @@
};
src = ./.;
# Basecamp's own UI shell is NOT a plugin any more. It used to be
# built here as `mainUIPlugin` (nix/main-ui.nix), bundled with
# nix-bundle-logos-module-install and shipped as
# plugins/main_ui/main_ui.{so,dylib,dll}; it is now compiled straight
# into the LogosBasecamp binary by nix/app.nix.
#
# Deleting these bindings is the load-bearing half of that fold. With
# the C++ folded in but the plugin still built and installed, nothing
# loads it, nothing breaks, and every check still passes -- while the
# output ships a second, stale copy of the entire UI.
# Basecamp's own UI shell: a privilege-free plugin that links Qt and
# nothing else from this workspace, which nix/symbol-gate.nix enforces
# across the in-process image set. It builds from the SAME `src` as
# the app; its CMakeLists lives in src/ and can only see
# app/interfaces/, so it cannot include a host header even by accident.
mainUIPlugin = import ./nix/main-ui.nix {
inherit pkgs common src logosDesignSystem;
};
packageManagerUIPlugin = logosPackageManagerUI;
# Pre-installed modules/plugins (bundle + lgpm install in one step).
@@ -340,7 +339,7 @@
# App package (development build)
app = import ./nix/app.nix {
inherit pkgs common src logosModule logosLiblogos logosSdk logosProtocolPkg logosQtHost logosQtSdk logosDesignSystem logosViewModuleRuntime logosPackageManagerModule logosPackageDownloaderModule logosPackageHeaders buildInfo logosSdkBuild;
inherit logosQtMcp;
inherit logosQtMcp mainUIPlugin;
installedModules = installedDev;
};
@@ -348,6 +347,7 @@
# Uses portable-compiled liblogos for portable variant selection
appDistributed = import ./nix/app.nix {
inherit pkgs common src logosModule logosSdk logosProtocolPkg logosQtHost logosQtSdk logosDesignSystem logosViewModuleRuntime logosPackageManagerModule logosPackageDownloaderModule logosPackageHeaders buildInfo logosSdkBuild;
inherit mainUIPlugin;
logosLiblogos = logosLiblogosPortable;
installedModules = installedDistributed;
portable = true;
@@ -357,7 +357,7 @@
# Distributed build with inspector enabled (for macOS integration tests)
appDistributedWithInspector = import ./nix/app.nix {
inherit pkgs common src logosModule logosSdk logosProtocolPkg logosQtHost logosQtSdk logosDesignSystem logosViewModuleRuntime logosPackageManagerModule logosPackageDownloaderModule logosPackageHeaders buildInfo logosSdkBuild;
inherit logosQtMcp;
inherit logosQtMcp mainUIPlugin;
logosLiblogos = logosLiblogosPortable;
installedModules = installedDistributed;
portable = true;
@@ -503,8 +503,8 @@
binBundleDirInspector = withMainProgram (dirBundler appDistributedWithInspector);
in
{
# Individual outputs. `main-ui-plugin` is deliberately gone: the UI
# shell is part of `app` now, not a separately buildable plugin.
# Individual outputs.
main-ui-plugin = mainUIPlugin;
package-manager-ui-plugin = packageManagerUIPlugin;
app = app;
+26 -23
View File
@@ -1,11 +1,5 @@
# Builds the logos-basecamp standalone application.
#
# Since the main_ui fold this is the ONLY derivation that compiles basecamp's
# own UI: nix/main-ui.nix is gone, and everything its preConfigure staged (the
# build-info header, the package_manager / package_downloader generated API
# headers, the shared semver headers, and logos-cpp-generator's --general-only
# output) is staged here instead, into the single app/generated directory.
{ pkgs, common, src, logosModule, logosLiblogos, logosSdk, logosSdkBuild ? logosSdk, logosProtocolPkg, logosQtHost, logosQtSdk, logosDesignSystem, logosViewModuleRuntime, logosPackageManagerModule, logosPackageDownloaderModule, logosPackageHeaders, buildInfo, logosQtMcp ? null, installedModules ? [], portable ? false, enableInspector ? true }:
{ pkgs, common, src, logosModule, logosLiblogos, logosSdk, logosSdkBuild ? logosSdk, logosProtocolPkg, logosQtHost, logosQtSdk, logosDesignSystem, logosViewModuleRuntime, logosPackageManagerModule, logosPackageDownloaderModule, logosPackageHeaders, buildInfo, logosQtMcp ? null, mainUIPlugin, installedModules ? [], portable ? false, enableInspector ? true }:
let
# webkitgtk became ABI-versioned; pick the newest available while staying
@@ -86,8 +80,7 @@ pkgs.stdenv.mkDerivation rec {
# the logos-protocol link interface (OpenSSL, Boost::system, nlohmann_json).
logosProtocolPkg
logosQtHost
# main_ui fold: Logos.Theme / .Icons / .Controls are STATIC qt_add_qml_module
# targets behind the Logos::DesignSystem umbrella, now linked by the app.
# app/CMakeLists.txt does find_package(LogosDesignSystem CONFIG REQUIRED).
logosDesignSystem
] ++ (
if pkgs.stdenv.isLinux then
@@ -182,12 +175,9 @@ pkgs.stdenv.mkDerivation rec {
# app/generated: the ONE generated-header directory
#
# Merged here from the deleted nix/main-ui.nix, which staged the same set
# into a SECOND directory (src/generated_code). Both ended up on one
# target's include path the moment the UI shell folded into the exe, and
# app/utils/BuildInfo.h resolves logos_build_info.h with __has_include
# so with two candidate directories the -I ORDER, not the build, would
# decide which header won. One directory removes the question.
# Keep it the only one: app/utils/BuildInfo.h resolves logos_build_info.h
# with __has_include, so a second staged directory on the same include path
# would leave -I ORDER deciding which header wins.
mkdir -p ./app/generated
# Auto-generated build info header (version + commit hashes): main.cpp logs
@@ -219,7 +209,7 @@ pkgs.stdenv.mkDerivation rec {
# logos-cpp-generator's general wrappers (logos_sdk.h / logos_sdk.cpp).
# --general-only: the per-module wrappers come from the module outputs
# copied above. metadata.json is the repo-root one, unchanged by the fold.
# copied above.
echo "Running logos-cpp-generator (general-only)..."
logos-cpp-generator --metadata ${src}/metadata.json --general-only --output-dir ./app/generated
@@ -412,11 +402,9 @@ pkgs.stdenv.mkDerivation rec {
# 1. $out/modules/<m>/ and $out/plugins/<p>/ are populated in installPhase
# from .lgx payloads and UI-plugin outputs -- i.e. AFTER that hook has
# run, and in directories it never looks at. Nothing ever read their
# import tables. Measured here before this block existed: main_ui.dll
# import tables. Measured before this block existed: main_ui.dll
# imported Qt6Qml.dll, Qt6QuickControls2.dll and Qt6QuickWidgets.dll and
# not one of them was in plugins/main_ui/ or in bin/. (main_ui itself is
# folded into the exe now and no longer ships; the mechanism is unchanged
# for package_manager_ui and every module payload.)
# not one of them was in plugins/main_ui/ or in bin/.
#
# 2. A DLL that is itself absent cannot have ITS imports read, so one pass
# over a tree is never enough. package_downloader needs three rounds:
@@ -766,6 +754,17 @@ WRAPPER_EOF
cp -L "$_sdklib" "$out/lib/"
done
# The UI shell. Staged from its own derivation into plugins/main_ui/, which
# is where app/window.cpp resolves it and where the PE import sweep above
# already walks ("$out"/plugins/*).
if [ -d "${mainUIPlugin}/plugins" ]; then
cp -r "${mainUIPlugin}/plugins/." "$out/plugins/"
echo "Installed the main_ui shell plugin"
else
echo "error: mainUIPlugin produced no plugins/ directory"
exit 1
fi
# Copy pre-installed modules and plugins from bundled install outputs.
# Each entry in installedModules has modules/ and/or plugins/ subdirectories.
for installed in ${pkgs.lib.concatStringsSep " " (map toString installedModules)}; do
@@ -778,9 +777,13 @@ WRAPPER_EOF
done
echo "Pre-installed modules and plugins from install bundles"
# Logos.Theme / .Icons / .Controls are STATIC-linked into the LogosBasecamp
# binary via find_package(LogosDesignSystem CONFIG) the modules register
# into the process qrc at startup. Nothing to copy to $out/lib/Logos.
# Logos.Theme / .Icons / .Controls are STATIC-linked into the main_ui PLUGIN,
# not into this binary, and register into the process-wide QML registry when
# Window loads the plugin at startup before any UI plugin can import them.
# Exactly one image may link them: a STATIC qt_add_qml_module registers from
# a static initializer and QML registration is process-global, so a second
# image aborts startup with "Cannot add multiple registrations for
# Logos.Icons". Nothing to copy to $out/lib/Logos.
# Install desktop file and icon for FreeDesktop / Wayland icon lookup (Linux only)
if [ "$(uname)" = "Linux" ]; then
+81
View File
@@ -0,0 +1,81 @@
# Builds the main UI shell as a Qt plugin: plugins/main_ui/main_ui.{so,dylib,dll}
#
# The shell talks to the host through IShellHost and compiles against Qt plus
# app/interfaces/ alone. logosDesignSystem is the only logos input, and it is
# QML-only. If this ever needs a logos input that carries CODE, something has
# leaked across the boundary -- check nix/symbol-gate.nix before adding it.
{ pkgs, common, src, logosDesignSystem, distributed ? false }:
pkgs.stdenv.mkDerivation {
pname = "${common.pname}-main-ui-plugin";
version = common.version;
inherit src;
inherit (common) meta;
nativeBuildInputs = common.nativeBuildInputs;
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtdeclarative
logosDesignSystem
];
configurePhase = ''
runHook preConfigure
# Match the deployment target the Qt frameworks were built against.
export MACOSX_DEPLOYMENT_TARGET=12.0
# $cmakeFlags FIRST -- this hand-rolled configurePhase bypasses the cmake
# setup hook, so without it -DCMAKE_SYSTEM_NAME=Windows is dropped and
# FindThreads probes for pthreads: "Qt6 could not be found because
# dependency Threads could not be found". The next line carries Qt's
# host-TOOL package paths. Both are empty on native builds.
cmake -S src -B build \
$cmakeFlags \
${pkgs.lib.escapeShellArgs (pkgs.logosQtCrossCmakeFlags or [ ])} \
-GNinja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 \
-DLOGOS_DISTRIBUTED_BUILD=${if distributed then "ON" else "OFF"} \
-DLOGOS_PORTABLE_BUILD=${if distributed then "ON" else "OFF"} \
-DLogosDesignSystem_DIR=${logosDesignSystem}/lib/cmake/LogosDesignSystem
runHook postConfigure
'';
buildPhase = ''
runHook preBuild
cmake --build build
runHook postBuild
'';
installPhase = ''
runHook preInstall
# plugins/main_ui/, not lib/: the layout app/window.cpp resolves and
# nix/app.nix's PE import sweep walks.
mkdir -p $out/plugins/main_ui
_found=""
for _ext in dylib dll so; do
if [ -f "build/main_ui.$_ext" ]; then
cp "build/main_ui.$_ext" "$out/plugins/main_ui/"
_found="build/main_ui.$_ext"
break
fi
done
# Fail loudly: a silently missing plugin is an app that starts, finds no
# shell, and qFatal()s far from this derivation.
if [ -z "$_found" ]; then
echo "error: no main_ui library was produced in build/"
ls -la build/ || true
exit 1
fi
echo "Installed $_found"
runHook postInstall
'';
}
+14 -18
View File
@@ -15,16 +15,15 @@
# it moved, this check failed while every real assertion still passed. See
# logos-protocol/cpp/logos_shared_api.h and cmake/LogosSharedFromDll.cmake.
#
# This gate exists because nothing else in the tree measured it. The main_ui
# fold shipped an executable that defined nine runtime entry points
# liblogos_core.dylib did not export, and it was caught by hand, after the fact,
# from 31 "ModuleProxy: rejecting unauthorized call" lines at runtime.
# Nothing else in the tree measures this, and it has no build diagnostic: it
# surfaces at runtime as "ModuleProxy: rejecting unauthorized call" and
# "LogosAPIClient: No token found for module".
#
# THE IN-PROCESS IMAGE SET — this scoping IS the correctness of the gate:
# IN bin/LogosBasecamp the app
# IN lib/liblogos_core.* the single provider
# IN plugins/*/*_replica_factory.* QPluginLoader'd by LogosQmlBridge
# IN plugins/main_ui/main_ui.* QPluginLoader'd by Window, when split out
# IN plugins/main_ui/main_ui.* QPluginLoader'd by Window
# OUT bin/logos_host, bin/ui-host SEPARATE PROCESSES; they correctly keep
# their own statics
# OUT plugins/*/*_plugin.* a ui_qml backend is loaded by ui-host,
@@ -69,11 +68,10 @@ pkgs.runCommand "logos-basecamp-symbol-gate${pkgs.lib.optionalString negativeCon
# wrapper) -- nm reads ZERO symbols from it
# * makeBinaryWrapper's COMPILED stub beside bin/.<name>-wrapped -- nm reads
# ~10 symbols from it, so the "did nm read anything" guard below does NOT
# catch this one. Measured in logos-logoscore-cli: bin/logoscore is such a
# stub, and measuring it reports 0 runtime symbols for a binary that in
# fact imports 12.
# So the rule is deterministic rather than heuristic: if a hidden sibling
# exists, it IS the image, and we never measure bin/<name>.
# catch this one (logos-logoscore-cli's bin/logoscore is such a stub: it
# measures 0 runtime symbols for a binary that in fact imports 12).
# Hence the deterministic rule: a hidden sibling IS the image, and we never
# measure bin/<name>.
resolve_image() {
local f="$1" d b real
d=$(dirname "$f"); b=$(basename "$f")
@@ -152,14 +150,12 @@ pkgs.runCommand "logos-basecamp-symbol-gate${pkgs.lib.optionalString negativeCon
echo
echo "== each runtime type is defined by EXACTLY ONE image =="
valid "$PROVIDER" || exit 1
# StoreRegistry is deliberately absent from this list. It is file-local --
# token_manager.cpp defines `static StoreRegistry r;` inside registry(), so it
# has a LOCAL symbol and no external one, and is reachable only through
# TokenManager's accessors. Measured: 1 symbol under `nm -a`, 0 under `nm -gU`.
# Requiring exactly one DEFINER of something that is never exported would fail
# forever. It stays in the TIER 1 scan below, where the assertion is that no
# consumer defines it -- which is meaningful precisely because it should never
# become external.
# StoreRegistry is deliberately absent from this list: token_manager.cpp
# defines `static StoreRegistry r;` inside registry(), so it is file-local
# (1 symbol under `nm -a`, 0 under `nm -gU`) and requiring exactly one DEFINER
# of something never exported would fail forever. It stays in the TIER 1 scan
# below, where "no consumer defines it" is meaningful precisely because it
# should never become external.
for fam in TokenManager LogosAPI LogosAPIClient; do
_n=0; _owners=""
for img in "''${ALL[@]}"; do
@@ -1,6 +1,7 @@
#include "AppsFilterProxy.h"
#include "AppsModel.h"
#include "BasecampModelRoles.h"
#include "InstallEnums.h"
#include "InstallEnums.h"
#include <climits>
@@ -46,8 +47,8 @@ int AppsFilterProxy::installedCount() const
const int n = rowCount();
for (int i = 0; i < n; ++i) {
const QModelIndex mi = index(i, 0);
if (data(mi, AppsModel::IsInstalledRole).toBool()
|| data(mi, AppsModel::InstallStageRole).toInt()
if (data(mi, AppsModelRoles::IsInstalledRole).toBool()
|| data(mi, AppsModelRoles::InstallStageRole).toInt()
== InstallStage::Installed) {
++c;
}
@@ -60,7 +61,7 @@ int AppsFilterProxy::installFreshCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("install")) ++c;
}
return c;
@@ -71,7 +72,7 @@ int AppsFilterProxy::upgradeCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("upgrade")) ++c;
}
return c;
@@ -82,7 +83,7 @@ int AppsFilterProxy::reinstallCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("reinstall")) ++c;
}
return c;
@@ -93,7 +94,7 @@ int AppsFilterProxy::alreadyInstalledCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("installed")) ++c;
}
return c;
@@ -104,7 +105,7 @@ int AppsFilterProxy::installingCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("installing")) ++c;
}
return c;
@@ -115,7 +116,7 @@ int AppsFilterProxy::errorCount() const
int c = 0;
const int n = rowCount();
for (int i = 0; i < n; ++i) {
if (data(index(i, 0), AppsModel::ActionRole).toString()
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
== QStringLiteral("error")) ++c;
}
return c;
@@ -127,15 +128,15 @@ qlonglong AppsFilterProxy::totalDownloadBytes() const
const int n = rowCount();
for (int i = 0; i < n; ++i) {
const QModelIndex mi = index(i, 0);
const QString action = data(mi, AppsModel::ActionRole).toString();
const QString action = data(mi, AppsModelRoles::ActionRole).toString();
if (action != QStringLiteral("install")
&& action != QStringLiteral("upgrade")
&& action != QStringLiteral("downgrade")
&& action != QStringLiteral("reinstall")) {
continue;
}
const QString toVersion = data(mi, AppsModel::ToVersionRole).toString();
const QVariantList versions = data(mi, AppsModel::VersionsRole).toList();
const QString toVersion = data(mi, AppsModelRoles::ToVersionRole).toString();
const QVariantList versions = data(mi, AppsModelRoles::VersionsRole).toList();
for (const QVariant& v : versions) {
const QVariantMap entry = v.toMap();
const QString entryVersion =
@@ -154,7 +155,7 @@ bool AppsFilterProxy::hasResolutionErrors() const
const int n = rowCount();
for (int i = 0; i < n; ++i) {
const QModelIndex mi = index(i, 0);
if (data(mi, AppsModel::ActionRole).toString() == QStringLiteral("error"))
if (data(mi, AppsModelRoles::ActionRole).toString() == QStringLiteral("error"))
return true;
}
return false;
@@ -172,14 +173,14 @@ QStringList AppsFilterProxy::categories() const
for (int i = 0; i < n; ++i) {
const QModelIndex mi = src->index(i, 0);
if (m_excludeMainUi) {
const QString nm = src->data(mi, AppsModel::NameRole).toString();
const QString nm = src->data(mi, AppsModelRoles::NameRole).toString();
if (nm == QStringLiteral("main_ui")) continue;
}
if (!m_typeFilter.isEmpty()) {
const QString t = src->data(mi, AppsModel::TypeRole).toString();
const QString t = src->data(mi, AppsModelRoles::TypeRole).toString();
if (t != m_typeFilter) continue;
}
QString c = src->data(mi, AppsModel::CategoryRole).toString();
QString c = src->data(mi, AppsModelRoles::CategoryRole).toString();
if (c.isEmpty()) continue;
c[0] = c[0].toUpper();
if (!seen.contains(c)) seen.append(c);
@@ -261,6 +262,13 @@ QStringList AppsFilterProxy::requiredPackages() const
void AppsFilterProxy::setRequiredPackages(const QVariantList& entries)
{
// Bound from QML, so this runs on every notify — including the re-set of an
// identical list. Bail early to avoid an invalidate()/notify loop.
if (m_requiredPackagesActive && m_requiredPackageEntries == entries) {
return;
}
m_requiredPackageEntries = entries;
m_requiredPackagesActive = true;
m_requiredPackagesByName.clear();
m_requiredPackagesOrder.clear();
@@ -290,36 +298,36 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
// main_ui exclusion — basecamp's own placeholder
if (m_excludeMainUi) {
const QString name = src->data(idx, AppsModel::NameRole).toString();
const QString name = src->data(idx, AppsModelRoles::NameRole).toString();
if (name == QStringLiteral("main_ui")) return false;
}
// Type filter.
if (!m_typeFilter.isEmpty()) {
const QString t = src->data(idx, AppsModel::TypeRole).toString();
const QString t = src->data(idx, AppsModelRoles::TypeRole).toString();
if (t != m_typeFilter) return false;
}
// Category filter. "All" / "" both mean "no filter".
if (!m_categoryFilter.isEmpty() && m_categoryFilter != QStringLiteral("All")) {
const QString c = capitalizeFirst(
src->data(idx, AppsModel::CategoryRole).toString());
src->data(idx, AppsModelRoles::CategoryRole).toString());
if (c != m_categoryFilter) return false;
}
// Install-state filter.
if (m_installStateFilter == QStringLiteral("installed")) {
if (!src->data(idx, AppsModel::IsInstalledRole).toBool()) return false;
if (!src->data(idx, AppsModelRoles::IsInstalledRole).toBool()) return false;
} else if (m_installStateFilter == QStringLiteral("notInstalled")) {
if (src->data(idx, AppsModel::IsInstalledRole).toBool()) return false;
if (src->data(idx, AppsModelRoles::IsInstalledRole).toBool()) return false;
}
// Search the visible fields — users type what they see, not the internal
// package name. (DisplayNameRole falls back to name, so name search works.)
if (!m_searchText.isEmpty()) {
const QString n = src->data(idx, AppsModel::NameRole).toString();
const QString dn = src->data(idx, AppsModel::DisplayNameRole).toString();
const QString ds = src->data(idx, AppsModel::DescriptionRole).toString();
const QString n = src->data(idx, AppsModelRoles::NameRole).toString();
const QString dn = src->data(idx, AppsModelRoles::DisplayNameRole).toString();
const QString ds = src->data(idx, AppsModelRoles::DescriptionRole).toString();
if (!n.contains(m_searchText, Qt::CaseInsensitive)
&& !dn.contains(m_searchText, Qt::CaseInsensitive)
&& !ds.contains(m_searchText, Qt::CaseInsensitive))
@@ -330,22 +338,22 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
// sections. matchLocalOnly inverts the semantics: accept only rows with
// an empty repositoryUrl (the synthetic "Local" bucket).
if (m_matchLocalOnly) {
const QString repo = src->data(idx, AppsModel::RepositoryUrlRole).toString();
const QString repo = src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
if (!repo.isEmpty()) return false;
} else if (!m_repositoryUrlFilter.isEmpty()) {
const QString repo = src->data(idx, AppsModel::RepositoryUrlRole).toString();
const QString repo = src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
if (repo != m_repositoryUrlFilter) return false;
}
// requiredPackages: name in map AND (pinned repo empty OR matches row).
// Without the repo pin, two repos publishing the same name both pass.
if (m_requiredPackagesActive) {
const QString n = src->data(idx, AppsModel::NameRole).toString();
const QString n = src->data(idx, AppsModelRoles::NameRole).toString();
const auto it = m_requiredPackagesByName.constFind(n);
if (it == m_requiredPackagesByName.constEnd()) return false;
if (!it.value().isEmpty()) {
const QString rowRepo =
src->data(idx, AppsModel::RepositoryUrlRole).toString();
src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
if (rowRepo != it.value()) return false;
}
}
@@ -356,8 +364,8 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
bool AppsFilterProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const
{
if (m_requiredPackagesActive) {
const QString ln = sourceModel()->data(left, AppsModel::NameRole).toString();
const QString rn = sourceModel()->data(right, AppsModel::NameRole).toString();
const QString ln = sourceModel()->data(left, AppsModelRoles::NameRole).toString();
const QString rn = sourceModel()->data(right, AppsModelRoles::NameRole).toString();
return m_requiredPackagesOrder.value(ln, INT_MAX)
< m_requiredPackagesOrder.value(rn, INT_MAX);
}
@@ -21,6 +21,10 @@ class AppsFilterProxy : public QSortFilterProxyModel {
Q_PROPERTY(bool matchLocalOnly READ matchLocalOnly WRITE setMatchLocalOnly NOTIFY matchLocalOnlyChanged)
Q_PROPERTY(bool excludeMainUi READ excludeMainUi WRITE setExcludeMainUi NOTIFY excludeMainUiChanged)
Q_PROPERTY(QStringList requiredPackages READ requiredPackages NOTIFY requiredPackagesChanged)
// The resolver's entries, in install order. Writable so QML can BIND it to
// the backend rather than have the host reach in and call the setter — the
// host must not hold a pointer to a shell-side proxy.
Q_PROPERTY(QVariantList requiredPackageEntries READ requiredPackageEntries WRITE setRequiredPackages NOTIFY requiredPackagesChanged)
Q_PROPERTY(int installedCount READ installedCount NOTIFY installedCountChanged)
Q_PROPERTY(int installFreshCount READ installFreshCount NOTIFY breakdownChanged)
Q_PROPERTY(int upgradeCount READ upgradeCount NOTIFY breakdownChanged)
@@ -51,6 +55,7 @@ public:
void setMatchLocalOnly(bool v);
void setExcludeMainUi(bool e);
QStringList requiredPackages() const;
QVariantList requiredPackageEntries() const { return m_requiredPackageEntries; }
Q_INVOKABLE void setRequiredPackages(const QVariantList& entries);
int installedCount() const;
@@ -97,6 +102,7 @@ private:
QString m_repositoryUrlFilter;
bool m_matchLocalOnly = false;
bool m_excludeMainUi = true;
QVariantList m_requiredPackageEntries;
QHash<QString, QString> m_requiredPackagesByName;
QHash<QString, int> m_requiredPackagesOrder;
bool m_requiredPackagesActive = false;

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 439 B

After

Width:  |  Height:  |  Size: 439 B

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

@@ -4,6 +4,7 @@ import QtQuick.Layouts
import Basecamp.AppManager
import Basecamp.Settings
import Basecamp.Backend
Item {
id: root
@@ -14,6 +15,17 @@ Item {
readonly property int sidebarAppManager: 1
readonly property int sidebarSettings: 3
// The App Manager's view of the catalog. Declared here rather than handed
// over by the backend: a filter proxy is view configuration, so it belongs
// to whoever draws the view. Only the source model crosses, as a plain
// QAbstractItemModel*.
AppsFilterProxy {
id: uiAppsProxy
sourceModel: backend.appsModel
typeFilter: "ui_qml"
excludeMainUi: true
}
Connections {
target: backend
function onRepositoryOperationCompleted(operation, url, success, error) {
@@ -44,7 +56,7 @@ Item {
// App Manager (sidebar sidebarAppManager -> stack index 0)
AppManagerView {
id: appManagerView
appsProxy: backend.uiAppsProxy
appsProxy: uiAppsProxy
repositories: backend.repositories
loading: backend.appsLoading
onAppClicked: function(name, repositoryUrl) {
@@ -131,10 +131,22 @@ Item {
displayNameLookup: _dialogDeps.displayNameLookup
}
// The "Required Packages" view of the catalog, restricted to whatever the
// resolver last returned. requiredPackageEntries is BOUND to the backend
// property, replacing a host-side setRequiredPackages() write into an
// object the host should not have been holding.
AppsFilterProxy {
id: requiredPackagesProxy
sourceModel: backend.appsModel
excludeMainUi: false
installStateFilter: ""
requiredPackageEntries: backend.requiredPackages
}
// App-Manager "Add Application" dialog.
AddApplicationDialog {
id: addApplicationDialog
requiredPackagesModel: backend.requiredPackagesModel
requiredPackagesModel: requiredPackagesProxy
onClosed: backend.notifyAddApplicationDialogClosed()
onUninstallRequested: function(name, repositoryUrl) {
backend.uninstallApp(name, repositoryUrl)
+237
View File
@@ -0,0 +1,237 @@
cmake_minimum_required(VERSION 3.21)
project(main_ui LANGUAGES CXX)
# ─────────────────────────────────────────────────────────────────────────────
# main_ui — Basecamp's UI shell, as a Qt plugin.
#
# Links Qt and NOTHING ELSE from this workspace: no logos runtime, no
# logos-qt-host, no liblogos, no generated module wrappers. nix/symbol-gate.nix
# enforces this, failing if a runtime type is defined in more than one image
# loaded into the app's process.
#
# The host is reached only through IShellHost, and the only host headers on the
# include path are the contract ones in app/interfaces/ — IShellHost.h,
# IShellView.h, InstallEnums.h, BasecampModelRoles.h — so including
# MainUIBackend.h or logos_api.h from here does not compile.
# ─────────────────────────────────────────────────────────────────────────────
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 COMPONENTS Core Gui Widgets Quick Qml QuickWidgets QuickControls2 REQUIRED)
qt_standard_project_setup(REQUIRES 6.5)
# The design system ships Logos.Theme / .Icons / .Controls as STATIC
# qt_add_qml_module targets behind the Logos::DesignSystem umbrella.
find_package(LogosDesignSystem CONFIG REQUIRED)
# The ONLY host directory this target may see. Widening this to app/ would put
# MainUIBackend.h, logos_api.h and token_manager.h one #include away.
set(BASECAMP_INTERFACES ${CMAKE_CURRENT_SOURCE_DIR}/../app/interfaces)
# Global, not per-target: each qt_add_qml_module below creates its OWN target,
# which does not inherit main_ui's include directories — without this
# basecamp_backend_qml fails to find BasecampModelRoles.h.
include_directories(${BASECAMP_INTERFACES})
set(SOURCES
MainShellView.h
MainShellView.cpp
MainContainer.h
MainContainer.cpp
WorkspaceArea.h
WorkspaceArea.cpp
ShortcutBridge.h
ShortcutBridge.cpp
${BASECAMP_INTERFACES}/IShellHost.h
${BASECAMP_INTERFACES}/IShellView.h
${BASECAMP_INTERFACES}/BasecampModelRoles.h
)
# SHARED, not MODULE: QPluginLoader is happy with either, and with the
# PREFIX/SUFFIX block below SHARED produces exactly the artefact names
# app/window.cpp and the bundler look for.
add_library(main_ui SHARED ${SOURCES})
# ── QML modules ─────────────────────────────────────────────────────────────
# QML_FILES/RESOURCES resolve against CMAKE_CURRENT_SOURCE_DIR, and a `../`
# prefix changes where each file lands in the resource tree — so Basecamp/ must
# stay beside this file.
set_source_files_properties(Basecamp/Icons/BasecampIcons.qml
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
set_source_files_properties(Basecamp/AppManager/AppColors.qml
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(basecamp_backend_qml
URI Basecamp.Backend
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Backend
SOURCES
../app/interfaces/InstallEnums.h
InstallEnums.cpp
AppsFilterProxy.h
AppsFilterProxy.cpp
ModulesFilterProxy.h
ModulesFilterProxy.cpp
)
qt_add_qml_module(basecamp_common_qml
URI Basecamp.Common
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Common
QML_FILES
Basecamp/Common/LoadingOverlay.qml
Basecamp/Common/EmptyView.qml
)
qt_add_qml_module(basecamp_sidebar_qml
URI Basecamp.Sidebar
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Sidebar
QML_FILES
Basecamp/Sidebar/SidebarPanel.qml
Basecamp/Sidebar/SidebarIconButton.qml
Basecamp/Sidebar/SidebarCircleButton.qml
Basecamp/Sidebar/SidebarAppDelegate.qml
)
qt_add_qml_module(basecamp_appmanager_qml
URI Basecamp.AppManager
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/AppManager
QML_FILES
Basecamp/AppManager/AppManagerView.qml
Basecamp/AppManager/AppManagerPanelHeader.qml
Basecamp/AppManager/AppRepoSection.qml
Basecamp/AppManager/AppContextMenu.qml
Basecamp/AppManager/AppGrid.qml
Basecamp/AppManager/AppGridDelegate.qml
Basecamp/AppManager/AppListDelegate.qml
Basecamp/AppManager/PackageRowDelegate.qml
Basecamp/AppManager/AddApplicationDialog.qml
Basecamp/AppManager/AppColors.qml
)
qt_add_qml_module(basecamp_settings_qml
URI Basecamp.Settings
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Settings
QML_FILES
Basecamp/Settings/SettingsView.qml
Basecamp/Settings/DashboardView.qml
Basecamp/Settings/AppsInspectorView.qml
Basecamp/Settings/ModuleInspectorView.qml
Basecamp/Settings/InspectorPanelHeader.qml
Basecamp/Settings/ModuleStatusBadge.qml
Basecamp/Settings/ModuleRowActions.qml
Basecamp/Settings/PluginInterfaceView.qml
Basecamp/Settings/RepositoriesView.qml
)
qt_add_qml_module(basecamp_shell_qml
URI Basecamp.Shell
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Shell
QML_FILES
Basecamp/Shell/ContentViews.qml
Basecamp/Shell/OverlayDialogs.qml
Basecamp/Shell/ConfirmationDialog.qml
Basecamp/Shell/UninstallDialog.qml
Basecamp/Shell/WelcomePage.qml
)
qt_add_qml_module(basecamp_icons_qml
URI Basecamp.Icons
VERSION 1.0
STATIC
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Icons
QML_FILES
Basecamp/Icons/BasecampIcons.qml
RESOURCES
Basecamp/Icons/basecamp.svg
Basecamp/Icons/dashboard.svg
Basecamp/Icons/module.svg
Basecamp/Icons/settings.svg
Basecamp/Icons/tent.png
Basecamp/Icons/workspace.svg
)
# Registers the STATIC qml module plugins with this target. Without it the
# resource roots exist but nothing imports them.
qt_import_qml_plugins(main_ui)
target_include_directories(main_ui PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${BASECAMP_INTERFACES}
)
# The WHOLE_ARCHIVE entries must not be trimmed: a STATIC qt_add_qml_module
# registers its types from a static initializer inside the *_qmlplugin archive,
# so dropping one lets the linker collect it — no build error, blank pane.
target_link_libraries(main_ui
PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Quick
Qt6::Qml
Qt6::QuickWidgets
Qt6::QuickControls2
Logos::DesignSystem
basecamp_backend_qml
basecamp_common_qml
basecamp_sidebar_qml
basecamp_appmanager_qml
basecamp_settings_qml
basecamp_shell_qml
basecamp_icons_qml
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_backend_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_common_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_sidebar_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_appmanager_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_settings_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_shell_qmlplugin>
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_icons_qmlplugin>
)
if(APPLE)
set_target_properties(main_ui PROPERTIES
BUNDLE FALSE
FRAMEWORK FALSE
PREFIX ""
SUFFIX ".dylib"
)
elseif(WIN32)
# MUST be tested before the else-branch, or the plugin is emitted as
# main_ui.so -- a genuine PE carrying a Unix extension. app/window.cpp
# looks for ".dll" on Q_OS_WIN, so it never finds it and the app reports
# "No main UI" with nothing obviously wrong.
set_target_properties(main_ui PROPERTIES
PREFIX ""
SUFFIX ".dll"
)
else()
set_target_properties(main_ui PROPERTIES
PREFIX ""
SUFFIX ".so"
)
endif()
install(TARGETS main_ui LIBRARY DESTINATION lib RUNTIME DESTINATION lib)
+15 -24
View File
@@ -10,13 +10,10 @@ class QQuickWidget;
class WorkspaceArea;
class ShortcutBridge;
// MainContainer — the UI shell.
//
// Holds exactly one host-side pointer: an IShellHost*. It has no LogosAPI*, no
// QtLogosCore*, and no MainUIBackend*; every host operation it needs goes
// through the eight methods on that interface, and QML reaches the backend as
// an opaque QObject* via IShellHost::backendObject(). That is what lets this
// class — and everything below it — compile against Qt alone.
// MainContainer — the UI shell. Holds exactly one host-side pointer, an
// IShellHost*: no LogosAPI*, no QtLogosCore*, no MainUIBackend*. QML reaches
// the backend as an opaque QObject* via IShellHost::backendObject(). That is
// what lets this class, and everything below it, compile against Qt alone.
class MainContainer : public QWidget, public IShellObserver
{
Q_OBJECT
@@ -26,13 +23,12 @@ public:
explicit MainContainer(IShellHost* host, QWidget* parent = nullptr);
~MainContainer();
// Get the workspace area
WorkspaceArea* getWorkspaceArea() const { return m_workspaceArea; }
// Stops the host from calling back into this object. Idempotent, and safe
// to call more than once — MainShellView::destroyShell() calls it before
// deleting, and the destructor calls it again for the path where Qt's
// parent-child teardown gets here first.
// Stops the host from calling back into this object. Idempotent:
// MainShellView::destroyShell() calls it before deleting, and the
// destructor calls it again for the path where Qt's parent-child teardown
// gets here first.
void detachFromHost();
// ── IShellObserver ──────────────────────────────────────────────────────
@@ -43,31 +39,26 @@ public:
void onPluginWindowActivateRequested(QWidget* widget) override;
protected:
// Kept in sync with the full MainContainer geometry — the overlay
// widget floats over both the sidebar and the content stack, so it
// can't sit in the HBoxLayout.
// Keeps the overlay sized to the full MainContainer: it floats over both
// the sidebar and the content stack, so it can't sit in the HBoxLayout.
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
// Called from QML whenever the combined visibility of the three
// overlay dialogs flips. We use it to toggle mouse-event
// passthrough on the overlay QQuickWidget transparent to mouse
// input when no dialog is open (so the sidebar / content still
// receive clicks), intercepting when one is.
// Called from QML when the combined visibility of the three overlay
// dialogs flips. Toggles mouse-event passthrough on the overlay
// QQuickWidget: transparent when no dialog is open, so the sidebar and
// content still receive clicks, intercepting when one is.
void onOverlayActiveChanged(bool active);
void onSidebarTooltipRequested(const QString& text, qreal y);
private:
void setupUi();
// Main layout
QHBoxLayout* m_mainLayout;
// Sidebar (QML)
QQuickWidget* m_sidebarWidget;
// Content area
QStackedWidget* m_contentStack;
// Workspace (QDockWidget-based, replaces the old QMdiArea workspace)
@@ -87,7 +78,7 @@ private:
// Apps/MDI screen was active.
QQuickWidget* m_overlayWidget;
// The host. Not owned — Window owns it and it outlives this widget.
// Not owned — Window owns it and it outlives this widget.
IShellHost* m_host;
ShortcutBridge* m_shortcutBridge = nullptr;
@@ -12,16 +12,15 @@ MainShellView::MainShellView(QObject* parent)
MainShellView::~MainShellView()
{
// If Window already destroyed the central widget, m_shell is null and this
// is a no-op; otherwise this is the last chance to detach the observer.
// Null if Window already destroyed the central widget; otherwise this is
// the last chance to detach the observer.
destroyShell(m_shell.data());
}
QWidget* MainShellView::createShell(IShellHost* host)
{
// Not a degraded mode: the shell reaches every host operation through this
// pointer, so a null one would surface later as a null dereference in a
// click handler rather than here.
// Every host operation goes through this pointer; a null one would surface
// later as a null dereference inside a click handler instead of here.
if (!host) {
qFatal("MainShellView::createShell requires an IShellHost");
}
@@ -39,9 +38,8 @@ void MainShellView::destroyShell(QWidget* widget)
}
// Detach before deleting so no in-flight host callback reaches a
// half-destroyed observer. MainContainer also does this in its own
// destructor, for the path where Qt's parent-child teardown gets there
// first; both are idempotent.
// half-destroyed observer. MainContainer's destructor does the same when
// Qt's teardown gets there first; both are idempotent.
if (widget == m_shell) {
m_shell->detachFromHost();
m_shell = nullptr;
+7 -8
View File
@@ -10,17 +10,17 @@ class MainContainer;
// ─────────────────────────────────────────────────────────────────────────────
// MainShellView — the IShellView implementation for Basecamp's own UI shell.
//
// A QObject with Q_INTERFACES so that `qobject_cast<IShellView*>` works on it.
// That cast is what Window will use once this class ships inside the main_ui
// plugin again; today Window constructs it directly, and the cast path costs
// nothing to keep working in the meantime.
// Q_INTERFACES + Q_PLUGIN_METADATA: QPluginLoader instantiates this class and
// Window reaches it with `qobject_cast<IShellView*>`. A real cast, not
// QMetaObject::invokeMethod by name — a signature change that way still
// compiles on both sides and misses only at runtime.
//
// Deliberately holds no state beyond the shell it built: everything the shell
// needs arrives through the IShellHost* it is handed.
// Holds no state beyond the shell it built; the rest arrives via IShellHost*.
// ─────────────────────────────────────────────────────────────────────────────
class MainShellView : public QObject, public IShellView {
Q_OBJECT
Q_INTERFACES(IShellView)
Q_PLUGIN_METADATA(IID IShellView_iid FILE "metadata.json")
public:
explicit MainShellView(QObject* parent = nullptr);
@@ -34,7 +34,6 @@ public:
private:
// QPointer auto-nulls when the widget is destroyed by its Qt parent (Window
// owns it as the central widget), so a later destroyShell() is a no-op
// rather than a double delete. The pre-fold MainUIPlugin carried exactly
// this guard and it is the reason it survived process exit.
// rather than a double delete.
QPointer<MainContainer> m_shell;
};
@@ -1,6 +1,6 @@
#include "ModulesFilterProxy.h"
#include "ModuleInstanceModel.h"
#include "BasecampModelRoles.h"
#include <QAbstractItemModel>
#include <QByteArray>
@@ -91,12 +91,12 @@ void ModulesFilterProxy::applySortOrder(int order)
int ModulesFilterProxy::roleFromName(const QByteArray& name) const
{
QAbstractItemModel* src = sourceModel();
if (!src) return ModuleInstanceModel::LabelRole;
if (!src) return ModuleInstanceRoles::LabelRole;
const auto roles = src->roleNames();
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
if (it.value() == name) return it.key();
}
return ModuleInstanceModel::LabelRole;
return ModuleInstanceRoles::LabelRole;
}
bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
@@ -109,9 +109,9 @@ bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
// State filter.
if (m_stateFilter == QLatin1String("loaded")
&& !src->data(idx, ModuleInstanceModel::IsLoadedRole).toBool()) return false;
&& !src->data(idx, ModuleInstanceRoles::IsLoadedRole).toBool()) return false;
if (m_stateFilter == QLatin1String("notLoaded")
&& src->data(idx, ModuleInstanceModel::IsLoadedRole).toBool()) return false;
&& src->data(idx, ModuleInstanceRoles::IsLoadedRole).toBool()) return false;
// Search filter — trimmed + lowercased against a fixed set of textual
// roles. Empty needle accepts everything.
@@ -119,11 +119,11 @@ bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
if (needle.isEmpty()) return true;
static const int textRoles[] = {
ModuleInstanceModel::NameRole,
ModuleInstanceModel::LabelRole,
ModuleInstanceModel::StatusTextRole,
ModuleInstanceModel::DescriptionRole,
ModuleInstanceModel::VersionRole,
ModuleInstanceRoles::NameRole,
ModuleInstanceRoles::LabelRole,
ModuleInstanceRoles::StatusTextRole,
ModuleInstanceRoles::DescriptionRole,
ModuleInstanceRoles::VersionRole,
};
for (int role : textRoles) {
if (src->data(idx, role).toString().toLower().contains(needle))
@@ -159,10 +159,10 @@ bool ModulesFilterProxy::lessThan(const QModelIndex& left,
// Stable tie-break by name so equal-status / equal-stat rows don't shuffle
// between refreshes (the core stats poll fires every 2s).
if (result == 0 && role != ModuleInstanceModel::NameRole) {
result = src->data(left, ModuleInstanceModel::NameRole).toString()
if (result == 0 && role != ModuleInstanceRoles::NameRole) {
result = src->data(left, ModuleInstanceRoles::NameRole).toString()
.localeAwareCompare(
src->data(right, ModuleInstanceModel::NameRole).toString());
src->data(right, ModuleInstanceRoles::NameRole).toString());
}
return result < 0;
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "main_ui",
"version": "1.0.0",
"description": "Logos Basecamp UI shell",
"type": "ui"
}
+24 -3
View File
@@ -9,7 +9,10 @@ find_package(Qt6 REQUIRED COMPONENTS Core Qml Test Widgets Quick QuickWidgets)
enable_testing()
set(BASECAMP_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../app")
# Two source roots: the host in ../app, the main_ui shell in ../src. The unit
# tests cover both; ../app/interfaces holds the contract headers they share.
set(BASECAMP_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../app")
set(BASECAMP_SHELL "${CMAKE_CURRENT_SOURCE_DIR}/../src")
if(DEFINED LOGOS_PACKAGE_HEADERS)
set(LOGOS_PACKAGE_HEADERS_INCLUDE "${LOGOS_PACKAGE_HEADERS}")
@@ -30,16 +33,34 @@ foreach(test_src IN LISTS TEST_SOURCES)
if(_head MATCHES "//[ \t]*srcdeps:[ \t]*([^\n]*)")
string(STRIP "${CMAKE_MATCH_1}" _deps_line)
string(REGEX REPLACE "[ \t]+" ";" _dep_list "${_deps_line}")
# Resolve a bare srcdep path against the host root, then the shell
# root. Missing in BOTH is fatal: dropping it silently surfaces much
# later as an undefined reference from the test's own object file.
foreach(dep IN LISTS _dep_list)
if(dep)
list(APPEND _extra_srcs "${BASECAMP_SRC}/${dep}")
if(EXISTS "${BASECAMP_SRC}/${dep}")
list(APPEND _extra_srcs "${BASECAMP_SRC}/${dep}")
elseif(EXISTS "${BASECAMP_SHELL}/${dep}")
list(APPEND _extra_srcs "${BASECAMP_SHELL}/${dep}")
else()
message(FATAL_ERROR
"srcdep '${dep}' for ${test_name} not found under "
"${BASECAMP_SRC} or ${BASECAMP_SHELL}")
endif()
endif()
endforeach()
endif()
add_executable(${test_name} ${test_src} ${_extra_srcs})
# AUTOMOC pairs a header with a .cpp of the same basename in the SAME
# directory, and InstallEnums.cpp (src/) and its header (app/interfaces/)
# are in different ones -- so the header rides along on every target to get
# InstallStage/InstallStatus' staticMetaObject. Elsewhere: a spare moc TU.
add_executable(${test_name} ${test_src} ${_extra_srcs}
${BASECAMP_SRC}/interfaces/InstallEnums.h)
target_include_directories(${test_name} PRIVATE
${BASECAMP_SRC}
${BASECAMP_SRC}/interfaces
${BASECAMP_SHELL}
${LOGOS_PACKAGE_HEADERS_INCLUDE}
)
target_link_libraries(${test_name} PRIVATE
+13 -3
View File
@@ -9,7 +9,10 @@ find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickTest Test)
enable_testing()
set(BASECAMP_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../app")
# AppsModel and InstallRegistry are host-side; AppsFilterProxy and InstallEnums
# live in the main_ui shell. This suite covers the seam, so it builds from both.
set(BASECAMP_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../app")
set(BASECAMP_SHELL "${CMAKE_CURRENT_SOURCE_DIR}/../../src")
if(DEFINED LOGOS_PACKAGE_HEADERS)
set(LOGOS_PACKAGE_HEADERS_INCLUDE "${LOGOS_PACKAGE_HEADERS}")
@@ -18,12 +21,19 @@ endif()
add_executable(qml_tests
main.cpp
${BASECAMP_SRC}/AppsModel.cpp
${BASECAMP_SRC}/AppsFilterProxy.cpp
${BASECAMP_SRC}/InstallEnums.cpp
${BASECAMP_SRC}/InstallRegistry.cpp # AppsModel depends on InstallRegistry
${BASECAMP_SHELL}/AppsFilterProxy.cpp
${BASECAMP_SHELL}/InstallEnums.cpp
# AUTOMOC pairs a header with a .cpp of the same basename in the SAME
# directory; InstallEnums.cpp is in src/ and its header in app/interfaces/,
# so it must be listed or the link fails on InstallStage/InstallStatus'
# staticMetaObject.
${BASECAMP_SRC}/interfaces/InstallEnums.h
)
target_include_directories(qml_tests PRIVATE
${BASECAMP_SRC}
${BASECAMP_SRC}/interfaces
${BASECAMP_SHELL}
${LOGOS_PACKAGE_HEADERS_INCLUDE}
)
target_link_libraries(qml_tests PRIVATE
+86
View File
@@ -47,8 +47,94 @@ TestCase {
compare(spy.count, 1, "no spurious re-emit");
}
// ── Phase 4: the proxies are declared in QML and BOUND to the backend ───
//
// ContentViews.qml and OverlayDialogs.qml now declare their own
// AppsFilterProxy and bind sourceModel / requiredPackageEntries to backend
// properties, instead of receiving prebuilt proxies the host owned. That
// migration compiles and runs even when the binding never fires — it just
// renders an empty list — so these cover the wiring itself.
function test_requiredPackageEntries_is_writable_and_drives_the_name_list() {
var proxy = filterProxyComp.createObject(testCase);
// Property assignment, not the Q_INVOKABLE setter: this is the path a
// QML binding actually takes.
proxy.requiredPackageEntries = [
{ name: "wallet_ui", repositoryUrl: "https://repo1/" },
{ name: "wallet_module", repositoryUrl: "https://repo2/" },
];
compare(proxy.requiredPackageEntries.length, 2, "entries round-trip");
compare(proxy.requiredPackages.length, 2, "and drive the name list");
compare(proxy.requiredPackages[0], "wallet_ui", "in resolver order");
compare(proxy.requiredPackages[1], "wallet_module");
}
function test_requiredPackageEntries_binding_tracks_its_source() {
// Stands in for `requiredPackageEntries: backend.requiredPackages`.
var source = sourceHolderComp.createObject(testCase);
var proxy = boundProxyComp.createObject(testCase, { holder: source });
compare(proxy.requiredPackages.length, 0, "empty before the source is set");
source.entries = [{ name: "extras", repositoryUrl: "" }];
compare(proxy.requiredPackages.length, 1, "binding re-evaluated on change");
compare(proxy.requiredPackages[0], "extras");
source.entries = [
{ name: "extras", repositoryUrl: "" },
{ name: "another", repositoryUrl: "" },
];
compare(proxy.requiredPackages.length, 2, "and again on the next change");
}
function test_requiredPackageEntries_does_not_re_notify_for_an_equal_list() {
var proxy = filterProxyComp.createObject(testCase);
var entries = [{ name: "extras", repositoryUrl: "" }];
proxy.requiredPackageEntries = entries;
var spy = spyComp.createObject(testCase, {
target: proxy, signalName: "requiredPackagesChanged",
});
proxy.requiredPackageEntries = entries;
compare(spy.count, 0, "an identical re-set is dropped, so a binding cannot loop");
}
function test_required_packages_proxy_configuration_matches_OverlayDialogs() {
// Mirrors the declaration in OverlayDialogs.qml. installStateFilter is
// the one that matters: "" is NOT the default ("all"), and silently
// reverting it would filter the Required Packages list down to nothing.
var proxy = filterProxyComp.createObject(testCase, {
excludeMainUi: false,
installStateFilter: "",
});
compare(proxy.excludeMainUi, false, "required-packages list includes main_ui");
compare(proxy.installStateFilter, "", "and is not restricted by install state");
}
function test_ui_apps_proxy_configuration_matches_ContentViews() {
// Mirrors the declaration in ContentViews.qml.
var proxy = filterProxyComp.createObject(testCase, {
typeFilter: "ui_qml",
excludeMainUi: true,
});
compare(proxy.typeFilter, "ui_qml", "App Manager shows ui_qml apps");
compare(proxy.excludeMainUi, true, "and never basecamp's own shell");
}
Component { id: filterProxyComp; AppsFilterProxy {} }
Component { id: spyComp; SignalSpy {} }
// Stand-in for the backend object a real binding reads from.
Component {
id: sourceHolderComp
QtObject { property var entries: [] }
}
Component {
id: boundProxyComp
AppsFilterProxy {
property QtObject holder
requiredPackageEntries: holder ? holder.entries : []
}
}
property var testCase: this
}
+5 -9
View File
@@ -191,18 +191,14 @@ async function openModuleInspector(app) {
test("apps inspector: shows installed UI plugins", async (app) => {
await openAppsInspector(app);
await app.waitFor(
// Basecamp's own shell is compiled into the executable since the main_ui
// fold, so it is not an installed UI plugin and has no "Main UI" row —
// this used to assert ["Main UI", "Package Manager"].
//
// Dropping to ["Package Manager"] alone made the test vacuous: the
// sidebar's own section button carries exactly that text, so the
// assertion held with the table completely empty. Assert the RAW module
// name instead — AppsInspectorView.qml:187-192 renders it in the row
// Asserting ["Package Manager"] alone is vacuous: the sidebar's own
// section button carries exactly that text, so it holds with the table
// completely empty. Assert the RAW module name, which
// AppsInspectorView.qml:187-192 renders in the row
// (`visible: rowItem.label !== rowItem.name`). Measured on a fresh app:
// findByProperty(text,"Package Manager") -> 2 (SidebarCircleButton, row)
// findByProperty(text,"package_manager_ui")-> 1 (the row's LogosText)
// findByProperty(text,"Main UI") -> 0 (folded into the exe)
// findByProperty(text,"Main UI") -> 0 (not an installed plugin)
async () => { await app.expectTexts(["package_manager_ui"]); },
{ timeout: 10000, interval: 500, description: "Apps Inspector list to populate" }
);