feat(shell): ship main_ui as a plugin that links no logos runtime
Measured on the built artefact: main_ui.dylib DEFINES zero and IMPORTS zero of
TokenManager, StoreRegistry, LogosAPI, LogosAPIClient and logos_core_*, out of
3410 symbols read. It links Qt and nothing else from this workspace, which
nix/symbol-gate.nix enforces across the in-process image set.
Getting there needed the last non-Qt types off the boundary: the model
properties cross as QAbstractItemModel*, catalogInstallStageChanged carries an
int rather than InstallStage::Value, and the two prebuilt AppsFilterProxy
instances are declared in QML instead of owned by MainUIBackend. That last one
removes a real inversion -- PackageCoordinator called setRequiredPackages() on
a proxy the host held a pointer to; it now emits requiredPackagesResolved() and
QML binds to the republished property.
Window resolves the plugin, qobject_casts it to IShellView, checks
hostAbiVersion() against IShellHost_abi, and calls createShell(IShellHost*).
No error-label fallback widget: that degraded to something that looked like a
working app with an empty window.
Filter proxies read role constants off host-side models and InstallEnums is
used by nine host files, so app/interfaces/ gains the contract headers both
sides compile against -- the models inherit the role structs, leaving every
AppsModel::NameRole call site unchanged. The plugin's include path is
app/interfaces only, so including a host header does not compile.
Four things only running it finds:
* Logos::DesignSystem may be linked by exactly ONE image -- both linked it
and the app aborted with "Cannot add multiple registrations for
Logos.Icons"; QML module registration is process-global
* qmltyperegistrar emits no #include for a SOURCES header given as an
absolute path outside the project
* each qt_add_qml_module is its own target and inherits no include dirs
* AUTOMOC pairs header<->cpp by same-basename-same-DIRECTORY, which the
split breaks
tst_AppManagerView.qml grows five tests for the QML binding, checked with a
negative control: breaking one assertion fails qml-tests, so they run.
@@ -12,18 +12,18 @@ nix build
|
||||
nix build && ./result/bin/LogosBasecamp
|
||||
|
||||
# Iterate on QML without rebuilding — relaunch to pick up edits.
|
||||
DEV_QML_PATH=$PWD/app nix build && DEV_QML_PATH=$PWD/app ./result/bin/LogosBasecamp
|
||||
DEV_QML_PATH=$PWD/src nix build && DEV_QML_PATH=$PWD/src ./result/bin/LogosBasecamp
|
||||
```
|
||||
|
||||
QML lives in feature-axis qt_add_qml_module modules (Basecamp.Sidebar,
|
||||
.AppManager, .Settings, .Shell, plus .Backend for C++ types) — bytecode is
|
||||
embedded in the LogosBasecamp binary. No runtime QML disk cache, so the qrc-keyed cache
|
||||
embedded in the main_ui plugin. No runtime QML disk cache, so the qrc-keyed cache
|
||||
staleness bug doesn't apply.
|
||||
|
||||
### `DEV_QML_PATH` — iterate on view layouts without rebuilding
|
||||
|
||||
Point `DEV_QML_PATH` at a directory whose layout mirrors the QML URI hierarchy
|
||||
(typically `<repo>/app`, which contains `Basecamp/Sidebar/`,
|
||||
(typically `<repo>/src`, which contains `Basecamp/Sidebar/`,
|
||||
`Basecamp/Shell/`, etc.). MainContainer's three view-entry `setSource` calls
|
||||
will read from `$DEV_QML_PATH/Basecamp/<Feature>/<Entry>.qml` instead of the
|
||||
embedded qrc resource. Relaunch the app to pick up edits.
|
||||
@@ -66,7 +66,7 @@ nix build .#integration-test -L
|
||||
- **Sidebar** (left): Contains app plugin icons (top/middle) and system buttons at the bottom (Dashboard, Modules, Settings)
|
||||
- **Plugins** appear as sidebar icons: `package_manager_ui`
|
||||
- Plugins are loaded from `~/Library/Application Support/Logos/LogosBasecampDev/plugins/`
|
||||
- Main UI is in `app/Basecamp/`, organised by feature: `Sidebar/`, `AppManager/`, `Settings/`, `Shell/`, `Icons/`
|
||||
- Main UI is in `src/Basecamp/` (the main_ui plugin), organised by feature: `Sidebar/`, `AppManager/`, `Settings/`, `Shell/`, `Icons/`
|
||||
|
||||
## C++ Architecture
|
||||
|
||||
@@ -114,12 +114,12 @@ CoreModuleManager is constructed first, UIPluginManager second (receives CoreMod
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/Basecamp/Shell/OverlayDialogs.qml` | Global dialog layer (missing deps, cascade confirm, install gate, install failure) — hosted in a transparent top-level QQuickWidget |
|
||||
| `app/Basecamp/Shell/ConfirmationDialog.qml` | Multi-mode dialog: `missingDeps`, `unloadCascade`, `upgradeCascade`, `installGate`, `installError` — uninstall confirmation lives in `UninstallDialog.qml` |
|
||||
| `app/Basecamp/Sidebar/SidebarPanel.qml` | App icons + system nav buttons |
|
||||
| `app/Basecamp/Settings/AppsInspectorView.qml` | Apps Inspector (UI plugins) — view-only, load/unload; uninstall lives in PMUI |
|
||||
| `app/Basecamp/Settings/ModuleInspectorView.qml` | Module Inspector (core modules) — view-only, load/unload + stats; uninstall lives in PMUI |
|
||||
| `app/Basecamp/Shell/ContentViews.qml` | StackLayout switching between Dashboard, Repositories, Apps/Module Inspector |
|
||||
| `src/Basecamp/Shell/OverlayDialogs.qml` | Global dialog layer (missing deps, cascade confirm, install gate, install failure) — hosted in a transparent top-level QQuickWidget |
|
||||
| `src/Basecamp/Shell/ConfirmationDialog.qml` | Multi-mode dialog: `missingDeps`, `unloadCascade`, `upgradeCascade`, `installGate`, `installError` — uninstall confirmation lives in `UninstallDialog.qml` |
|
||||
| `src/Basecamp/Sidebar/SidebarPanel.qml` | App icons + system nav buttons |
|
||||
| `src/Basecamp/Settings/AppsInspectorView.qml` | Apps Inspector (UI plugins) — view-only, load/unload; uninstall lives in PMUI |
|
||||
| `src/Basecamp/Settings/ModuleInspectorView.qml` | Module Inspector (core modules) — view-only, load/unload + stats; uninstall lives in PMUI |
|
||||
| `src/Basecamp/Shell/ContentViews.qml` | StackLayout switching between Dashboard, Repositories, Apps/Module Inspector |
|
||||
|
||||
## QML Inspector (MCP)
|
||||
|
||||
@@ -139,7 +139,7 @@ The app runs an inspector server (default: localhost:3768) that the `qml-inspect
|
||||
|
||||
## Key Directories
|
||||
|
||||
- `app/Basecamp/` - QML UI source files, organised by feature (Sidebar/AppManager/Settings/Shell/Icons)
|
||||
- `src/Basecamp/` - QML UI source files, organised by feature (Sidebar/AppManager/Settings/Shell/Icons)
|
||||
- `nix/` - Nix build configurations (app.nix, smoke-test.nix, integration-test.nix)
|
||||
- `logos-qt-mcp` - QML Inspector: MCP server, test framework, Qt plugin (separate repo, flake input)
|
||||
- `tests/` - UI integration tests
|
||||
|
||||
@@ -87,7 +87,7 @@ Releases are cut from `release/**` branches by the maintainers — see [`docs/RE
|
||||
## Style and conventions
|
||||
|
||||
- **C++**: C++17. No formatter enforced yet (`.clang-format` planned) — match the style of files you touch.
|
||||
- **QML**: Feature-axis `qt_add_qml_module` modules under `app/Basecamp/<Feature>/`. Follow the pattern: view files under a feature emit signals; backend calls belong in `Basecamp/Shell/ContentViews.qml`. See `CLAUDE.md` for the C++ backend split (`MainUIBackend` / `CoreModuleManager` / `UIPluginManager` / `PackageCoordinator`) — respect the dependency direction.
|
||||
- **QML**: Feature-axis `qt_add_qml_module` modules under `src/Basecamp/<Feature>/`. Follow the pattern: view files under a feature emit signals; backend calls belong in `Basecamp/Shell/ContentViews.qml`. See `CLAUDE.md` for the C++ backend split (`MainUIBackend` / `CoreModuleManager` / `UIPluginManager` / `PackageCoordinator`) — respect the dependency direction.
|
||||
- **Nix**: modular files under `nix/`. `flake.nix` inputs follow `logos-cpp-sdk`'s `nixpkgs` — never pin a separate one.
|
||||
|
||||
## Licensing
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "BasecampModelRoles.h"
|
||||
#include "InstallEnums.h"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
@@ -13,42 +14,10 @@ class InstallRegistry;
|
||||
|
||||
// AppsModel — the single source of truth for every package the App Manager
|
||||
// (and Modules tab) cares about.
|
||||
class AppsModel : public QAbstractListModel {
|
||||
class AppsModel : public QAbstractListModel, public AppsModelRoles {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QStringList categories READ categories NOTIFY categoriesChanged)
|
||||
public:
|
||||
enum Roles {
|
||||
NameRole = Qt::UserRole + 1,
|
||||
RepositoryUrlRole,
|
||||
DisplayNameRole,
|
||||
DescriptionRole,
|
||||
CategoryRole,
|
||||
TypeRole, // "ui_qml" | "core"
|
||||
IconUrlRole, // resolved icon URL; "" → monogram fallback
|
||||
SupportsFullBleedIconRole, // manifest >= 0.4.0: icon is a validated
|
||||
// 256x256 asset, safe to render edge-to-edge
|
||||
VersionsRole, // QVariantList — all known catalog versions
|
||||
DependenciesRole, // QVariantList — direct deps of versions[0]'s manifest,
|
||||
// normalized to [{name, version}, ...]. version "" = no constraint.
|
||||
InstalledVersionRole, // "" when not installed
|
||||
LatestVersionRole, // versions[0].version
|
||||
HasUpdateRole, // installedVersion != "" && installed != latest
|
||||
IsInstalledRole, // installedVersion != ""
|
||||
MissingDepsRole, // QStringList of dep names whose LGX isn't on
|
||||
// disk (sourced from
|
||||
// PackageCoordinator::m_missingDepsByModule).
|
||||
// Empty when the install is complete.
|
||||
InstallStatusRole, // InstallStatus enum — per-row catalog-vs-disk
|
||||
// state (Install/Launch/Upgrade/Downgrade/
|
||||
// Reinstall in QML terms). PMUI mirror.
|
||||
InstallTypeRole, // "embedded" | "user" | ""
|
||||
ActionRole,
|
||||
ToVersionRole,
|
||||
IsTopLevelRole,
|
||||
ResolverErrorRole,
|
||||
InstallStageRole, // InstallStage::Value (int) — see InstallEnums.h
|
||||
InstallErrorRole, // failure message when InstallStage == Failed
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit AppsModel(QObject* parent = nullptr);
|
||||
|
||||
@@ -9,7 +9,7 @@ set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON) # Enable Qt's Meta-Object Compiler
|
||||
set(CMAKE_AUTORCC ON) # Enable automatic compilation of resource files
|
||||
set(CMAKE_AUTOUIC ON) # carried over from the folded main_ui plugin
|
||||
set(CMAKE_AUTOUIC ON) # Enable Qt's UI compiler
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
||||
# RPATH settings for macOS
|
||||
@@ -57,9 +57,8 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/macos)
|
||||
# semver headers and logos-cpp-generator's --general-only output. Absent in
|
||||
# non-nix builds — BuildInfo.h guards its include with __has_include.
|
||||
#
|
||||
# Deliberately a single directory. The plugin build had its own
|
||||
# src/generated_code alongside this one, so once both targets are the same
|
||||
# target BuildInfo.h's __has_include would be decided by -I ORDER, silently.
|
||||
# Keep it a SINGLE directory: a second generated dir on the include path would
|
||||
# make that __has_include depend on -I ORDER, silently.
|
||||
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/generated)
|
||||
|
||||
# Allow override from environment or command line (for nix builds)
|
||||
@@ -156,9 +155,7 @@ else()
|
||||
endif()
|
||||
|
||||
# ── logos-view-module-runtime (provides ViewModuleHost.h + the ui-host lib) ───
|
||||
# Folded in from the former src/CMakeLists.txt. -DLOGOS_VIEW_MODULE_RUNTIME_ROOT
|
||||
# was already being passed to this target by nix/app.nix and was a NO-OP; it is
|
||||
# load-bearing now that UIPluginManager/PluginLoader compile into the exe.
|
||||
# Load-bearing: UIPluginManager/PluginLoader compile into the exe.
|
||||
if(NOT DEFINED LOGOS_VIEW_MODULE_RUNTIME_ROOT)
|
||||
set(_candidate "${REAL_SOURCE_DIR}/../../logos-view-module-runtime")
|
||||
if(EXISTS "${_candidate}/include/ViewModuleHost.h")
|
||||
@@ -244,20 +241,14 @@ set(PROJECT_SOURCES
|
||||
interfaces/IComponent.h
|
||||
interfaces/IShellHost.h
|
||||
interfaces/IShellView.h
|
||||
interfaces/InstallEnums.h
|
||||
interfaces/BasecampModelRoles.h
|
||||
resources.qrc
|
||||
|
||||
# The host side of the shell boundary. ShellHostAdapter implements
|
||||
# IShellHost over MainUIBackend; MainShellView is the IShellView
|
||||
# implementation that will move into the main_ui plugin when the shell is
|
||||
# split back out.
|
||||
# The host side of the shell boundary. The shell itself lives in ../src and
|
||||
# builds as the main_ui plugin; this image never compiles a line of it.
|
||||
ShellHostAdapter.cpp
|
||||
MainShellView.cpp
|
||||
|
||||
# The UI shell, still compiled in. Everything from MainContainer down
|
||||
# reaches the host only through IShellHost — no LogosAPI, no QtLogosCore,
|
||||
# no MainUIBackend — which is what makes splitting it back into a plugin a
|
||||
# packaging change rather than a privilege change.
|
||||
MainContainer.cpp
|
||||
MainUIBackend.cpp
|
||||
CoreModuleManager.cpp
|
||||
UIPluginManager.cpp
|
||||
@@ -267,8 +258,6 @@ set(PROJECT_SOURCES
|
||||
AppsModel.cpp
|
||||
ModuleInstanceModel.cpp
|
||||
PluginLoader.cpp
|
||||
WorkspaceArea.cpp
|
||||
ShortcutBridge.cpp
|
||||
restricted/DenyAllReply.cpp
|
||||
restricted/DenyAllNetworkAccessManager.cpp
|
||||
restricted/DenyAllNAMFactory.cpp
|
||||
@@ -297,125 +286,9 @@ qt_add_executable(LogosBasecamp
|
||||
${PROJECT_SOURCES}
|
||||
)
|
||||
|
||||
# ── QML modules (folded in verbatim from the former src/CMakeLists.txt) ──────
|
||||
# These paths are relative to CMAKE_CURRENT_SOURCE_DIR, which is why Basecamp/
|
||||
# was moved PHYSICALLY into app/ rather than referenced as ../src/Basecamp:
|
||||
# qt_add_qml_module's QML_FILES/RESOURCES are resolved against this directory
|
||||
# and a `../` prefix changes where each file lands in the resource tree.
|
||||
set_source_files_properties(Basecamp/Icons/BasecampIcons.qml
|
||||
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
set_source_files_properties(Basecamp/AppManager/AppColors.qml
|
||||
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
|
||||
qt_add_qml_module(basecamp_backend_qml
|
||||
URI Basecamp.Backend
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Backend
|
||||
SOURCES
|
||||
InstallEnums.h
|
||||
InstallEnums.cpp
|
||||
AppsFilterProxy.h
|
||||
AppsFilterProxy.cpp
|
||||
ModulesFilterProxy.h
|
||||
ModulesFilterProxy.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_common_qml
|
||||
URI Basecamp.Common
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Common
|
||||
QML_FILES
|
||||
Basecamp/Common/LoadingOverlay.qml
|
||||
Basecamp/Common/EmptyView.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_sidebar_qml
|
||||
URI Basecamp.Sidebar
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Sidebar
|
||||
QML_FILES
|
||||
Basecamp/Sidebar/SidebarPanel.qml
|
||||
Basecamp/Sidebar/SidebarIconButton.qml
|
||||
Basecamp/Sidebar/SidebarCircleButton.qml
|
||||
Basecamp/Sidebar/SidebarAppDelegate.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_appmanager_qml
|
||||
URI Basecamp.AppManager
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/AppManager
|
||||
QML_FILES
|
||||
Basecamp/AppManager/AppManagerView.qml
|
||||
Basecamp/AppManager/AppManagerPanelHeader.qml
|
||||
Basecamp/AppManager/AppRepoSection.qml
|
||||
Basecamp/AppManager/AppContextMenu.qml
|
||||
Basecamp/AppManager/AppGrid.qml
|
||||
Basecamp/AppManager/AppGridDelegate.qml
|
||||
Basecamp/AppManager/AppListDelegate.qml
|
||||
Basecamp/AppManager/PackageRowDelegate.qml
|
||||
Basecamp/AppManager/AddApplicationDialog.qml
|
||||
Basecamp/AppManager/AppColors.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_settings_qml
|
||||
URI Basecamp.Settings
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Settings
|
||||
QML_FILES
|
||||
Basecamp/Settings/SettingsView.qml
|
||||
Basecamp/Settings/DashboardView.qml
|
||||
Basecamp/Settings/AppsInspectorView.qml
|
||||
Basecamp/Settings/ModuleInspectorView.qml
|
||||
Basecamp/Settings/InspectorPanelHeader.qml
|
||||
Basecamp/Settings/ModuleStatusBadge.qml
|
||||
Basecamp/Settings/ModuleRowActions.qml
|
||||
Basecamp/Settings/PluginInterfaceView.qml
|
||||
Basecamp/Settings/RepositoriesView.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_shell_qml
|
||||
URI Basecamp.Shell
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Shell
|
||||
QML_FILES
|
||||
Basecamp/Shell/ContentViews.qml
|
||||
Basecamp/Shell/OverlayDialogs.qml
|
||||
Basecamp/Shell/ConfirmationDialog.qml
|
||||
Basecamp/Shell/UninstallDialog.qml
|
||||
Basecamp/Shell/WelcomePage.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_icons_qml
|
||||
URI Basecamp.Icons
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Icons
|
||||
QML_FILES
|
||||
Basecamp/Icons/BasecampIcons.qml
|
||||
RESOURCES
|
||||
Basecamp/Icons/basecamp.svg
|
||||
Basecamp/Icons/dashboard.svg
|
||||
Basecamp/Icons/module.svg
|
||||
Basecamp/Icons/settings.svg
|
||||
Basecamp/Icons/tent.png
|
||||
Basecamp/Icons/workspace.svg
|
||||
)
|
||||
|
||||
qt_import_qml_plugins(LogosBasecamp)
|
||||
|
||||
# The UI shell's seven qt_add_qml_module targets live in ../src. They are STATIC
|
||||
# modules registering their types from a static initializer, so they link into
|
||||
# the main_ui plugin — the image that loads the QML — not this executable.
|
||||
target_include_directories(LogosBasecamp PRIVATE
|
||||
${LOGOS_VIEW_MODULE_RUNTIME_ROOT}/include
|
||||
${GENERATED_LOGOS_SDK_DIR}
|
||||
@@ -445,14 +318,13 @@ endif()
|
||||
# logos-qt-sdk and the host split moved to logos-plugin-qt; it carries the
|
||||
# logos-protocol library transitively.
|
||||
#
|
||||
# Qt6::Network is named EXPLICITLY. find_package() has always listed it, but
|
||||
# nothing linked it: it arrived transitively through QuickWidgets/Quick, and
|
||||
# restricted/DenyAllNetworkAccessManager.cpp subclasses QNetworkAccessManager.
|
||||
# Qt6::Network is named EXPLICITLY rather than left to arrive transitively
|
||||
# through Quick/QuickWidgets: restricted/DenyAllNetworkAccessManager.cpp
|
||||
# subclasses QNetworkAccessManager.
|
||||
#
|
||||
# The seven $<LINK_LIBRARY:WHOLE_ARCHIVE,...qmlplugin> entries are load-bearing
|
||||
# and must not be trimmed: a STATIC qt_add_qml_module registers its types from a
|
||||
# static initializer inside the *_qmlplugin archive, so dropping one lets the
|
||||
# linker garbage-collect it — no build error, blank pane at runtime.
|
||||
# This executable owns no QML. Qt6::Quick / Qml / QuickWidgets / QuickControls2
|
||||
# are linked for the out-of-process UI plugins, and so the portable bundler
|
||||
# picks them up.
|
||||
target_link_libraries(LogosBasecamp PRIVATE
|
||||
Qt6::Widgets
|
||||
Qt6::RemoteObjects
|
||||
@@ -463,45 +335,16 @@ target_link_libraries(LogosBasecamp PRIVATE
|
||||
Qt6::Network
|
||||
logos-qt-host::logos_qt_host_shared
|
||||
${LOGOS_VIEW_MODULE_RUNTIME_LIB}
|
||||
Logos::DesignSystem
|
||||
basecamp_backend_qml
|
||||
basecamp_common_qml
|
||||
basecamp_sidebar_qml
|
||||
basecamp_appmanager_qml
|
||||
basecamp_settings_qml
|
||||
basecamp_shell_qml
|
||||
basecamp_icons_qml
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_backend_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_common_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_sidebar_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_appmanager_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_settings_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_shell_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_icons_qmlplugin>
|
||||
$<$<BOOL:${ENABLE_QML_INSPECTOR}>:qml-inspector>
|
||||
# logos_core is LAST, and that position is load-bearing on Windows.
|
||||
#
|
||||
# On Windows liblogos_core.dll is the single provider of the shared C++
|
||||
# runtime, so the static archives that define those types are emptied out
|
||||
# (LogosSharedFromDll.cmake) and every reference to them has to be satisfied
|
||||
# by liblogos_core's IMPORT LIBRARY. GNU ld resolves an archive left to
|
||||
# right: it only takes members that satisfy references that are already
|
||||
# undefined when it reaches them. With logos_core listed before
|
||||
# LOGOS_VIEW_MODULE_RUNTIME_LIB, the view runtime's references to
|
||||
# LogosAPIClient::whenObjectAvailable / ::eventSubscriptionState /
|
||||
# ::pendingEventSubscriptions and logos::qvariantToNlohmann came up AFTER
|
||||
# the import library had already been passed, and the mingw link failed with
|
||||
# four undefined references — even though liblogos_core.dll exports all four
|
||||
# (measured: they are in its export table and its .dll.a).
|
||||
#
|
||||
# This did not bite before the main_ui fold only because of where the two
|
||||
# sat: the plugin's CMakeLists linked LOGOS_VIEW_MODULE_RUNTIME_LIB in its
|
||||
# main target_link_libraries() and logos_core in a separate, LATER call, so
|
||||
# the generated order was already correct. Folding the UI shell into this
|
||||
# executable brought the view runtime onto a link line whose logos_core came
|
||||
# eighth. Nothing else on this list defines symbols logos_core needs — it is
|
||||
# an import library for a DLL — so last is both correct and the position
|
||||
# that cannot be broken by adding another archive above it.
|
||||
# logos_core is LAST, and that position is load-bearing on Windows: it is the
|
||||
# IMPORT LIBRARY for liblogos_core.dll, the single provider of the shared C++
|
||||
# runtime (LogosSharedFromDll.cmake empties the static archives defining those
|
||||
# types). GNU ld takes only archive members satisfying references already
|
||||
# undefined when it reaches them, so placing it ahead of
|
||||
# LOGOS_VIEW_MODULE_RUNTIME_LIB fails the mingw link with four undefined
|
||||
# references — LogosAPIClient::whenObjectAvailable / ::eventSubscriptionState
|
||||
# / ::pendingEventSubscriptions and logos::qvariantToNlohmann — despite all
|
||||
# four being in liblogos_core.dll's export table and its .dll.a.
|
||||
logos_core
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "MainUIBackend.h"
|
||||
#include "AppsFilterProxy.h"
|
||||
#include "AppsModel.h"
|
||||
#include "CoreModuleManager.h"
|
||||
#include "ModuleInstanceModel.h"
|
||||
@@ -37,20 +36,9 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
|
||||
|
||||
m_appsModel = new AppsModel(this);
|
||||
|
||||
m_uiAppsProxy = new AppsFilterProxy(this);
|
||||
m_uiAppsProxy->setSourceModel(m_appsModel);
|
||||
m_uiAppsProxy->setTypeFilter(QStringLiteral("ui_qml"));
|
||||
m_uiAppsProxy->setExcludeMainUi(true);
|
||||
|
||||
m_requiredPackagesModel = new AppsFilterProxy(this);
|
||||
m_requiredPackagesModel->setSourceModel(m_appsModel);
|
||||
m_requiredPackagesModel->setExcludeMainUi(false);
|
||||
m_requiredPackagesModel->setInstallStateFilter(QString());
|
||||
|
||||
m_coreModuleManager = new CoreModuleManager(m_logosAPI, m_core, this);
|
||||
m_uiPluginManager = new UIPluginManager(m_logosAPI, m_coreModuleManager, this);
|
||||
m_packageCoordinator = new PackageCoordinator(m_logosAPI, m_coreModuleManager, m_uiPluginManager, m_appsModel, this);
|
||||
m_packageCoordinator->setRequiredPackagesModel(m_requiredPackagesModel);
|
||||
m_appsModel->setInstallRegistry(m_packageCoordinator->installRegistry());
|
||||
|
||||
// Setter-injection closes the cycle — UIPluginManager queries
|
||||
@@ -125,8 +113,23 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
|
||||
this, &MainUIBackend::addApplicationDataUpdated);
|
||||
connect(m_packageCoordinator, &PackageCoordinator::launchAppRequested,
|
||||
this, &MainUIBackend::launchAppRequested);
|
||||
// The resolver's required packages are cached here and published as a
|
||||
// property; QML binds a shell-declared AppsFilterProxy to it. Nothing on
|
||||
// this side holds a pointer to that proxy.
|
||||
connect(m_packageCoordinator, &PackageCoordinator::requiredPackagesResolved,
|
||||
this, [this](const QVariantList& entries) {
|
||||
if (m_requiredPackages == entries) return;
|
||||
m_requiredPackages = entries;
|
||||
emit requiredPackagesChanged();
|
||||
});
|
||||
|
||||
// Widened to int at this hop rather than connected signal-to-signal, so
|
||||
// the conversion is written down instead of relying on the implicit
|
||||
// enum-to-int the connect check would otherwise permit silently.
|
||||
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallStageChanged,
|
||||
this, &MainUIBackend::catalogInstallStageChanged);
|
||||
this, [this](const QString& name, InstallStage::Value stage) {
|
||||
emit catalogInstallStageChanged(name, static_cast<int>(stage));
|
||||
});
|
||||
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallFinished,
|
||||
this, &MainUIBackend::catalogInstallFinished);
|
||||
connect(m_packageCoordinator, &PackageCoordinator::catalogInstallFailed,
|
||||
@@ -166,6 +169,11 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, Q
|
||||
|
||||
MainUIBackend::~MainUIBackend() = default;
|
||||
|
||||
QAbstractItemModel* MainUIBackend::appsModel() const
|
||||
{
|
||||
return m_appsModel;
|
||||
}
|
||||
|
||||
void MainUIBackend::beginShutdown()
|
||||
{
|
||||
if (m_uiPluginManager) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "InstallEnums.h"
|
||||
#include "AppsFilterProxy.h"
|
||||
#include "ModuleInstanceModel.h"
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
@@ -63,20 +63,24 @@ class MainUIBackend : public QObject {
|
||||
// ModulesFilterProxy. The QVariantList uiModules()/coreModules() shape
|
||||
// used to be a Q_PROPERTY too; it now lives on this class only as a
|
||||
// private helper feeding the models on each *Changed tick.
|
||||
Q_PROPERTY(ModuleInstanceModel* uiModulesModel READ uiModulesModel CONSTANT)
|
||||
Q_PROPERTY(ModuleInstanceModel* coreModulesModel READ coreModulesModel CONSTANT)
|
||||
//
|
||||
// Declared as QAbstractItemModel*, not ModuleInstanceModel*. The shell's
|
||||
// QML only ever uses them as models, and typing them as the Qt base is
|
||||
// what keeps the concrete class from having to be nameable — and
|
||||
// therefore present — on the shell's side of the boundary.
|
||||
Q_PROPERTY(QAbstractItemModel* uiModulesModel READ uiModulesModel CONSTANT)
|
||||
Q_PROPERTY(QAbstractItemModel* coreModulesModel READ coreModulesModel CONSTANT)
|
||||
// AppsModel — the single source of truth for catalog rows + installed
|
||||
// state + live install pipeline. QML views bind directly. Lifetime is
|
||||
// tied to MainUIBackend; the pointer is stable across the app's life.
|
||||
Q_PROPERTY(AppsModel* appsModel READ appsModel CONSTANT)
|
||||
// Prebuilt filter proxy over appsModel, pre-configured with
|
||||
// type="ui_qml" and excludeMainUi=true
|
||||
Q_PROPERTY(AppsFilterProxy* uiAppsProxy READ uiAppsProxy CONSTANT)
|
||||
// Prebuilt filter proxy used exclusively by AddApplicationDialog's
|
||||
// "Required Packages" list. PackageCoordinator sets its
|
||||
// requiredPackages per resolver call so the ListView shows only the
|
||||
// resolver's tree in install order.
|
||||
Q_PROPERTY(AppsFilterProxy* requiredPackagesModel READ requiredPackagesModel CONSTANT)
|
||||
// Same reasoning as the two above: crosses as QAbstractItemModel*.
|
||||
Q_PROPERTY(QAbstractItemModel* appsModel READ appsModel CONSTANT)
|
||||
// The resolver's required-package entries, in install order. QML binds
|
||||
// them into a shell-declared AppsFilterProxy as a filter input
|
||||
// (requiredPackageEntries) for AddApplicationDialog's "Required Packages"
|
||||
// list. Only the data crosses: a proxy is view configuration, and the host
|
||||
// must not own — or be able to name — a shell-side type.
|
||||
Q_PROPERTY(QVariantList requiredPackages READ requiredPackages NOTIFY requiredPackagesChanged)
|
||||
|
||||
// App Launcher
|
||||
Q_PROPERTY(QVariantList launcherApps READ launcherApps NOTIFY launcherAppsChanged)
|
||||
@@ -144,11 +148,16 @@ public:
|
||||
CoreModuleManager* coreModuleManager() const { return m_coreModuleManager; }
|
||||
UIPluginManager* uiPluginManager() const { return m_uiPluginManager; }
|
||||
PackageCoordinator* packageCoordinator() const { return m_packageCoordinator; }
|
||||
AppsModel* appsModel() const { return m_appsModel; }
|
||||
AppsFilterProxy* uiAppsProxy() const { return m_uiAppsProxy; }
|
||||
AppsFilterProxy* requiredPackagesModel() const { return m_requiredPackagesModel; }
|
||||
ModuleInstanceModel* uiModulesModel() const { return m_uiModulesModel; }
|
||||
ModuleInstanceModel* coreModulesModel() const { return m_coreModulesModel; }
|
||||
// Base-typed on purpose — see the Q_PROPERTY comments above. Internal
|
||||
// code uses the concrete members directly and does not go through these.
|
||||
//
|
||||
// Defined out of line: AppsModel is only forward-declared here, so the
|
||||
// derived-to-base conversion needs the definition, and pulling AppsModel.h
|
||||
// into this header would push it into every TU that includes it.
|
||||
QAbstractItemModel* appsModel() const;
|
||||
QVariantList requiredPackages() const { return m_requiredPackages; }
|
||||
QAbstractItemModel* uiModulesModel() const { return m_uiModulesModel; }
|
||||
QAbstractItemModel* coreModulesModel() const { return m_coreModulesModel; }
|
||||
|
||||
public slots:
|
||||
// Navigation
|
||||
@@ -233,7 +242,12 @@ signals:
|
||||
void requestOpenAddApplicationDialog(const QVariantMap& metadata);
|
||||
void addApplicationDataUpdated(const QVariantMap& metadata);
|
||||
void launchAppRequested(const QString& name);
|
||||
void catalogInstallStageChanged(const QString& name, InstallStage::Value stage);
|
||||
// `stage` is an int, not InstallStage::Value. QML already declares
|
||||
// installStage as an int and compares against the QML_ELEMENT-registered
|
||||
// InstallStage.* values, so nothing changes for the consumer — but the
|
||||
// signal signature stops naming a type that would otherwise have to exist
|
||||
// in both images once the shell splits out.
|
||||
void catalogInstallStageChanged(const QString& name, int stage);
|
||||
void catalogInstallFinished(const QString& name);
|
||||
void catalogInstallFailed(const QString& name, const QString& error);
|
||||
void launcherAppsChanged();
|
||||
@@ -257,6 +271,7 @@ signals:
|
||||
void uninstallPlanRequested(const QVariantMap& plan);
|
||||
|
||||
void dependencyDataReadyChanged();
|
||||
void requiredPackagesChanged();
|
||||
// Distinct signal for upgrade/downgrade/reinstall — see
|
||||
// PackageCoordinator::upgradeCascadeConfirmationRequested for why we
|
||||
// can't reuse the uninstall variant (the dialog needs the target
|
||||
@@ -321,8 +336,7 @@ private:
|
||||
// uiPluginManager second, packageCoordinator third. See class comment for
|
||||
// lifetime reasoning.
|
||||
AppsModel* m_appsModel;
|
||||
AppsFilterProxy* m_uiAppsProxy;
|
||||
AppsFilterProxy* m_requiredPackagesModel;
|
||||
QVariantList m_requiredPackages;
|
||||
CoreModuleManager* m_coreModuleManager;
|
||||
UIPluginManager* m_uiPluginManager;
|
||||
PackageCoordinator* m_packageCoordinator;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "BasecampModelRoles.h"
|
||||
#include <QAbstractListModel>
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
@@ -22,25 +23,9 @@
|
||||
// `replaceRows` patches in place when the row identities are unchanged, so the
|
||||
// 2-second core-stats poll doesn't reset the model (which would drop the
|
||||
// selection + flicker every delegate).
|
||||
class ModuleInstanceModel : public QAbstractListModel {
|
||||
class ModuleInstanceModel : public QAbstractListModel, public ModuleInstanceRoles {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum Roles {
|
||||
NameRole = Qt::UserRole + 1,
|
||||
LabelRole, // displayName if set, else name
|
||||
DescriptionRole,
|
||||
CategoryRole,
|
||||
TypeRole, // "ui_qml" | "core" | ""
|
||||
VersionRole,
|
||||
IconPathRole,
|
||||
InstallTypeRole, // "user" | "embedded" | ""
|
||||
IsLoadedRole,
|
||||
IsMainUiRole,
|
||||
HasMissingDepsRole,
|
||||
StatusTextRole, // derived: Main UI / Missing deps / Loaded / Not loaded
|
||||
CpuRole, // core modules only; 0 when unknown
|
||||
MemoryRole, // core modules only; 0 when unknown
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit ModuleInstanceModel(QObject* parent = nullptr);
|
||||
|
||||
@@ -1,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);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <QSet>
|
||||
#include "logos_api.h"
|
||||
|
||||
class AppsFilterProxy;
|
||||
class AppsModel;
|
||||
class InstallRegistry;
|
||||
class CoreModuleManager;
|
||||
@@ -57,7 +56,7 @@ public:
|
||||
QObject* parent = nullptr);
|
||||
~PackageCoordinator() override;
|
||||
|
||||
void setRequiredPackagesModel(AppsFilterProxy* proxy) { m_requiredPackagesModel = proxy; }
|
||||
|
||||
|
||||
// Read-only accessors over the package-state caches. Empty when the
|
||||
// async refresh chain hasn't completed yet; QML and UIPluginManager
|
||||
@@ -185,6 +184,12 @@ signals:
|
||||
void catalogInstallFailed(const QString& name, const QString& error);
|
||||
void launchAppRequested(const QString& name);
|
||||
void uninstallPlanRequested(const QVariantMap& plan);
|
||||
|
||||
// The resolver's required-package entries, in install order. Published
|
||||
// rather than written: this used to call setRequiredPackages() on an
|
||||
// AppsFilterProxy the host held a pointer to, which is a host reaching
|
||||
// across into a shell-owned object. QML binds to it now.
|
||||
void requiredPackagesResolved(const QVariantList& entries);
|
||||
void dependencyDataReadyChanged();
|
||||
|
||||
// Upgrade/Downgrade/Reinstall cascade dialog trigger. Same dependent-
|
||||
@@ -402,7 +407,6 @@ private:
|
||||
bool m_warnedPackageDownloaderMissing = false;
|
||||
UIPluginManager* m_uiPluginManager;
|
||||
AppsModel* m_appsModel;
|
||||
AppsFilterProxy* m_requiredPackagesModel = nullptr;
|
||||
|
||||
// Package-state caches sourced from the package_manager module.
|
||||
QMap<QString, QString> m_installTypeByModule;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,26 +9,21 @@
|
||||
class MainUIBackend;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ShellHostAdapter — the host's side of the shell boundary.
|
||||
// ShellHostAdapter — the host's side of the shell boundary. Implements
|
||||
// IShellHost over MainUIBackend and translates its five signals into
|
||||
// IShellObserver calls; every operation is a one-line delegation.
|
||||
//
|
||||
// Implements IShellHost over MainUIBackend and translates the five backend
|
||||
// signals the shell cares about into IShellObserver virtual calls. It adds no
|
||||
// policy of its own: every operation is a one-line delegation.
|
||||
// It exists so the shell never names MainUIBackend, whose header pulls in
|
||||
// InstallEnums, the models and logos_api.h and whose signals carry
|
||||
// InstallStage::Value — none of which a Qt-only shell could compile against.
|
||||
//
|
||||
// It exists so the shell never names MainUIBackend. That matters twice over:
|
||||
// MainUIBackend's header pulls in InstallEnums, the models and logos_api.h, and
|
||||
// its signal signatures carry InstallStage::Value — none of which a Qt-only
|
||||
// shell could compile against.
|
||||
//
|
||||
// Owned by Window, alongside the MainUIBackend it wraps. Neither is owned by
|
||||
// the shell.
|
||||
// Owned by Window, alongside the MainUIBackend it wraps.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class ShellHostAdapter : public QObject, public IShellHost {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// `backend` is borrowed and must outlive this adapter. Both are owned by
|
||||
// Window, which destroys the adapter first.
|
||||
// `backend` is borrowed; Window owns both and destroys this adapter first.
|
||||
explicit ShellHostAdapter(MainUIBackend* backend, QObject* parent = nullptr);
|
||||
~ShellHostAdapter() override;
|
||||
|
||||
@@ -43,14 +38,13 @@ public:
|
||||
void setObserver(IShellObserver* observer) override;
|
||||
|
||||
private:
|
||||
// QPointer, not a raw pointer: Window deletes the backend during teardown,
|
||||
// and a queued backend signal can still be in flight when it does.
|
||||
// QPointer: Window deletes the backend during teardown, and a queued
|
||||
// backend signal can still be in flight when it does.
|
||||
QPointer<MainUIBackend> m_backend;
|
||||
|
||||
// Raw by necessity — IShellObserver is not a QObject, so QPointer cannot
|
||||
// track it. The shell's destroyShell() contract is what nulls this; every
|
||||
// forward below is guarded anyway, because the host dispatches through
|
||||
// QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout, both of
|
||||
// which can fire after teardown has begun.
|
||||
// Raw: IShellObserver is not a QObject, so QPointer cannot track it.
|
||||
// destroyShell() nulls it; forwards are guarded anyway, since
|
||||
// QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout can both
|
||||
// fire after teardown has begun.
|
||||
IShellObserver* m_observer = nullptr;
|
||||
};
|
||||
|
||||
@@ -218,13 +218,10 @@ private:
|
||||
|
||||
// Loaded-plugin state
|
||||
QMap<QString, IComponent*> m_loadedUiModules;
|
||||
// QPointer, not raw. These widgets are docked inside the shell, so the
|
||||
// shell's Qt parent can destroy them without going through unloadUiModule
|
||||
// -- and every read below then hands out a dangling pointer. Window's
|
||||
// ordered teardown calls beginShutdown() before the shell dies, which
|
||||
// closes that window; QPointer is what keeps it closed if some future path
|
||||
// does not. The pre-fold MainUIPlugin carried the same guard, and the fold
|
||||
// deleted it.
|
||||
// QPointer, not raw: these widgets are docked inside the shell, so the
|
||||
// shell's Qt parent can destroy them without going through unloadUiModule,
|
||||
// leaving every read below dangling. Window's ordered teardown calls
|
||||
// beginShutdown() first; QPointer is the backstop if some path does not.
|
||||
QMap<QString, QPointer<QWidget>> m_uiModuleWidgets;
|
||||
QMap<QString, QPointer<QQuickWidget>> m_qmlPluginWidgets;
|
||||
QMap<QString, ViewModuleHost*> m_viewModuleHosts;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
// qnamespace.h, not QtGlobal: the role bases are Qt::UserRole, and QtGlobal
|
||||
// does not declare the Qt namespace. This header is included FIRST by
|
||||
// AppsModel.h, so there is nothing ahead of it to pull the declaration in.
|
||||
#include <QtCore/qnamespace.h>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BasecampModelRoles.h — the model role contract, shared across the boundary.
|
||||
//
|
||||
// AppsModel and ModuleInstanceModel live host-side; the filter proxies over
|
||||
// them live in the shell and need the role NUMBERS and no symbol of any kind.
|
||||
// The models inherit these structs rather than redeclaring the enum, so
|
||||
// `AppsModel::NameRole` resolves host-side and the shell spells the same
|
||||
// constant `AppsModelRoles::NameRole` — one definition either way.
|
||||
//
|
||||
// Adding a role is a boundary change: append at the END. Renumbering shifts
|
||||
// values the shell was compiled against, and the failure is a proxy silently
|
||||
// reading the wrong role rather than anything that fails to build.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct AppsModelRoles {
|
||||
enum Roles {
|
||||
NameRole = Qt::UserRole + 1,
|
||||
RepositoryUrlRole,
|
||||
DisplayNameRole,
|
||||
DescriptionRole,
|
||||
CategoryRole,
|
||||
TypeRole, // "ui_qml" | "core"
|
||||
IconUrlRole, // resolved icon URL; "" → monogram fallback
|
||||
SupportsFullBleedIconRole, // manifest >= 0.4.0: icon is a validated
|
||||
// 256x256 asset, safe to render edge-to-edge
|
||||
VersionsRole, // QVariantList — all known catalog versions
|
||||
DependenciesRole, // QVariantList — direct deps of versions[0]'s manifest,
|
||||
// normalized to [{name, version}, ...]. version "" = no constraint.
|
||||
InstalledVersionRole, // "" when not installed
|
||||
LatestVersionRole, // versions[0].version
|
||||
HasUpdateRole, // installedVersion != "" && installed != latest
|
||||
IsInstalledRole, // installedVersion != ""
|
||||
MissingDepsRole, // QStringList of dep names whose LGX isn't on
|
||||
// disk (sourced from
|
||||
// PackageCoordinator::m_missingDepsByModule).
|
||||
// Empty when the install is complete.
|
||||
InstallStatusRole, // InstallStatus enum — per-row catalog-vs-disk
|
||||
// state (Install/Launch/Upgrade/Downgrade/
|
||||
// Reinstall in QML terms). PMUI mirror.
|
||||
InstallTypeRole, // "embedded" | "user" | ""
|
||||
ActionRole,
|
||||
ToVersionRole,
|
||||
IsTopLevelRole,
|
||||
ResolverErrorRole,
|
||||
InstallStageRole, // InstallStage::Value (int) — see InstallEnums.h
|
||||
InstallErrorRole, // failure message when InstallStage == Failed
|
||||
};
|
||||
};
|
||||
|
||||
struct ModuleInstanceRoles {
|
||||
enum Roles {
|
||||
NameRole = Qt::UserRole + 1,
|
||||
LabelRole, // displayName if set, else name
|
||||
DescriptionRole,
|
||||
CategoryRole,
|
||||
TypeRole, // "ui_qml" | "core" | ""
|
||||
VersionRole,
|
||||
IconPathRole,
|
||||
InstallTypeRole, // "user" | "embedded" | ""
|
||||
IsLoadedRole,
|
||||
IsMainUiRole,
|
||||
HasMissingDepsRole,
|
||||
StatusTextRole, // derived: Main UI / Missing deps / Loaded / Not loaded
|
||||
CpuRole, // core modules only; 0 when unknown
|
||||
MemoryRole, // core modules only; 0 when unknown
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -8,31 +8,27 @@ class IShellHost;
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IShellView — the UI shell, from the host's side.
|
||||
//
|
||||
// This is the seam that replaces IComponent for the main UI. IComponent stays
|
||||
// exactly as it is, for the third-party legacy widget plugins PluginLoader
|
||||
// still loads; it is not reused here because its signature
|
||||
// The seam for the main UI. IComponent is not reused here: its signature
|
||||
// (`createWidget(LogosAPI*)`) names the very type this boundary exists to keep
|
||||
// out of the shell.
|
||||
// out of the shell. It stays exactly as it is for the third-party legacy widget
|
||||
// plugins PluginLoader still loads.
|
||||
//
|
||||
// Today the implementation is compiled into the executable and Window
|
||||
// constructs it directly. Once the shell is a plugin again, Window will resolve
|
||||
// it with a real qobject_cast<IShellView*> on the QPluginLoader instance —
|
||||
// NOT a QMetaObject::invokeMethod("createWidget") string call, which is what
|
||||
// the pre-fold code did and is a silent-failure path: change an argument type
|
||||
// and both sides still compile, then miss at runtime.
|
||||
// Window resolves the shell with qobject_cast<IShellView*> on the QPluginLoader
|
||||
// instance — NOT a QMetaObject::invokeMethod("createWidget") string call, which
|
||||
// is a silent-failure path: change an argument type and both sides still
|
||||
// compile, then miss at runtime.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class IShellView {
|
||||
public:
|
||||
virtual ~IShellView() = default;
|
||||
|
||||
// Builds the shell widget. `host` is borrowed and outlives the shell; a
|
||||
// null host is FATAL, not a degraded mode — there is no meaningful shell
|
||||
// without one, and failing here beats a null dereference later.
|
||||
// null host is FATAL, not a degraded mode — failing here beats a null
|
||||
// dereference later.
|
||||
virtual QWidget* createShell(IShellHost* host) = 0;
|
||||
|
||||
// Tears the shell down. Implementations must detach from the host
|
||||
// (setObserver(nullptr)) before returning, so no late callback can reach a
|
||||
// destroyed observer.
|
||||
// Tears the shell down. Must detach from the host (setObserver(nullptr))
|
||||
// before returning, so no late callback reaches a destroyed observer.
|
||||
virtual void destroyShell(QWidget* widget) = 0;
|
||||
|
||||
// Must return IShellHost_abi as compiled into the shell. The host compares
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
#include <QPointer>
|
||||
#include <QScopedValueRollback>
|
||||
#include "LogosBasecampPaths.h"
|
||||
#include "MainShellView.h"
|
||||
#include "MainUIBackend.h"
|
||||
#include "ShellHostAdapter.h"
|
||||
#include "IShellHost.h"
|
||||
#include "IShellView.h"
|
||||
#include "logos_api.h"
|
||||
#include "win_dll_search.h"
|
||||
#include <QPluginLoader>
|
||||
#ifdef Q_OS_MAC
|
||||
#include "trafficLightsTitleBar.h"
|
||||
#include "macWindowStyle.h"
|
||||
@@ -55,28 +57,27 @@ Window::Window(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, QWidget *parent
|
||||
|
||||
Window::~Window()
|
||||
{
|
||||
// Explicit, ordered teardown. Qt's reverse child destruction would get
|
||||
// most of this right by accident; doing it here makes the ordering a
|
||||
// stated contract rather than a consequence of construction order.
|
||||
//
|
||||
// 1. beginShutdown() unmounts every in-process UI plugin widget WHILE
|
||||
// the shell's widget tree is still intact. Those widgets are docked
|
||||
// inside the shell, so the shell must outlive this step.
|
||||
// 2. destroyShell() detaches the observer, then deletes the shell — so
|
||||
// no late host callback can reach a destroyed observer.
|
||||
// 3. only then does the backend go, tearing down
|
||||
// PackageCoordinator -> UIPluginManager -> CoreModuleManager in the
|
||||
// order MainUIBackend.h documents.
|
||||
//
|
||||
// main() then destroys the core facade, which is step 4.
|
||||
// Ordered teardown, stated as a contract instead of left to Qt's reverse
|
||||
// child destruction:
|
||||
// 1. beginShutdown() unmounts every in-process UI plugin widget while the
|
||||
// shell's widget tree is still intact — those widgets are docked in
|
||||
// the shell, so the shell must outlive this step.
|
||||
// 2. destroyShell() detaches the observer before deleting the shell, so
|
||||
// no late host callback reaches a destroyed observer.
|
||||
// 3. only then the backend: PackageCoordinator -> UIPluginManager ->
|
||||
// CoreModuleManager, the order MainUIBackend.h documents.
|
||||
if (m_backend) {
|
||||
m_backend->beginShutdown();
|
||||
}
|
||||
|
||||
if (m_shellView) {
|
||||
// Out of the central-widget slot before handing it back, or QMainWindow
|
||||
// deletes it too.
|
||||
QWidget* shell = takeCentralWidget();
|
||||
m_shellView->destroyShell(shell);
|
||||
delete m_shellView;
|
||||
// NOT deleted: m_shellView is QPluginLoader's root instance, destroyed
|
||||
// by Qt in QLibraryStore::cleanup() at process exit — deleting it here
|
||||
// double-frees.
|
||||
m_shellView = nullptr;
|
||||
}
|
||||
|
||||
@@ -93,69 +94,86 @@ Window::~Window()
|
||||
|
||||
void Window::setupUi()
|
||||
{
|
||||
// The UI shell is compiled into this executable, and reached through
|
||||
// IShellView / IShellHost.
|
||||
// The UI shell is a Qt plugin — plugins/main_ui/main_ui.{so,dylib,dll} —
|
||||
// reached through IShellView / IShellHost. It gets a QWidget* out and eight
|
||||
// named operations in, holds no host privilege, and links no logos runtime;
|
||||
// nix/symbol-gate.nix enforces that rather than trusting it.
|
||||
//
|
||||
// It used to be a Qt plugin, folded in here because the boundary "bought
|
||||
// nothing": the shell needed a QWidget* handed back, the logos_core_*
|
||||
// lifecycle, and TokenManager access, and two of those three are host
|
||||
// privileges. That argument is what this seam dismantles — the shell now
|
||||
// gets a QWidget* out and eight named operations in, and holds no host
|
||||
// privilege at all. What is left to do is packaging, not privilege.
|
||||
//
|
||||
// Note the shape of the resolution below: a typed hostAbiVersion() check
|
||||
// and a direct call, NOT the QMetaObject::invokeMethod("createWidget")
|
||||
// string dispatch the pre-fold code used. That was a silent-failure path —
|
||||
// change an argument type and both sides still compile, then miss at
|
||||
// runtime. When this becomes a plugin again the only change here is
|
||||
// qobject_cast<IShellView*> on the QPluginLoader instance.
|
||||
//
|
||||
// There is deliberately no "No main UI module found" fallback: failure to
|
||||
// construct the shell is not a runtime resolution that can miss.
|
||||
//
|
||||
// Ownership inverts here: the backend and its host adapter belong to the
|
||||
// Window, and the shell only borrows them through IShellHost. The shell
|
||||
// holds no LogosAPI*, no QtLogosCore* and no MainUIBackend* — which is
|
||||
// what will let it link Qt and nothing else once it is a plugin again.
|
||||
//
|
||||
// Deliberately NOT parented to `this`: ~Window destroys these explicitly
|
||||
// and in order, and a Qt parent would delete them a second time.
|
||||
// Ownership stays HERE: the backend and its adapter belong to the Window and
|
||||
// the shell only borrows them. Deliberately not parented to `this`; ~Window
|
||||
// destroys them explicitly and in order.
|
||||
m_backend = new MainUIBackend(m_logosAPI, m_core, nullptr);
|
||||
m_hostAdapter = new ShellHostAdapter(m_backend, nullptr);
|
||||
m_shellView = new MainShellView(nullptr);
|
||||
|
||||
// A real ABI check, even though both sides are in this image today. Once
|
||||
// the shell is loaded from a plugin this is the only thing between a stale
|
||||
// build and a jump through a mismatched vtable slot.
|
||||
QString pluginExtension;
|
||||
#if defined(Q_OS_WIN)
|
||||
pluginExtension = ".dll";
|
||||
#elif defined(Q_OS_MAC)
|
||||
pluginExtension = ".dylib";
|
||||
#else // Linux and other Unix-like systems
|
||||
pluginExtension = ".so";
|
||||
#endif
|
||||
|
||||
// Embedded (pre-installed at build time) first, then the user-writable dir.
|
||||
const QString embeddedPath = LogosBasecampPaths::embeddedPluginsDirectory()
|
||||
+ "/main_ui/main_ui" + pluginExtension;
|
||||
const QString mainUiPluginPath =
|
||||
QFile::exists(embeddedPath)
|
||||
? embeddedPath
|
||||
: LogosBasecampPaths::pluginsDirectory() + "/main_ui/main_ui" + pluginExtension;
|
||||
|
||||
// main_ui lives in its own plugins/main_ui/ directory, so anything vendored
|
||||
// beside it is invisible to Windows' loader without this. No-op elsewhere;
|
||||
// the reference is intentionally held for the process lifetime.
|
||||
ModuleLib::preloadPluginWithOwnDirSearch(mainUiPluginPath);
|
||||
|
||||
QPluginLoader loader(mainUiPluginPath);
|
||||
if (!loader.load()) {
|
||||
qFatal("Failed to load the main UI plugin from %s: %s",
|
||||
qUtf8Printable(mainUiPluginPath), qUtf8Printable(loader.errorString()));
|
||||
}
|
||||
|
||||
// A real cast, not QMetaObject::invokeMethod("createWidget"): the string
|
||||
// form is a silent-failure path — change an argument type and both sides
|
||||
// still compile, then miss at runtime.
|
||||
m_shellView = qobject_cast<IShellView*>(loader.instance());
|
||||
if (!m_shellView) {
|
||||
qFatal("main_ui at %s does not implement IShellView",
|
||||
qUtf8Printable(mainUiPluginPath));
|
||||
}
|
||||
|
||||
// All that stands between a stale plugin build and a jump through a
|
||||
// mismatched vtable slot. Both sides take IShellHost_abi from the one copy
|
||||
// in app/interfaces/, so a difference means the plugin was built against a
|
||||
// different revision of that header.
|
||||
if (m_shellView->hostAbiVersion() != IShellHost_abi) {
|
||||
qFatal("main_ui shell was built against IShellHost ABI %d, host is %d",
|
||||
qFatal("main_ui was built against IShellHost ABI %d, host is %d",
|
||||
m_shellView->hostAbiVersion(), IShellHost_abi);
|
||||
}
|
||||
|
||||
// Deliberately no "No main UI module found" fallback widget: an error-label
|
||||
// widget reads as a working app with an empty window. A missing or
|
||||
// mismatched shell is a broken install, and the qFatal paths above say
|
||||
// which.
|
||||
setCentralWidget(m_shellView->createShell(m_hostAdapter));
|
||||
|
||||
// Set window title and size. The default launch width is wide enough for
|
||||
// the Package Manager's full table (category sidebar + columns through
|
||||
// Action and Description) to be visible without horizontal scrolling; at
|
||||
// the old 1024px those rightmost columns were clipped off-screen. The
|
||||
// window can still be resized down to MainContainer's 800x600 minimum.
|
||||
// The launch width is sized for the Package Manager's full table (category
|
||||
// sidebar through the Action and Description columns) without horizontal
|
||||
// scrolling; below it those rightmost columns clip off-screen. Still
|
||||
// resizable down to MainContainer's 800x600 minimum.
|
||||
setWindowTitle("Logos Basecamp");
|
||||
{
|
||||
// ...but never larger than the screen actually offers. A window bigger
|
||||
// than the work area opens with its right and bottom edges off-screen,
|
||||
// and on Windows it cannot be dragged back into view, so whatever hangs
|
||||
// off is simply unreachable -- here, the sidebar's bottom-anchored
|
||||
// system buttons. Measured on a 1271x839-logical RDP session: the
|
||||
// 1600x900 request overflowed right by ~342 and bottom by ~97 logical
|
||||
// px. Nothing else in this repo consults the screen; the QScreen
|
||||
// include at the top of this file has been unused until now.
|
||||
// ...but never larger than the screen offers: an oversized window opens
|
||||
// with its right and bottom edges off-screen and, on Windows, cannot be
|
||||
// dragged back, so whatever hangs off -- here the sidebar's
|
||||
// bottom-anchored system buttons -- is unreachable. Measured on a
|
||||
// 1271x839-logical RDP session: 1600x900 overflowed right by ~342 and
|
||||
// bottom by ~97 logical px.
|
||||
//
|
||||
// Clamp to availableGeometry EXACTLY, not to a fraction of it. This has
|
||||
// to be a no-op on every display big enough to hold the design size --
|
||||
// which is all of them in normal use -- or it silently undoes the
|
||||
// widening documented above. Qt raises the result back to
|
||||
// MainContainer's 800x600 minimum on its own, so there is no second
|
||||
// floor to keep in sync here.
|
||||
// Clamp to availableGeometry EXACTLY, not to a fraction of it, so this
|
||||
// is a no-op on any display big enough for the design size; otherwise it
|
||||
// silently undoes the width above. Qt raises the result back to
|
||||
// MainContainer's 800x600 minimum on its own.
|
||||
//
|
||||
// Skipped under the offscreen platform: QOffscreenScreen hardcodes its
|
||||
// geometry to 800x600, so clamping to it would shrink every headless UI
|
||||
@@ -163,9 +181,8 @@ void Window::setupUi()
|
||||
//
|
||||
// A screen that shrinks AFTER launch -- an RDP session with
|
||||
// /dynamic-resolution -- is handled by rebindScreenWatch() +
|
||||
// scheduleFitToScreen(), wired from showEvent once the platform window
|
||||
// exists. It cannot be done here: the window has no screen it has
|
||||
// actually landed on yet, and its frame margins are not yet knowable.
|
||||
// scheduleFitToScreen() from showEvent: here the window has not landed
|
||||
// on a screen yet and its frame margins are not knowable.
|
||||
QSize target(1600, 900);
|
||||
m_desiredSize = target; // what to grow back to when room returns
|
||||
const QScreen* windowScreen = screen();
|
||||
@@ -228,14 +245,12 @@ void Window::changeEvent(QEvent* event)
|
||||
void Window::resizeEvent(QResizeEvent* event)
|
||||
{
|
||||
QMainWindow::resizeEvent(event);
|
||||
// A resize the user performed redefines what the window is entitled to when
|
||||
// A resize the USER performed redefines what the window is entitled to when
|
||||
// room returns; one WE performed must not, or the fit becomes
|
||||
// min(previous, available) -- monotone non-increasing, so the window would
|
||||
// converge on the smallest work area seen all session and never grow back.
|
||||
// Guarded on isVisible() so Qt's own pre-show sizing does not count.
|
||||
// Also skipped while maximized/fullscreen: that size belongs to the window
|
||||
// manager, not to the user's intent, and recording it would make a later
|
||||
// un-maximize try to grow the window back to full-screen size.
|
||||
// min(previous, available) and converges on the smallest work area seen all
|
||||
// session. isVisible() keeps Qt's own pre-show sizing out. Maximized and
|
||||
// fullscreen sizes belong to the window manager, not to the user's intent:
|
||||
// recording one would grow the window to full screen on un-maximize.
|
||||
if (!m_applyingFit && isVisible()
|
||||
&& !(windowState() & (Qt::WindowMaximized | Qt::WindowFullScreen)))
|
||||
m_desiredSize = size();
|
||||
@@ -248,12 +263,10 @@ void Window::resizeEvent(QResizeEvent* event)
|
||||
void Window::showEvent(QShowEvent* event)
|
||||
{
|
||||
QMainWindow::showEvent(event);
|
||||
// Both idempotent, so this runs unconditionally rather than behind a
|
||||
// one-shot flag. That is what makes a tray restore re-fit if -- and only if
|
||||
// -- the screen shrank while the window was hidden: the fit is skipped
|
||||
// while !isVisible(), so a screen change during that time is picked up here
|
||||
// instead. A process-wide `static` would also have been wrong outright,
|
||||
// gating every Window ever constructed on the first one.
|
||||
// Both idempotent, so no one-shot flag: that is what makes a tray restore
|
||||
// re-fit if -- and only if -- the screen shrank while the window was hidden,
|
||||
// since the fit is skipped while !isVisible(). A process-wide `static` would
|
||||
// gate every Window ever constructed on the first one.
|
||||
rebindScreenWatch();
|
||||
fitFrameToAvailableGeometry();
|
||||
#ifdef Q_OS_MAC
|
||||
@@ -332,38 +345,32 @@ void Window::fitFrameToAvailableGeometry()
|
||||
return;
|
||||
|
||||
// Compare SIZES, not rectangles, and never touch the position.
|
||||
//
|
||||
// QRect::contains() is the obvious formulation and is wrong here on two
|
||||
// counts. On Windows, frameGeometry() comes from GetWindowRect, which
|
||||
// includes DWM's INVISIBLE resize border (~13px per side at 192dpi), so a
|
||||
// perfectly placed window never reports as contained and this would run on
|
||||
// every single launch. On macOS, Qt hands back a frame whose title bar sits
|
||||
// above availableGeometry's origin, so containment fails there too -- and
|
||||
// acting on it moved the window 66px down at launch, a visible change on a
|
||||
// platform where nothing is broken. Measured both.
|
||||
// QRect::contains() is wrong here twice over: on Windows frameGeometry()
|
||||
// comes from GetWindowRect, which includes DWM's INVISIBLE resize border
|
||||
// (~13px per side at 192dpi), so a perfectly placed window never reports as
|
||||
// contained and this would run on every launch; on macOS Qt's frame title
|
||||
// bar sits above availableGeometry's origin, and acting on that moved the
|
||||
// window 66px down at launch. Measured both.
|
||||
const QSize avail = windowScreen->availableGeometry().size();
|
||||
|
||||
// resize() sets the CLIENT size while availableGeometry() bounds the FRAME,
|
||||
// so the constructor's clamp is short by the decoration margins -- and those
|
||||
// are only knowable once the platform window exists, which is why this runs
|
||||
// here and not there. Measured on a 3456x1826 work area: the clamped client
|
||||
// filled the work area exactly and the frame still hung 72px below it,
|
||||
// so the constructor's clamp is short by the decoration margins, knowable
|
||||
// only once the platform window exists. Measured on a 3456x1826 work area:
|
||||
// the clamped client filled it exactly and the frame still hung 72px below,
|
||||
// putting the sidebar's bottom-anchored system buttons under the taskbar.
|
||||
const QSize decoration = frameGeometry().size() - size();
|
||||
|
||||
// Bounded by what the window is ENTITLED to, not by its current size. Using
|
||||
// the current size makes this min(previous, available) on every event, a
|
||||
// monotone non-increasing map: the window would ratchet down to the smallest
|
||||
// work area seen all session and never grow back -- so a taskbar that
|
||||
// auto-hides and reappears, or an RDP window dragged smaller and back, would
|
||||
// permanently shrink Basecamp. m_desiredSize is the launch design size,
|
||||
// replaced by any resize the USER performs (see resizeEvent), so growing
|
||||
// back can never exceed what was actually asked for.
|
||||
// Bounded by what the window is ENTITLED to, not by its current size: the
|
||||
// latter makes this min(previous, available) on every event, ratcheting down
|
||||
// to the smallest work area seen all session -- an auto-hiding taskbar, or
|
||||
// an RDP window dragged smaller and back, would shrink Basecamp for good.
|
||||
// m_desiredSize is the launch design size, replaced by any resize the USER
|
||||
// performs (see resizeEvent), so growing back never exceeds what was asked.
|
||||
const QSize want = m_desiredSize.isValid() ? m_desiredSize : size();
|
||||
const QSize target = (avail - decoration).boundedTo(want).expandedTo(minimumSize());
|
||||
|
||||
// The whole idempotence of this function. It is also why no re-entrancy flag
|
||||
// is needed: our own resize lands here again with the same inputs and stops.
|
||||
// The whole idempotence of this function, and why no re-entrancy flag is
|
||||
// needed: our own resize re-enters with the same inputs and stops here.
|
||||
if (target == size())
|
||||
return;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Builds the logos-basecamp standalone application.
|
||||
#
|
||||
# Since the main_ui fold this is the ONLY derivation that compiles basecamp's
|
||||
# own UI: nix/main-ui.nix is gone, and everything its preConfigure staged (the
|
||||
# build-info header, the package_manager / package_downloader generated API
|
||||
# headers, the shared semver headers, and logos-cpp-generator's --general-only
|
||||
# output) is staged here instead, into the single app/generated directory.
|
||||
{ pkgs, common, src, logosModule, logosLiblogos, logosSdk, logosSdkBuild ? logosSdk, logosProtocolPkg, logosQtHost, logosQtSdk, logosDesignSystem, logosViewModuleRuntime, logosPackageManagerModule, logosPackageDownloaderModule, logosPackageHeaders, buildInfo, logosQtMcp ? null, installedModules ? [], portable ? false, enableInspector ? true }:
|
||||
{ pkgs, common, src, logosModule, logosLiblogos, logosSdk, logosSdkBuild ? logosSdk, logosProtocolPkg, logosQtHost, logosQtSdk, logosDesignSystem, logosViewModuleRuntime, logosPackageManagerModule, logosPackageDownloaderModule, logosPackageHeaders, buildInfo, logosQtMcp ? null, mainUIPlugin, installedModules ? [], portable ? false, enableInspector ? true }:
|
||||
|
||||
let
|
||||
# webkitgtk became ABI-versioned; pick the newest available while staying
|
||||
@@ -86,8 +80,7 @@ pkgs.stdenv.mkDerivation rec {
|
||||
# the logos-protocol link interface (OpenSSL, Boost::system, nlohmann_json).
|
||||
logosProtocolPkg
|
||||
logosQtHost
|
||||
# main_ui fold: Logos.Theme / .Icons / .Controls are STATIC qt_add_qml_module
|
||||
# targets behind the Logos::DesignSystem umbrella, now linked by the app.
|
||||
# app/CMakeLists.txt does find_package(LogosDesignSystem CONFIG REQUIRED).
|
||||
logosDesignSystem
|
||||
] ++ (
|
||||
if pkgs.stdenv.isLinux then
|
||||
@@ -182,12 +175,9 @@ pkgs.stdenv.mkDerivation rec {
|
||||
|
||||
# ── app/generated: the ONE generated-header directory ──────────────────
|
||||
#
|
||||
# Merged here from the deleted nix/main-ui.nix, which staged the same set
|
||||
# into a SECOND directory (src/generated_code). Both ended up on one
|
||||
# target's include path the moment the UI shell folded into the exe, and
|
||||
# app/utils/BuildInfo.h resolves logos_build_info.h with __has_include —
|
||||
# so with two candidate directories the -I ORDER, not the build, would
|
||||
# decide which header won. One directory removes the question.
|
||||
# Keep it the only one: app/utils/BuildInfo.h resolves logos_build_info.h
|
||||
# with __has_include, so a second staged directory on the same include path
|
||||
# would leave -I ORDER deciding which header wins.
|
||||
mkdir -p ./app/generated
|
||||
|
||||
# Auto-generated build info header (version + commit hashes): main.cpp logs
|
||||
@@ -219,7 +209,7 @@ pkgs.stdenv.mkDerivation rec {
|
||||
|
||||
# logos-cpp-generator's general wrappers (logos_sdk.h / logos_sdk.cpp).
|
||||
# --general-only: the per-module wrappers come from the module outputs
|
||||
# copied above. metadata.json is the repo-root one, unchanged by the fold.
|
||||
# copied above.
|
||||
echo "Running logos-cpp-generator (general-only)..."
|
||||
logos-cpp-generator --metadata ${src}/metadata.json --general-only --output-dir ./app/generated
|
||||
|
||||
@@ -412,11 +402,9 @@ pkgs.stdenv.mkDerivation rec {
|
||||
# 1. $out/modules/<m>/ and $out/plugins/<p>/ are populated in installPhase
|
||||
# from .lgx payloads and UI-plugin outputs -- i.e. AFTER that hook has
|
||||
# run, and in directories it never looks at. Nothing ever read their
|
||||
# import tables. Measured here before this block existed: main_ui.dll
|
||||
# import tables. Measured before this block existed: main_ui.dll
|
||||
# imported Qt6Qml.dll, Qt6QuickControls2.dll and Qt6QuickWidgets.dll and
|
||||
# not one of them was in plugins/main_ui/ or in bin/. (main_ui itself is
|
||||
# folded into the exe now and no longer ships; the mechanism is unchanged
|
||||
# for package_manager_ui and every module payload.)
|
||||
# not one of them was in plugins/main_ui/ or in bin/.
|
||||
#
|
||||
# 2. A DLL that is itself absent cannot have ITS imports read, so one pass
|
||||
# over a tree is never enough. package_downloader needs three rounds:
|
||||
@@ -766,6 +754,17 @@ WRAPPER_EOF
|
||||
cp -L "$_sdklib" "$out/lib/"
|
||||
done
|
||||
|
||||
# The UI shell. Staged from its own derivation into plugins/main_ui/, which
|
||||
# is where app/window.cpp resolves it and where the PE import sweep above
|
||||
# already walks ("$out"/plugins/*).
|
||||
if [ -d "${mainUIPlugin}/plugins" ]; then
|
||||
cp -r "${mainUIPlugin}/plugins/." "$out/plugins/"
|
||||
echo "Installed the main_ui shell plugin"
|
||||
else
|
||||
echo "error: mainUIPlugin produced no plugins/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy pre-installed modules and plugins from bundled install outputs.
|
||||
# Each entry in installedModules has modules/ and/or plugins/ subdirectories.
|
||||
for installed in ${pkgs.lib.concatStringsSep " " (map toString installedModules)}; do
|
||||
@@ -778,9 +777,13 @@ WRAPPER_EOF
|
||||
done
|
||||
echo "Pre-installed modules and plugins from install bundles"
|
||||
|
||||
# Logos.Theme / .Icons / .Controls are STATIC-linked into the LogosBasecamp
|
||||
# binary via find_package(LogosDesignSystem CONFIG) — the modules register
|
||||
# into the process qrc at startup. Nothing to copy to $out/lib/Logos.
|
||||
# Logos.Theme / .Icons / .Controls are STATIC-linked into the main_ui PLUGIN,
|
||||
# not into this binary, and register into the process-wide QML registry when
|
||||
# Window loads the plugin at startup — before any UI plugin can import them.
|
||||
# Exactly one image may link them: a STATIC qt_add_qml_module registers from
|
||||
# a static initializer and QML registration is process-global, so a second
|
||||
# image aborts startup with "Cannot add multiple registrations for
|
||||
# Logos.Icons". Nothing to copy to $out/lib/Logos.
|
||||
|
||||
# Install desktop file and icon for FreeDesktop / Wayland icon lookup (Linux only)
|
||||
if [ "$(uname)" = "Linux" ]; then
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
@@ -15,16 +15,15 @@
|
||||
# it moved, this check failed while every real assertion still passed. See
|
||||
# logos-protocol/cpp/logos_shared_api.h and cmake/LogosSharedFromDll.cmake.
|
||||
#
|
||||
# This gate exists because nothing else in the tree measured it. The main_ui
|
||||
# fold shipped an executable that defined nine runtime entry points
|
||||
# liblogos_core.dylib did not export, and it was caught by hand, after the fact,
|
||||
# from 31 "ModuleProxy: rejecting unauthorized call" lines at runtime.
|
||||
# Nothing else in the tree measures this, and it has no build diagnostic: it
|
||||
# surfaces at runtime as "ModuleProxy: rejecting unauthorized call" and
|
||||
# "LogosAPIClient: No token found for module".
|
||||
#
|
||||
# THE IN-PROCESS IMAGE SET — this scoping IS the correctness of the gate:
|
||||
# IN bin/LogosBasecamp the app
|
||||
# IN lib/liblogos_core.* the single provider
|
||||
# IN plugins/*/*_replica_factory.* QPluginLoader'd by LogosQmlBridge
|
||||
# IN plugins/main_ui/main_ui.* QPluginLoader'd by Window, when split out
|
||||
# IN plugins/main_ui/main_ui.* QPluginLoader'd by Window
|
||||
# OUT bin/logos_host, bin/ui-host SEPARATE PROCESSES; they correctly keep
|
||||
# their own statics
|
||||
# OUT plugins/*/*_plugin.* a ui_qml backend is loaded by ui-host,
|
||||
@@ -69,11 +68,10 @@ pkgs.runCommand "logos-basecamp-symbol-gate${pkgs.lib.optionalString negativeCon
|
||||
# wrapper) -- nm reads ZERO symbols from it
|
||||
# * makeBinaryWrapper's COMPILED stub beside bin/.<name>-wrapped -- nm reads
|
||||
# ~10 symbols from it, so the "did nm read anything" guard below does NOT
|
||||
# catch this one. Measured in logos-logoscore-cli: bin/logoscore is such a
|
||||
# stub, and measuring it reports 0 runtime symbols for a binary that in
|
||||
# fact imports 12.
|
||||
# So the rule is deterministic rather than heuristic: if a hidden sibling
|
||||
# exists, it IS the image, and we never measure bin/<name>.
|
||||
# catch this one (logos-logoscore-cli's bin/logoscore is such a stub: it
|
||||
# measures 0 runtime symbols for a binary that in fact imports 12).
|
||||
# Hence the deterministic rule: a hidden sibling IS the image, and we never
|
||||
# measure bin/<name>.
|
||||
resolve_image() {
|
||||
local f="$1" d b real
|
||||
d=$(dirname "$f"); b=$(basename "$f")
|
||||
@@ -152,14 +150,12 @@ pkgs.runCommand "logos-basecamp-symbol-gate${pkgs.lib.optionalString negativeCon
|
||||
echo
|
||||
echo "== each runtime type is defined by EXACTLY ONE image =="
|
||||
valid "$PROVIDER" || exit 1
|
||||
# StoreRegistry is deliberately absent from this list. It is file-local --
|
||||
# token_manager.cpp defines `static StoreRegistry r;` inside registry(), so it
|
||||
# has a LOCAL symbol and no external one, and is reachable only through
|
||||
# TokenManager's accessors. Measured: 1 symbol under `nm -a`, 0 under `nm -gU`.
|
||||
# Requiring exactly one DEFINER of something that is never exported would fail
|
||||
# forever. It stays in the TIER 1 scan below, where the assertion is that no
|
||||
# consumer defines it -- which is meaningful precisely because it should never
|
||||
# become external.
|
||||
# StoreRegistry is deliberately absent from this list: token_manager.cpp
|
||||
# defines `static StoreRegistry r;` inside registry(), so it is file-local
|
||||
# (1 symbol under `nm -a`, 0 under `nm -gU`) and requiring exactly one DEFINER
|
||||
# of something never exported would fail forever. It stays in the TIER 1 scan
|
||||
# below, where "no consumer defines it" is meaningful precisely because it
|
||||
# should never become external.
|
||||
for fam in TokenManager LogosAPI LogosAPIClient; do
|
||||
_n=0; _owners=""
|
||||
for img in "''${ALL[@]}"; do
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "AppsFilterProxy.h"
|
||||
|
||||
#include "AppsModel.h"
|
||||
#include "BasecampModelRoles.h"
|
||||
#include "InstallEnums.h"
|
||||
#include "InstallEnums.h"
|
||||
|
||||
#include <climits>
|
||||
@@ -46,8 +47,8 @@ int AppsFilterProxy::installedCount() const
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QModelIndex mi = index(i, 0);
|
||||
if (data(mi, AppsModel::IsInstalledRole).toBool()
|
||||
|| data(mi, AppsModel::InstallStageRole).toInt()
|
||||
if (data(mi, AppsModelRoles::IsInstalledRole).toBool()
|
||||
|| data(mi, AppsModelRoles::InstallStageRole).toInt()
|
||||
== InstallStage::Installed) {
|
||||
++c;
|
||||
}
|
||||
@@ -60,7 +61,7 @@ int AppsFilterProxy::installFreshCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("install")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -71,7 +72,7 @@ int AppsFilterProxy::upgradeCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("upgrade")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -82,7 +83,7 @@ int AppsFilterProxy::reinstallCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("reinstall")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -93,7 +94,7 @@ int AppsFilterProxy::alreadyInstalledCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("installed")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -104,7 +105,7 @@ int AppsFilterProxy::installingCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("installing")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -115,7 +116,7 @@ int AppsFilterProxy::errorCount() const
|
||||
int c = 0;
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (data(index(i, 0), AppsModel::ActionRole).toString()
|
||||
if (data(index(i, 0), AppsModelRoles::ActionRole).toString()
|
||||
== QStringLiteral("error")) ++c;
|
||||
}
|
||||
return c;
|
||||
@@ -127,15 +128,15 @@ qlonglong AppsFilterProxy::totalDownloadBytes() const
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QModelIndex mi = index(i, 0);
|
||||
const QString action = data(mi, AppsModel::ActionRole).toString();
|
||||
const QString action = data(mi, AppsModelRoles::ActionRole).toString();
|
||||
if (action != QStringLiteral("install")
|
||||
&& action != QStringLiteral("upgrade")
|
||||
&& action != QStringLiteral("downgrade")
|
||||
&& action != QStringLiteral("reinstall")) {
|
||||
continue;
|
||||
}
|
||||
const QString toVersion = data(mi, AppsModel::ToVersionRole).toString();
|
||||
const QVariantList versions = data(mi, AppsModel::VersionsRole).toList();
|
||||
const QString toVersion = data(mi, AppsModelRoles::ToVersionRole).toString();
|
||||
const QVariantList versions = data(mi, AppsModelRoles::VersionsRole).toList();
|
||||
for (const QVariant& v : versions) {
|
||||
const QVariantMap entry = v.toMap();
|
||||
const QString entryVersion =
|
||||
@@ -154,7 +155,7 @@ bool AppsFilterProxy::hasResolutionErrors() const
|
||||
const int n = rowCount();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QModelIndex mi = index(i, 0);
|
||||
if (data(mi, AppsModel::ActionRole).toString() == QStringLiteral("error"))
|
||||
if (data(mi, AppsModelRoles::ActionRole).toString() == QStringLiteral("error"))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -172,14 +173,14 @@ QStringList AppsFilterProxy::categories() const
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QModelIndex mi = src->index(i, 0);
|
||||
if (m_excludeMainUi) {
|
||||
const QString nm = src->data(mi, AppsModel::NameRole).toString();
|
||||
const QString nm = src->data(mi, AppsModelRoles::NameRole).toString();
|
||||
if (nm == QStringLiteral("main_ui")) continue;
|
||||
}
|
||||
if (!m_typeFilter.isEmpty()) {
|
||||
const QString t = src->data(mi, AppsModel::TypeRole).toString();
|
||||
const QString t = src->data(mi, AppsModelRoles::TypeRole).toString();
|
||||
if (t != m_typeFilter) continue;
|
||||
}
|
||||
QString c = src->data(mi, AppsModel::CategoryRole).toString();
|
||||
QString c = src->data(mi, AppsModelRoles::CategoryRole).toString();
|
||||
if (c.isEmpty()) continue;
|
||||
c[0] = c[0].toUpper();
|
||||
if (!seen.contains(c)) seen.append(c);
|
||||
@@ -261,6 +262,13 @@ QStringList AppsFilterProxy::requiredPackages() const
|
||||
|
||||
void AppsFilterProxy::setRequiredPackages(const QVariantList& entries)
|
||||
{
|
||||
// Bound from QML, so this runs on every notify — including the re-set of an
|
||||
// identical list. Bail early to avoid an invalidate()/notify loop.
|
||||
if (m_requiredPackagesActive && m_requiredPackageEntries == entries) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_requiredPackageEntries = entries;
|
||||
m_requiredPackagesActive = true;
|
||||
m_requiredPackagesByName.clear();
|
||||
m_requiredPackagesOrder.clear();
|
||||
@@ -290,36 +298,36 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
|
||||
|
||||
// main_ui exclusion — basecamp's own placeholder
|
||||
if (m_excludeMainUi) {
|
||||
const QString name = src->data(idx, AppsModel::NameRole).toString();
|
||||
const QString name = src->data(idx, AppsModelRoles::NameRole).toString();
|
||||
if (name == QStringLiteral("main_ui")) return false;
|
||||
}
|
||||
|
||||
// Type filter.
|
||||
if (!m_typeFilter.isEmpty()) {
|
||||
const QString t = src->data(idx, AppsModel::TypeRole).toString();
|
||||
const QString t = src->data(idx, AppsModelRoles::TypeRole).toString();
|
||||
if (t != m_typeFilter) return false;
|
||||
}
|
||||
|
||||
// Category filter. "All" / "" both mean "no filter".
|
||||
if (!m_categoryFilter.isEmpty() && m_categoryFilter != QStringLiteral("All")) {
|
||||
const QString c = capitalizeFirst(
|
||||
src->data(idx, AppsModel::CategoryRole).toString());
|
||||
src->data(idx, AppsModelRoles::CategoryRole).toString());
|
||||
if (c != m_categoryFilter) return false;
|
||||
}
|
||||
|
||||
// Install-state filter.
|
||||
if (m_installStateFilter == QStringLiteral("installed")) {
|
||||
if (!src->data(idx, AppsModel::IsInstalledRole).toBool()) return false;
|
||||
if (!src->data(idx, AppsModelRoles::IsInstalledRole).toBool()) return false;
|
||||
} else if (m_installStateFilter == QStringLiteral("notInstalled")) {
|
||||
if (src->data(idx, AppsModel::IsInstalledRole).toBool()) return false;
|
||||
if (src->data(idx, AppsModelRoles::IsInstalledRole).toBool()) return false;
|
||||
}
|
||||
|
||||
// Search the visible fields — users type what they see, not the internal
|
||||
// package name. (DisplayNameRole falls back to name, so name search works.)
|
||||
if (!m_searchText.isEmpty()) {
|
||||
const QString n = src->data(idx, AppsModel::NameRole).toString();
|
||||
const QString dn = src->data(idx, AppsModel::DisplayNameRole).toString();
|
||||
const QString ds = src->data(idx, AppsModel::DescriptionRole).toString();
|
||||
const QString n = src->data(idx, AppsModelRoles::NameRole).toString();
|
||||
const QString dn = src->data(idx, AppsModelRoles::DisplayNameRole).toString();
|
||||
const QString ds = src->data(idx, AppsModelRoles::DescriptionRole).toString();
|
||||
if (!n.contains(m_searchText, Qt::CaseInsensitive)
|
||||
&& !dn.contains(m_searchText, Qt::CaseInsensitive)
|
||||
&& !ds.contains(m_searchText, Qt::CaseInsensitive))
|
||||
@@ -330,22 +338,22 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
|
||||
// sections. matchLocalOnly inverts the semantics: accept only rows with
|
||||
// an empty repositoryUrl (the synthetic "Local" bucket).
|
||||
if (m_matchLocalOnly) {
|
||||
const QString repo = src->data(idx, AppsModel::RepositoryUrlRole).toString();
|
||||
const QString repo = src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
|
||||
if (!repo.isEmpty()) return false;
|
||||
} else if (!m_repositoryUrlFilter.isEmpty()) {
|
||||
const QString repo = src->data(idx, AppsModel::RepositoryUrlRole).toString();
|
||||
const QString repo = src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
|
||||
if (repo != m_repositoryUrlFilter) return false;
|
||||
}
|
||||
|
||||
// requiredPackages: name in map AND (pinned repo empty OR matches row).
|
||||
// Without the repo pin, two repos publishing the same name both pass.
|
||||
if (m_requiredPackagesActive) {
|
||||
const QString n = src->data(idx, AppsModel::NameRole).toString();
|
||||
const QString n = src->data(idx, AppsModelRoles::NameRole).toString();
|
||||
const auto it = m_requiredPackagesByName.constFind(n);
|
||||
if (it == m_requiredPackagesByName.constEnd()) return false;
|
||||
if (!it.value().isEmpty()) {
|
||||
const QString rowRepo =
|
||||
src->data(idx, AppsModel::RepositoryUrlRole).toString();
|
||||
src->data(idx, AppsModelRoles::RepositoryUrlRole).toString();
|
||||
if (rowRepo != it.value()) return false;
|
||||
}
|
||||
}
|
||||
@@ -356,8 +364,8 @@ bool AppsFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceP
|
||||
bool AppsFilterProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const
|
||||
{
|
||||
if (m_requiredPackagesActive) {
|
||||
const QString ln = sourceModel()->data(left, AppsModel::NameRole).toString();
|
||||
const QString rn = sourceModel()->data(right, AppsModel::NameRole).toString();
|
||||
const QString ln = sourceModel()->data(left, AppsModelRoles::NameRole).toString();
|
||||
const QString rn = sourceModel()->data(right, AppsModelRoles::NameRole).toString();
|
||||
return m_requiredPackagesOrder.value(ln, INT_MAX)
|
||||
< m_requiredPackagesOrder.value(rn, INT_MAX);
|
||||
}
|
||||
@@ -21,6 +21,10 @@ class AppsFilterProxy : public QSortFilterProxyModel {
|
||||
Q_PROPERTY(bool matchLocalOnly READ matchLocalOnly WRITE setMatchLocalOnly NOTIFY matchLocalOnlyChanged)
|
||||
Q_PROPERTY(bool excludeMainUi READ excludeMainUi WRITE setExcludeMainUi NOTIFY excludeMainUiChanged)
|
||||
Q_PROPERTY(QStringList requiredPackages READ requiredPackages NOTIFY requiredPackagesChanged)
|
||||
// The resolver's entries, in install order. Writable so QML can BIND it to
|
||||
// the backend rather than have the host reach in and call the setter — the
|
||||
// host must not hold a pointer to a shell-side proxy.
|
||||
Q_PROPERTY(QVariantList requiredPackageEntries READ requiredPackageEntries WRITE setRequiredPackages NOTIFY requiredPackagesChanged)
|
||||
Q_PROPERTY(int installedCount READ installedCount NOTIFY installedCountChanged)
|
||||
Q_PROPERTY(int installFreshCount READ installFreshCount NOTIFY breakdownChanged)
|
||||
Q_PROPERTY(int upgradeCount READ upgradeCount NOTIFY breakdownChanged)
|
||||
@@ -51,6 +55,7 @@ public:
|
||||
void setMatchLocalOnly(bool v);
|
||||
void setExcludeMainUi(bool e);
|
||||
QStringList requiredPackages() const;
|
||||
QVariantList requiredPackageEntries() const { return m_requiredPackageEntries; }
|
||||
Q_INVOKABLE void setRequiredPackages(const QVariantList& entries);
|
||||
|
||||
int installedCount() const;
|
||||
@@ -97,6 +102,7 @@ private:
|
||||
QString m_repositoryUrlFilter;
|
||||
bool m_matchLocalOnly = false;
|
||||
bool m_excludeMainUi = true;
|
||||
QVariantList m_requiredPackageEntries;
|
||||
QHash<QString, QString> m_requiredPackagesByName;
|
||||
QHash<QString, int> m_requiredPackagesOrder;
|
||||
bool m_requiredPackagesActive = false;
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 439 B After Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
@@ -4,6 +4,7 @@ import QtQuick.Layouts
|
||||
|
||||
import Basecamp.AppManager
|
||||
import Basecamp.Settings
|
||||
import Basecamp.Backend
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -14,6 +15,17 @@ Item {
|
||||
readonly property int sidebarAppManager: 1
|
||||
readonly property int sidebarSettings: 3
|
||||
|
||||
// The App Manager's view of the catalog. Declared here rather than handed
|
||||
// over by the backend: a filter proxy is view configuration, so it belongs
|
||||
// to whoever draws the view. Only the source model crosses, as a plain
|
||||
// QAbstractItemModel*.
|
||||
AppsFilterProxy {
|
||||
id: uiAppsProxy
|
||||
sourceModel: backend.appsModel
|
||||
typeFilter: "ui_qml"
|
||||
excludeMainUi: true
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onRepositoryOperationCompleted(operation, url, success, error) {
|
||||
@@ -44,7 +56,7 @@ Item {
|
||||
// App Manager (sidebar sidebarAppManager -> stack index 0)
|
||||
AppManagerView {
|
||||
id: appManagerView
|
||||
appsProxy: backend.uiAppsProxy
|
||||
appsProxy: uiAppsProxy
|
||||
repositories: backend.repositories
|
||||
loading: backend.appsLoading
|
||||
onAppClicked: function(name, repositoryUrl) {
|
||||
@@ -131,10 +131,22 @@ Item {
|
||||
displayNameLookup: _dialogDeps.displayNameLookup
|
||||
}
|
||||
|
||||
// The "Required Packages" view of the catalog, restricted to whatever the
|
||||
// resolver last returned. requiredPackageEntries is BOUND to the backend
|
||||
// property, replacing a host-side setRequiredPackages() write into an
|
||||
// object the host should not have been holding.
|
||||
AppsFilterProxy {
|
||||
id: requiredPackagesProxy
|
||||
sourceModel: backend.appsModel
|
||||
excludeMainUi: false
|
||||
installStateFilter: ""
|
||||
requiredPackageEntries: backend.requiredPackages
|
||||
}
|
||||
|
||||
// App-Manager "Add Application" dialog.
|
||||
AddApplicationDialog {
|
||||
id: addApplicationDialog
|
||||
requiredPackagesModel: backend.requiredPackagesModel
|
||||
requiredPackagesModel: requiredPackagesProxy
|
||||
onClosed: backend.notifyAddApplicationDialogClosed()
|
||||
onUninstallRequested: function(name, repositoryUrl) {
|
||||
backend.uninstallApp(name, repositoryUrl)
|
||||
@@ -0,0 +1,237 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(main_ui LANGUAGES CXX)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# main_ui — Basecamp's UI shell, as a Qt plugin.
|
||||
#
|
||||
# Links Qt and NOTHING ELSE from this workspace: no logos runtime, no
|
||||
# logos-qt-host, no liblogos, no generated module wrappers. nix/symbol-gate.nix
|
||||
# enforces this, failing if a runtime type is defined in more than one image
|
||||
# loaded into the app's process.
|
||||
#
|
||||
# The host is reached only through IShellHost, and the only host headers on the
|
||||
# include path are the contract ones in app/interfaces/ — IShellHost.h,
|
||||
# IShellView.h, InstallEnums.h, BasecampModelRoles.h — so including
|
||||
# MainUIBackend.h or logos_api.h from here does not compile.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
find_package(Qt6 COMPONENTS Core Gui Widgets Quick Qml QuickWidgets QuickControls2 REQUIRED)
|
||||
qt_standard_project_setup(REQUIRES 6.5)
|
||||
|
||||
# The design system ships Logos.Theme / .Icons / .Controls as STATIC
|
||||
# qt_add_qml_module targets behind the Logos::DesignSystem umbrella.
|
||||
find_package(LogosDesignSystem CONFIG REQUIRED)
|
||||
|
||||
# The ONLY host directory this target may see. Widening this to app/ would put
|
||||
# MainUIBackend.h, logos_api.h and token_manager.h one #include away.
|
||||
set(BASECAMP_INTERFACES ${CMAKE_CURRENT_SOURCE_DIR}/../app/interfaces)
|
||||
|
||||
# Global, not per-target: each qt_add_qml_module below creates its OWN target,
|
||||
# which does not inherit main_ui's include directories — without this
|
||||
# basecamp_backend_qml fails to find BasecampModelRoles.h.
|
||||
include_directories(${BASECAMP_INTERFACES})
|
||||
|
||||
set(SOURCES
|
||||
MainShellView.h
|
||||
MainShellView.cpp
|
||||
MainContainer.h
|
||||
MainContainer.cpp
|
||||
WorkspaceArea.h
|
||||
WorkspaceArea.cpp
|
||||
ShortcutBridge.h
|
||||
ShortcutBridge.cpp
|
||||
${BASECAMP_INTERFACES}/IShellHost.h
|
||||
${BASECAMP_INTERFACES}/IShellView.h
|
||||
${BASECAMP_INTERFACES}/BasecampModelRoles.h
|
||||
)
|
||||
|
||||
# SHARED, not MODULE: QPluginLoader is happy with either, and with the
|
||||
# PREFIX/SUFFIX block below SHARED produces exactly the artefact names
|
||||
# app/window.cpp and the bundler look for.
|
||||
add_library(main_ui SHARED ${SOURCES})
|
||||
|
||||
# ── QML modules ─────────────────────────────────────────────────────────────
|
||||
# QML_FILES/RESOURCES resolve against CMAKE_CURRENT_SOURCE_DIR, and a `../`
|
||||
# prefix changes where each file lands in the resource tree — so Basecamp/ must
|
||||
# stay beside this file.
|
||||
set_source_files_properties(Basecamp/Icons/BasecampIcons.qml
|
||||
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
set_source_files_properties(Basecamp/AppManager/AppColors.qml
|
||||
PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
|
||||
qt_add_qml_module(basecamp_backend_qml
|
||||
URI Basecamp.Backend
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Backend
|
||||
SOURCES
|
||||
../app/interfaces/InstallEnums.h
|
||||
InstallEnums.cpp
|
||||
AppsFilterProxy.h
|
||||
AppsFilterProxy.cpp
|
||||
ModulesFilterProxy.h
|
||||
ModulesFilterProxy.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_common_qml
|
||||
URI Basecamp.Common
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Common
|
||||
QML_FILES
|
||||
Basecamp/Common/LoadingOverlay.qml
|
||||
Basecamp/Common/EmptyView.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_sidebar_qml
|
||||
URI Basecamp.Sidebar
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Sidebar
|
||||
QML_FILES
|
||||
Basecamp/Sidebar/SidebarPanel.qml
|
||||
Basecamp/Sidebar/SidebarIconButton.qml
|
||||
Basecamp/Sidebar/SidebarCircleButton.qml
|
||||
Basecamp/Sidebar/SidebarAppDelegate.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_appmanager_qml
|
||||
URI Basecamp.AppManager
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/AppManager
|
||||
QML_FILES
|
||||
Basecamp/AppManager/AppManagerView.qml
|
||||
Basecamp/AppManager/AppManagerPanelHeader.qml
|
||||
Basecamp/AppManager/AppRepoSection.qml
|
||||
Basecamp/AppManager/AppContextMenu.qml
|
||||
Basecamp/AppManager/AppGrid.qml
|
||||
Basecamp/AppManager/AppGridDelegate.qml
|
||||
Basecamp/AppManager/AppListDelegate.qml
|
||||
Basecamp/AppManager/PackageRowDelegate.qml
|
||||
Basecamp/AppManager/AddApplicationDialog.qml
|
||||
Basecamp/AppManager/AppColors.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_settings_qml
|
||||
URI Basecamp.Settings
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Settings
|
||||
QML_FILES
|
||||
Basecamp/Settings/SettingsView.qml
|
||||
Basecamp/Settings/DashboardView.qml
|
||||
Basecamp/Settings/AppsInspectorView.qml
|
||||
Basecamp/Settings/ModuleInspectorView.qml
|
||||
Basecamp/Settings/InspectorPanelHeader.qml
|
||||
Basecamp/Settings/ModuleStatusBadge.qml
|
||||
Basecamp/Settings/ModuleRowActions.qml
|
||||
Basecamp/Settings/PluginInterfaceView.qml
|
||||
Basecamp/Settings/RepositoriesView.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_shell_qml
|
||||
URI Basecamp.Shell
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Shell
|
||||
QML_FILES
|
||||
Basecamp/Shell/ContentViews.qml
|
||||
Basecamp/Shell/OverlayDialogs.qml
|
||||
Basecamp/Shell/ConfirmationDialog.qml
|
||||
Basecamp/Shell/UninstallDialog.qml
|
||||
Basecamp/Shell/WelcomePage.qml
|
||||
)
|
||||
|
||||
qt_add_qml_module(basecamp_icons_qml
|
||||
URI Basecamp.Icons
|
||||
VERSION 1.0
|
||||
STATIC
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/Basecamp/Icons
|
||||
QML_FILES
|
||||
Basecamp/Icons/BasecampIcons.qml
|
||||
RESOURCES
|
||||
Basecamp/Icons/basecamp.svg
|
||||
Basecamp/Icons/dashboard.svg
|
||||
Basecamp/Icons/module.svg
|
||||
Basecamp/Icons/settings.svg
|
||||
Basecamp/Icons/tent.png
|
||||
Basecamp/Icons/workspace.svg
|
||||
)
|
||||
|
||||
|
||||
# Registers the STATIC qml module plugins with this target. Without it the
|
||||
# resource roots exist but nothing imports them.
|
||||
qt_import_qml_plugins(main_ui)
|
||||
|
||||
target_include_directories(main_ui PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${BASECAMP_INTERFACES}
|
||||
)
|
||||
|
||||
# The WHOLE_ARCHIVE entries must not be trimmed: a STATIC qt_add_qml_module
|
||||
# registers its types from a static initializer inside the *_qmlplugin archive,
|
||||
# so dropping one lets the linker collect it — no build error, blank pane.
|
||||
target_link_libraries(main_ui
|
||||
PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Widgets
|
||||
Qt6::Quick
|
||||
Qt6::Qml
|
||||
Qt6::QuickWidgets
|
||||
Qt6::QuickControls2
|
||||
Logos::DesignSystem
|
||||
basecamp_backend_qml
|
||||
basecamp_common_qml
|
||||
basecamp_sidebar_qml
|
||||
basecamp_appmanager_qml
|
||||
basecamp_settings_qml
|
||||
basecamp_shell_qml
|
||||
basecamp_icons_qml
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_backend_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_common_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_sidebar_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_appmanager_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_settings_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_shell_qmlplugin>
|
||||
$<LINK_LIBRARY:WHOLE_ARCHIVE,basecamp_icons_qmlplugin>
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
set_target_properties(main_ui PROPERTIES
|
||||
BUNDLE FALSE
|
||||
FRAMEWORK FALSE
|
||||
PREFIX ""
|
||||
SUFFIX ".dylib"
|
||||
)
|
||||
elseif(WIN32)
|
||||
# MUST be tested before the else-branch, or the plugin is emitted as
|
||||
# main_ui.so -- a genuine PE carrying a Unix extension. app/window.cpp
|
||||
# looks for ".dll" on Q_OS_WIN, so it never finds it and the app reports
|
||||
# "No main UI" with nothing obviously wrong.
|
||||
set_target_properties(main_ui PROPERTIES
|
||||
PREFIX ""
|
||||
SUFFIX ".dll"
|
||||
)
|
||||
else()
|
||||
set_target_properties(main_ui PROPERTIES
|
||||
PREFIX ""
|
||||
SUFFIX ".so"
|
||||
)
|
||||
endif()
|
||||
|
||||
install(TARGETS main_ui LIBRARY DESTINATION lib RUNTIME DESTINATION lib)
|
||||
@@ -10,13 +10,10 @@ class QQuickWidget;
|
||||
class WorkspaceArea;
|
||||
class ShortcutBridge;
|
||||
|
||||
// MainContainer — the UI shell.
|
||||
//
|
||||
// Holds exactly one host-side pointer: an IShellHost*. It has no LogosAPI*, no
|
||||
// QtLogosCore*, and no MainUIBackend*; every host operation it needs goes
|
||||
// through the eight methods on that interface, and QML reaches the backend as
|
||||
// an opaque QObject* via IShellHost::backendObject(). That is what lets this
|
||||
// class — and everything below it — compile against Qt alone.
|
||||
// MainContainer — the UI shell. Holds exactly one host-side pointer, an
|
||||
// IShellHost*: no LogosAPI*, no QtLogosCore*, no MainUIBackend*. QML reaches
|
||||
// the backend as an opaque QObject* via IShellHost::backendObject(). That is
|
||||
// what lets this class, and everything below it, compile against Qt alone.
|
||||
class MainContainer : public QWidget, public IShellObserver
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -26,13 +23,12 @@ public:
|
||||
explicit MainContainer(IShellHost* host, QWidget* parent = nullptr);
|
||||
~MainContainer();
|
||||
|
||||
// Get the workspace area
|
||||
WorkspaceArea* getWorkspaceArea() const { return m_workspaceArea; }
|
||||
|
||||
// Stops the host from calling back into this object. Idempotent, and safe
|
||||
// to call more than once — MainShellView::destroyShell() calls it before
|
||||
// deleting, and the destructor calls it again for the path where Qt's
|
||||
// parent-child teardown gets here first.
|
||||
// Stops the host from calling back into this object. Idempotent:
|
||||
// MainShellView::destroyShell() calls it before deleting, and the
|
||||
// destructor calls it again for the path where Qt's parent-child teardown
|
||||
// gets here first.
|
||||
void detachFromHost();
|
||||
|
||||
// ── IShellObserver ──────────────────────────────────────────────────────
|
||||
@@ -43,31 +39,26 @@ public:
|
||||
void onPluginWindowActivateRequested(QWidget* widget) override;
|
||||
|
||||
protected:
|
||||
// Kept in sync with the full MainContainer geometry — the overlay
|
||||
// widget floats over both the sidebar and the content stack, so it
|
||||
// can't sit in the HBoxLayout.
|
||||
// Keeps the overlay sized to the full MainContainer: it floats over both
|
||||
// the sidebar and the content stack, so it can't sit in the HBoxLayout.
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||
|
||||
private slots:
|
||||
// Called from QML whenever the combined visibility of the three
|
||||
// overlay dialogs flips. We use it to toggle mouse-event
|
||||
// passthrough on the overlay QQuickWidget — transparent to mouse
|
||||
// input when no dialog is open (so the sidebar / content still
|
||||
// receive clicks), intercepting when one is.
|
||||
// Called from QML when the combined visibility of the three overlay
|
||||
// dialogs flips. Toggles mouse-event passthrough on the overlay
|
||||
// QQuickWidget: transparent when no dialog is open, so the sidebar and
|
||||
// content still receive clicks, intercepting when one is.
|
||||
void onOverlayActiveChanged(bool active);
|
||||
void onSidebarTooltipRequested(const QString& text, qreal y);
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
|
||||
// Main layout
|
||||
QHBoxLayout* m_mainLayout;
|
||||
|
||||
// Sidebar (QML)
|
||||
QQuickWidget* m_sidebarWidget;
|
||||
|
||||
// Content area
|
||||
QStackedWidget* m_contentStack;
|
||||
|
||||
// Workspace (QDockWidget-based, replaces the old QMdiArea workspace)
|
||||
@@ -87,7 +78,7 @@ private:
|
||||
// Apps/MDI screen was active.
|
||||
QQuickWidget* m_overlayWidget;
|
||||
|
||||
// The host. Not owned — Window owns it and it outlives this widget.
|
||||
// Not owned — Window owns it and it outlives this widget.
|
||||
IShellHost* m_host;
|
||||
|
||||
ShortcutBridge* m_shortcutBridge = nullptr;
|
||||
@@ -12,16 +12,15 @@ MainShellView::MainShellView(QObject* parent)
|
||||
|
||||
MainShellView::~MainShellView()
|
||||
{
|
||||
// If Window already destroyed the central widget, m_shell is null and this
|
||||
// is a no-op; otherwise this is the last chance to detach the observer.
|
||||
// Null if Window already destroyed the central widget; otherwise this is
|
||||
// the last chance to detach the observer.
|
||||
destroyShell(m_shell.data());
|
||||
}
|
||||
|
||||
QWidget* MainShellView::createShell(IShellHost* host)
|
||||
{
|
||||
// Not a degraded mode: the shell reaches every host operation through this
|
||||
// pointer, so a null one would surface later as a null dereference in a
|
||||
// click handler rather than here.
|
||||
// Every host operation goes through this pointer; a null one would surface
|
||||
// later as a null dereference inside a click handler instead of here.
|
||||
if (!host) {
|
||||
qFatal("MainShellView::createShell requires an IShellHost");
|
||||
}
|
||||
@@ -39,9 +38,8 @@ void MainShellView::destroyShell(QWidget* widget)
|
||||
}
|
||||
|
||||
// Detach before deleting so no in-flight host callback reaches a
|
||||
// half-destroyed observer. MainContainer also does this in its own
|
||||
// destructor, for the path where Qt's parent-child teardown gets there
|
||||
// first; both are idempotent.
|
||||
// half-destroyed observer. MainContainer's destructor does the same when
|
||||
// Qt's teardown gets there first; both are idempotent.
|
||||
if (widget == m_shell) {
|
||||
m_shell->detachFromHost();
|
||||
m_shell = nullptr;
|
||||
@@ -10,17 +10,17 @@ class MainContainer;
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MainShellView — the IShellView implementation for Basecamp's own UI shell.
|
||||
//
|
||||
// A QObject with Q_INTERFACES so that `qobject_cast<IShellView*>` works on it.
|
||||
// That cast is what Window will use once this class ships inside the main_ui
|
||||
// plugin again; today Window constructs it directly, and the cast path costs
|
||||
// nothing to keep working in the meantime.
|
||||
// Q_INTERFACES + Q_PLUGIN_METADATA: QPluginLoader instantiates this class and
|
||||
// Window reaches it with `qobject_cast<IShellView*>`. A real cast, not
|
||||
// QMetaObject::invokeMethod by name — a signature change that way still
|
||||
// compiles on both sides and misses only at runtime.
|
||||
//
|
||||
// Deliberately holds no state beyond the shell it built: everything the shell
|
||||
// needs arrives through the IShellHost* it is handed.
|
||||
// Holds no state beyond the shell it built; the rest arrives via IShellHost*.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class MainShellView : public QObject, public IShellView {
|
||||
Q_OBJECT
|
||||
Q_INTERFACES(IShellView)
|
||||
Q_PLUGIN_METADATA(IID IShellView_iid FILE "metadata.json")
|
||||
|
||||
public:
|
||||
explicit MainShellView(QObject* parent = nullptr);
|
||||
@@ -34,7 +34,6 @@ public:
|
||||
private:
|
||||
// QPointer auto-nulls when the widget is destroyed by its Qt parent (Window
|
||||
// owns it as the central widget), so a later destroyShell() is a no-op
|
||||
// rather than a double delete. The pre-fold MainUIPlugin carried exactly
|
||||
// this guard and it is the reason it survived process exit.
|
||||
// rather than a double delete.
|
||||
QPointer<MainContainer> m_shell;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "ModulesFilterProxy.h"
|
||||
|
||||
#include "ModuleInstanceModel.h"
|
||||
#include "BasecampModelRoles.h"
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QByteArray>
|
||||
@@ -91,12 +91,12 @@ void ModulesFilterProxy::applySortOrder(int order)
|
||||
int ModulesFilterProxy::roleFromName(const QByteArray& name) const
|
||||
{
|
||||
QAbstractItemModel* src = sourceModel();
|
||||
if (!src) return ModuleInstanceModel::LabelRole;
|
||||
if (!src) return ModuleInstanceRoles::LabelRole;
|
||||
const auto roles = src->roleNames();
|
||||
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
|
||||
if (it.value() == name) return it.key();
|
||||
}
|
||||
return ModuleInstanceModel::LabelRole;
|
||||
return ModuleInstanceRoles::LabelRole;
|
||||
}
|
||||
|
||||
bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
|
||||
@@ -109,9 +109,9 @@ bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
|
||||
|
||||
// State filter.
|
||||
if (m_stateFilter == QLatin1String("loaded")
|
||||
&& !src->data(idx, ModuleInstanceModel::IsLoadedRole).toBool()) return false;
|
||||
&& !src->data(idx, ModuleInstanceRoles::IsLoadedRole).toBool()) return false;
|
||||
if (m_stateFilter == QLatin1String("notLoaded")
|
||||
&& src->data(idx, ModuleInstanceModel::IsLoadedRole).toBool()) return false;
|
||||
&& src->data(idx, ModuleInstanceRoles::IsLoadedRole).toBool()) return false;
|
||||
|
||||
// Search filter — trimmed + lowercased against a fixed set of textual
|
||||
// roles. Empty needle accepts everything.
|
||||
@@ -119,11 +119,11 @@ bool ModulesFilterProxy::filterAcceptsRow(int sourceRow,
|
||||
if (needle.isEmpty()) return true;
|
||||
|
||||
static const int textRoles[] = {
|
||||
ModuleInstanceModel::NameRole,
|
||||
ModuleInstanceModel::LabelRole,
|
||||
ModuleInstanceModel::StatusTextRole,
|
||||
ModuleInstanceModel::DescriptionRole,
|
||||
ModuleInstanceModel::VersionRole,
|
||||
ModuleInstanceRoles::NameRole,
|
||||
ModuleInstanceRoles::LabelRole,
|
||||
ModuleInstanceRoles::StatusTextRole,
|
||||
ModuleInstanceRoles::DescriptionRole,
|
||||
ModuleInstanceRoles::VersionRole,
|
||||
};
|
||||
for (int role : textRoles) {
|
||||
if (src->data(idx, role).toString().toLower().contains(needle))
|
||||
@@ -159,10 +159,10 @@ bool ModulesFilterProxy::lessThan(const QModelIndex& left,
|
||||
|
||||
// Stable tie-break by name so equal-status / equal-stat rows don't shuffle
|
||||
// between refreshes (the core stats poll fires every 2s).
|
||||
if (result == 0 && role != ModuleInstanceModel::NameRole) {
|
||||
result = src->data(left, ModuleInstanceModel::NameRole).toString()
|
||||
if (result == 0 && role != ModuleInstanceRoles::NameRole) {
|
||||
result = src->data(left, ModuleInstanceRoles::NameRole).toString()
|
||||
.localeAwareCompare(
|
||||
src->data(right, ModuleInstanceModel::NameRole).toString());
|
||||
src->data(right, ModuleInstanceRoles::NameRole).toString());
|
||||
}
|
||||
return result < 0;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "main_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "Logos Basecamp UI shell",
|
||||
"type": "ui"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||