diff --git a/CLAUDE.md b/CLAUDE.md index 35b0599..b1dd8da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `/app`, which contains `Basecamp/Sidebar/`, +(typically `/src`, which contains `Basecamp/Sidebar/`, `Basecamp/Shell/`, etc.). MainContainer's three view-entry `setSource` calls will read from `$DEV_QML_PATH/Basecamp//.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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 706d497..bb81c95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//`. 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//`. 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 diff --git a/README.md b/README.md index fd19542..b7216c9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/AppsModel.h b/app/AppsModel.h index 34fda3f..2d6c2ee 100644 --- a/app/AppsModel.h +++ b/app/AppsModel.h @@ -1,5 +1,6 @@ #pragma once +#include "BasecampModelRoles.h" #include "InstallEnums.h" #include @@ -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); diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index fdf8806..b9944d2 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -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 $ 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 - $ - $ - $ - $ - $ - $ - $ $<$: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 ) diff --git a/app/CoreModuleManager.h b/app/CoreModuleManager.h index 865ce8a..7acf03f 100644 --- a/app/CoreModuleManager.h +++ b/app/CoreModuleManager.h @@ -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: diff --git a/app/MainUIBackend.cpp b/app/MainUIBackend.cpp index 3f3ca52..8f33c70 100644 --- a/app/MainUIBackend.cpp +++ b/app/MainUIBackend.cpp @@ -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(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) { diff --git a/app/MainUIBackend.h b/app/MainUIBackend.h index 9e4b270..a3370c3 100644 --- a/app/MainUIBackend.h +++ b/app/MainUIBackend.h @@ -1,9 +1,9 @@ #pragma once #include "InstallEnums.h" -#include "AppsFilterProxy.h" #include "ModuleInstanceModel.h" +#include #include #include #include @@ -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; diff --git a/app/ModuleInstanceModel.h b/app/ModuleInstanceModel.h index 5d9ea93..60363d8 100644 --- a/app/ModuleInstanceModel.h +++ b/app/ModuleInstanceModel.h @@ -1,5 +1,6 @@ #pragma once +#include "BasecampModelRoles.h" #include #include #include @@ -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); diff --git a/app/PackageCoordinator.cpp b/app/PackageCoordinator.cpp index 1bc3b1f..2210fd4 100644 --- a/app/PackageCoordinator.cpp +++ b/app/PackageCoordinator.cpp @@ -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); diff --git a/app/PackageCoordinator.h b/app/PackageCoordinator.h index 1937f7c..0c93b4b 100644 --- a/app/PackageCoordinator.h +++ b/app/PackageCoordinator.h @@ -11,7 +11,6 @@ #include #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 m_installTypeByModule; diff --git a/app/ShellHostAdapter.cpp b/app/ShellHostAdapter.cpp index 4935fea..b7274fe 100644 --- a/app/ShellHostAdapter.cpp +++ b/app/ShellHostAdapter.cpp @@ -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; } diff --git a/app/ShellHostAdapter.h b/app/ShellHostAdapter.h index ef936e9..63966c5 100644 --- a/app/ShellHostAdapter.h +++ b/app/ShellHostAdapter.h @@ -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 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; }; diff --git a/app/UIPluginManager.h b/app/UIPluginManager.h index 7c5fae6..d31cbd5 100644 --- a/app/UIPluginManager.h +++ b/app/UIPluginManager.h @@ -218,13 +218,10 @@ private: // Loaded-plugin state QMap 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> m_uiModuleWidgets; QMap> m_qmlPluginWidgets; QMap m_viewModuleHosts; diff --git a/app/interfaces/BasecampModelRoles.h b/app/interfaces/BasecampModelRoles.h new file mode 100644 index 0000000..1195e86 --- /dev/null +++ b/app/interfaces/BasecampModelRoles.h @@ -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 + +// ───────────────────────────────────────────────────────────────────────────── +// 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 + }; +}; diff --git a/app/interfaces/IShellHost.h b/app/interfaces/IShellHost.h index 0e1b1ab..7fb912c 100644 --- a/app/interfaces/IShellHost.h +++ b/app/interfaces/IShellHost.h @@ -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; }; diff --git a/app/interfaces/IShellView.h b/app/interfaces/IShellView.h index ea265e1..2e9db44 100644 --- a/app/interfaces/IShellView.h +++ b/app/interfaces/IShellView.h @@ -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 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 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 diff --git a/app/InstallEnums.h b/app/interfaces/InstallEnums.h similarity index 100% rename from app/InstallEnums.h rename to app/interfaces/InstallEnums.h diff --git a/app/utils/BuildInfo.h b/app/utils/BuildInfo.h index 28e2b47..aa703a9 100644 --- a/app/utils/BuildInfo.h +++ b/app/utils/BuildInfo.h @@ -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. diff --git a/app/window.cpp b/app/window.cpp index c209469..c55eb5d 100644 --- a/app/window.cpp +++ b/app/window.cpp @@ -19,11 +19,13 @@ #include #include #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 #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 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(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; diff --git a/app/window.h b/app/window.h index bae5cfd..7f44f6a 100644 --- a/app/window.h +++ b/app/window.h @@ -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; diff --git a/docs/project.md b/docs/project.md index 4cf3e9a..656bac8 100644 --- a/docs/project.md +++ b/docs/project.md @@ -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. diff --git a/doctests/basecamp-modules-bundle.test.yaml b/doctests/basecamp-modules-bundle.test.yaml index 4f591eb..33e6427 100644 --- a/doctests/basecamp-modules-bundle.test.yaml +++ b/doctests/basecamp-modules-bundle.test.yaml @@ -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: | diff --git a/doctests/basecamp-modules.test.yaml b/doctests/basecamp-modules.test.yaml index 4b1dc86..a40282d 100644 --- a/doctests/basecamp-modules.test.yaml +++ b/doctests/basecamp-modules.test.yaml @@ -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: | diff --git a/doctests/basecamp-shortcut-bridge.test.yaml b/doctests/basecamp-shortcut-bridge.test.yaml index 3af1cad..721fb46 100644 --- a/doctests/basecamp-shortcut-bridge.test.yaml +++ b/doctests/basecamp-shortcut-bridge.test.yaml @@ -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` diff --git a/flake.nix b/flake.nix index 5fb267d..df818f0 100644 --- a/flake.nix +++ b/flake.nix @@ -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; diff --git a/nix/app.nix b/nix/app.nix index d0d0a16..d1c8efa 100644 --- a/nix/app.nix +++ b/nix/app.nix @@ -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// and $out/plugins/

/ 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 diff --git a/nix/main-ui.nix b/nix/main-ui.nix new file mode 100644 index 0000000..f9cb258 --- /dev/null +++ b/nix/main-ui.nix @@ -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 + ''; +} diff --git a/nix/symbol-gate.nix b/nix/symbol-gate.nix index 5d0e548..7b7a651 100644 --- a/nix/symbol-gate.nix +++ b/nix/symbol-gate.nix @@ -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/.-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/. + # 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/. 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 diff --git a/app/AppsFilterProxy.cpp b/src/AppsFilterProxy.cpp similarity index 78% rename from app/AppsFilterProxy.cpp rename to src/AppsFilterProxy.cpp index fff5952..886adb0 100644 --- a/app/AppsFilterProxy.cpp +++ b/src/AppsFilterProxy.cpp @@ -1,6 +1,7 @@ #include "AppsFilterProxy.h" -#include "AppsModel.h" +#include "BasecampModelRoles.h" +#include "InstallEnums.h" #include "InstallEnums.h" #include @@ -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); } diff --git a/app/AppsFilterProxy.h b/src/AppsFilterProxy.h similarity index 91% rename from app/AppsFilterProxy.h rename to src/AppsFilterProxy.h index 8592b21..7af9a18 100644 --- a/app/AppsFilterProxy.h +++ b/src/AppsFilterProxy.h @@ -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 m_requiredPackagesByName; QHash m_requiredPackagesOrder; bool m_requiredPackagesActive = false; diff --git a/app/Basecamp/AppManager/AddApplicationDialog.qml b/src/Basecamp/AppManager/AddApplicationDialog.qml similarity index 100% rename from app/Basecamp/AppManager/AddApplicationDialog.qml rename to src/Basecamp/AppManager/AddApplicationDialog.qml diff --git a/app/Basecamp/AppManager/AppColors.qml b/src/Basecamp/AppManager/AppColors.qml similarity index 100% rename from app/Basecamp/AppManager/AppColors.qml rename to src/Basecamp/AppManager/AppColors.qml diff --git a/app/Basecamp/AppManager/AppContextMenu.qml b/src/Basecamp/AppManager/AppContextMenu.qml similarity index 100% rename from app/Basecamp/AppManager/AppContextMenu.qml rename to src/Basecamp/AppManager/AppContextMenu.qml diff --git a/app/Basecamp/AppManager/AppGrid.qml b/src/Basecamp/AppManager/AppGrid.qml similarity index 100% rename from app/Basecamp/AppManager/AppGrid.qml rename to src/Basecamp/AppManager/AppGrid.qml diff --git a/app/Basecamp/AppManager/AppGridDelegate.qml b/src/Basecamp/AppManager/AppGridDelegate.qml similarity index 100% rename from app/Basecamp/AppManager/AppGridDelegate.qml rename to src/Basecamp/AppManager/AppGridDelegate.qml diff --git a/app/Basecamp/AppManager/AppListDelegate.qml b/src/Basecamp/AppManager/AppListDelegate.qml similarity index 100% rename from app/Basecamp/AppManager/AppListDelegate.qml rename to src/Basecamp/AppManager/AppListDelegate.qml diff --git a/app/Basecamp/AppManager/AppManagerPanelHeader.qml b/src/Basecamp/AppManager/AppManagerPanelHeader.qml similarity index 100% rename from app/Basecamp/AppManager/AppManagerPanelHeader.qml rename to src/Basecamp/AppManager/AppManagerPanelHeader.qml diff --git a/app/Basecamp/AppManager/AppManagerView.qml b/src/Basecamp/AppManager/AppManagerView.qml similarity index 100% rename from app/Basecamp/AppManager/AppManagerView.qml rename to src/Basecamp/AppManager/AppManagerView.qml diff --git a/app/Basecamp/AppManager/AppRepoSection.qml b/src/Basecamp/AppManager/AppRepoSection.qml similarity index 100% rename from app/Basecamp/AppManager/AppRepoSection.qml rename to src/Basecamp/AppManager/AppRepoSection.qml diff --git a/app/Basecamp/AppManager/PackageRowDelegate.qml b/src/Basecamp/AppManager/PackageRowDelegate.qml similarity index 100% rename from app/Basecamp/AppManager/PackageRowDelegate.qml rename to src/Basecamp/AppManager/PackageRowDelegate.qml diff --git a/app/Basecamp/Common/EmptyView.qml b/src/Basecamp/Common/EmptyView.qml similarity index 100% rename from app/Basecamp/Common/EmptyView.qml rename to src/Basecamp/Common/EmptyView.qml diff --git a/app/Basecamp/Common/LoadingOverlay.qml b/src/Basecamp/Common/LoadingOverlay.qml similarity index 100% rename from app/Basecamp/Common/LoadingOverlay.qml rename to src/Basecamp/Common/LoadingOverlay.qml diff --git a/app/Basecamp/Icons/BasecampIcons.qml b/src/Basecamp/Icons/BasecampIcons.qml similarity index 100% rename from app/Basecamp/Icons/BasecampIcons.qml rename to src/Basecamp/Icons/BasecampIcons.qml diff --git a/app/Basecamp/Icons/basecamp.svg b/src/Basecamp/Icons/basecamp.svg similarity index 100% rename from app/Basecamp/Icons/basecamp.svg rename to src/Basecamp/Icons/basecamp.svg diff --git a/app/Basecamp/Icons/dashboard.svg b/src/Basecamp/Icons/dashboard.svg similarity index 100% rename from app/Basecamp/Icons/dashboard.svg rename to src/Basecamp/Icons/dashboard.svg diff --git a/app/Basecamp/Icons/module.svg b/src/Basecamp/Icons/module.svg similarity index 100% rename from app/Basecamp/Icons/module.svg rename to src/Basecamp/Icons/module.svg diff --git a/app/Basecamp/Icons/settings.svg b/src/Basecamp/Icons/settings.svg similarity index 100% rename from app/Basecamp/Icons/settings.svg rename to src/Basecamp/Icons/settings.svg diff --git a/app/Basecamp/Icons/tent.png b/src/Basecamp/Icons/tent.png similarity index 100% rename from app/Basecamp/Icons/tent.png rename to src/Basecamp/Icons/tent.png diff --git a/app/Basecamp/Icons/workspace.svg b/src/Basecamp/Icons/workspace.svg similarity index 100% rename from app/Basecamp/Icons/workspace.svg rename to src/Basecamp/Icons/workspace.svg diff --git a/app/Basecamp/Settings/AppsInspectorView.qml b/src/Basecamp/Settings/AppsInspectorView.qml similarity index 100% rename from app/Basecamp/Settings/AppsInspectorView.qml rename to src/Basecamp/Settings/AppsInspectorView.qml diff --git a/app/Basecamp/Settings/DashboardView.qml b/src/Basecamp/Settings/DashboardView.qml similarity index 100% rename from app/Basecamp/Settings/DashboardView.qml rename to src/Basecamp/Settings/DashboardView.qml diff --git a/app/Basecamp/Settings/InspectorPanelHeader.qml b/src/Basecamp/Settings/InspectorPanelHeader.qml similarity index 100% rename from app/Basecamp/Settings/InspectorPanelHeader.qml rename to src/Basecamp/Settings/InspectorPanelHeader.qml diff --git a/app/Basecamp/Settings/ModuleInspectorView.qml b/src/Basecamp/Settings/ModuleInspectorView.qml similarity index 100% rename from app/Basecamp/Settings/ModuleInspectorView.qml rename to src/Basecamp/Settings/ModuleInspectorView.qml diff --git a/app/Basecamp/Settings/ModuleRowActions.qml b/src/Basecamp/Settings/ModuleRowActions.qml similarity index 100% rename from app/Basecamp/Settings/ModuleRowActions.qml rename to src/Basecamp/Settings/ModuleRowActions.qml diff --git a/app/Basecamp/Settings/ModuleStatusBadge.qml b/src/Basecamp/Settings/ModuleStatusBadge.qml similarity index 100% rename from app/Basecamp/Settings/ModuleStatusBadge.qml rename to src/Basecamp/Settings/ModuleStatusBadge.qml diff --git a/app/Basecamp/Settings/PluginInterfaceView.qml b/src/Basecamp/Settings/PluginInterfaceView.qml similarity index 100% rename from app/Basecamp/Settings/PluginInterfaceView.qml rename to src/Basecamp/Settings/PluginInterfaceView.qml diff --git a/app/Basecamp/Settings/RepositoriesView.qml b/src/Basecamp/Settings/RepositoriesView.qml similarity index 100% rename from app/Basecamp/Settings/RepositoriesView.qml rename to src/Basecamp/Settings/RepositoriesView.qml diff --git a/app/Basecamp/Settings/SettingsView.qml b/src/Basecamp/Settings/SettingsView.qml similarity index 100% rename from app/Basecamp/Settings/SettingsView.qml rename to src/Basecamp/Settings/SettingsView.qml diff --git a/app/Basecamp/Shell/ConfirmationDialog.qml b/src/Basecamp/Shell/ConfirmationDialog.qml similarity index 100% rename from app/Basecamp/Shell/ConfirmationDialog.qml rename to src/Basecamp/Shell/ConfirmationDialog.qml diff --git a/app/Basecamp/Shell/ContentViews.qml b/src/Basecamp/Shell/ContentViews.qml similarity index 89% rename from app/Basecamp/Shell/ContentViews.qml rename to src/Basecamp/Shell/ContentViews.qml index d62619e..595927d 100644 --- a/app/Basecamp/Shell/ContentViews.qml +++ b/src/Basecamp/Shell/ContentViews.qml @@ -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) { diff --git a/app/Basecamp/Shell/OverlayDialogs.qml b/src/Basecamp/Shell/OverlayDialogs.qml similarity index 94% rename from app/Basecamp/Shell/OverlayDialogs.qml rename to src/Basecamp/Shell/OverlayDialogs.qml index 5e380ba..368b348 100644 --- a/app/Basecamp/Shell/OverlayDialogs.qml +++ b/src/Basecamp/Shell/OverlayDialogs.qml @@ -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) diff --git a/app/Basecamp/Shell/UninstallDialog.qml b/src/Basecamp/Shell/UninstallDialog.qml similarity index 100% rename from app/Basecamp/Shell/UninstallDialog.qml rename to src/Basecamp/Shell/UninstallDialog.qml diff --git a/app/Basecamp/Shell/WelcomePage.qml b/src/Basecamp/Shell/WelcomePage.qml similarity index 100% rename from app/Basecamp/Shell/WelcomePage.qml rename to src/Basecamp/Shell/WelcomePage.qml diff --git a/app/Basecamp/Sidebar/SidebarAppDelegate.qml b/src/Basecamp/Sidebar/SidebarAppDelegate.qml similarity index 100% rename from app/Basecamp/Sidebar/SidebarAppDelegate.qml rename to src/Basecamp/Sidebar/SidebarAppDelegate.qml diff --git a/app/Basecamp/Sidebar/SidebarCircleButton.qml b/src/Basecamp/Sidebar/SidebarCircleButton.qml similarity index 100% rename from app/Basecamp/Sidebar/SidebarCircleButton.qml rename to src/Basecamp/Sidebar/SidebarCircleButton.qml diff --git a/app/Basecamp/Sidebar/SidebarIconButton.qml b/src/Basecamp/Sidebar/SidebarIconButton.qml similarity index 100% rename from app/Basecamp/Sidebar/SidebarIconButton.qml rename to src/Basecamp/Sidebar/SidebarIconButton.qml diff --git a/app/Basecamp/Sidebar/SidebarPanel.qml b/src/Basecamp/Sidebar/SidebarPanel.qml similarity index 100% rename from app/Basecamp/Sidebar/SidebarPanel.qml rename to src/Basecamp/Sidebar/SidebarPanel.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..2c1f3e0 --- /dev/null +++ b/src/CMakeLists.txt @@ -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 + $ + $ + $ + $ + $ + $ + $ +) + +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) diff --git a/app/InstallEnums.cpp b/src/InstallEnums.cpp similarity index 100% rename from app/InstallEnums.cpp rename to src/InstallEnums.cpp diff --git a/app/MainContainer.cpp b/src/MainContainer.cpp similarity index 100% rename from app/MainContainer.cpp rename to src/MainContainer.cpp diff --git a/app/MainContainer.h b/src/MainContainer.h similarity index 63% rename from app/MainContainer.h rename to src/MainContainer.h index 5c91719..9e8e0dc 100644 --- a/app/MainContainer.h +++ b/src/MainContainer.h @@ -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; diff --git a/app/MainShellView.cpp b/src/MainShellView.cpp similarity index 61% rename from app/MainShellView.cpp rename to src/MainShellView.cpp index d2eee44..8b3790c 100644 --- a/app/MainShellView.cpp +++ b/src/MainShellView.cpp @@ -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; diff --git a/app/MainShellView.h b/src/MainShellView.h similarity index 71% rename from app/MainShellView.h rename to src/MainShellView.h index 54e7c59..430b0d7 100644 --- a/app/MainShellView.h +++ b/src/MainShellView.h @@ -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` 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`. 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 m_shell; }; diff --git a/app/ModulesFilterProxy.cpp b/src/ModulesFilterProxy.cpp similarity index 88% rename from app/ModulesFilterProxy.cpp rename to src/ModulesFilterProxy.cpp index b4c2c7b..d344406 100644 --- a/app/ModulesFilterProxy.cpp +++ b/src/ModulesFilterProxy.cpp @@ -1,6 +1,6 @@ #include "ModulesFilterProxy.h" -#include "ModuleInstanceModel.h" +#include "BasecampModelRoles.h" #include #include @@ -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; } diff --git a/app/ModulesFilterProxy.h b/src/ModulesFilterProxy.h similarity index 100% rename from app/ModulesFilterProxy.h rename to src/ModulesFilterProxy.h diff --git a/app/ShortcutBridge.cpp b/src/ShortcutBridge.cpp similarity index 100% rename from app/ShortcutBridge.cpp rename to src/ShortcutBridge.cpp diff --git a/app/ShortcutBridge.h b/src/ShortcutBridge.h similarity index 100% rename from app/ShortcutBridge.h rename to src/ShortcutBridge.h diff --git a/app/WorkspaceArea.cpp b/src/WorkspaceArea.cpp similarity index 100% rename from app/WorkspaceArea.cpp rename to src/WorkspaceArea.cpp diff --git a/app/WorkspaceArea.h b/src/WorkspaceArea.h similarity index 100% rename from app/WorkspaceArea.h rename to src/WorkspaceArea.h diff --git a/src/metadata.json b/src/metadata.json new file mode 100644 index 0000000..ec5a873 --- /dev/null +++ b/src/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "main_ui", + "version": "1.0.0", + "description": "Logos Basecamp UI shell", + "type": "ui" +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9ee4722..a760df1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/qml/CMakeLists.txt b/tests/qml/CMakeLists.txt index 208bbdd..e02cf91 100644 --- a/tests/qml/CMakeLists.txt +++ b/tests/qml/CMakeLists.txt @@ -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 diff --git a/tests/qml/tst_AppManagerView.qml b/tests/qml/tst_AppManagerView.qml index 5708d89..6bb3a73 100644 --- a/tests/qml/tst_AppManagerView.qml +++ b/tests/qml/tst_AppManagerView.qml @@ -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 } diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 9e206ad..73a0041 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -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" } );