From c838e512a99ed977245830fca6fe858e032f2194 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:03:12 +0300 Subject: [PATCH 01/64] [phase-02] Reach launcher services through LauncherContext Application derives from QApplication and owns the main window, the settings dialog and the theme manager. Every core file that wanted the network manager or the settings object reached for it through APPLICATION->, and so depended transitively on QtWidgets and on the entire widget page tree. That is what stops the core from being reused under a QML user interface. So the dependency is inverted. core/LauncherContext.h declares the services code outside the user interface is allowed to reach for; Application implements it and registers itself; the core says LAUNCHER-> instead. Measuring first paid off -- the interface is ten accessors, not the dozens the API surface suggested, because most of Application is only ever touched from ui/. Files outside launcher/ui/ that include Application.h: 44 before, 5 after. The five that remain are Application.cpp and main.cpp (the shell itself), PluginManager.cpp (the plugin host, which keeps QtWidgets until the plugin ABI work), InstancePageProvider.h (pure widget glue that dies with the widget UI), and LaunchController.cpp. Two files were not coupled to widgets so much as misfiled, and moved rather than being ported: - JavaCommon is entirely message boxes and a java-check driver. It was already listed in the UI source group; only its path said otherwise. Now ui/. - ShortcutUtils reports every failure with a QMessageBox and opens a QFileDialog, and its only caller is CreateShortcutDialog. Now ui/. PasteUpload took a QWidget* and stored it in m_window, which nothing ever read. The parameter and the member are gone, so a network task no longer has a widget in its signature. AuthRequest called PluginManager directly to run MMCO_HOOK_AUTH_REQUEST, and PluginManager builds plugin-supplied user interface -- so that one line tied authentication to the widget toolkit. The hook body moved behind core/AuthRequestDecorator.h: the core declares what it needs, the plugin layer supplies it, neither knows the other. This edge had to go before the core can be a target that cannot link QtWidgets at all. While moving it, its contract turned out to be documented backwards: the function returns true when a plugin CANCELLED the request, not when it modified it. The interface says so explicitly now. Also fixed along the way: InstanceImportTask.cpp included Application.h twice, PackFetchTask.cpp included it without using it, and MeshMCPartLaunch.cpp was reaching Logging.h transitively through Application.h -- it declares that include itself now. Co-Authored-By: Claude Opus 5 Signed-off-by: grxtor --- launcher/Application.cpp | 18 ++++ launcher/Application.h | 29 +++--- launcher/CMakeLists.txt | 19 +++- launcher/InstanceImportTask.cpp | 31 +++--- launcher/LaunchController.cpp | 2 +- launcher/SkinUtils.cpp | 4 +- launcher/VersionProxyModel.cpp | 8 +- launcher/core/AuthRequestDecorator.h | 53 ++++++++++ launcher/core/LauncherContext.cpp | 38 +++++++ launcher/core/LauncherContext.h | 98 +++++++++++++++++++ launcher/java/JavaChecker.cpp | 4 +- launcher/java/download/JavaDownloadTask.cpp | 8 +- launcher/meta/BaseEntity.cpp | 6 +- launcher/minecraft/AssetsUtils.cpp | 4 +- launcher/minecraft/Component.cpp | 12 +-- launcher/minecraft/ComponentUpdateTask.cpp | 10 +- launcher/minecraft/MinecraftInstance.cpp | 4 +- launcher/minecraft/PackProfile.cpp | 6 +- launcher/minecraft/auth/AuthRequest.cpp | 68 +++---------- launcher/minecraft/auth/steps/MSAStep.cpp | 6 +- launcher/minecraft/launch/ClaimAccount.cpp | 4 +- .../minecraft/launch/MeshMCPartLaunch.cpp | 5 +- .../minecraft/launch/VerifyJavaInstall.cpp | 8 +- launcher/minecraft/services/CapeChange.cpp | 6 +- launcher/minecraft/services/SkinDelete.cpp | 4 +- launcher/minecraft/services/SkinUpload.cpp | 4 +- .../minecraft/skins/ProfileSkinImport.cpp | 8 +- launcher/minecraft/update/AssetUpdateTask.cpp | 8 +- .../minecraft/update/FMLLibrariesTask.cpp | 8 +- launcher/minecraft/update/LibrariesTask.cpp | 6 +- launcher/modplatform/ContentDownloadTask.cpp | 4 +- launcher/modplatform/ContentProviderModel.cpp | 16 +-- launcher/modplatform/DependencyResolver.cpp | 4 +- launcher/modplatform/ModUpdateCheckTask.cpp | 4 +- .../atlauncher/ATLPackInstallTask.cpp | 22 ++--- .../modplatform/legacy_ftb/PackFetchTask.cpp | 1 - .../legacy_ftb/PackInstallTask.cpp | 4 +- .../modpacksch/FTBPackInstallTask.cpp | 8 +- .../modrinth/ModrinthPackExportTask.cpp | 4 +- .../technic/SingleZipPackInstallTask.cpp | 8 +- launcher/net/JsonPost.cpp | 4 +- launcher/net/MetaCacheSink.cpp | 4 +- launcher/net/PasteUpload.cpp | 7 +- launcher/net/PasteUpload.h | 3 +- .../notifications/NotificationChecker.cpp | 6 +- .../plugin/PluginAuthRequestDecorator.cpp | 80 +++++++++++++++ launcher/plugin/PluginAuthRequestDecorator.h | 44 +++++++++ launcher/screenshots/ImgurAlbumCreation.cpp | 4 +- launcher/translations/TranslationsModel.cpp | 10 +- launcher/ui/GuiUtil.cpp | 2 +- launcher/{ => ui}/JavaCommon.cpp | 2 +- launcher/{ => ui}/JavaCommon.h | 0 launcher/ui/MainWindow.cpp | 2 +- launcher/{minecraft => ui}/ShortcutUtils.cpp | 2 +- launcher/{minecraft => ui}/ShortcutUtils.h | 0 launcher/ui/dialogs/CreateShortcutDialog.cpp | 2 +- launcher/ui/pages/global/JavaPage.cpp | 2 +- launcher/ui/pages/global/JavaPage.h | 2 +- .../pages/instance/InstanceSettingsPage.cpp | 2 +- .../ui/pages/instance/InstanceSettingsPage.h | 2 +- launcher/ui/setupwizard/JavaWizardPage.cpp | 2 +- 61 files changed, 528 insertions(+), 218 deletions(-) create mode 100644 launcher/core/AuthRequestDecorator.h create mode 100644 launcher/core/LauncherContext.cpp create mode 100644 launcher/core/LauncherContext.h create mode 100644 launcher/plugin/PluginAuthRequestDecorator.cpp create mode 100644 launcher/plugin/PluginAuthRequestDecorator.h rename launcher/{ => ui}/JavaCommon.cpp (99%) rename launcher/{ => ui}/JavaCommon.h (100%) rename launcher/{minecraft => ui}/ShortcutUtils.cpp (99%) rename launcher/{minecraft => ui}/ShortcutUtils.h (100%) diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 916cba8c..545187c2 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -19,6 +19,7 @@ #include "Application.h" #include "BuildConfig.h" +#include "plugin/PluginAuthRequestDecorator.h" #include "plugin/PluginManager.h" #include "ui/MainWindow.h" @@ -329,6 +330,11 @@ namespace Application::Application(int& argc, char** argv) : QApplication(argc, argv) { + /* Before anything else: things constructed further down this function + * already reach services through LAUNCHER->, and they would see a null + * context otherwise. */ + LauncherContext::setInstance(this); + initPlatform(); if (m_status != StartingUp) return; @@ -421,6 +427,8 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) // Do NOT pass `this` as QObject parent, or the PluginManager // will be double-freed (once by unique_ptr, once by ~QObject). m_pluginManager = std::make_unique(this, nullptr); + m_authRequestDecorator = + std::make_unique(m_pluginManager.get()); m_pluginManager->initializeAll(); if (createSetupWizard()) { @@ -1559,6 +1567,11 @@ void Application::showFatalErrorMessage(const QString& title, Application::~Application() { + /* Stop handing out a context that is being torn down. Anything still + * running past this point has to cope with LAUNCHER being null, which is + * what the accessor documents. */ + LauncherContext::setInstance(nullptr); + // Shut down plugin system before tearing down the rest. // shutdownAll() was already called from aboutToQuit; this // is a no-op guard for any other exit path. @@ -2293,3 +2306,8 @@ const QString Application::javaPath() { return m_settings->get("JavaDir").toString(); } + +AuthRequestDecorator* Application::authRequestDecorator() const +{ + return m_authRequestDecorator.get(); +} diff --git a/launcher/Application.h b/launcher/Application.h index 57d9b4e0..16eb2fa4 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -34,6 +34,7 @@ #include "Logging.h" #include "minecraft/launch/MinecraftServerTarget.h" +#include "core/LauncherContext.h" class LaunchController; class LocalPeer; @@ -88,7 +89,7 @@ enum class LaunchMode #endif #define APPLICATION (static_cast(QCoreApplication::instance())) -class Application : public QApplication +class Application : public QApplication, public LauncherContext { // friends for the purpose of limiting access to deprecated stuff Q_OBJECT @@ -104,7 +105,7 @@ class Application : public QApplication return m_pluginManager.get(); } - std::shared_ptr settings() const + std::shared_ptr settings() const override { return m_settings; } @@ -114,7 +115,7 @@ class Application : public QApplication return startTime.msecsTo(QDateTime::currentDateTime()); } - QIcon getThemedIcon(const QString& name); + QIcon getThemedIcon(const QString& name) override; void setIconTheme(const QString& name); @@ -140,12 +141,12 @@ class Application : public QApplication std::shared_ptr javalist(); - std::shared_ptr instances() const + std::shared_ptr instances() const override { return m_instances; } - std::shared_ptr icons() const + std::shared_ptr icons() const override { return m_icons; } @@ -155,12 +156,12 @@ class Application : public QApplication return m_mcedit.get(); } - shared_qobject_ptr accounts() const + shared_qobject_ptr accounts() const override { return m_accounts; } - QString msaClientId() const; + QString msaClientId() const override; Status status() const { @@ -175,13 +176,15 @@ class Application : public QApplication void updateProxySettings(QString proxyTypeStr, QString addr, int port, QString user, QString password); - shared_qobject_ptr network(); + shared_qobject_ptr network() override; - shared_qobject_ptr metacache(); + shared_qobject_ptr metacache() override; - shared_qobject_ptr metadataIndex(); + shared_qobject_ptr metadataIndex() override; - QString getJarsPath(); + QString getJarsPath() override; + + AuthRequestDecorator* authRequestDecorator() const override; /// this is the root of the 'installation'. Used for automatic updates const QString& root() @@ -325,6 +328,10 @@ class Application : public QApplication SetupWizard* m_setupWizard = nullptr; std::unique_ptr m_pluginManager; + /* Built alongside m_pluginManager, so it is null in builds without the + * plugin host (MeshMC_PLUGINS is OFF by default). */ + std::unique_ptr m_authRequestDecorator; + public: QString m_instanceIdToLaunch; QString m_serverToJoin; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 174ef3d8..dcb26b85 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -7,6 +7,13 @@ project(application) include (UnitTest) set(CORE_SOURCES + # The services the core is allowed to reach for, and the interfaces it + # declares for things it needs but must not depend on. Nothing here may + # mention QtWidgets. + core/LauncherContext.h + core/LauncherContext.cpp + core/AuthRequestDecorator.h + # LOGIC - Base classes and infrastructure BaseInstaller.h BaseInstaller.cpp @@ -381,8 +388,6 @@ set(MINECRAFT_SOURCES minecraft/ParseUtils.h minecraft/ProfileUtils.cpp minecraft/ProfileUtils.h - minecraft/ShortcutUtils.cpp - minecraft/ShortcutUtils.h minecraft/Library.cpp minecraft/Library.h minecraft/MojangDownloadInfo.h @@ -694,6 +699,10 @@ add_unit_test(PackContents ) set(PLUGIN_SOURCES + # Supplies core-declared interfaces that need the plugin host to run. + plugin/PluginAuthRequestDecorator.h + plugin/PluginAuthRequestDecorator.cpp + # MMCO Plugin System plugin/MMCOFormat.h plugin/PluginHooks.h @@ -799,6 +808,8 @@ SET(MESHMC_SOURCES ui/ColorCache.h ui/ColorCache.cpp ui/MainWindow.h + ui/ShortcutUtils.cpp + ui/ShortcutUtils.h ui/MainWindow.cpp ui/MacMenuBar.h ui/MacMenuBar.cpp @@ -848,8 +859,8 @@ SET(MESHMC_SOURCES InstancePageProvider.h # Common java checking UI - JavaCommon.h - JavaCommon.cpp + ui/JavaCommon.h + ui/JavaCommon.cpp # GUI - paged dialog base ui/pages/BasePage.h diff --git a/launcher/InstanceImportTask.cpp b/launcher/InstanceImportTask.cpp index ccb9ecd4..1af5ed77 100644 --- a/launcher/InstanceImportTask.cpp +++ b/launcher/InstanceImportTask.cpp @@ -21,7 +21,7 @@ #include "InstanceImportTask.h" #include "BaseInstance.h" #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" #include "InstanceList.h" #include "MMCZip.h" #include "archive/ExtractZipTask.h" @@ -43,7 +43,6 @@ #include "modplatform/technic/TechnicPackProcessor.h" #include "icons/IconList.h" -#include "Application.h" #include "modplatform/flame/FlameApi.h" #include "modplatform/modrinth/ModrinthApi.h" #include "ui/dialogs/BlockedModsDialog.h" @@ -75,11 +74,11 @@ void InstanceImportTask::executeTask() m_downloadRequired = true; const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); - auto entry = APPLICATION->metacache()->resolveEntry("general", path); + auto entry = LAUNCHER->metacache()->resolveEntry("general", path); entry->setStale(true); m_archiveEntry = entry; m_filesNetJob = - new NetJob(tr("Modpack download"), APPLICATION->network()); + new NetJob(tr("Modpack download"), LAUNCHER->network()); m_filesNetJob->addNetAction( Net::Download::makeCached(m_sourceUrl, entry)); m_archivePath = entry->getFullPath(); @@ -314,7 +313,7 @@ void InstanceImportTask::extractFailed() if (!QFile::remove(m_archivePath)) { qWarning() << "Could not remove" << m_archivePath; } - APPLICATION->metacache()->evictEntry(m_archiveEntry); + LAUNCHER->metacache()->evictEntry(m_archiveEntry); m_archiveEntry.reset(); emitFailed(tr("Failed to extract modpack. The downloaded archive is " "damaged; it has been discarded, so trying again will " @@ -637,7 +636,7 @@ void InstanceImportTask::processFlame() listFilesRelative(FS::PathCombine(m_stagingPath, gameDirName())); m_modIdResolver = - new Flame::FileResolvingTask(APPLICATION->network(), pack); + new Flame::FileResolvingTask(LAUNCHER->network(), pack); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, this, &InstanceImportTask::onFlameFileResolutionSucceeded); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, @@ -828,7 +827,7 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() } } - m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); + m_filesNetJob = new NetJob(tr("Mod download"), LAUNCHER->network()); // Collect restricted mods that need browser download QList blockedMods; @@ -853,7 +852,7 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() QDir installedGameDir; bool canReuseInstalled = false; if (!m_updateTarget.isEmpty()) { - auto previous = APPLICATION->instances()->getInstanceById( + auto previous = LAUNCHER->instances()->getInstanceById( m_updateTarget.instanceId); if (auto minecraftPrevious = std::dynamic_pointer_cast(previous)) { @@ -1239,7 +1238,7 @@ void InstanceImportTask::processModrinth() // Download all mod files m_filesNetJob = - new NetJob(tr("Modrinth mod download"), APPLICATION->network()); + new NetJob(tr("Modrinth mod download"), LAUNCHER->network()); auto minecraftDir = FS::PathCombine(m_stagingPath, gameDirName()); auto canonicalBase = QDir(minecraftDir).canonicalPath(); /* What this version is responsible for. Built from the same loop that @@ -1260,7 +1259,7 @@ void InstanceImportTask::processModrinth() QDir installedGameDir; bool canReuseInstalled = false; if (!m_updateTarget.isEmpty()) { - auto previous = APPLICATION->instances()->getInstanceById( + auto previous = LAUNCHER->instances()->getInstanceById( m_updateTarget.instanceId); if (auto minecraftPrevious = std::dynamic_pointer_cast(previous)) { @@ -1477,7 +1476,7 @@ void InstanceImportTask::processMeshMC() IconUtils::findBestIconIn(instance.instanceRoot(), m_instIcon); if (!importIconPath.isNull() && QFile::exists(importIconPath)) { // import icon - auto iconList = APPLICATION->icons(); + auto iconList = LAUNCHER->icons(); if (iconList->iconFileExists(m_instIcon)) { iconList->deleteIcon(m_instIcon); } @@ -1651,14 +1650,14 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() /* Not a catalogue install, so there is no id to match on. */ return true; } - if (APPLICATION->settings()->get("SkipModpackUpdatePrompt").toBool()) { + if (LAUNCHER->settings()->get("SkipModpackUpdatePrompt").toBool()) { /* Turned off, so installing means installing: a second instance, * without the question. Checked before looking anything up so * that the answer costs nothing when nobody wants it. */ return true; } - auto existing = APPLICATION->instances()->getInstanceByManagedPack( + auto existing = LAUNCHER->instances()->getInstanceByManagedPack( m_packHint.provider, m_packHint.packId); if (!existing) { return true; @@ -1731,7 +1730,7 @@ QString InstanceImportTask::gameDirName() } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); auto minecraftPrevious = std::dynamic_pointer_cast(previous); if (!minecraftPrevious) { @@ -1842,7 +1841,7 @@ bool InstanceImportTask::recordPackContents( } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); if (!previous) { /* Gone between the page opening and the update running. The * commit step will have its own opinion about that; there is @@ -1956,7 +1955,7 @@ void InstanceImportTask::carryOverUserSettings(BaseInstance& instance) } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); if (!previous) { /* The instance vanished between the page opening and the update * running. The staging step will fail to find it too; nothing diff --git a/launcher/LaunchController.cpp b/launcher/LaunchController.cpp index 3f3d5fcd..c6a46192 100644 --- a/launcher/LaunchController.cpp +++ b/launcher/LaunchController.cpp @@ -40,7 +40,7 @@ #include #include "BuildConfig.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "tasks/Task.h" #include "minecraft/auth/AccountTask.h" #include "launch/steps/CreateBackup.h" diff --git a/launcher/SkinUtils.cpp b/launcher/SkinUtils.cpp index 8f76bf4c..a28115bd 100644 --- a/launcher/SkinUtils.cpp +++ b/launcher/SkinUtils.cpp @@ -20,7 +20,7 @@ #include "SkinUtils.h" #include "net/HttpMetaCache.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include @@ -36,7 +36,7 @@ namespace SkinUtils */ QPixmap getFaceFromCache(QString username, int height, int width) { - QFile fskin(APPLICATION->metacache() + QFile fskin(LAUNCHER->metacache() ->resolveEntry("skins", username + ".png") ->getFullPath()); diff --git a/launcher/VersionProxyModel.cpp b/launcher/VersionProxyModel.cpp index c5dc07d3..b7466255 100644 --- a/launcher/VersionProxyModel.cpp +++ b/launcher/VersionProxyModel.cpp @@ -18,7 +18,7 @@ */ #include "VersionProxyModel.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include #include @@ -214,15 +214,15 @@ QVariant VersionProxyModel::data(const QModelIndex& index, int role) const auto value = sourceModel()->data( parentIndex, BaseVersionList::RecommendedRole); if (value.toBool()) { - return APPLICATION->getThemedIcon("star"); + return LAUNCHER->getThemedIcon("star"); } else if (hasLatest) { auto value = sourceModel()->data( parentIndex, BaseVersionList::LatestRole); if (value.toBool()) { - return APPLICATION->getThemedIcon("bug"); + return LAUNCHER->getThemedIcon("bug"); } } else if (index.row() == 0) { - return APPLICATION->getThemedIcon("bug"); + return LAUNCHER->getThemedIcon("bug"); } QPixmap pixmap; if (!QPixmapCache::find("placeholder", &pixmap)) { diff --git a/launcher/core/AuthRequestDecorator.h b/launcher/core/AuthRequestDecorator.h new file mode 100644 index 00000000..a8b8007e --- /dev/null +++ b/launcher/core/AuthRequestDecorator.h @@ -0,0 +1,53 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +class QNetworkRequest; + +/* + * Lets something outside the core add headers to an outgoing authentication + * request. + * + * In practice the implementation is the plugin host, which forwards to the + * MMCO auth-request hook. AuthRequest needs that hook, but PluginManager + * renders plugin-supplied user interface and so drags in QtWidgets; having the + * authentication code call it directly would tie the core to the widget + * toolkit through a single line. Hence this interface: the core declares what + * it needs, the plugin layer supplies it, and neither knows about the other. + */ +class AuthRequestDecorator +{ + public: + virtual ~AuthRequestDecorator() = default; + + /* Header and redirect changes are applied to `request` in place. + * + * Returns true when the request was CANCELLED -- the caller must then + * abort it and report a network error. It does NOT return true merely + * because the request was modified. + * + * `method` is the HTTP verb as an ASCII literal ("GET", "POST"); `body` + * is empty for verbs that carry none. */ + virtual bool dispatchAuthRequest(QNetworkRequest& request, + const QByteArray& body, + const char* method) = 0; +}; diff --git a/launcher/core/LauncherContext.cpp b/launcher/core/LauncherContext.cpp new file mode 100644 index 00000000..50d73a69 --- /dev/null +++ b/launcher/core/LauncherContext.cpp @@ -0,0 +1,38 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "core/LauncherContext.h" + +namespace +{ + /* Deliberately a plain pointer and not an owning one: the implementation + * is the Application object itself, which owns its own lifetime and + * unregisters on the way out. */ + LauncherContext* g_context = nullptr; +} + +LauncherContext* LauncherContext::instance() +{ + return g_context; +} + +void LauncherContext::setInstance(LauncherContext* context) +{ + g_context = context; +} diff --git a/launcher/core/LauncherContext.h b/launcher/core/LauncherContext.h new file mode 100644 index 00000000..df074194 --- /dev/null +++ b/launcher/core/LauncherContext.h @@ -0,0 +1,98 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "QObjectPtr.h" + +class AccountList; +class AuthRequestDecorator; +class HttpMetaCache; +class IconList; +class InstanceList; +class QNetworkAccessManager; +class SettingsObject; + +namespace Meta +{ + class Index; +} + +/* + * The launcher services that code outside the user interface is allowed to + * reach for. + * + * Everything here used to be read off `Application`, which derives from + * QApplication and owns the main window, the settings dialog and the theme + * manager. That made every file that wanted, say, the network manager depend + * transitively on QtWidgets and on the entire widget page tree -- which is + * exactly what stops the core from being reused under a QML user interface. + * + * So the dependency is inverted: `Application` implements this interface and + * registers itself, and the core reaches services through LAUNCHER-> instead + * of APPLICATION->. Nothing declared here is allowed to mention QtWidgets, and + * nothing here returns a widget, a window or a dialog. Asking the user a + * question is not a service -- that inversion belongs to UiHost. + * + * QIcon is fine despite appearances: it lives in QtGui, not QtWidgets. QML + * cannot consume it, so getThemedIcon() is a transitional accessor that the + * image-provider work will eventually replace with icon names. + */ +class LauncherContext +{ + public: + virtual ~LauncherContext() = default; + + /* Null before Application's constructor has run and after it has been + * destroyed -- notably in the crash handler, which runs while the + * application object is being torn down. Callers on those paths must + * check. */ + static LauncherContext* instance(); + + virtual std::shared_ptr settings() const = 0; + virtual std::shared_ptr instances() const = 0; + virtual std::shared_ptr icons() const = 0; + virtual shared_qobject_ptr accounts() const = 0; + + virtual shared_qobject_ptr network() = 0; + virtual shared_qobject_ptr metacache() = 0; + virtual shared_qobject_ptr metadataIndex() = 0; + + virtual QIcon getThemedIcon(const QString& name) = 0; + virtual QString getJarsPath() = 0; + virtual QString msaClientId() const = 0; + + /* Null when no plugin host is present -- plugins are an optional build + * (MeshMC_PLUGINS, OFF by default). Core code calls through this rather + * than reaching PluginManager directly, because PluginManager renders + * plugin-supplied user interface and therefore pulls in QtWidgets. */ + virtual AuthRequestDecorator* authRequestDecorator() const = 0; + + protected: + /* Called by the implementation's constructor/destructor. Registering is + * not thread safe and is expected to happen once, on the main thread, + * before anything else runs. */ + static void setInstance(LauncherContext* context); +}; + +#define LAUNCHER (LauncherContext::instance()) diff --git a/launcher/java/JavaChecker.cpp b/launcher/java/JavaChecker.cpp index bf8f0443..1b822956 100644 --- a/launcher/java/JavaChecker.cpp +++ b/launcher/java/JavaChecker.cpp @@ -28,14 +28,14 @@ #include "JavaUtils.h" #include "FileSystem.h" #include "Commandline.h" -#include "Application.h" +#include "core/LauncherContext.h" JavaChecker::JavaChecker(QObject* parent) : QObject(parent) {} void JavaChecker::performCheck() { QString checkerJar = - FS::PathCombine(APPLICATION->getJarsPath(), "JavaCheck.jar"); + FS::PathCombine(LAUNCHER->getJarsPath(), "JavaCheck.jar"); QStringList args; diff --git a/launcher/java/download/JavaDownloadTask.cpp b/launcher/java/download/JavaDownloadTask.cpp index 1730c1ba..3606e04d 100644 --- a/launcher/java/download/JavaDownloadTask.cpp +++ b/launcher/java/download/JavaDownloadTask.cpp @@ -26,12 +26,12 @@ #include #include -#include "Application.h" #include "FileSystem.h" #include "Json.h" #include "net/Download.h" #include "net/ChecksumValidator.h" #include "MMCZip.h" +#include "core/LauncherContext.h" JavaDownloadTask::JavaDownloadTask(const JavaDownload::RuntimeEntry& runtime, const QString& targetDir, QObject* parent) @@ -64,7 +64,7 @@ void JavaDownloadTask::downloadArchive() return; } - m_downloadJob = new NetJob(tr("Java download"), APPLICATION->network()); + m_downloadJob = new NetJob(tr("Java download"), LAUNCHER->network()); auto dl = Net::Download::makeFile(m_runtime.url, m_archivePath); @@ -188,7 +188,7 @@ void JavaDownloadTask::downloadManifest() } m_downloadJob = - new NetJob(tr("Java manifest download"), APPLICATION->network()); + new NetJob(tr("Java manifest download"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(m_runtime.url), &m_manifestData); @@ -242,7 +242,7 @@ void JavaDownloadTask::manifestDownloaded() // Queue file downloads setStatus(tr("Downloading %1 files...").arg(m_runtime.name)); m_downloadJob = - new NetJob(tr("Java runtime files"), APPLICATION->network()); + new NetJob(tr("Java runtime files"), LAUNCHER->network()); for (auto it = files.begin(); it != files.end(); ++it) { auto entry = it.value().toObject(); diff --git a/launcher/meta/BaseEntity.cpp b/launcher/meta/BaseEntity.cpp index 644cd31a..2871ed63 100644 --- a/launcher/meta/BaseEntity.cpp +++ b/launcher/meta/BaseEntity.cpp @@ -27,7 +27,7 @@ #include "Json.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" class ParsingValidator : public Net::Validator { @@ -110,10 +110,10 @@ void Meta::BaseEntity::load(Net::Mode loadType) } m_updateTask = new NetJob(QObject::tr("Download of meta file %1").arg(localFilename()), - APPLICATION->network()); + LAUNCHER->network()); auto url = this->url(); auto entry = - APPLICATION->metacache()->resolveEntry("meta", localFilename()); + LAUNCHER->metacache()->resolveEntry("meta", localFilename()); entry->setStale(true); auto dl = Net::Download::makeCached(url, entry); /* diff --git a/launcher/minecraft/AssetsUtils.cpp b/launcher/minecraft/AssetsUtils.cpp index cb21deeb..0587a2c7 100644 --- a/launcher/minecraft/AssetsUtils.cpp +++ b/launcher/minecraft/AssetsUtils.cpp @@ -34,7 +34,7 @@ #include "net/ChecksumValidator.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace { @@ -313,7 +313,7 @@ QString AssetObject::getRelPath() NetJob::Ptr AssetsIndex::getDownloadJob() { auto job = new NetJob(QObject::tr("Assets for %1").arg(id), - APPLICATION->network()); + LAUNCHER->network()); for (auto& object : objects.values()) { auto dl = object.getDownloadAction(); if (dl) { diff --git a/launcher/minecraft/Component.cpp b/launcher/minecraft/Component.cpp index 424e81eb..de5051cb 100644 --- a/launcher/minecraft/Component.cpp +++ b/launcher/minecraft/Component.cpp @@ -28,7 +28,7 @@ #include "minecraft/PackProfile.h" #include "FileSystem.h" #include "OneSixVersionFormat.h" -#include "Application.h" +#include "core/LauncherContext.h" #include @@ -172,8 +172,8 @@ std::shared_ptr Component::getVersionFile() const std::shared_ptr Component::getVersionList() const { // FIXME: what if the metadata index isn't loaded yet? - if (APPLICATION->metadataIndex()->hasUid(m_uid)) { - return APPLICATION->metadataIndex()->get(m_uid); + if (LAUNCHER->metadataIndex()->hasUid(m_uid)) { + return LAUNCHER->metadataIndex()->get(m_uid); } return nullptr; } @@ -270,7 +270,7 @@ bool Component::isRemovable() bool Component::isRevertible() { if (isCustom()) { - if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + if (LAUNCHER->metadataIndex()->hasUid(m_uid)) { return true; } } @@ -336,7 +336,7 @@ void Component::setVersion(const QString& version) m_cachedVersion = version; // see if the meta version is loaded auto metaVersion = - APPLICATION->metadataIndex()->get(m_uid, version); + LAUNCHER->metadataIndex()->get(m_uid, version); if (metaVersion->isLoaded()) { // if yes, we can continue with that. m_metaVersion = metaVersion; @@ -404,7 +404,7 @@ bool Component::revert() m_file.reset(); // check local cache for metadata... - auto version = APPLICATION->metadataIndex()->get(m_uid, m_version); + auto version = LAUNCHER->metadataIndex()->get(m_uid, m_version); if (version->isLoaded()) { m_metaVersion = version; } else { diff --git a/launcher/minecraft/ComponentUpdateTask.cpp b/launcher/minecraft/ComponentUpdateTask.cpp index c1da1d08..b55d4790 100644 --- a/launcher/minecraft/ComponentUpdateTask.cpp +++ b/launcher/minecraft/ComponentUpdateTask.cpp @@ -31,7 +31,7 @@ #include "net/Mode.h" #include "OneSixVersionFormat.h" -#include "Application.h" +#include "core/LauncherContext.h" /* * This is responsible for loading the components of a component list AND @@ -115,7 +115,7 @@ namespace component->m_loaded = true; result = LoadResult::LoadedLocal; } else { - auto metaVersion = APPLICATION->metadataIndex()->get( + auto metaVersion = LAUNCHER->metadataIndex()->get( component->m_uid, component->m_version); component->m_metaVersion = metaVersion; if (metaVersion->isLoaded()) { @@ -166,12 +166,12 @@ namespace static LoadResult loadIndex(Task::Ptr& loadTask, Net::Mode netmode) { // FIXME: DECIDE. do we want to run the update task anyway? - if (APPLICATION->metadataIndex()->isLoaded()) { + if (LAUNCHER->metadataIndex()->isLoaded()) { qDebug() << "Index is already loaded"; return LoadResult::LoadedLocal; } - APPLICATION->metadataIndex()->load(netmode); - loadTask = APPLICATION->metadataIndex()->getCurrentTask(); + LAUNCHER->metadataIndex()->load(netmode); + loadTask = LAUNCHER->metadataIndex()->getCurrentTask(); if (loadTask) { return LoadResult::RequiresRemote; } diff --git a/launcher/minecraft/MinecraftInstance.cpp b/launcher/minecraft/MinecraftInstance.cpp index f4317e22..a4e6af73 100644 --- a/launcher/minecraft/MinecraftInstance.cpp +++ b/launcher/minecraft/MinecraftInstance.cpp @@ -23,7 +23,7 @@ #include "minecraft/launch/PrintInstanceInfo.h" #include "settings/Setting.h" #include "settings/SettingsObject.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include "MMCStrings.h" @@ -992,7 +992,7 @@ MinecraftInstance::createLaunchTask(AuthSessionPtr session, std::dynamic_pointer_cast(shared_from_this())); auto pptr = process.get(); - APPLICATION->icons()->saveIcon( + LAUNCHER->icons()->saveIcon( iconKey(), FS::PathCombine(gameRoot(), "icon.png"), "PNG"); // print a header diff --git a/launcher/minecraft/PackProfile.cpp b/launcher/minecraft/PackProfile.cpp index 24d9e0e6..da96bb85 100644 --- a/launcher/minecraft/PackProfile.cpp +++ b/launcher/minecraft/PackProfile.cpp @@ -40,7 +40,7 @@ #include "PackProfile_p.h" #include "ComponentUpdateTask.h" -#include "Application.h" +#include "core/LauncherContext.h" PackProfile::PackProfile(MinecraftInstance* instance) : QAbstractListModel() { @@ -481,7 +481,7 @@ bool PackProfile::migratePreComponentConfig() component->m_version = intendedVersion; } else if (!intendedVersion.isEmpty()) { auto metaVersion = - APPLICATION->metadataIndex()->get(uid, intendedVersion); + LAUNCHER->metadataIndex()->get(uid, intendedVersion); component = new Component(this, metaVersion); } else { return; @@ -543,7 +543,7 @@ bool PackProfile::migratePreComponentConfig() auto patchVersion = d->getOldConfigVersion(uid); if (!patchVersion.isEmpty() && !loadedComponents.contains(uid)) { auto patch = new Component( - this, APPLICATION->metadataIndex()->get(uid, patchVersion)); + this, LAUNCHER->metadataIndex()->get(uid, patchVersion)); patch->setOrder(order); loadedComponents[uid] = patch; } diff --git a/launcher/minecraft/auth/AuthRequest.cpp b/launcher/minecraft/auth/AuthRequest.cpp index 3d2d9e3a..09c5963e 100644 --- a/launcher/minecraft/auth/AuthRequest.cpp +++ b/launcher/minecraft/auth/AuthRequest.cpp @@ -24,69 +24,33 @@ #include #include -#include "Application.h" #include "AuthRequest.h" -#include "plugin/PluginHooks.h" -#include "plugin/PluginManager.h" +#include "core/AuthRequestDecorator.h" +#include "core/LauncherContext.h" #include "katabasis/Globals.h" namespace { /* - * dispatchAuthRequestHook — run MMCO_HOOK_AUTH_REQUEST over the - * in-flight request and apply any redirect/header mutations the - * plugins request. + * dispatchAuthRequestHook — hand the in-flight request to whatever the + * plugin host hooked into outgoing authentication requests, if anything. * - * Returns true if the hook chain *cancelled* the request — the - * caller must abort and emit a network error in that case. + * Returns true if the request was *cancelled* — the caller must abort and + * emit a network error in that case. Header and redirect mutations are + * applied to `request` in place. + * + * The hook body itself lives in the plugin layer: running it means + * touching PluginManager, which builds plugin-supplied user interface and + * so drags QtWidgets in behind it. */ bool dispatchAuthRequestHook(QNetworkRequest& request, const QByteArray& body, const char* method) { - auto* pm = APPLICATION ? APPLICATION->pluginManager() : nullptr; - if (!pm) + auto* decorator = LAUNCHER ? LAUNCHER->authRequestDecorator() : nullptr; + if (!decorator) return false; - /* The add_header callback closes over the request reference and - * appends raw headers. We keep it as a thread-local C function - * pointer with a sidecar state struct so the closure can survive - * the C ABI boundary. */ - struct HeaderCtx { - QNetworkRequest* req; - }; - HeaderCtx hctx{&request}; - - auto add_header_fn = [](void* handle, const char* key, - const char* value) -> int { - if (!handle || !key || !value) - return -1; - auto* h = static_cast(handle); - h->req->setRawHeader(QByteArray(key), QByteArray(value)); - return 0; - }; - - const QByteArray urlUtf8 = request.url().toString().toUtf8(); - - MMCOAuthRequestEvent ev{}; - ev.url = urlUtf8.constData(); - ev.method = method; - ev.body = body.isEmpty() ? nullptr : body.constData(); - ev.body_size = body.size(); - ev.redirect_url = nullptr; - ev.request_handle = &hctx; - ev.add_header = add_header_fn; - - const bool cancelled = pm->dispatchHook(MMCO_HOOK_AUTH_REQUEST, &ev); - if (cancelled) - return true; - - if (ev.redirect_url && *ev.redirect_url) { - const QUrl rewritten = - QUrl::fromUserInput(QString::fromUtf8(ev.redirect_url)); - if (rewritten.isValid()) - request.setUrl(rewritten); - } - return false; + return decorator->dispatchAuthRequest(request, body, method); } } // namespace @@ -109,7 +73,7 @@ void AuthRequest::get(const QNetworkRequest& req, int timeout /* = 60*1000*/) return; } - reply_ = APPLICATION->network()->get(request_); + reply_ = LAUNCHER->network()->get(request_); status_ = Requesting; timedReplies_.add(new Katabasis::Reply(reply_, timeout)); connect(reply_, &QNetworkReply::errorOccurred, this, @@ -138,7 +102,7 @@ void AuthRequest::post(const QNetworkRequest& req, const QByteArray& data, } status_ = Requesting; - reply_ = APPLICATION->network()->post(request_, data_); + reply_ = LAUNCHER->network()->post(request_, data_); timedReplies_.add(new Katabasis::Reply(reply_, timeout)); connect(reply_, &QNetworkReply::errorOccurred, this, &AuthRequest::onRequestError); diff --git a/launcher/minecraft/auth/steps/MSAStep.cpp b/launcher/minecraft/auth/steps/MSAStep.cpp index 4eb4640f..ba84342b 100644 --- a/launcher/minecraft/auth/steps/MSAStep.cpp +++ b/launcher/minecraft/auth/steps/MSAStep.cpp @@ -25,7 +25,7 @@ #include "minecraft/auth/AuthRequest.h" #include "minecraft/auth/Parsers.h" -#include "Application.h" +#include "core/LauncherContext.h" MSAStep::MSAStep(AccountData* data, Action action) : AuthStep(data), m_action(action) @@ -35,7 +35,7 @@ MSAStep::MSAStep(AccountData* data, Action action) tr("Login successful! You can close this page and return to MeshMC.")); m_oauth2 = new QOAuth2AuthorizationCodeFlow(this); - m_oauth2->setClientIdentifier(APPLICATION->msaClientId()); + m_oauth2->setClientIdentifier(LAUNCHER->msaClientId()); m_oauth2->setAuthorizationUrl(QUrl( "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")); #if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) @@ -45,7 +45,7 @@ MSAStep::MSAStep(AccountData* data, Action action) #endif m_oauth2->setScope("XboxLive.signin offline_access"); m_oauth2->setReplyHandler(m_replyHandler); - m_oauth2->setNetworkAccessManager(APPLICATION->network().get()); + m_oauth2->setNetworkAccessManager(LAUNCHER->network().get()); connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, &MSAStep::onGranted); diff --git a/launcher/minecraft/launch/ClaimAccount.cpp b/launcher/minecraft/launch/ClaimAccount.cpp index 47945ed9..338efa08 100644 --- a/launcher/minecraft/launch/ClaimAccount.cpp +++ b/launcher/minecraft/launch/ClaimAccount.cpp @@ -20,7 +20,7 @@ #include "ClaimAccount.h" #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/auth/AccountList.h" ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) @@ -28,7 +28,7 @@ ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) { if (session->status == AuthSession::Status::PlayableOnline && !session->demo) { - auto accounts = APPLICATION->accounts(); + auto accounts = LAUNCHER->accounts(); m_account = accounts->getAccountByProfileName(session->player_name); } } diff --git a/launcher/minecraft/launch/MeshMCPartLaunch.cpp b/launcher/minecraft/launch/MeshMCPartLaunch.cpp index b73e44d4..fea4d75c 100644 --- a/launcher/minecraft/launch/MeshMCPartLaunch.cpp +++ b/launcher/minecraft/launch/MeshMCPartLaunch.cpp @@ -27,7 +27,8 @@ #include "minecraft/MinecraftInstance.h" #include "FileSystem.h" #include "Commandline.h" -#include "Application.h" +#include "Logging.h" +#include "core/LauncherContext.h" MeshMCPartLaunch::MeshMCPartLaunch(LaunchTask* parent) : LaunchStep(parent) { @@ -88,7 +89,7 @@ void MeshMCPartLaunch::executeTask() auto classPath = minecraftInstance->getClassPath(); classPath.prepend( - FS::PathCombine(APPLICATION->getJarsPath(), "NewLaunch.jar")); + FS::PathCombine(LAUNCHER->getJarsPath(), "NewLaunch.jar")); auto natPath = minecraftInstance->getNativePath(); #ifdef Q_OS_WIN diff --git a/launcher/minecraft/launch/VerifyJavaInstall.cpp b/launcher/minecraft/launch/VerifyJavaInstall.cpp index fefa4985..d02e98a2 100644 --- a/launcher/minecraft/launch/VerifyJavaInstall.cpp +++ b/launcher/minecraft/launch/VerifyJavaInstall.cpp @@ -33,7 +33,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "FileSystem.h" #include "Json.h" #include "java/JavaUtils.h" @@ -56,7 +56,7 @@ namespace std::optional probeJavaVersion(const QString& javaPath) { const auto checkerJar = - FS::PathCombine(APPLICATION->getJarsPath(), "JavaCheck.jar"); + FS::PathCombine(LAUNCHER->getJarsPath(), "JavaCheck.jar"); if (!QFileInfo::exists(checkerJar)) { return std::nullopt; } @@ -221,7 +221,7 @@ void VerifyJavaInstall::fetchVersionList(int requiredMajor) QString uid = m_preferredVendor; QString url = QString("%1%2/index.json").arg(BuildConfig.META_URL, uid); - m_fetchJob = new NetJob(tr("Fetch Java versions"), APPLICATION->network()); + m_fetchJob = new NetJob(tr("Fetch Java versions"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); m_fetchJob->addNetAction(dl); @@ -287,7 +287,7 @@ void VerifyJavaInstall::fetchRuntimes(const QString& versionId, QString("%1%2/%3.json").arg(BuildConfig.META_URL, uid, versionId); m_fetchJob = - new NetJob(tr("Fetch Java runtime details"), APPLICATION->network()); + new NetJob(tr("Fetch Java runtime details"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); m_fetchJob->addNetAction(dl); diff --git a/launcher/minecraft/services/CapeChange.cpp b/launcher/minecraft/services/CapeChange.cpp index 65019047..86ed20db 100644 --- a/launcher/minecraft/services/CapeChange.cpp +++ b/launcher/minecraft/services/CapeChange.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" CapeChange::CapeChange(QObject* parent, QString token, QString cape) : Task(parent), m_capeId(cape), m_token(token) @@ -37,7 +37,7 @@ void CapeChange::setCape(QString& cape) request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); QNetworkReply* rep = - APPLICATION->network()->put(request, requestString.toUtf8()); + LAUNCHER->network()->put(request, requestString.toUtf8()); setStatus(tr("Equipping cape")); @@ -55,7 +55,7 @@ void CapeChange::clearCape() auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + QNetworkReply* rep = LAUNCHER->network()->deleteResource(request); setStatus(tr("Removing cape")); diff --git a/launcher/minecraft/services/SkinDelete.cpp b/launcher/minecraft/services/SkinDelete.cpp index 7c5a4c56..26da39af 100644 --- a/launcher/minecraft/services/SkinDelete.cpp +++ b/launcher/minecraft/services/SkinDelete.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" SkinDelete::SkinDelete(QObject* parent, QString token) : Task(parent), m_token(token) @@ -35,7 +35,7 @@ void SkinDelete::executeTask() "https://api.minecraftservices.com/minecraft/profile/skins/active")); request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + QNetworkReply* rep = LAUNCHER->network()->deleteResource(request); m_reply = shared_qobject_ptr(rep); setStatus(tr("Deleting skin")); diff --git a/launcher/minecraft/services/SkinUpload.cpp b/launcher/minecraft/services/SkinUpload.cpp index af52e0e7..d89aa6dd 100644 --- a/launcher/minecraft/services/SkinUpload.cpp +++ b/launcher/minecraft/services/SkinUpload.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" QByteArray getVariant(SkinUpload::Model model) { @@ -65,7 +65,7 @@ void SkinUpload::executeTask() multiPart->append(skin); multiPart->append(model); - QNetworkReply* rep = APPLICATION->network()->post(request, multiPart); + QNetworkReply* rep = LAUNCHER->network()->post(request, multiPart); m_reply = shared_qobject_ptr(rep); setStatus(tr("Uploading skin")); diff --git a/launcher/minecraft/skins/ProfileSkinImport.cpp b/launcher/minecraft/skins/ProfileSkinImport.cpp index 755bedc6..41f6776d 100644 --- a/launcher/minecraft/skins/ProfileSkinImport.cpp +++ b/launcher/minecraft/skins/ProfileSkinImport.cpp @@ -25,7 +25,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/auth/AccountData.h" #include "minecraft/auth/Parsers.h" #include "net/Download.h" @@ -65,7 +65,7 @@ void ProfileSkinImport::lookUpUuid() setStatus(tr("Looking up %1").arg(m_username)); m_response.clear(); - m_job = new NetJob(tr("Look up user"), APPLICATION->network()); + m_job = new NetJob(tr("Look up user"), LAUNCHER->network()); /* Percent-encoded: Mojang names are restricted to word characters today, * but this string comes straight out of a text field. */ const QString endpoint = @@ -110,7 +110,7 @@ void ProfileSkinImport::fetchProfile() setStatus(tr("Fetching the profile of %1").arg(m_username)); m_response.clear(); - m_job = new NetJob(tr("Download user profile"), APPLICATION->network()); + m_job = new NetJob(tr("Download user profile"), LAUNCHER->network()); const QString endpoint = QString::fromLatin1(kSessionProfileEndpoint) + m_uuid; m_job->addNetAction( @@ -151,7 +151,7 @@ void ProfileSkinImport::downloadTexture() { setStatus(tr("Downloading the skin of %1").arg(m_username)); - m_job = new NetJob(tr("Download user skin"), APPLICATION->network()); + m_job = new NetJob(tr("Download user skin"), LAUNCHER->network()); m_job->addNetAction( Net::Download::makeFile(QUrl(m_textureUrl), m_targetPath)); diff --git a/launcher/minecraft/update/AssetUpdateTask.cpp b/launcher/minecraft/update/AssetUpdateTask.cpp index 7ee01873..4fccce2d 100644 --- a/launcher/minecraft/update/AssetUpdateTask.cpp +++ b/launcher/minecraft/update/AssetUpdateTask.cpp @@ -24,7 +24,7 @@ #include "net/ChecksumValidator.h" #include "minecraft/AssetsUtils.h" -#include "Application.h" +#include "core/LauncherContext.h" AssetUpdateTask::AssetUpdateTask(MinecraftInstance* inst) { @@ -42,9 +42,9 @@ void AssetUpdateTask::executeTask() QUrl indexUrl = assets->url; QString localPath = assets->id + ".json"; auto job = new NetJob(tr("Asset index for %1").arg(m_inst->name()), - APPLICATION->network()); + LAUNCHER->network()); - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto entry = metacache->resolveEntry("asset_indexes", localPath); entry->setStale(true); auto hexSha1 = assets->sha1.toLatin1(); @@ -87,7 +87,7 @@ void AssetUpdateTask::assetIndexFinished() // FIXME: this looks like a job for a generic validator based on json // schema? if (!AssetsUtils::loadAssetsIndexJson(assets->id, asset_fname, index)) { - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto entry = metacache->resolveEntry("asset_indexes", assets->id + ".json"); metacache->evictEntry(entry); diff --git a/launcher/minecraft/update/FMLLibrariesTask.cpp b/launcher/minecraft/update/FMLLibrariesTask.cpp index 144a4546..a930cbe8 100644 --- a/launcher/minecraft/update/FMLLibrariesTask.cpp +++ b/launcher/minecraft/update/FMLLibrariesTask.cpp @@ -25,7 +25,7 @@ #include "minecraft/PackProfile.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" FMLLibrariesTask::FMLLibrariesTask(MinecraftInstance* inst) { @@ -75,8 +75,8 @@ void FMLLibrariesTask::executeTask() // download missing libs to our place setStatus(tr("Downloading FML libraries...")); - auto dljob = new NetJob("FML libraries", APPLICATION->network()); - auto metacache = APPLICATION->metacache(); + auto dljob = new NetJob("FML libraries", LAUNCHER->network()); + auto metacache = LAUNCHER->metacache(); for (auto& lib : fmlLibsToProcess) { auto entry = metacache->resolveEntry("fmllibs", lib.filename); QString urlString = BuildConfig.FMLLIBS_BASE_URL + lib.filename; @@ -104,7 +104,7 @@ void FMLLibrariesTask::fmllibsFinished() if (!fmlLibsToProcess.isEmpty()) { setStatus(tr("Copying FML libraries into the instance...")); MinecraftInstance* inst = (MinecraftInstance*)m_inst; - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); int index = 0; for (auto& lib : fmlLibsToProcess) { progress(index, fmlLibsToProcess.size()); diff --git a/launcher/minecraft/update/LibrariesTask.cpp b/launcher/minecraft/update/LibrariesTask.cpp index d8ec2852..b1de75d6 100644 --- a/launcher/minecraft/update/LibrariesTask.cpp +++ b/launcher/minecraft/update/LibrariesTask.cpp @@ -22,7 +22,7 @@ #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" -#include "Application.h" +#include "core/LauncherContext.h" LibrariesTask::LibrariesTask(MinecraftInstance* inst) { @@ -40,10 +40,10 @@ void LibrariesTask::executeTask() auto profile = components->getProfile(); auto job = new NetJob(tr("Libraries for instance %1").arg(inst->name()), - APPLICATION->network()); + LAUNCHER->network()); downloadJob.reset(job); - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto processArtifactPool = [&](const QList& pool, QStringList& errors, diff --git a/launcher/modplatform/ContentDownloadTask.cpp b/launcher/modplatform/ContentDownloadTask.cpp index 119257cf..23df7fd4 100644 --- a/launcher/modplatform/ContentDownloadTask.cpp +++ b/launcher/modplatform/ContentDownloadTask.cpp @@ -18,7 +18,7 @@ */ #include "ContentDownloadTask.h" -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "net/Download.h" #include "net/ChecksumValidator.h" @@ -77,7 +77,7 @@ void ContentDownloadTask::executeTask() setStatus(tr("Downloading %1 file(s)...").arg(m_items.size())); - m_netJob = new NetJob("ContentDownload", APPLICATION->network()); + m_netJob = new NetJob("ContentDownload", LAUNCHER->network()); int skipped = 0; // Last-resort safety net: even if an upstream stage (dependency diff --git a/launcher/modplatform/ContentProviderModel.cpp b/launcher/modplatform/ContentProviderModel.cpp index e7bee33c..54e7deb0 100644 --- a/launcher/modplatform/ContentProviderModel.cpp +++ b/launcher/modplatform/ContentProviderModel.cpp @@ -24,7 +24,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "net/Download.h" #include "net/HttpMetaCache.h" @@ -90,7 +90,7 @@ QVariant ContentProviderModel::data(const QModelIndex& index, int role) const } const_cast(this)->requestLogo( project.logoKey, project.logoUrl); - return APPLICATION->getThemedIcon("screenshot-placeholder"); + return LAUNCHER->getThemedIcon("screenshot-placeholder"); } case Qt::SizeHintRole: @@ -195,7 +195,7 @@ void ContentProviderModel::loadCategories() auto response = std::make_shared(); auto* job = new NetJob(QString("%1::Categories").arg(m_api.id()), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.categoriesUrl(m_contentType), response.get())); @@ -366,7 +366,7 @@ void ContentProviderModel::performPaginatedSearch() .arg(m_api.id(), m_projectLookupId); } - auto* job = new NetJob(jobName, APPLICATION->network()); + auto* job = new NetJob(jobName, LAUNCHER->network()); job->addNetAction( Net::Download::makeByteArray(url, &m_searchResponse)); @@ -504,7 +504,7 @@ void ContentProviderModel::loadEntry(int viewRow) auto response = std::make_shared(); auto* job = new NetJob(QString("%1::Versions(%2)") .arg(m_api.id(), projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.projectVersionsUrl(query), response.get())); @@ -532,7 +532,7 @@ void ContentProviderModel::loadEntry(int viewRow) auto response = std::make_shared(); auto* job = new NetJob( QString("%1::Description(%2)").arg(m_api.id(), projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.projectBodyUrl(projectId), response.get())); @@ -675,11 +675,11 @@ void ContentProviderModel::requestLogo(const QString& key, const QString& url) return; } - MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry( + MetaEntryPtr entry = LAUNCHER->metacache()->resolveEntry( iconCacheName(), QString("logos/%1").arg(key.section(".", 0, 0))); auto* job = new NetJob(QString("%1 Icon %2").arg(m_api.id(), key), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); const QString fullPath = entry->getFullPath(); diff --git a/launcher/modplatform/DependencyResolver.cpp b/launcher/modplatform/DependencyResolver.cpp index 6bca1332..a7551ac2 100644 --- a/launcher/modplatform/DependencyResolver.cpp +++ b/launcher/modplatform/DependencyResolver.cpp @@ -18,8 +18,8 @@ */ #include "DependencyResolver.h" -#include "Application.h" #include "Json.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "modplatform/ContentType.h" #include "modplatform/VersionPicker.h" @@ -222,7 +222,7 @@ void DependencyResolver::request(const QString& name, const QUrl& url, * of them runs, and if the job dies without either firing the buffer * goes with it instead of leaking. */ auto response = std::make_shared(); - auto* job = new NetJob(name, APPLICATION->network()); + auto* job = new NetJob(name, LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray(url, response.get())); m_pendingRequests++; diff --git a/launcher/modplatform/ModUpdateCheckTask.cpp b/launcher/modplatform/ModUpdateCheckTask.cpp index d23660a8..cea793da 100644 --- a/launcher/modplatform/ModUpdateCheckTask.cpp +++ b/launcher/modplatform/ModUpdateCheckTask.cpp @@ -25,8 +25,8 @@ #include #include -#include "Application.h" #include "Json.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "modplatform/ContentType.h" #include "modplatform/VersionPicker.h" @@ -255,7 +255,7 @@ void ModUpdateCheckTask::executeTask() auto response = std::make_shared(); NetJob* job = new NetJob( QString("UpdateCheck(%1:%2)").arg(e.platform, e.projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction( Net::Download::makeByteArray(QUrl(url), response.get())); diff --git a/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp index 8e987870..c8fef695 100644 --- a/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp +++ b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp @@ -39,7 +39,7 @@ #include "meta/VersionList.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace ATLauncher { @@ -71,7 +71,7 @@ namespace ATLauncher qDebug() << "PackInstallTask::executeTask: " << QThread::currentThreadId(); auto* netJob = - new NetJob("ATLauncher::VersionFetch", APPLICATION->network()); + new NetJob("ATLauncher::VersionFetch", LAUNCHER->network()); auto searchUrl = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "packs/%1/versions/%2/Configs.json") .arg(m_pack) @@ -114,7 +114,7 @@ namespace ATLauncher } m_version = version; - auto vlist = APPLICATION->metadataIndex()->get("net.minecraft"); + auto vlist = LAUNCHER->metadataIndex()->get("net.minecraft"); if (!vlist) { emitFailed(tr("Failed to get local metadata index for %1") .arg("net.minecraft")); @@ -198,7 +198,7 @@ namespace ATLauncher { if (m_version.loader.recommended || m_version.loader.latest || m_version.loader.choose) { - auto vlist = APPLICATION->metadataIndex()->get(uid); + auto vlist = LAUNCHER->metadataIndex()->get(uid); if (!vlist) { emitFailed( tr("Failed to get local metadata index for %1").arg(uid)); @@ -460,7 +460,7 @@ namespace ATLauncher qDebug() << "PackInstallTask::installConfigs: " << QThread::currentThreadId(); setStatus(tr("Downloading configs...")); - jobPtr = new NetJob(tr("Config download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Config download"), LAUNCHER->network()); auto path = QString("Configs/%1/%2.zip").arg(m_pack).arg(m_version_name); @@ -469,7 +469,7 @@ namespace ATLauncher .arg(m_pack) .arg(m_version_name); auto entry = - APPLICATION->metacache()->resolveEntry("ATLauncherPacks", path); + LAUNCHER->metacache()->resolveEntry("ATLauncherPacks", path); entry->setStale(true); auto dl = Net::Download::makeCached(url, entry); @@ -546,7 +546,7 @@ namespace ATLauncher setStatus(tr("Downloading mods...")); jarmods.clear(); - jobPtr = new NetJob(tr("Mod download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Mod download"), LAUNCHER->network()); for (const auto& mod : m_version.mods) { // skip non-client mods if (!mod.client) @@ -581,7 +581,7 @@ namespace ATLauncher if (mod.type == ModType::Extract || mod.type == ModType::TexturePackExtract || mod.type == ModType::ResourcePackExtract) { - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); modsToExtract.insert(entry->getFullPath(), mod); @@ -594,7 +594,7 @@ namespace ATLauncher } jobPtr->addNetAction(dl); } else if (mod.type == ModType::Decomp) { - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); modsToDecomp.insert(entry->getFullPath(), mod); @@ -611,7 +611,7 @@ namespace ATLauncher if (relpath == Q_NULLPTR) continue; - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); @@ -630,7 +630,7 @@ namespace ATLauncher if (mod.type == ModType::Forge) { auto vlist = - APPLICATION->metadataIndex()->get("net.minecraftforge"); + LAUNCHER->metadataIndex()->get("net.minecraftforge"); if (vlist) { auto ver = vlist->getVersion(mod.version); if (ver) { diff --git a/launcher/modplatform/legacy_ftb/PackFetchTask.cpp b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp index d3335e6a..8ce1baba 100644 --- a/launcher/modplatform/legacy_ftb/PackFetchTask.cpp +++ b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp @@ -22,7 +22,6 @@ #include #include "BuildConfig.h" -#include "Application.h" namespace LegacyFTB { diff --git a/launcher/modplatform/legacy_ftb/PackInstallTask.cpp b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp index efbe883c..6d248f94 100644 --- a/launcher/modplatform/legacy_ftb/PackInstallTask.cpp +++ b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp @@ -30,7 +30,7 @@ #include "minecraft/GradleSpecifier.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace LegacyFTB { @@ -57,7 +57,7 @@ namespace LegacyFTB QString("%1/%2/%3") .arg(m_pack.dir, m_version.replace(".", "_"), m_pack.file); auto entry = - APPLICATION->metacache()->resolveEntry("FTBPacks", packoffset); + LAUNCHER->metacache()->resolveEntry("FTBPacks", packoffset); netJobContainer = new NetJob("Download FTB Pack", m_network); entry->setStale(true); diff --git a/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp b/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp index f5c85e1a..63092191 100644 --- a/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp +++ b/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp @@ -29,7 +29,7 @@ #include "settings/INISettingsObject.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" #include @@ -77,7 +77,7 @@ namespace ModpacksCH } auto* netJob = - new NetJob("ModpacksCH::VersionFetch", APPLICATION->network()); + new NetJob("ModpacksCH::VersionFetch", LAUNCHER->network()); auto searchUrl = QString(BuildConfig.MODPACKSCH_API_BASE_URL + "public/modpack/%1/%2") .arg(m_pack.id) @@ -130,7 +130,7 @@ namespace ModpacksCH { setStatus(tr("Downloading mods...")); - jobPtr = new NetJob(tr("Mod download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Mod download"), LAUNCHER->network()); for (auto file : m_version.files) { if (file.serverOnly) continue; @@ -144,7 +144,7 @@ namespace ModpacksCH auto cacheName = fileName.completeBaseName() + "-" + file.sha1 + "." + fileName.suffix(); - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ModpacksCHPacks", cacheName); entry->setStale(true); diff --git a/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp index 84e7df2a..779b44c0 100644 --- a/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp +++ b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp @@ -35,8 +35,8 @@ #include #include -#include "Application.h" #include "archive/ExportToZipTask.h" +#include "core/LauncherContext.h" #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" #include "minecraft/mod/ModMetadataIndex.h" @@ -329,7 +329,7 @@ void ModrinthPackExportTask::lookUpPendingFiles() for (const PendingFile& pending : m_pending) { auto response = std::make_shared(); auto* job = new NetJob(QString("MR::ExportLookup(%1)").arg(pending.path), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( ModrinthApi::versionByHashUrl(pending.sha1), response.get())); diff --git a/launcher/modplatform/technic/SingleZipPackInstallTask.cpp b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp index 1b1b564b..7bbcdf80 100644 --- a/launcher/modplatform/technic/SingleZipPackInstallTask.cpp +++ b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp @@ -26,7 +26,7 @@ #include "TechnicPackProcessor.h" #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" Technic::SingleZipPackInstallTask::SingleZipPackInstallTask( const QUrl& sourceUrl, const QString& minecraftVersion) @@ -48,10 +48,10 @@ void Technic::SingleZipPackInstallTask::executeTask() setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString())); const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); - auto entry = APPLICATION->metacache()->resolveEntry("general", path); + auto entry = LAUNCHER->metacache()->resolveEntry("general", path); entry->setStale(true); m_archiveEntry = entry; - m_filesNetJob = new NetJob(tr("Modpack download"), APPLICATION->network()); + m_filesNetJob = new NetJob(tr("Modpack download"), LAUNCHER->network()); m_filesNetJob->addNetAction(Net::Download::makeCached(m_sourceUrl, entry)); m_archivePath = entry->getFullPath(); auto job = m_filesNetJob.get(); @@ -139,7 +139,7 @@ void Technic::SingleZipPackInstallTask::extractFinished() if (!QFile::remove(m_archivePath)) { qWarning() << "Could not remove" << m_archivePath; } - APPLICATION->metacache()->evictEntry(m_archiveEntry); + LAUNCHER->metacache()->evictEntry(m_archiveEntry); m_archiveEntry.reset(); } emitFailed(tr("Failed to extract modpack. The downloaded archive is " diff --git a/launcher/net/JsonPost.cpp b/launcher/net/JsonPost.cpp index 8326aa8a..3106b767 100644 --- a/launcher/net/JsonPost.cpp +++ b/launcher/net/JsonPost.cpp @@ -24,9 +24,9 @@ #include -#include "Application.h" #include "BuildConfig.h" #include "modplatform/flame/FlameApi.h" +#include "core/LauncherContext.h" namespace Net { @@ -62,7 +62,7 @@ namespace Net BuildConfig.CURSEFORGE_API_KEY.toUtf8()); } - m_reply.reset(APPLICATION->network()->post(request, m_body)); + m_reply.reset(LAUNCHER->network()->post(request, m_body)); connect(m_reply.get(), &QNetworkReply::finished, this, &JsonPost::requestFinished); } diff --git a/launcher/net/MetaCacheSink.cpp b/launcher/net/MetaCacheSink.cpp index 2c959469..1e968c6f 100644 --- a/launcher/net/MetaCacheSink.cpp +++ b/launcher/net/MetaCacheSink.cpp @@ -21,7 +21,7 @@ #include #include #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace Net { @@ -72,7 +72,7 @@ namespace Net m_entry->setLocalChangedTimestamp( output_file_info.lastModified().toUTC().toMSecsSinceEpoch()); m_entry->setStale(false); - APPLICATION->metacache()->updateEntry(m_entry); + LAUNCHER->metacache()->updateEntry(m_entry); return Job_Finished; } diff --git a/launcher/net/PasteUpload.cpp b/launcher/net/PasteUpload.cpp index 2c9581c7..ef1cefb4 100644 --- a/launcher/net/PasteUpload.cpp +++ b/launcher/net/PasteUpload.cpp @@ -19,7 +19,7 @@ #include "PasteUpload.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" #include "Logging.h" #include @@ -97,8 +97,7 @@ static QString applyFilters(QString logContent) return logContent; } -PasteUpload::PasteUpload(QWidget* window, QString text, QString key) - : m_window(window) +PasteUpload::PasteUpload(QString text, QString key) { m_key = key; QString censoredText = applyFilters(text); @@ -133,7 +132,7 @@ void PasteUpload::executeTask() QByteArray::number(m_jsonContent.size())); request.setRawHeader("X-Auth-Token", m_key.toStdString().c_str()); - QNetworkReply* rep = APPLICATION->network()->post(request, m_jsonContent); + QNetworkReply* rep = LAUNCHER->network()->post(request, m_jsonContent); m_reply = std::shared_ptr(rep); setStatus(tr("Uploading to paste.ee")); diff --git a/launcher/net/PasteUpload.h b/launcher/net/PasteUpload.h index 38b591e4..cec6dc48 100644 --- a/launcher/net/PasteUpload.h +++ b/launcher/net/PasteUpload.h @@ -27,7 +27,7 @@ class PasteUpload : public Task { Q_OBJECT public: - PasteUpload(QWidget* window, QString text, QString key = "public"); + PasteUpload(QString text, QString key = "public"); virtual ~PasteUpload(); QString pasteLink() @@ -54,7 +54,6 @@ class PasteUpload : public Task private: bool parseResult(QJsonDocument doc); QString m_error; - QWidget* m_window; QString m_pasteID; QString m_pasteLink; QString m_key; diff --git a/launcher/notifications/NotificationChecker.cpp b/launcher/notifications/NotificationChecker.cpp index 0b2062ee..6ec7c72d 100644 --- a/launcher/notifications/NotificationChecker.cpp +++ b/launcher/notifications/NotificationChecker.cpp @@ -26,7 +26,7 @@ #include "net/Download.h" -#include "Application.h" +#include "core/LauncherContext.h" NotificationChecker::NotificationChecker(QObject* parent) : QObject(parent) {} @@ -70,9 +70,9 @@ void NotificationChecker::checkForNotifications() return; } m_checkJob = - new NetJob("Checking for notifications", APPLICATION->network()); + new NetJob("Checking for notifications", LAUNCHER->network()); auto entry = - APPLICATION->metacache()->resolveEntry("root", "notifications.json"); + LAUNCHER->metacache()->resolveEntry("root", "notifications.json"); entry->setStale(true); m_checkJob->addNetAction( m_download = Net::Download::makeCached(m_notificationsUrl, entry)); diff --git a/launcher/plugin/PluginAuthRequestDecorator.cpp b/launcher/plugin/PluginAuthRequestDecorator.cpp new file mode 100644 index 00000000..9b4faad9 --- /dev/null +++ b/launcher/plugin/PluginAuthRequestDecorator.cpp @@ -0,0 +1,80 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "plugin/PluginAuthRequestDecorator.h" + +#include +#include + +#include "plugin/PluginHooks.h" +#include "plugin/PluginManager.h" + +PluginAuthRequestDecorator::PluginAuthRequestDecorator(PluginManager* manager) + : m_manager(manager) +{ +} + +bool PluginAuthRequestDecorator::dispatchAuthRequest(QNetworkRequest& request, + const QByteArray& body, + const char* method) +{ + if (!m_manager) + return false; + + /* The add_header callback closes over the request reference and + * appends raw headers. We keep it as a plain C function pointer with a + * sidecar state struct so the closure can survive the C ABI boundary. */ + struct HeaderCtx { + QNetworkRequest* req; + }; + HeaderCtx hctx{&request}; + + auto add_header_fn = [](void* handle, const char* key, + const char* value) -> int { + if (!handle || !key || !value) + return -1; + auto* h = static_cast(handle); + h->req->setRawHeader(QByteArray(key), QByteArray(value)); + return 0; + }; + + const QByteArray urlUtf8 = request.url().toString().toUtf8(); + + MMCOAuthRequestEvent ev{}; + ev.url = urlUtf8.constData(); + ev.method = method; + ev.body = body.isEmpty() ? nullptr : body.constData(); + ev.body_size = body.size(); + ev.redirect_url = nullptr; + ev.request_handle = &hctx; + ev.add_header = add_header_fn; + + const bool cancelled = + m_manager->dispatchHook(MMCO_HOOK_AUTH_REQUEST, &ev); + if (cancelled) + return true; + + if (ev.redirect_url && *ev.redirect_url) { + const QUrl rewritten = + QUrl::fromUserInput(QString::fromUtf8(ev.redirect_url)); + if (rewritten.isValid()) + request.setUrl(rewritten); + } + return false; +} diff --git a/launcher/plugin/PluginAuthRequestDecorator.h b/launcher/plugin/PluginAuthRequestDecorator.h new file mode 100644 index 00000000..5037cd29 --- /dev/null +++ b/launcher/plugin/PluginAuthRequestDecorator.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "core/AuthRequestDecorator.h" + +class PluginManager; + +/* + * Feeds outgoing authentication requests through MMCO_HOOK_AUTH_REQUEST. + * + * This used to live inside AuthRequest.cpp, which meant the authentication + * code reached PluginManager directly -- and PluginManager builds + * plugin-supplied user interface, so that single call pulled QtWidgets into + * the core. The hook logic is unchanged; only its address moved. + */ +class PluginAuthRequestDecorator final : public AuthRequestDecorator +{ + public: + explicit PluginAuthRequestDecorator(PluginManager* manager); + + bool dispatchAuthRequest(QNetworkRequest& request, const QByteArray& body, + const char* method) override; + + private: + PluginManager* m_manager; +}; diff --git a/launcher/screenshots/ImgurAlbumCreation.cpp b/launcher/screenshots/ImgurAlbumCreation.cpp index 7154c4ed..dde97f32 100644 --- a/launcher/screenshots/ImgurAlbumCreation.cpp +++ b/launcher/screenshots/ImgurAlbumCreation.cpp @@ -27,7 +27,7 @@ #include #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" ImgurAlbumCreation::ImgurAlbumCreation(QList screenshots) : NetAction(), m_screenshots(screenshots) @@ -58,7 +58,7 @@ void ImgurAlbumCreation::startImpl() const QByteArray data = "deletehashes=" + hashes.join(',').toUtf8() + "&title=Minecraft%20Screenshots&privacy=hidden"; - QNetworkReply* rep = APPLICATION->network()->post(request, data); + QNetworkReply* rep = LAUNCHER->network()->post(request, data); m_reply.reset(rep); connect(rep, &QNetworkReply::uploadProgress, this, diff --git a/launcher/translations/TranslationsModel.cpp b/launcher/translations/TranslationsModel.cpp index 96c29ba4..52bd6fe5 100644 --- a/launcher/translations/TranslationsModel.cpp +++ b/launcher/translations/TranslationsModel.cpp @@ -34,7 +34,7 @@ #include "POTranslator.h" -#include "Application.h" +#include "core/LauncherContext.h" const static QLatin1String defaultLangCode("en_US"); @@ -526,9 +526,9 @@ void TranslationsModel::downloadIndex() return; } qDebug() << "Downloading Translations Index..."; - d->m_index_job = new NetJob("Translations Index", APPLICATION->network()); + d->m_index_job = new NetJob("Translations Index", LAUNCHER->network()); MetaEntryPtr entry = - APPLICATION->metacache()->resolveEntry("translations", "index_v2.json"); + LAUNCHER->metacache()->resolveEntry("translations", "index_v2.json"); entry->setStale(true); d->m_index_task = Net::Download::makeCached( QUrl("https://i18n.projecttick.org/index_v2.json"), entry); @@ -569,7 +569,7 @@ void TranslationsModel::downloadTranslation(QString key) } d->m_downloadingTranslation = key; - MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry( + MetaEntryPtr entry = LAUNCHER->metacache()->resolveEntry( "translations", "mmc_" + key + ".qm"); entry->setStale(true); @@ -580,7 +580,7 @@ void TranslationsModel::downloadTranslation(QString key) new Net::ChecksumValidator(QCryptographicHash::Sha1, rawHash)); dl->m_total_progress = lang->file_size; - d->m_dl_job = new NetJob("Translation for " + key, APPLICATION->network()); + d->m_dl_job = new NetJob("Translation for " + key, LAUNCHER->network()); d->m_dl_job->addNetAction(dl); connect(d->m_dl_job.get(), &NetJob::succeeded, this, diff --git a/launcher/ui/GuiUtil.cpp b/launcher/ui/GuiUtil.cpp index b1b52b3c..1de069a5 100644 --- a/launcher/ui/GuiUtil.cpp +++ b/launcher/ui/GuiUtil.cpp @@ -42,7 +42,7 @@ QString GuiUtil::uploadPaste(const QString& text, QWidget* parentWidget) APIKeySetting = BuildConfig.PASTE_EE_KEY; } std::unique_ptr paste( - new PasteUpload(parentWidget, text, APIKeySetting)); + new PasteUpload(text, APIKeySetting)); if (!paste->validateText()) { CustomMessageBox::selectable( diff --git a/launcher/JavaCommon.cpp b/launcher/ui/JavaCommon.cpp similarity index 99% rename from launcher/JavaCommon.cpp rename to launcher/ui/JavaCommon.cpp index 0eee0efb..7c3f230d 100644 --- a/launcher/JavaCommon.cpp +++ b/launcher/ui/JavaCommon.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "ui/dialogs/CustomMessageBox.h" #include #include diff --git a/launcher/JavaCommon.h b/launcher/ui/JavaCommon.h similarity index 100% rename from launcher/JavaCommon.h rename to launcher/ui/JavaCommon.h diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index e72538b7..462e3171 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -81,7 +81,7 @@ #include #include "InstanceWindow.h" #include "InstancePageProvider.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "LaunchController.h" #include "ui/instanceview/InstanceProxyModel.h" diff --git a/launcher/minecraft/ShortcutUtils.cpp b/launcher/ui/ShortcutUtils.cpp similarity index 99% rename from launcher/minecraft/ShortcutUtils.cpp rename to launcher/ui/ShortcutUtils.cpp index fee9470d..b0f59bbb 100644 --- a/launcher/minecraft/ShortcutUtils.cpp +++ b/launcher/ui/ShortcutUtils.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "ShortcutUtils.h" +#include "ui/ShortcutUtils.h" #include #include diff --git a/launcher/minecraft/ShortcutUtils.h b/launcher/ui/ShortcutUtils.h similarity index 100% rename from launcher/minecraft/ShortcutUtils.h rename to launcher/ui/ShortcutUtils.h diff --git a/launcher/ui/dialogs/CreateShortcutDialog.cpp b/launcher/ui/dialogs/CreateShortcutDialog.cpp index aa82133b..8dad669a 100644 --- a/launcher/ui/dialogs/CreateShortcutDialog.cpp +++ b/launcher/ui/dialogs/CreateShortcutDialog.cpp @@ -27,7 +27,7 @@ #include "FileSystem.h" #include "icons/IconList.h" #include "minecraft/MinecraftInstance.h" -#include "minecraft/ShortcutUtils.h" +#include "ui/ShortcutUtils.h" #include "minecraft/World.h" #include "minecraft/WorldList.h" #include "minecraft/auth/AccountList.h" diff --git a/launcher/ui/pages/global/JavaPage.cpp b/launcher/ui/pages/global/JavaPage.cpp index a0e42871..f56fc68e 100644 --- a/launcher/ui/pages/global/JavaPage.cpp +++ b/launcher/ui/pages/global/JavaPage.cpp @@ -19,7 +19,7 @@ */ #include "JavaPage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "ui_JavaPage.h" #include diff --git a/launcher/ui/pages/global/JavaPage.h b/launcher/ui/pages/global/JavaPage.h index 80104e1c..44d7b353 100644 --- a/launcher/ui/pages/global/JavaPage.h +++ b/launcher/ui/pages/global/JavaPage.h @@ -23,7 +23,7 @@ #include #include #include "ui/pages/BasePage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include #include diff --git a/launcher/ui/pages/instance/InstanceSettingsPage.cpp b/launcher/ui/pages/instance/InstanceSettingsPage.cpp index 8ccd5c52..91f76f3a 100644 --- a/launcher/ui/pages/instance/InstanceSettingsPage.cpp +++ b/launcher/ui/pages/instance/InstanceSettingsPage.cpp @@ -29,7 +29,7 @@ #include "ui/dialogs/VersionSelectDialog.h" #include "ui/widgets/CustomCommands.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "Application.h" #include "java/JavaInstallList.h" diff --git a/launcher/ui/pages/instance/InstanceSettingsPage.h b/launcher/ui/pages/instance/InstanceSettingsPage.h index 78f89373..bb73ee49 100644 --- a/launcher/ui/pages/instance/InstanceSettingsPage.h +++ b/launcher/ui/pages/instance/InstanceSettingsPage.h @@ -26,7 +26,7 @@ #include "BaseInstance.h" #include #include "ui/pages/BasePage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "Application.h" class JavaChecker; diff --git a/launcher/ui/setupwizard/JavaWizardPage.cpp b/launcher/ui/setupwizard/JavaWizardPage.cpp index 65be8211..569ae50c 100644 --- a/launcher/ui/setupwizard/JavaWizardPage.cpp +++ b/launcher/ui/setupwizard/JavaWizardPage.cpp @@ -35,7 +35,7 @@ #include "FileSystem.h" #include "java/JavaInstall.h" #include "java/JavaUtils.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "java/download/JavaRuntime.h" #include "ui/widgets/VersionSelectWidget.h" From 0cd4251c17b33099bad65f86b408f03340bac046 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:10:40 +0300 Subject: [PATCH 02/64] [phase-02] Move LaunchController into the UI layer LaunchController orchestrates account selection, authentication, progress reporting and window handling. It opens roughly fifteen modal dialogs: message boxes, yes/no questions, custom-button prompts, text input, a profile picker, a profile setup dialog and two ProgressDialog runs. That is not a core service with an unfortunate dependency -- it is user interface. The build already said so: it sits in MESHMC_SOURCES alongside MainWindow, and setParentWidget() is only ever called from Application. Its callers are Application, MainWindow and InstanceWindow. The three references to it from outside the UI (launch/steps/CreateBackup.h, plugin/PluginHooks.h, plugin/PluginManager.h) are all comments, not code. Only its path claimed it was core. So it moves rather than being inverted. Building a UiHost interface wide enough to serve fifteen widget-shaped interactions, purely so a file could keep living in the wrong directory, would have bought worse architecture than it removed. When the QML shell needs launch orchestration it gets its own controller; this one dies with the widget UI it belongs to. InstanceImportTask is the opposite case and is not touched here: it is a real Task doing real work, and its modals are a genuine layering violation that has to be inverted rather than relocated. Co-Authored-By: Claude Opus 5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 4 ++-- launcher/ui/InstanceWindow.h | 2 +- launcher/{ => ui}/LaunchController.cpp | 2 +- launcher/{ => ui}/LaunchController.h | 0 launcher/ui/MainWindow.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename launcher/{ => ui}/LaunchController.cpp (99%) rename launcher/{ => ui}/LaunchController.h (100%) diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index dcb26b85..d01d3e86 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -852,8 +852,8 @@ SET(MESHMC_SOURCES ui/themes/CatPack.h # Processes - LaunchController.h - LaunchController.cpp + ui/LaunchController.h + ui/LaunchController.cpp # page provider for instances InstancePageProvider.h diff --git a/launcher/ui/InstanceWindow.h b/launcher/ui/InstanceWindow.h index 2fb7c38f..d829bb07 100644 --- a/launcher/ui/InstanceWindow.h +++ b/launcher/ui/InstanceWindow.h @@ -23,7 +23,7 @@ #include #include -#include "LaunchController.h" +#include "ui/LaunchController.h" #include "launch/LaunchTask.h" #include "ui/pages/BasePageContainer.h" diff --git a/launcher/LaunchController.cpp b/launcher/ui/LaunchController.cpp similarity index 99% rename from launcher/LaunchController.cpp rename to launcher/ui/LaunchController.cpp index c6a46192..73c0b858 100644 --- a/launcher/LaunchController.cpp +++ b/launcher/ui/LaunchController.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "LaunchController.h" +#include "ui/LaunchController.h" #include "minecraft/auth/AccountList.h" #include "Application.h" #include "plugin/PluginManager.h" diff --git a/launcher/LaunchController.h b/launcher/ui/LaunchController.h similarity index 100% rename from launcher/LaunchController.h rename to launcher/ui/LaunchController.h diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index 462e3171..c4d0367b 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -82,7 +82,7 @@ #include "InstanceWindow.h" #include "InstancePageProvider.h" #include "ui/JavaCommon.h" -#include "LaunchController.h" +#include "ui/LaunchController.h" #include "ui/instanceview/InstanceProxyModel.h" #include "ui/instanceview/InstanceView.h" From ffa39c5e9c4469674ad837a46935fa44197ea6e1 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:15:48 +0300 Subject: [PATCH 03/64] [phase-02] Ask the user through UiHost instead of building dialogs InstanceImportTask stopped five times to ask the user something, and each time it did so by constructing a QDialog. A task that downloads and unpacks modpacks had ui/dialogs/ in its include list and a QWidget* threaded through its API purely to have something to parent those dialogs to. core/UiHost.h states the questions instead: acknowledge this, confirm that, choose between these, resolve these blocked files, approve these untrusted ones. The shell answers them; how the answer is obtained on screen is no longer the task's business. WidgetUiHost supplies today's message boxes, and parents them to whatever window is active when the question is asked rather than to a widget handed over in advance -- which is what setDialogParent() existed for, and why it is gone along with m_dialogParent and its three call sites. BlockedMod moved from ui/dialogs/BlockedModsDialog.h to modplatform/. It is a plain struct describing a file that could not be downloaded; it was only living in a QDialog header, which forced the install task to include that header to describe its own data. The wording of every prompt is unchanged, including the two that name their actions rather than answering yes or no ("Remove saves"/"Keep saves", and the three-way choice between updating an instance and creating a separate one). confirm() takes optional labels for that reason: "Yes" and "No" make the reader go back and re-read the question. These calls are synchronous, deliberately. They are decision points in the middle of a task that branches immediately on the answer, so making them asynchronous would mean restructuring modpack installation to gain nothing today -- the widget implementation blocks either way. The interface says nothing about how an answer is obtained, so a later implementation can pump an event loop or run the caller on a worker thread. The cost is documented in the header rather than hidden: a blocking call reached from the GUI thread runs a nested event loop. launcher/InstanceImportTask.cpp now includes nothing from ui/. Co-Authored-By: Claude Opus 5 Signed-off-by: grxtor --- launcher/Application.cpp | 7 ++ launcher/Application.h | 6 + launcher/CMakeLists.txt | 4 + launcher/InstanceImportTask.cpp | 82 +++++------- launcher/InstanceImportTask.h | 6 - launcher/core/LauncherContext.h | 6 + launcher/core/UiHost.h | 86 +++++++++++++ launcher/modplatform/BlockedMod.h | 39 ++++++ launcher/ui/WidgetUiHost.cpp | 119 ++++++++++++++++++ launcher/ui/WidgetUiHost.h | 49 ++++++++ launcher/ui/dialogs/BlockedModsDialog.h | 9 +- .../ui/pages/instance/ManagedPackPage.cpp | 1 - .../ui/pages/modplatform/flame/FlamePage.cpp | 1 - .../modplatform/modrinth/ModrinthPage.cpp | 1 - 14 files changed, 347 insertions(+), 69 deletions(-) create mode 100644 launcher/core/UiHost.h create mode 100644 launcher/modplatform/BlockedMod.h create mode 100644 launcher/ui/WidgetUiHost.cpp create mode 100644 launcher/ui/WidgetUiHost.h diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 545187c2..875360da 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -20,6 +20,7 @@ #include "Application.h" #include "BuildConfig.h" #include "plugin/PluginAuthRequestDecorator.h" +#include "ui/WidgetUiHost.h" #include "plugin/PluginManager.h" #include "ui/MainWindow.h" @@ -334,6 +335,7 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) * already reach services through LAUNCHER->, and they would see a null * context otherwise. */ LauncherContext::setInstance(this); + m_uiHost = std::make_unique(); initPlatform(); if (m_status != StartingUp) @@ -2311,3 +2313,8 @@ AuthRequestDecorator* Application::authRequestDecorator() const { return m_authRequestDecorator.get(); } + +UiHost* Application::uiHost() const +{ + return m_uiHost.get(); +} diff --git a/launcher/Application.h b/launcher/Application.h index 16eb2fa4..fa276abd 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -186,6 +186,8 @@ class Application : public QApplication, public LauncherContext AuthRequestDecorator* authRequestDecorator() const override; + UiHost* uiHost() const override; + /// this is the root of the 'installation'. Used for automatic updates const QString& root() { @@ -332,6 +334,10 @@ class Application : public QApplication, public LauncherContext * plugin host (MeshMC_PLUGINS is OFF by default). */ std::unique_ptr m_authRequestDecorator; + /* Built before anything that could ask the user a question, because + * uiHost() promises never to return null. */ + std::unique_ptr m_uiHost; + public: QString m_instanceIdToLaunch; QString m_serverToJoin; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index d01d3e86..856827ab 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -13,6 +13,8 @@ set(CORE_SOURCES core/LauncherContext.h core/LauncherContext.cpp core/AuthRequestDecorator.h + core/UiHost.h + modplatform/BlockedMod.h # LOGIC - Base classes and infrastructure BaseInstaller.h @@ -810,6 +812,8 @@ SET(MESHMC_SOURCES ui/MainWindow.h ui/ShortcutUtils.cpp ui/ShortcutUtils.h + ui/WidgetUiHost.cpp + ui/WidgetUiHost.h ui/MainWindow.cpp ui/MacMenuBar.h ui/MacMenuBar.cpp diff --git a/launcher/InstanceImportTask.cpp b/launcher/InstanceImportTask.cpp index 1af5ed77..3139dc01 100644 --- a/launcher/InstanceImportTask.cpp +++ b/launcher/InstanceImportTask.cpp @@ -22,6 +22,7 @@ #include "BaseInstance.h" #include "FileSystem.h" #include "core/LauncherContext.h" +#include "core/UiHost.h" #include "InstanceList.h" #include "MMCZip.h" #include "archive/ExtractZipTask.h" @@ -45,11 +46,7 @@ #include "icons/IconList.h" #include "modplatform/flame/FlameApi.h" #include "modplatform/modrinth/ModrinthApi.h" -#include "ui/dialogs/BlockedModsDialog.h" -#include "ui/dialogs/CustomMessageBox.h" -#include "ui/dialogs/UntrustedModsDialog.h" -#include #include #include #include @@ -976,16 +973,15 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() // Handle restricted mods via dialog if (!blockedMods.isEmpty()) { - BlockedModsDialog dlg(nullptr, tr("Restricted Mods"), - tr("The following mods have restricted downloads " - "and are not available through the API.\n" - "Click the Download button next to each mod " - "to open its download page in your browser.\n" - "Once all files appear in your Downloads " - "folder, click Continue."), - blockedMods); - - if (dlg.exec() == QDialog::Accepted) { + if (LAUNCHER->uiHost()->resolveBlockedMods( + tr("Restricted Mods"), + tr("The following mods have restricted downloads " + "and are not available through the API.\n" + "Click the Download button next to each mod " + "to open its download page in your browser.\n" + "Once all files appear in your Downloads " + "folder, click Continue."), + blockedMods)) { QString downloadDir = QStandardPaths::writableLocation( QStandardPaths::DownloadLocation); for (const auto& mod : blockedMods) { @@ -1633,10 +1629,9 @@ bool InstanceImportTask::confirmUntrustedFiles(const QStringList& suspectPaths) qWarning() << "Untrusted modpack carries" << suspectPaths.size() << "file(s) we cannot vouch for"; - /* A dialog of its own, with the files listed in it and consent as a - * separate deliberate act - see UntrustedModsDialog. */ - UntrustedModsDialog dialog(suspectPaths, m_dialogParent); - return dialog.exec() == QDialog::Accepted; + /* A surface of its own, with the files listed on it and consent as a + * separate deliberate act. */ + return LAUNCHER->uiHost()->confirmUntrustedMods(suspectPaths); } bool InstanceImportTask::resolveUpdateTargetFromCatalogue() @@ -1669,8 +1664,10 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() ? QString() : tr(", at version %1").arg(installedVersion); - auto* box = CustomMessageBox::selectable( - m_dialogParent, tr("This modpack is already installed"), + /* Named actions rather than yes/no: there are three answers here and + * two of them install something. */ + const int choice = LAUNCHER->uiHost()->choose( + tr("This modpack is already installed"), tr("The instance \"%1\" was installed from this modpack%2.\n\n" "Updating it replaces the pack's own files and keeps everything " "that is yours: worlds, screenshots, play time and the " @@ -1681,18 +1678,10 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() "changes or removes mods can leave worlds made with the older " "version unusable.") .arg(existing->name(), versionSuffix), - QMessageBox::Question, QMessageBox::Cancel, QMessageBox::Cancel); - - /* Named actions rather than yes/no: there are three answers here and - * two of them install something. */ - auto* update = - box->addButton(tr("Update existing instance"), QMessageBox::AcceptRole); - auto* separate = - box->addButton(tr("Create separate instance"), QMessageBox::ResetRole); + UiHost::Severity::Question, + {tr("Update existing instance"), tr("Create separate instance")}); - box->exec(); - - if (box->clickedButton() == update) { + if (choice == 0) { /* The version fields are the catalogue entry the user picked - * the same thing the pack page would pass - because the instance * has to end up claiming the version it now actually has. */ @@ -1704,7 +1693,7 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() qDebug() << "Installing over existing instance" << target.instanceId; return true; } - if (box->clickedButton() == separate) { + if (choice == 1) { return true; } @@ -1799,26 +1788,17 @@ static QString sidecarPathForModFile( * that cannot be downloaded again, and a pack that shipped one has no * way of knowing whether the copy on disk is still the one it shipped or * a hundred hours of somebody's game. */ -static bool askAboutDeletingSaves(QWidget* parent) +static bool askAboutDeletingSaves() { - auto* box = CustomMessageBox::selectable( - parent, QObject::tr("Delete existing save files"), + return LAUNCHER->uiHost()->confirm( + QObject::tr("Delete existing save files"), QObject::tr("The installed version of this modpack came with save " "files that the new version no longer includes.\n\n" "Would you like to remove them as part of this update? " "Keeping them is safe - they simply stay where they " "are, along with any progress made in them."), - QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, - QMessageBox::No); - - if (auto* remove = box->button(QMessageBox::Yes)) { - remove->setText(QObject::tr("Remove saves")); - } - if (auto* keep = box->button(QMessageBox::No)) { - keep->setText(QObject::tr("Keep saves")); - } - - return box->exec() == QMessageBox::Yes; + UiHost::Severity::Question, QObject::tr("Remove saves"), + QObject::tr("Keep saves")); } bool InstanceImportTask::recordPackContents( @@ -1869,8 +1849,8 @@ bool InstanceImportTask::recordPackContents( * the update goes ahead without cleaning up, and says so - the * leftovers are visible to the user as duplicated mods, and being * surprised by that is worse than being told. */ - auto* box = CustomMessageBox::selectable( - m_dialogParent, tr("No file list for the installed version"), + return LAUNCHER->uiHost()->confirm( + tr("No file list for the installed version"), tr("The launcher has no record of which files the installed " "version of this modpack put into this instance, so it " "cannot remove the ones the new version no longer " @@ -1881,9 +1861,7 @@ bool InstanceImportTask::recordPackContents( "Instances installed before the launcher started keeping " "that record have no list. This update writes one, so the " "update after it will be able to clean up."), - QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Cancel, - QMessageBox::Ok); - return box->exec() == QMessageBox::Ok; + UiHost::Severity::Warning, tr("Update anyway"), tr("Cancel")); } const QStringList stale = @@ -1916,7 +1894,7 @@ bool InstanceImportTask::recordPackContents( if (relativePath.startsWith(QLatin1String("saves/"), Qt::CaseInsensitive)) { if (m_savesDeletion == SavesDeletion::NotAsked) { - m_savesDeletion = askAboutDeletingSaves(m_dialogParent) + m_savesDeletion = askAboutDeletingSaves() ? SavesDeletion::Allowed : SavesDeletion::Refused; } diff --git a/launcher/InstanceImportTask.h b/launcher/InstanceImportTask.h index 13936e90..92ee77b1 100644 --- a/launcher/InstanceImportTask.h +++ b/launcher/InstanceImportTask.h @@ -110,11 +110,6 @@ class InstanceImportTask : public InstanceTask /* Parent for the dialogs the task may need to raise (confirmations, * warnings). Null is allowed and simply means the dialog is * parentless; it is not a reason to skip asking. */ - void setDialogParent(QWidget* parent) - { - m_dialogParent = parent; - } - /* Whether the archive came from somewhere we vouch for. * * A modpack is a list of code to execute. When the launcher itself @@ -248,7 +243,6 @@ class InstanceImportTask : public InstanceTask * this is opened - two live settings objects over one instance.cfg * means whichever writes last wins. */ std::shared_ptr m_gameFilesInstance; - QWidget* m_dialogParent = nullptr; bool m_trustedSource = false; /* Helper: persist the pack source hint into the freshly created diff --git a/launcher/core/LauncherContext.h b/launcher/core/LauncherContext.h index df074194..964ba093 100644 --- a/launcher/core/LauncherContext.h +++ b/launcher/core/LauncherContext.h @@ -32,6 +32,7 @@ class IconList; class InstanceList; class QNetworkAccessManager; class SettingsObject; +class UiHost; namespace Meta { @@ -88,6 +89,11 @@ class LauncherContext * plugin-supplied user interface and therefore pulls in QtWidgets. */ virtual AuthRequestDecorator* authRequestDecorator() const = 0; + /* Never null: a core task that has to ask the user a question cannot + * meaningfully carry on without an answer, so the shell installs one + * before anything that might ask is allowed to run. */ + virtual UiHost* uiHost() const = 0; + protected: /* Called by the implementation's constructor/destructor. Registering is * not thread safe and is expected to happen once, on the main thread, diff --git a/launcher/core/UiHost.h b/launcher/core/UiHost.h new file mode 100644 index 00000000..0f7f3084 --- /dev/null +++ b/launcher/core/UiHost.h @@ -0,0 +1,86 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "modplatform/BlockedMod.h" + +/* + * Questions the core needs answered by a human. + * + * Work that runs outside the user interface sometimes has to stop and ask -- + * an install task finding files the provider will not serve, or an update + * that would overwrite an instance. Those decisions used to be taken by + * constructing a QDialog inside the task, which put QtWidgets in the + * dependency graph of code that has nothing to do with drawing. + * + * So the core states the question and the shell answers it. What the answer + * looks like on screen -- a message box today, something in QML later -- is + * none of the core's business. + * + * ON BLOCKING: these are synchronous on purpose. The callers are decision + * points in the middle of a task, branching immediately on the answer, and + * making them asynchronous would mean restructuring the control flow of + * modpack installation for no gain today -- the widget implementation blocks + * either way. The interface says nothing about how the answer is obtained, + * so a future implementation is free to pump an event loop or to be called + * from a worker thread. That cost is real and deferred, not hidden: a + * blocking call reached from the GUI thread runs a nested event loop, with + * the re-entrancy that implies. + */ +class UiHost +{ + public: + enum class Severity { Information, Question, Warning, Critical }; + + virtual ~UiHost() = default; + + /* Something the user only has to acknowledge. */ + virtual void message(const QString& title, const QString& text, + Severity severity) = 0; + + /* A two-way decision. True means the user agreed to go ahead. + * + * The labels are optional: leaving them empty gets the platform's own + * wording. Naming the actions is better where the question is not a + * plain yes/no -- "Remove saves" and "Keep saves" say what will happen, + * where "Yes" and "No" make the reader re-read the question. */ + virtual bool confirm(const QString& title, const QString& text, + Severity severity, + const QString& acceptLabel = QString(), + const QString& rejectLabel = QString()) = 0; + + /* A decision with more than two answers. Returns the index into + * `actions`, or -1 when the user backed out. */ + virtual int choose(const QString& title, const QString& text, + Severity severity, const QStringList& actions) = 0; + + /* Files the provider's API will not serve, which the user fetches by + * hand while the host watches the download folder. `mods` is updated in + * place. False means the user gave up. */ + virtual bool resolveBlockedMods(const QString& title, const QString& text, + QList& mods) = 0; + + /* Files that failed the trust check. True means install them anyway. */ + virtual bool confirmUntrustedMods(const QStringList& suspectPaths) = 0; +}; diff --git a/launcher/modplatform/BlockedMod.h b/launcher/modplatform/BlockedMod.h new file mode 100644 index 00000000..c588a2cc --- /dev/null +++ b/launcher/modplatform/BlockedMod.h @@ -0,0 +1,39 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +/* + * A pack file the provider's API refuses to hand out, which the user has to + * fetch by hand. + * + * `found` is written by whoever is watching the download folder, so this + * travels from the install task out to the user interface and back with the + * answer filled in. It used to be declared inside BlockedModsDialog.h, which + * meant the install task included a QDialog header to describe its own data. + */ +struct BlockedMod { + int projectId; + int fileId; + QString fileName; + QString targetPath; + bool found = false; +}; diff --git a/launcher/ui/WidgetUiHost.cpp b/launcher/ui/WidgetUiHost.cpp new file mode 100644 index 00000000..a2f21cd3 --- /dev/null +++ b/launcher/ui/WidgetUiHost.cpp @@ -0,0 +1,119 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ui/WidgetUiHost.h" + +#include +#include +#include +#include + +#include "ui/dialogs/BlockedModsDialog.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/UntrustedModsDialog.h" + +namespace +{ + QMessageBox::Icon toIcon(UiHost::Severity severity) + { + switch (severity) { + case UiHost::Severity::Information: + return QMessageBox::Information; + case UiHost::Severity::Question: + return QMessageBox::Question; + case UiHost::Severity::Warning: + return QMessageBox::Warning; + case UiHost::Severity::Critical: + return QMessageBox::Critical; + } + return QMessageBox::NoIcon; + } + + QWidget* activeWindow() + { + return QApplication::activeWindow(); + } +} // namespace + +void WidgetUiHost::message(const QString& title, const QString& text, + Severity severity) +{ + CustomMessageBox::selectable(activeWindow(), title, text, + toIcon(severity), QMessageBox::Ok, + QMessageBox::Ok) + ->exec(); +} + +bool WidgetUiHost::confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel, + const QString& rejectLabel) +{ + auto* box = CustomMessageBox::selectable( + activeWindow(), title, text, toIcon(severity), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (!acceptLabel.isEmpty()) { + if (auto* accept = box->button(QMessageBox::Yes)) { + accept->setText(acceptLabel); + } + } + if (!rejectLabel.isEmpty()) { + if (auto* reject = box->button(QMessageBox::No)) { + reject->setText(rejectLabel); + } + } + + return box->exec() == QMessageBox::Yes; +} + +int WidgetUiHost::choose(const QString& title, const QString& text, + Severity severity, const QStringList& actions) +{ + auto* box = + CustomMessageBox::selectable(activeWindow(), title, text, + toIcon(severity), QMessageBox::Cancel, + QMessageBox::Cancel); + + /* AcceptRole for every action: the roles would otherwise decide the + * button order for us, and the caller's order is the meaningful one. */ + QVector buttons; + buttons.reserve(actions.size()); + for (const QString& action : actions) { + buttons.append(box->addButton(action, QMessageBox::AcceptRole)); + } + + box->exec(); + + const int index = buttons.indexOf(box->clickedButton()); + return index; /* -1 when Cancel or the window's close button was used */ +} + +bool WidgetUiHost::resolveBlockedMods(const QString& title, + const QString& text, + QList& mods) +{ + BlockedModsDialog dialog(activeWindow(), title, text, mods); + return dialog.exec() == QDialog::Accepted; +} + +bool WidgetUiHost::confirmUntrustedMods(const QStringList& suspectPaths) +{ + UntrustedModsDialog dialog(suspectPaths, activeWindow()); + return dialog.exec() == QDialog::Accepted; +} diff --git a/launcher/ui/WidgetUiHost.h b/launcher/ui/WidgetUiHost.h new file mode 100644 index 00000000..e4f94667 --- /dev/null +++ b/launcher/ui/WidgetUiHost.h @@ -0,0 +1,49 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "core/UiHost.h" + +/* + * Answers the core's questions with QtWidgets dialogs. + * + * Parented to whatever window is active at the moment the question is asked + * rather than to a widget handed over in advance: the tasks that ask these + * questions outlive any particular window, and the old code threaded a + * QWidget* through the task API purely to have something to parent to. + */ +class WidgetUiHost final : public UiHost +{ + public: + void message(const QString& title, const QString& text, + Severity severity) override; + + bool confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel = QString(), + const QString& rejectLabel = QString()) override; + + int choose(const QString& title, const QString& text, Severity severity, + const QStringList& actions) override; + + bool resolveBlockedMods(const QString& title, const QString& text, + QList& mods) override; + + bool confirmUntrustedMods(const QStringList& suspectPaths) override; +}; diff --git a/launcher/ui/dialogs/BlockedModsDialog.h b/launcher/ui/dialogs/BlockedModsDialog.h index 3346ddee..ff33ff95 100644 --- a/launcher/ui/dialogs/BlockedModsDialog.h +++ b/launcher/ui/dialogs/BlockedModsDialog.h @@ -25,14 +25,7 @@ #include #include #include - -struct BlockedMod { - int projectId; - int fileId; - QString fileName; - QString targetPath; - bool found = false; -}; +#include "modplatform/BlockedMod.h" class BlockedModsDialog : public QDialog { diff --git a/launcher/ui/pages/instance/ManagedPackPage.cpp b/launcher/ui/pages/instance/ManagedPackPage.cpp index 1b067118..adfc38c8 100644 --- a/launcher/ui/pages/instance/ManagedPackPage.cpp +++ b/launcher/ui/pages/instance/ManagedPackPage.cpp @@ -792,7 +792,6 @@ void ManagedPackPage::updatePack(const QUrl& url, bool trusted, target.versionId = versionId; target.versionLabel = versionName; task->setUpdateTarget(target); - task->setDialogParent(this); task->setTrustedSource(trusted); /* Carry the pack's identity across explicitly. diff --git a/launcher/ui/pages/modplatform/flame/FlamePage.cpp b/launcher/ui/pages/modplatform/flame/FlamePage.cpp index 4691a345..fb35fa03 100644 --- a/launcher/ui/pages/modplatform/flame/FlamePage.cpp +++ b/launcher/ui/pages/modplatform/flame/FlamePage.cpp @@ -236,7 +236,6 @@ void FlamePage::suggestCurrent() * to update an instance this pack is already installed in, most * likely - and a question about what the user is doing in this window * belongs to this window. */ - task->setDialogParent(this); task->setPackSourceHint(hint); dialog->setSuggestedPack(current.name, task); diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp index fb8ac69c..b363b739 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -243,7 +243,6 @@ void ModrinthPage::suggestCurrent() task->setTrustedSource(true); /* Same as the CurseForge page: any question the task raises is a * question about what the user is doing here. */ - task->setDialogParent(this); task->setPackSourceHint(hint); dialog->setSuggestedPack(current.name, task); QString editedLogoName; From de53acc7143f043e22d85b797be3c31f697f6227 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:29:30 +0300 Subject: [PATCH 04/64] [phase-02] Route the external updater's prompts through UiHost MeshMCExternalUpdater sits in UPDATE_SOURCES, which is headed for the core library that cannot link QtWidgets. It built its own QMessageBoxes through a file-local showMessage() helper, constructed UpdateAvailableDialog directly, and had a QWidget* threaded through its constructor to parent both. The seven plain notices now go through UiHost::message(). The update offer does not: it shows the running version, the offered one and formatted release notes, and flattening that into the generic choose() would have degraded the notes to a message-box body. It gets its own method instead, the same way resolveBlockedMods() does -- UiHost::offerUpdate() returning Install, Later or Skip. WidgetUiHost answers it with the existing UpdateAvailableDialog, and maps anything that is neither Install nor Skip to Later, so closing the window keeps meaning "remind me later" as it did. The settings side effects are unchanged: a skip is remembered, an install forgets any earlier skip and syncs before returning, a deferral forgets it. One presentational change, deliberately: UiHost::message() has no collapsible "Show Details" section, so the four notices that carried details now append them to the body. Nothing is lost, but the details are no longer folded away. The QWidget* constructor parameter, m_parent and the forward declaration are gone; the file no longer mentions QWidget, QMessageBox or anything under ui/. checkForUpdates() still builds a QProgressDialog, now unparented. That one is progress reporting rather than a question, and belongs with the TaskRunner work, not UiHost. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/Application.cpp | 6 +- launcher/core/UiHost.h | 10 ++ launcher/ui/WidgetUiHost.cpp | 19 ++++ launcher/ui/WidgetUiHost.h | 4 + launcher/updater/MeshMCExternalUpdater.cpp | 120 +++++++++++---------- launcher/updater/MeshMCExternalUpdater.h | 6 +- 6 files changed, 99 insertions(+), 66 deletions(-) diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 875360da..00c93c71 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -1166,8 +1166,8 @@ void Application::initSubsystems() // The updater is created before the main window on purpose: MainWindow's // constructor connects to it, so an updater made afterwards would be one - // nothing is listening to. Its dialogs therefore have no parent yet, - // which only matters for the "On Launch" check below. + // nothing is listening to. There is therefore no window to anchor its + // dialogs to yet, which only matters for the "On Launch" check below. if (updaterEnabled()) { qDebug() << "Initializing the updater"; #if defined(Q_OS_MAC) @@ -1176,7 +1176,7 @@ void Application::initSubsystems() #endif #else m_updater.reset(new MeshMCExternalUpdater( - m_mainWindow, m_rootPath, m_dataPath, + m_rootPath, m_dataPath, // Migrates the launcher's old "check on start" setting into the // updater's config, once. See the constructor. m_settings->get("AutoUpdate").toBool())); diff --git a/launcher/core/UiHost.h b/launcher/core/UiHost.h index 0f7f3084..6ba93090 100644 --- a/launcher/core/UiHost.h +++ b/launcher/core/UiHost.h @@ -83,4 +83,14 @@ class UiHost /* Files that failed the trust check. True means install them anyway. */ virtual bool confirmUntrustedMods(const QStringList& suspectPaths) = 0; + + enum class UpdateChoice { Install, Later, Skip }; + + /* A release being offered: the current version, the version on offer, + * and its release notes. Kept separate from choose() because the notes + * are formatted content -- Markdown today -- and a plain message-box + * body would flatten that formatting. */ + virtual UpdateChoice offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) = 0; }; diff --git a/launcher/ui/WidgetUiHost.cpp b/launcher/ui/WidgetUiHost.cpp index a2f21cd3..fe16b721 100644 --- a/launcher/ui/WidgetUiHost.cpp +++ b/launcher/ui/WidgetUiHost.cpp @@ -27,6 +27,7 @@ #include "ui/dialogs/BlockedModsDialog.h" #include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/UntrustedModsDialog.h" +#include "ui/dialogs/UpdateAvailableDialog.h" namespace { @@ -117,3 +118,21 @@ bool WidgetUiHost::confirmUntrustedMods(const QStringList& suspectPaths) UntrustedModsDialog dialog(suspectPaths, activeWindow()); return dialog.exec() == QDialog::Accepted; } + +UiHost::UpdateChoice WidgetUiHost::offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) +{ + UpdateAvailableDialog dialog(currentVersion, availableVersion, + releaseNotes, activeWindow()); + switch (dialog.exec()) { + case UpdateAvailableDialog::Install: + return UpdateChoice::Install; + case UpdateAvailableDialog::Skip: + return UpdateChoice::Skip; + default: + /* DontInstall, or the window was simply closed -- both leave + * the offer standing for next time. */ + return UpdateChoice::Later; + } +} diff --git a/launcher/ui/WidgetUiHost.h b/launcher/ui/WidgetUiHost.h index e4f94667..1712d479 100644 --- a/launcher/ui/WidgetUiHost.h +++ b/launcher/ui/WidgetUiHost.h @@ -46,4 +46,8 @@ class WidgetUiHost final : public UiHost QList& mods) override; bool confirmUntrustedMods(const QStringList& suspectPaths) override; + + UpdateChoice offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) override; }; diff --git a/launcher/updater/MeshMCExternalUpdater.cpp b/launcher/updater/MeshMCExternalUpdater.cpp index aafee074..7a20dc39 100644 --- a/launcher/updater/MeshMCExternalUpdater.cpp +++ b/launcher/updater/MeshMCExternalUpdater.cpp @@ -21,7 +21,6 @@ #include #include -#include #include #include #include @@ -31,7 +30,8 @@ #include #include "BuildConfig.h" -#include "ui/dialogs/UpdateAvailableDialog.h" +#include "core/LauncherContext.h" +#include "core/UiHost.h" namespace { @@ -65,20 +65,16 @@ namespace }; /*! - * All of the updater's message boxes look the same: wide enough that a - * path or a version string does not wrap into nonsense, and with the raw - * child output tucked behind "Show Details" when there is any. + * Folds raw child output into the message body. + * + * UiHost::message() has no "Show Details" affordance to tuck this + * behind, so it goes in the body instead when there is any. */ - void showMessage(QWidget* parent, QMessageBox::Icon icon, - const QString& title, const QString& text, - const QString& details = QString()) + QString withDetails(const QString& text, const QString& details = QString()) { - QMessageBox box(icon, title, text, QMessageBox::Ok, parent); - if (!details.isEmpty()) - box.setDetailedText(details); - box.setMinimumWidth(460); - box.adjustSize(); - box.exec(); + if (details.isEmpty()) + return text; + return text + QLatin1String("\n\n") + details; } /*! @@ -124,11 +120,10 @@ QString MeshMCExternalUpdater::updaterBinaryRelativePath() #endif } -MeshMCExternalUpdater::MeshMCExternalUpdater(QWidget* parent, - const QString& appDir, +MeshMCExternalUpdater::MeshMCExternalUpdater(const QString& appDir, const QString& dataDir, bool autoCheckDefault) - : m_appDir(appDir), m_dataDir(dataDir), m_parent(parent) + : m_appDir(appDir), m_dataDir(dataDir) { m_settings = std::make_unique( m_dataDir.absoluteFilePath(QLatin1String(kConfigFileName)), @@ -221,7 +216,7 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) // has not simply frozen. An automatic check gets none: nobody asked, and // a window stealing focus during startup is worse than no feedback. QProgressDialog progress(tr("Checking for updates..."), QString(), 0, 0, - m_parent); + nullptr); progress.setWindowTitle(tr("Checking for updates...")); progress.setMinimumDuration(0); progress.setCancelButton(nullptr); @@ -253,10 +248,11 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) << kStartTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); progress.cancel(); - showMessage(m_parent, QMessageBox::Information, - tr("Update Check Failed"), - tr("Failed to start after 5 seconds\nReason: %1.") - .arg(proc.errorString())); + LAUNCHER->uiHost()->message( + tr("Update Check Failed"), + tr("Failed to start after 5 seconds\nReason: %1.") + .arg(proc.errorString()), + UiHost::Severity::Information); noteCheckCompleted(); return; } @@ -270,11 +266,12 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) << kFinishTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); progress.cancel(); - showMessage(m_parent, QMessageBox::Information, - tr("Update Check Failed"), - tr("Updater failed to close 60 seconds\nReason: %1.") - .arg(proc.errorString()), - QString::fromUtf8(output)); + LAUNCHER->uiHost()->message( + tr("Update Check Failed"), + withDetails(tr("Updater failed to close 60 seconds\nReason: %1.") + .arg(proc.errorString()), + QString::fromUtf8(output)), + UiHost::Severity::Information); noteCheckCompleted(); return; } @@ -290,19 +287,21 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) case CheckExitCode::NoUpdate: qDebug() << "Updater: no update available."; if (triggeredByUser) { - showMessage(m_parent, QMessageBox::Information, - tr("No Update Available"), - tr("You are running the latest version.")); + LAUNCHER->uiHost()->message(tr("No Update Available"), + tr("You are running the latest " + "version."), + UiHost::Severity::Information); } break; case CheckExitCode::CheckError: qWarning() << "Updater: the check reported an error:" << qPrintable(QString::fromUtf8(stdError)); - showMessage(m_parent, QMessageBox::Warning, - tr("Update Check Error"), - tr("There was an error running the update check."), - QString::fromUtf8(stdError)); + LAUNCHER->uiHost()->message( + tr("Update Check Error"), + withDetails(tr("There was an error running the update check."), + QString::fromUtf8(stdError)), + UiHost::Severity::Warning); break; case CheckExitCode::UpdateAvailable: { @@ -326,12 +325,14 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) // remember as skipped, so this is an error, not an offer. qWarning() << "Updater: the check reported an update but no " "version tag."; - showMessage(m_parent, QMessageBox::Warning, - tr("Update Check Error"), - tr("There was an error running the update check."), - tr("StdOut: %1\nStdErr: %2") - .arg(QString::fromUtf8(stdOutput), - QString::fromUtf8(stdError))); + LAUNCHER->uiHost()->message( + tr("Update Check Error"), + withDetails( + tr("There was an error running the update check."), + tr("StdOut: %1\nStdErr: %2") + .arg(QString::fromUtf8(stdOutput), + QString::fromUtf8(stdError))), + UiHost::Severity::Warning); break; } @@ -343,14 +344,16 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) default: qWarning() << "Updater: the check exited with an unknown code" << exitCode; - showMessage( - m_parent, QMessageBox::Information, tr("Unknown Update Error"), - tr("The updater exited with an unknown condition.\nExit Code: " - "%1") - .arg(QString::number(exitCode)), - tr("StdOut: %1\nStdErr: %2") - .arg(QString::fromUtf8(stdOutput), - QString::fromUtf8(stdError))); + LAUNCHER->uiHost()->message( + tr("Unknown Update Error"), + withDetails( + tr("The updater exited with an unknown condition.\nExit " + "Code: %1") + .arg(QString::number(exitCode)), + tr("StdOut: %1\nStdErr: %2") + .arg(QString::fromUtf8(stdOutput), + QString::fromUtf8(stdError))), + UiHost::Severity::Information); } noteCheckCompleted(); @@ -385,18 +388,17 @@ void MeshMCExternalUpdater::offerUpdate(const QString& versionName, return; } - UpdateAvailableDialog dialog(BuildConfig.printableVersionString(), - versionName, releaseNotes, m_parent); - const int result = dialog.exec(); + const UiHost::UpdateChoice choice = LAUNCHER->uiHost()->offerUpdate( + BuildConfig.printableVersionString(), versionName, releaseNotes); m_settings->beginGroup(kGroupSkip); - switch (result) { - case UpdateAvailableDialog::Skip: + switch (choice) { + case UiHost::UpdateChoice::Skip: qDebug() << "Updater: remembering" << versionTag << "as skipped."; m_settings->setValue(versionTag, true); break; - case UpdateAvailableDialog::Install: + case UiHost::UpdateChoice::Install: // Forget any earlier skip: the user just chose to install this // very version. m_settings->remove(versionTag); @@ -405,7 +407,7 @@ void MeshMCExternalUpdater::offerUpdate(const QString& versionName, performUpdate(versionTag); return; - default: + case UiHost::UpdateChoice::Later: // "Remind Me Later", or the window was simply closed. qDebug() << "Updater: leaving" << versionTag << "for later."; m_settings->remove(versionTag); @@ -437,9 +439,11 @@ void MeshMCExternalUpdater::performUpdate(const QString& versionTag) if (!proc.startDetached()) { qCritical() << "Updater: failed to start the updater:" << proc.error() << proc.errorString(); - showMessage(m_parent, QMessageBox::Warning, tr("Update Failed"), - tr("Could not start the updater.\nReason: %1.") - .arg(proc.errorString())); + LAUNCHER->uiHost()->message( + tr("Update Failed"), + tr("Could not start the updater.\nReason: %1.") + .arg(proc.errorString()), + UiHost::Severity::Warning); return; } diff --git a/launcher/updater/MeshMCExternalUpdater.h b/launcher/updater/MeshMCExternalUpdater.h index 01c7b9f9..86a098c0 100644 --- a/launcher/updater/MeshMCExternalUpdater.h +++ b/launcher/updater/MeshMCExternalUpdater.h @@ -30,7 +30,6 @@ #include class QSettings; -class QWidget; class MeshMCExternalUpdater : public ExternalUpdater { @@ -38,7 +37,6 @@ class MeshMCExternalUpdater : public ExternalUpdater public: /*! - * \a parent widget the dialogs are centred on; may be null. * \a appDir installation root (Application::root()). * \a dataDir where the config, the log and the markers live. * \a autoCheckDefault what "check automatically" means for an @@ -52,8 +50,7 @@ class MeshMCExternalUpdater : public ExternalUpdater * Starts the automatic check schedule, and -- when the interval is set to * "On Launch" -- performs a silent check before returning. */ - MeshMCExternalUpdater(QWidget* parent, const QString& appDir, - const QString& dataDir, + MeshMCExternalUpdater(const QString& appDir, const QString& dataDir, bool autoCheckDefault = true); ~MeshMCExternalUpdater() override; @@ -109,7 +106,6 @@ class MeshMCExternalUpdater : public ExternalUpdater QDir m_appDir; QDir m_dataDir; - QWidget* m_parent = nullptr; std::unique_ptr m_settings; QTimer m_updateTimer; From 24f75176dbdd820b00ad688bd2196e21cef19807 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:29:55 +0300 Subject: [PATCH 05/64] [phase-02] Put the last misfiled UI code where it belongs Three more files outside launcher/ui/ turned out to be user interface living in the wrong directory rather than core code with a widget dependency -- the same finding as JavaCommon, ShortcutUtils and LaunchController earlier: - FastFileIconProvider was listed in CORE_SOURCES, but its icons come from QApplication::style()->standardIcon(), and its only users are ExportPackDialog and ExportInstanceDialog. Moved to ui/. (FileIgnoreProxy, listed next to it, stays: QFileSystemModel moved to QtGui in Qt 6.) - InstancePageProvider.h includes nineteen ui/pages headers and is included only by MainWindow and InstanceWindow. It was already in MESHMC_SOURCES; only its path disagreed. Moved to ui/. And one genuine inversion: ContentProviderModel included ui/widgets/ProjectItemDelegate.h purely to get the ProjectItemRole enum, so a data model depended on the thing that paints it. The enum now lives with the model that produces those roles, and the delegate includes the model instead. The role values are unchanged -- other code passes them to data() as bare integers. With these, nothing in launcher/ outside ui/ and plugin/ includes QtWidgets except Application itself, which is the shell and goes to the UI target. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 8 +++-- launcher/modplatform/ContentProviderModel.cpp | 1 - launcher/modplatform/ContentProviderModel.h | 31 +++++++++++++++++-- launcher/{ => ui}/FastFileIconProvider.cpp | 2 +- launcher/{ => ui}/FastFileIconProvider.h | 0 launcher/{ => ui}/InstancePageProvider.h | 0 launcher/ui/InstanceWindow.cpp | 2 +- launcher/ui/MainWindow.cpp | 2 +- launcher/ui/dialogs/ExportInstanceDialog.h | 2 +- launcher/ui/dialogs/ExportPackDialog.h | 2 +- launcher/ui/widgets/ProjectItemDelegate.h | 25 +++------------ 11 files changed, 42 insertions(+), 33 deletions(-) rename launcher/{ => ui}/FastFileIconProvider.cpp (97%) rename launcher/{ => ui}/FastFileIconProvider.h (100%) rename launcher/{ => ui}/InstancePageProvider.h (100%) diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 856827ab..44d4853c 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -14,6 +14,7 @@ set(CORE_SOURCES core/LauncherContext.cpp core/AuthRequestDecorator.h core/UiHost.h + modplatform/BlockedMod.h # LOGIC - Base classes and infrastructure @@ -71,8 +72,6 @@ set(CORE_SOURCES # from costing a per-file platform lookup. FileIgnoreProxy.h FileIgnoreProxy.cpp - FastFileIconProvider.h - FastFileIconProvider.cpp # String filters Filter.h @@ -812,6 +811,8 @@ SET(MESHMC_SOURCES ui/MainWindow.h ui/ShortcutUtils.cpp ui/ShortcutUtils.h + ui/FastFileIconProvider.cpp + ui/FastFileIconProvider.h ui/WidgetUiHost.cpp ui/WidgetUiHost.h ui/MainWindow.cpp @@ -860,7 +861,7 @@ SET(MESHMC_SOURCES ui/LaunchController.cpp # page provider for instances - InstancePageProvider.h + ui/InstancePageProvider.h # Common java checking UI ui/JavaCommon.h @@ -1219,6 +1220,7 @@ add_unit_test(CustomTheme LIBS MeshMC_logic ) + add_unit_test(IconTheme SOURCES ui/themes/IconTheme_test.cpp LIBS MeshMC_logic diff --git a/launcher/modplatform/ContentProviderModel.cpp b/launcher/modplatform/ContentProviderModel.cpp index 54e7deb0..a2cf5920 100644 --- a/launcher/modplatform/ContentProviderModel.cpp +++ b/launcher/modplatform/ContentProviderModel.cpp @@ -28,7 +28,6 @@ #include "minecraft/mod/ModMetadataIndex.h" #include "net/Download.h" #include "net/HttpMetaCache.h" -#include "ui/widgets/ProjectItemDelegate.h" ContentProviderModel::ContentProviderModel(const ModPlatform::ContentApi& api, ModPlatform::ContentType contentType, diff --git a/launcher/modplatform/ContentProviderModel.h b/launcher/modplatform/ContentProviderModel.h index 1c2a6552..2333ea41 100644 --- a/launcher/modplatform/ContentProviderModel.h +++ b/launcher/modplatform/ContentProviderModel.h @@ -94,6 +94,31 @@ namespace ModPlatform } // namespace ModPlatform +/* Extra data roles this model exposes for ProjectItemDelegate + * (ui/widgets/ProjectItemDelegate.h) to paint a row. Declared here rather + * than on the delegate because these are roles the model's data() + * produces - a data model has no business depending on a view header. + * + * Qt::UserRole itself is already taken: both search models return the + * platform's project id there, and existing code reads it. These start + * one past it. + * + * Qt::DisplayRole is deliberately not reused for the title. The default + * delegate paints DisplayRole, so leaving it as the plain name keeps the + * list readable if a view is ever shown without this delegate attached. */ +namespace ProjectItemRole +{ + enum Role { + /* QString - project name, drawn large on the first line. */ + Title = Qt::UserRole + 1, + /* QString - short summary, wrapped over at most two lines. */ + Description, + /* bool - already present in the target folder. Such rows are + * dimmed and tagged, because installing them again is a no-op. */ + Installed, + }; +} + /* Search results for one content provider. * * There used to be two of these, one per provider, ninety percent @@ -111,9 +136,9 @@ class ContentProviderModel : public QAbstractListModel Q_OBJECT public: - /* Continues past ProjectItemRole (Qt::UserRole+1..+3, defined in - * ProjectItemDelegate.h), which the QtWidgets delegate already reads - * from this model's data(). */ + /* Continues past ProjectItemRole (Qt::UserRole+1..+3, declared + * above), which ProjectItemDelegate already reads from this + * model's data(). */ enum ModelRoles { LogoKeyRole = Qt::UserRole + 4 }; ~ContentProviderModel() override; diff --git a/launcher/FastFileIconProvider.cpp b/launcher/ui/FastFileIconProvider.cpp similarity index 97% rename from launcher/FastFileIconProvider.cpp rename to launcher/ui/FastFileIconProvider.cpp index b956383f..9bbc18f2 100644 --- a/launcher/FastFileIconProvider.cpp +++ b/launcher/ui/FastFileIconProvider.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" #include #include diff --git a/launcher/FastFileIconProvider.h b/launcher/ui/FastFileIconProvider.h similarity index 100% rename from launcher/FastFileIconProvider.h rename to launcher/ui/FastFileIconProvider.h diff --git a/launcher/InstancePageProvider.h b/launcher/ui/InstancePageProvider.h similarity index 100% rename from launcher/InstancePageProvider.h rename to launcher/ui/InstancePageProvider.h diff --git a/launcher/ui/InstanceWindow.cpp b/launcher/ui/InstanceWindow.cpp index f2e81ea5..f0441b1d 100644 --- a/launcher/ui/InstanceWindow.cpp +++ b/launcher/ui/InstanceWindow.cpp @@ -32,7 +32,7 @@ #include "ui/dialogs/ProgressDialog.h" #include "ui/widgets/PageContainer.h" -#include "InstancePageProvider.h" +#include "ui/InstancePageProvider.h" #include "icons/IconList.h" diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index c4d0367b..086de420 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -80,7 +80,7 @@ #include #include #include "InstanceWindow.h" -#include "InstancePageProvider.h" +#include "ui/InstancePageProvider.h" #include "ui/JavaCommon.h" #include "ui/LaunchController.h" diff --git a/launcher/ui/dialogs/ExportInstanceDialog.h b/launcher/ui/dialogs/ExportInstanceDialog.h index 2cba7295..728250ae 100644 --- a/launcher/ui/dialogs/ExportInstanceDialog.h +++ b/launcher/ui/dialogs/ExportInstanceDialog.h @@ -24,7 +24,7 @@ #include #include -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" class BaseInstance; class FileIgnoreProxy; diff --git a/launcher/ui/dialogs/ExportPackDialog.h b/launcher/ui/dialogs/ExportPackDialog.h index 399c7ac8..01f472e9 100644 --- a/launcher/ui/dialogs/ExportPackDialog.h +++ b/launcher/ui/dialogs/ExportPackDialog.h @@ -24,7 +24,7 @@ #include -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" #include "tasks/Task.h" class FileIgnoreProxy; diff --git a/launcher/ui/widgets/ProjectItemDelegate.h b/launcher/ui/widgets/ProjectItemDelegate.h index 924f00de..7a8cc914 100644 --- a/launcher/ui/widgets/ProjectItemDelegate.h +++ b/launcher/ui/widgets/ProjectItemDelegate.h @@ -22,27 +22,10 @@ #include #include -/* Extra data roles the project list models expose for the delegate below. - * - * Qt::UserRole itself is already taken: both search models return the - * platform's project id there, and existing code reads it. These start - * one past it. - * - * Qt::DisplayRole is deliberately not reused for the title. The default - * delegate paints DisplayRole, so leaving it as the plain name keeps the - * list readable if a view is ever shown without this delegate attached. */ -namespace ProjectItemRole -{ - enum Role { - /* QString - project name, drawn large on the first line. */ - Title = Qt::UserRole + 1, - /* QString - short summary, wrapped over at most two lines. */ - Description, - /* bool - already present in the target folder. Such rows are - * dimmed and tagged, because installing them again is a no-op. */ - Installed, - }; -} +/* For ProjectItemRole: the data roles this delegate reads off the model's + * data() when painting a row. Declared with the model rather than here, + * since the model is what produces them. */ +#include "modplatform/ContentProviderModel.h" /* Draws one search result: optional checkbox, icon, title, description. * From 48a2d7e17267dfc893f9c7faac278448724811ae Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:29:55 +0300 Subject: [PATCH 06/64] [phase-02] Add WCAG contrast math for the theme tokens The QML theme will be token-based, and every text-on-surface pair it ships has to clear WCAG AA. Those ratios need to be computed and asserted, not written into a table by hand -- hand-written ratios in this project's design notes have already turned out wrong more than once. theme/Contrast provides relative luminance, the contrast ratio, AA/AAA checks with the large-text variants, and compositeOver() for translucent tokens, which have no meaningful contrast until they are placed on something. It uses QColor only; nothing from QtWidgets or ui/. Decisions worth knowing about: - ratio() ignores alpha rather than silently compositing against an assumed background. Forgetting to composite should show up, not be papered over. - The thresholds are inclusive: WCAG says "at least", so 4.5:1 passes AA. - "Large text" is the caller's call. This has no font metrics, so deciding what counts as 18pt, or 14pt bold, belongs to the QML side. The test pins black on white at exactly 21:1, symmetry, the identity case, a hand-derived reference pair (pure red on white, 1.05/0.2626 = 3.998477), and both sides of every threshold. The boundary helpers reimplement the inverse sRGB transfer independently so the test is not the production code checking itself, and stay 0.1 away from each threshold: QColor's 16-bit channels can push a value built for an exact ratio to either side. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 8 ++ launcher/theme/Contrast.cpp | 76 ++++++++++++++ launcher/theme/Contrast.h | 87 +++++++++++++++ launcher/theme/Contrast_test.cpp | 175 +++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 launcher/theme/Contrast.cpp create mode 100644 launcher/theme/Contrast.h create mode 100644 launcher/theme/Contrast_test.cpp diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 44d4853c..ce7ee873 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -15,6 +15,10 @@ set(CORE_SOURCES core/AuthRequestDecorator.h core/UiHost.h + # WCAG 2.1 contrast math for the token-based theme. Confined to QtGui, + # same as the block above -- no QtWidgets, no ui/. + theme/Contrast.h + theme/Contrast.cpp modplatform/BlockedMod.h # LOGIC - Base classes and infrastructure @@ -1220,6 +1224,10 @@ add_unit_test(CustomTheme LIBS MeshMC_logic ) +add_unit_test(Contrast + SOURCES theme/Contrast_test.cpp + LIBS MeshMC_logic + ) add_unit_test(IconTheme SOURCES ui/themes/IconTheme_test.cpp diff --git a/launcher/theme/Contrast.cpp b/launcher/theme/Contrast.cpp new file mode 100644 index 00000000..72aea3e0 --- /dev/null +++ b/launcher/theme/Contrast.cpp @@ -0,0 +1,76 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "theme/Contrast.h" + +#include + +namespace Contrast +{ + + namespace + { + /* WCAG 2.1 SC 1.4.3: linearises a single sRGB channel already + * normalised to [0, 1]. The 0.03928 breakpoint and the two branches + * either side of it come straight from the spec -- there is no + * simpler formula that covers both of them at once. */ + qreal linearize(qreal channel) + { + return channel <= 0.03928 ? channel / 12.92 + : qPow((channel + 0.055) / 1.055, 2.4); + } + } + + qreal relativeLuminance(const QColor& color) + { + return 0.2126 * linearize(color.redF()) + + 0.7152 * linearize(color.greenF()) + + 0.0722 * linearize(color.blueF()); + } + + qreal ratio(const QColor& a, const QColor& b) + { + const qreal la = relativeLuminance(a); + const qreal lb = relativeLuminance(b); + const qreal lighter = qMax(la, lb); + const qreal darker = qMin(la, lb); + return (lighter + 0.05) / (darker + 0.05); + } + + bool meetsAA(const QColor& a, const QColor& b, bool largeText) + { + return ratio(a, b) >= (largeText ? 3.0 : 4.5); + } + + bool meetsAAA(const QColor& a, const QColor& b, bool largeText) + { + return ratio(a, b) >= (largeText ? 4.5 : 7.0); + } + + QColor compositeOver(const QColor& foreground, const QColor& background) + { + const qreal alpha = foreground.alphaF(); + const qreal behind = 1.0 - alpha; + return QColor::fromRgbF( + foreground.redF() * alpha + background.redF() * behind, + foreground.greenF() * alpha + background.greenF() * behind, + foreground.blueF() * alpha + background.blueF() * behind); + } + +} // namespace Contrast diff --git a/launcher/theme/Contrast.h b/launcher/theme/Contrast.h new file mode 100644 index 00000000..ba53e274 --- /dev/null +++ b/launcher/theme/Contrast.h @@ -0,0 +1,87 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +/* + * WCAG 2.1 contrast math for the token-based QML theme. + * + * Every colour token the theme exposes has to clear a minimum contrast + * against whatever it sits on, and the ratios written by hand into design + * notes for this project have already been wrong more than once. So a ratio + * is never a table entry here: it is computed from the WCAG formula and + * asserted against in Contrast_test.cpp instead of transcribed. + * + * Free functions rather than a class, because there is no state to hold -- + * only a pure conversion from colour to luminance and from luminance to + * ratio. QtGui only: nothing here may reach into QtWidgets or ui/, so both + * the core and the QML front end can use it. + */ +namespace Contrast +{ + + /** + * WCAG 2.1 relative luminance of @p color, in [0, 1]. + * + * Ignores alpha -- a translucent colour has no luminance of its own + * until it is known what it sits on. Run it through compositeOver() + * first if it carries transparency. + */ + qreal relativeLuminance(const QColor& color); + + /** + * WCAG 2.1 contrast ratio between @p a and @p b, in [1, 21]. + * + * The lighter of the two colours is always the numerator, so the result + * does not depend on argument order: ratio(a, b) == ratio(b, a). + */ + qreal ratio(const QColor& a, const QColor& b); + + /** + * Whether @p a on @p b clears the WCAG AA minimum: 4.5:1, or 3:1 when + * @p largeText (WCAG's definition of large: 18pt+, or 14pt+ bold -- + * classifying a token's text as one or the other is left to the caller, + * since this header has no notion of font metrics). + */ + bool meetsAA(const QColor& a, const QColor& b, bool largeText = false); + + /** + * Whether @p a on @p b clears the stricter WCAG AAA minimum: 7:1, or + * 4.5:1 when @p largeText. + */ + bool meetsAAA(const QColor& a, const QColor& b, bool largeText = false); + + /** + * Flattens @p foreground onto @p background using @p foreground's own + * alpha (standard "over" compositing), so the result is opaque and its + * contrast is actually meaningful. + * + * A token with alpha < 255 has no contrast in isolation -- the WCAG + * formula assumes two opaque colours -- so it has to be composited onto + * whatever it will actually render over before ratio(), meetsAA() or + * meetsAAA() say anything useful about it. @p background is treated as + * fully opaque regardless of its own alpha: compositing onto a second + * translucent layer would need that layer's own backdrop in turn, which + * is outside what this helper knows. + */ + QColor compositeOver(const QColor& foreground, const QColor& background); + +} // namespace Contrast diff --git a/launcher/theme/Contrast_test.cpp b/launcher/theme/Contrast_test.cpp new file mode 100644 index 00000000..af9e394d --- /dev/null +++ b/launcher/theme/Contrast_test.cpp @@ -0,0 +1,175 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include + +#include "theme/Contrast.h" + +/* + * Behavioural contract for Contrast. + * + * Design notes have hand-written the wrong WCAG ratio for a token more than + * once, so nothing here is allowed to compare against a literal that was not + * itself derived from the WCAG formula -- either the exact values (black on + * white, a colour on itself) or a pair worked out by hand in the comment + * next to the assertion. + */ + +namespace +{ + +/// Inverse of the WCAG sRGB transfer function used by Contrast::relativeLuminance(). +/// Reimplemented independently here, rather than reused from the production +/// code, so the boundary tests below do not just echo Contrast.cpp back at +/// itself. +qreal channelForLinear(qreal linear) +{ + return linear <= 0.0030186 ? linear * 12.92 + : 1.055 * qPow(linear, 1.0 / 2.4) - 0.055; +} + +/// A grey QColor(c, c, c) has relative luminance exactly equal to +/// channelForLinear's input, because the three WCAG coefficients +/// (0.2126 + 0.7152 + 0.0722) sum to 1. +QColor grayWithLuminance(qreal luminance) +{ + const qreal c = channelForLinear(luminance); + return QColor::fromRgbF(c, c, c); +} + +/// A grey against white with a chosen contrast ratio, solved from the WCAG +/// ratio formula with white's luminance fixed at 1: ratio = (1 + 0.05) / +/// (L + 0.05), so L = 1.05 / ratio - 0.05. +QColor grayOnWhiteWithRatio(qreal targetRatio) +{ + return grayWithLuminance(1.05 / targetRatio - 0.05); +} + +} // namespace + +class ContrastTest : public QObject +{ + Q_OBJECT + + private slots: + /// Black (L=0) against white (L=1): (1+0.05)/(0+0.05) = 21 exactly, up to + /// the decimal-to-binary rounding of the 0.2126/0.7152/0.0722 literals. + void test_blackOnWhiteIsMaximum() + { + QVERIFY(qAbs(Contrast::ratio(QColor(Qt::black), QColor(Qt::white)) - + 21.0) < 0.0001); + } + + /// Same pair, arguments swapped -- still 21, not 1/21. + void test_whiteOnBlackIsMaximumToo() + { + QVERIFY(qAbs(Contrast::ratio(QColor(Qt::white), QColor(Qt::black)) - + 21.0) < 0.0001); + } + + /// A colour against itself divides its own luminance by itself, so this + /// is exact regardless of what that luminance happens to be. + void test_colourAgainstItselfIsOne() + { + QCOMPARE(Contrast::ratio(QColor("#336699"), QColor("#336699")), 1.0); + QCOMPARE(Contrast::ratio(QColor(Qt::white), QColor(Qt::white)), 1.0); + QCOMPARE(Contrast::ratio(QColor(Qt::black), QColor(Qt::black)), 1.0); + } + + /// The lighter colour is always the numerator, whichever argument it + /// arrives as. + void test_ratioIsSymmetric() + { + const QColor a("#204080"); + const QColor b("#eeeecc"); + QCOMPARE(Contrast::ratio(a, b), Contrast::ratio(b, a)); + } + + /// Reference pair worked out by hand from the WCAG formula: pure red + /// (255, 0, 0) linearises R=1 to 1 and has G=B=0, so its luminance is + /// just the R coefficient, 0.2126. White's luminance is 1 (the three + /// coefficients sum to 1). ratio = (1 + 0.05) / (0.2126 + 0.05) = + /// 1.05 / 0.2626 = 3.998477 (long division to six places). + void test_knownReferencePair_redOnWhite() + { + const qreal r = Contrast::ratio(QColor(255, 0, 0), QColor(Qt::white)); + QVERIFY(qAbs(r - 3.998477) < 0.0001); + } + + /// AA normal text: 4.5:1. Margins of +-0.1 around the threshold keep the + /// check well clear of QColor's internal 16-bit-per-channel rounding + /// while still exercising the boundary rather than an arbitrary point. + void test_meetsAA_normalText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAA(white, grayOnWhiteWithRatio(4.6))); + QVERIFY(!Contrast::meetsAA(white, grayOnWhiteWithRatio(4.4))); + } + + /// AA large text relaxes the same boundary to 3:1. + void test_meetsAA_largeText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAA(white, grayOnWhiteWithRatio(3.1), true)); + QVERIFY(!Contrast::meetsAA(white, grayOnWhiteWithRatio(2.9), true)); + } + + /// AAA normal text: 7:1. + void test_meetsAAA_normalText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAAA(white, grayOnWhiteWithRatio(7.1))); + QVERIFY(!Contrast::meetsAAA(white, grayOnWhiteWithRatio(6.9))); + } + + /// AAA large text relaxes the same boundary to 4.5:1. + void test_meetsAAA_largeText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAAA(white, grayOnWhiteWithRatio(4.6), true)); + QVERIFY(!Contrast::meetsAAA(white, grayOnWhiteWithRatio(4.4), true)); + } + + /// A translucent foreground has no contrast of its own until it is + /// flattened onto what it sits on: 128/255 alpha white over black works + /// out to exactly (128, 128, 128), opaque. + void test_compositeOver_flattensTranslucentForeground() + { + const QColor result = Contrast::compositeOver( + QColor(255, 255, 255, 128), QColor(0, 0, 0)); + QCOMPARE(result.red(), 128); + QCOMPARE(result.green(), 128); + QCOMPARE(result.blue(), 128); + QCOMPARE(result.alpha(), 255); + } + + /// A fully opaque foreground has nothing behind it to blend in, so + /// compositing must be a no-op. + void test_compositeOver_opaqueForegroundIsUnchanged() + { + const QColor fg(30, 60, 90); + QCOMPARE(Contrast::compositeOver(fg, QColor(Qt::white)), fg); + } +}; + +QTEST_GUILESS_MAIN(ContrastTest) + +#include "Contrast_test.moc" From 365878e74fd5aa5edc3b2413601776ec9091507c Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:38:30 +0300 Subject: [PATCH 07/64] [phase-03] Split the core into a library that cannot link QtWidgets MeshMC_logic was one static library holding the core, the plugin host and the entire widget UI. It is now two: MeshMC_core instances, downloads, auth, mod platforms, settings, icons. Links QtCore/Gui/Network/NetworkAuth/Concurrent/Xml. Not QtWidgets, and it may not know the plugin host or ui/ exist. MeshMC_logic the plugin host and the widget UI, on top of the core. Keeps its name, so the application and the unit tests that link it need no change. The plugin host sits in MeshMC_logic for now rather than in a target of its own: PluginManager renders plugin-supplied widgets and includes MainWindow, so it and the widget UI depend on each other until the declarative plugin ABI replaces that. Leaving QtWidgets off the core's link line is not, by itself, enough to keep it out. A static library is never linked alone -- unresolved symbols are deferred to the final executable, which does link QtWidgets -- and on macOS every Qt framework sits in one directory, so #include resolves through the framework search path without the target. Both are real: probing the core with exactly that include compiled cleanly. So MeshMC_core_link_check links the whole core archive, every object whether referenced or not, into a program given nothing else. If any core object needs a QtWidgets symbol, it fails to link and the build breaks. Verified by adding a QWidget to tasks/Task.cpp: the build failed on QWidget::show() and its constructor and destructor, and passed again once reverted. It is part of `all` and is also a ctest named CoreLinksWithoutQtWidgets. Getting it to link surfaced what the text scans had missed: - minecraft/auth/flows/AuthFlow.cpp included -- angle brackets, which is why grepping for "Application.h" never found it -- and used nothing from it. Removed. - IconList, MMCIcon and DesktopServices were listed in the UI source group although the core calls into them and none of them use QtWidgets (QIcon and QDesktopServices are QtGui). Moved to ICONS_SOURCES and CORE_SOURCES. - MeshMCExternalUpdater's "Checking for updates..." QProgressDialog became UiHost::showBusy(), which returns a scoped indicator so it cannot be left on screen by an early return. MacSparkleUpdater.mm is core and imports Cocoa, so the Apple frameworks and Sparkle now attach to MeshMC_core (PUBLIC), and ThemeManager.mm inherits them. AppKit is Cocoa, not QtWidgets; the invariant is about the latter. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 77 +++++++++++++++++----- launcher/core/CoreLinkCheck.cpp | 27 ++++++++ launcher/core/UiHost.h | 16 +++++ launcher/minecraft/auth/flows/AuthFlow.cpp | 2 - launcher/ui/WidgetUiHost.cpp | 29 ++++++++ launcher/ui/WidgetUiHost.h | 2 + launcher/updater/MeshMCExternalUpdater.cpp | 18 ++--- 7 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 launcher/core/CoreLinkCheck.cpp diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index ce7ee873..c6f1c106 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -12,6 +12,10 @@ set(CORE_SOURCES # mention QtWidgets. core/LauncherContext.h core/LauncherContext.cpp + + # QDesktopServices lives in QtGui; opening a URL or a folder is not UI. + DesktopServices.h + DesktopServices.cpp core/AuthRequestDecorator.h core/UiHost.h @@ -282,6 +286,10 @@ set(ICONS_SOURCES # Icons System and related code icons/IconUtils.h icons/IconUtils.cpp + icons/MMCIcon.h + icons/MMCIcon.cpp + icons/IconList.h + icons/IconList.cpp ) # Support for Minecraft instances and launch @@ -763,7 +771,6 @@ set(LOGIC_SOURCES ${ATLAUNCHER_SOURCES} ${MODRINTH_SOURCES} ${CONTENT_DOWNLOAD_SOURCES} - ${PLUGIN_SOURCES} ) SET(MESHMC_SOURCES @@ -774,8 +781,6 @@ SET(MESHMC_SOURCES ApplicationMessage.cpp # GUI - general utilities - DesktopServices.h - DesktopServices.cpp VersionProxyModel.h VersionProxyModel.cpp HoeDown.h @@ -802,10 +807,6 @@ SET(MESHMC_SOURCES ${CMAKE_BINARY_DIR}/${MeshMC_AppBinaryName}.qrc # Icons - icons/MMCIcon.h - icons/MMCIcon.cpp - icons/IconList.h - icons/IconList.cpp # GUI - windows ui/GuiUtil.h @@ -1216,7 +1217,22 @@ if(WIN32) endif() # Add executable -add_library(MeshMC_logic STATIC ${LOGIC_SOURCES} ${MESHMC_SOURCES} ${MESHMC_UI} ${MESHMC_SHARED_UI} ${MESHMC_RESOURCES}) +# The launcher is two libraries. +# +# MeshMC_core is everything that works without a user interface: instances, +# downloads, authentication, mod platforms, settings. It must not link +# QtWidgets -- that is what lets a QML shell reuse it -- and it is not allowed +# to know the plugin host or anything under ui/ exists. +# +# MeshMC_logic is the plugin host and the widget user interface, built on top +# of the core. It keeps its old name so the application and the unit tests that +# link it need no change. The plugin host sits here rather than in the core for +# now: PluginManager renders plugin-supplied widgets and includes MainWindow, so +# it and the widget UI depend on each other until the declarative plugin ABI +# replaces that. +add_library(MeshMC_core STATIC ${LOGIC_SOURCES}) +add_library(MeshMC_logic STATIC ${PLUGIN_SOURCES} ${MESHMC_SOURCES} ${MESHMC_UI} ${MESHMC_SHARED_UI} ${MESHMC_RESOURCES}) +target_link_libraries(MeshMC_logic MeshMC_core) # Declared after MeshMC_logic because it links against it. add_unit_test(CustomTheme @@ -1241,7 +1257,7 @@ set_tests_properties(IconTheme PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORMTHEME=" ) -target_link_libraries(MeshMC_logic +target_link_libraries(MeshMC_core PUBLIC classparser nbt++ ZLIB::ZLIB @@ -1251,7 +1267,7 @@ target_link_libraries(MeshMC_logic ) if(MeshMC_DISABLE_JAVA_DOWNLOADER) - target_compile_definitions(MeshMC_logic PUBLIC MeshMC_DISABLE_JAVA_DOWNLOADER) + target_compile_definitions(MeshMC_core PUBLIC MeshMC_DISABLE_JAVA_DOWNLOADER) endif() # Keep the function/line of every log message in *all* configurations. Qt only @@ -1262,21 +1278,24 @@ endif() # ones in the application target. # Cost, knowingly accepted: __FILE__ is baked into the binary for each # translation unit that logs, so the binary grows and carries build paths. -target_compile_definitions(MeshMC_logic PUBLIC QT_MESSAGELOGCONTEXT) -target_link_libraries(MeshMC_logic +target_compile_definitions(MeshMC_core PUBLIC QT_MESSAGELOGCONTEXT) +target_link_libraries(MeshMC_core PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::NetworkAuth Qt${QT_VERSION_MAJOR}::Concurrent Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::Widgets LibArchive::LibArchive cmark::cmark rainbow LocalPeer::LocalPeer ) +# QtWidgets is linked here and only here. See MeshMC_core_link_check below for +# how that is enforced rather than merely intended. +target_link_libraries(MeshMC_logic Qt${QT_VERSION_MAJOR}::Widgets) + target_link_libraries(MeshMC_logic Qt6::OpenGL) # Plugin system needs dlopen/dlsym on Unix @@ -1299,7 +1318,9 @@ else() endif() if(APPLE) - target_link_libraries(MeshMC_logic + # PUBLIC on the core: MacSparkleUpdater.mm is core and imports Cocoa, and + # ThemeManager.mm in MeshMC_logic inherits it from there. + target_link_libraries(MeshMC_core PUBLIC "-framework AppKit" "-framework Foundation" objc @@ -1312,9 +1333,9 @@ if(MeshMC_SPARKLE_ENABLED) # PUBLIC: Application.cpp decides which updater to construct on the # strength of this define, and it is compiled into the application target # as well as into the logic library. - target_compile_definitions(MeshMC_logic PUBLIC MESHMC_SPARKLE_ENABLED) - target_include_directories(MeshMC_logic PRIVATE "${MACOSX_SPARKLE_DIR}") - target_link_libraries(MeshMC_logic ${SPARKLE_FRAMEWORK}) + target_compile_definitions(MeshMC_core PUBLIC MESHMC_SPARKLE_ENABLED) + target_include_directories(MeshMC_core PRIVATE "${MACOSX_SPARKLE_DIR}") + target_link_libraries(MeshMC_core PUBLIC ${SPARKLE_FRAMEWORK}) # Sparkle has to ship inside the bundle: it is loaded at runtime, and it # is what checks the signature of the update it installs. @@ -1323,6 +1344,28 @@ if(MeshMC_SPARKLE_ENABLED) USE_SOURCE_PERMISSIONS) endif() +######## The core must link without QtWidgets ######## + +# Not linking Qt::Widgets into MeshMC_core is not enough on its own to keep +# widgets out of it. A static library is never linked by itself -- unresolved +# symbols are left for the final executable, which does link QtWidgets through +# MeshMC_logic -- and on macOS all Qt frameworks sit in one directory, so +# #include resolves through the framework search path even +# without the target. Either way a widget dependency would slip in unnoticed. +# +# So link the core, whole, into a program that has nothing else. Every object +# in the archive is pulled in whether or not anything calls it, and the program +# is given nothing that could satisfy a QtWidgets symbol. If any core object +# needs one, this target fails to link and the build breaks, on every platform, +# in CI and locally alike. It is part of `all` on purpose. +add_executable(MeshMC_core_link_check core/CoreLinkCheck.cpp) +target_link_libraries(MeshMC_core_link_check PRIVATE + "$") + +# Listed as a test as well, so the invariant has a name in the ctest output +# and is not only visible as a link step when it fails. +add_test(NAME CoreLinksWithoutQtWidgets COMMAND MeshMC_core_link_check) + ######## QML user interface module ######## # Defined in its own directory so the module's resource aliases stay clean; see diff --git a/launcher/core/CoreLinkCheck.cpp b/launcher/core/CoreLinkCheck.cpp new file mode 100644 index 00000000..0a250519 --- /dev/null +++ b/launcher/core/CoreLinkCheck.cpp @@ -0,0 +1,27 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Intentionally empty. What matters is not what this program does but whether + * it links: see MeshMC_core_link_check in launcher/CMakeLists.txt. + */ +int main() +{ + return 0; +} diff --git a/launcher/core/UiHost.h b/launcher/core/UiHost.h index 6ba93090..82b9bc68 100644 --- a/launcher/core/UiHost.h +++ b/launcher/core/UiHost.h @@ -19,6 +19,8 @@ #pragma once +#include + #include #include #include @@ -51,10 +53,24 @@ class UiHost { public: + /* Proof that the launcher has not frozen while the caller blocks on + * something it cannot report progress for. The indication disappears when + * this object is destroyed, so it cannot be left showing on an early + * return. */ + class BusyIndicator + { + public: + virtual ~BusyIndicator() = default; + }; + enum class Severity { Information, Question, Warning, Critical }; virtual ~UiHost() = default; + /* Not a question -- nothing to answer and nothing to cancel. For work that + * blocks without being able to say how far along it is. */ + virtual std::unique_ptr showBusy(const QString& text) = 0; + /* Something the user only has to acknowledge. */ virtual void message(const QString& title, const QString& text, Severity severity) = 0; diff --git a/launcher/minecraft/auth/flows/AuthFlow.cpp b/launcher/minecraft/auth/flows/AuthFlow.cpp index aa493cf5..7ced191e 100644 --- a/launcher/minecraft/auth/flows/AuthFlow.cpp +++ b/launcher/minecraft/auth/flows/AuthFlow.cpp @@ -26,8 +26,6 @@ #include "AuthFlow.h" #include "katabasis/Globals.h" -#include - AuthFlow::AuthFlow(AccountData* data, QObject* parent) : AccountTask(data, parent) { diff --git a/launcher/ui/WidgetUiHost.cpp b/launcher/ui/WidgetUiHost.cpp index fe16b721..c9300b56 100644 --- a/launcher/ui/WidgetUiHost.cpp +++ b/launcher/ui/WidgetUiHost.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include "ui/dialogs/BlockedModsDialog.h" @@ -52,6 +53,34 @@ namespace } } // namespace +namespace +{ + /* Indeterminate and uncancellable: the caller has nothing to report and + * nothing it could abort. */ + class ProgressDialogBusy final : public UiHost::BusyIndicator + { + public: + explicit ProgressDialogBusy(const QString& text) + : m_dialog(text, QString(), 0, 0, QApplication::activeWindow()) + { + m_dialog.setWindowTitle(text); + m_dialog.setMinimumDuration(0); + m_dialog.setCancelButton(nullptr); + m_dialog.adjustSize(); + m_dialog.show(); + } + + private: + QProgressDialog m_dialog; + }; +} // namespace + +std::unique_ptr +WidgetUiHost::showBusy(const QString& text) +{ + return std::make_unique(text); +} + void WidgetUiHost::message(const QString& title, const QString& text, Severity severity) { diff --git a/launcher/ui/WidgetUiHost.h b/launcher/ui/WidgetUiHost.h index 1712d479..b5909350 100644 --- a/launcher/ui/WidgetUiHost.h +++ b/launcher/ui/WidgetUiHost.h @@ -32,6 +32,8 @@ class WidgetUiHost final : public UiHost { public: + std::unique_ptr showBusy(const QString& text) override; + void message(const QString& title, const QString& text, Severity severity) override; diff --git a/launcher/updater/MeshMCExternalUpdater.cpp b/launcher/updater/MeshMCExternalUpdater.cpp index 7a20dc39..63ed3171 100644 --- a/launcher/updater/MeshMCExternalUpdater.cpp +++ b/launcher/updater/MeshMCExternalUpdater.cpp @@ -23,7 +23,6 @@ #include #include #include -#include #include #include @@ -212,17 +211,12 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) m_checking = true; emit canCheckForUpdatesChanged(false); - // The check blocks, so the progress dialog exists to prove the launcher + // The check blocks, so the busy indication exists to prove the launcher // has not simply frozen. An automatic check gets none: nobody asked, and // a window stealing focus during startup is worse than no feedback. - QProgressDialog progress(tr("Checking for updates..."), QString(), 0, 0, - nullptr); - progress.setWindowTitle(tr("Checking for updates...")); - progress.setMinimumDuration(0); - progress.setCancelButton(nullptr); - progress.adjustSize(); + std::unique_ptr busy; if (triggeredByUser) - progress.show(); + busy = LAUNCHER->uiHost()->showBusy(tr("Checking for updates...")); QCoreApplication::processEvents(); QProcess proc; @@ -247,7 +241,7 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) qWarning() << "Updater: the check did not start within" << kStartTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); - progress.cancel(); + busy.reset(); LAUNCHER->uiHost()->message( tr("Update Check Failed"), tr("Failed to start after 5 seconds\nReason: %1.") @@ -265,7 +259,7 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) qWarning() << "Updater: the check did not finish within" << kFinishTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); - progress.cancel(); + busy.reset(); LAUNCHER->uiHost()->message( tr("Update Check Failed"), withDetails(tr("Updater failed to close 60 seconds\nReason: %1.") @@ -280,7 +274,7 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) const QByteArray stdOutput = proc.readAllStandardOutput(); const QByteArray stdError = proc.readAllStandardError(); - progress.cancel(); + busy.reset(); QCoreApplication::processEvents(); switch (exitCode) { From df08cdc3fe27f25b9d82839f70e1d42dcfe1faa9 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:49:46 +0300 Subject: [PATCH 08/64] [phase-08] Open a QML window on the core's real models The first time the QML side shows anything: QmlShell owns a QQmlApplicationEngine, hands it the core's InstanceList and loads Main.qml, now an ApplicationWindow listing every instance with its group. It is deliberately unstyled -- the point is the path from the core's models to the screen, with real data, before the design system lands on top. Both interfaces are always built; which one opens is decided at startup. MeshMC_QML_UI (OFF) picks the default and MESHMC_QML_UI=1/0 in the environment overrides it, so the two can be compared from one binary without rebuilding. Compiling the QML path unconditionally is what keeps it from rotting: CI builds it even while nobody opts in. If the QML fails to load, the launcher logs it and opens the widget window instead rather than leaving the user with nothing. The QML window is counted and closed through the same on_windowClose() path as MainWindow, so the launcher still quits when its last window goes. Models reach QML as required properties of the root window, not context properties: their types stay visible to the tooling and a missing one is a load error rather than a silent undefined. QmlShell::expose() is the single place that pins C++ ownership -- the engine would otherwise take any parentless QObject that crosses into JavaScript and delete a core model out from under the rest of the launcher. MESHMC_QML_SNAPSHOT= renders the real window into an image and exits. With QT_QPA_PLATFORM=offscreen it never touches a display, so it runs on a CI runner and can be diffed -- the basis for visual regression checks of the QML UI. It is also the only way screenshots of this work get taken: capturing the desktop picks up whatever else is on it. Build plumbing: - MeshMC_core publishes launcher/ as a PUBLIC include root. Core headers are included by their path under launcher/, and CMAKE_INCLUDE_CURRENT_DIR only covered targets declared in that directory -- not the QML module in launcher/qml/. - MeshMC_qml links MeshMC_core; MeshMC_logic links MeshMC_qml. - The executable imports the static QML plugin with qt_import_qml_plugins, for the same reason QmlModule_test does, and its link line switched to the keyword signature because CMake will not mix the two forms on one target. - QmlModule_test now supplies the root's required model; leaving it unset would itself be a load error, which is worth the test catching. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- CMakeLists.txt | 5 ++ launcher/Application.cpp | 43 ++++++++++++ launcher/Application.h | 5 ++ launcher/CMakeLists.txt | 24 ++++++- launcher/qml/CMakeLists.txt | 9 +++ launcher/qml/Main.qml | 77 ++++++++++++++++++--- launcher/qml/QmlModule_test.cpp | 9 ++- launcher/qml/QmlShell.cpp | 119 ++++++++++++++++++++++++++++++++ launcher/qml/QmlShell.h | 68 ++++++++++++++++++ 9 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 launcher/qml/QmlShell.cpp create mode 100644 launcher/qml/QmlShell.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f6fb4e91..8b2b18e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -154,6 +154,11 @@ set(QT_VERSION_MAJOR 6) # Still a cache variable so that scripts and CI invocations that pass # -DMeshMC_QT_VERSION_MAJOR=5 fail loudly instead of quietly building Qt 6 when # they asked for something else. +# The QML user interface is always built; this only picks which interface the +# launcher opens by default. MESHMC_QML_UI=1 or =0 in the environment overrides +# it at run time. +option(MeshMC_QML_UI "Open the QML user interface instead of the QtWidgets one by default (preview)" OFF) + set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (6 only)") set_property(CACHE MeshMC_QT_VERSION_MAJOR PROPERTY STRINGS "6") diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 00c93c71..0526bc45 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -21,6 +21,7 @@ #include "BuildConfig.h" #include "plugin/PluginAuthRequestDecorator.h" #include "ui/WidgetUiHost.h" +#include "qml/QmlShell.h" #include "plugin/PluginManager.h" #include "ui/MainWindow.h" @@ -2094,8 +2095,50 @@ void Application::registerGlobalSettingsPage(std::function creator) } } +namespace +{ + /* The QML user interface is a preview. It is always compiled -- so it + * cannot rot unnoticed -- but only shown when asked for. The build option + * MeshMC_QML_UI picks the default; MESHMC_QML_UI in the environment + * overrides it either way, so both interfaces can be compared from the + * same binary. */ + bool useQmlShell() + { +#ifdef MESHMC_QML_UI_DEFAULT + bool fallback = true; +#else + bool fallback = false; +#endif + const QByteArray env = qgetenv("MESHMC_QML_UI"); + if (env.isEmpty()) + return fallback; + return env != "0"; + } +} // namespace + MainWindow* Application::showMainWindow(bool minimized) { + if (useQmlShell()) { + if (!m_qmlShell) { + m_qmlShell = std::make_unique(); + /* Counted and closed through the same path as MainWindow, so the + * launcher quits when its last window goes, as it always has. */ + connect(m_qmlShell.get(), &QmlShell::closed, this, + &Application::on_windowClose); + if (m_qmlShell->show(minimized)) { + m_openWindows++; + return nullptr; + } + /* A QML load failure must not leave the user with nothing on + * screen: fall through to the widget window instead. */ + qWarning() << "QML shell failed to load; using the widget window"; + m_qmlShell.reset(); + } else { + m_qmlShell->show(minimized); + return nullptr; + } + } + if (m_mainWindow) { m_mainWindow->setWindowState(m_mainWindow->windowState() & ~Qt::WindowMinimized); diff --git a/launcher/Application.h b/launcher/Application.h index fa276abd..a22f553f 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -37,6 +37,7 @@ #include "core/LauncherContext.h" class LaunchController; +class QmlShell; class LocalPeer; class InstanceWindow; class InstanceSettingsPage; @@ -338,6 +339,10 @@ class Application : public QApplication, public LauncherContext * uiHost() promises never to return null. */ std::unique_ptr m_uiHost; + /* The QML user interface, when it is the one in use instead of + * MainWindow. See useQmlShell(). */ + std::unique_ptr m_qmlShell; + public: QString m_instanceIdToLaunch; QString m_serverToJoin; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index c6f1c106..c25ef382 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -1257,6 +1257,12 @@ set_tests_properties(IconTheme PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORMTHEME=" ) +# Core headers are included by their path under launcher/ ("core/UiHost.h", +# "InstanceList.h"), so that directory is part of the core's interface. Stated +# here rather than left to CMAKE_INCLUDE_CURRENT_DIR, which only covers targets +# declared in this directory -- the QML module is declared in launcher/qml/. +target_include_directories(MeshMC_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(MeshMC_core PUBLIC classparser nbt++ @@ -1296,6 +1302,13 @@ target_link_libraries(MeshMC_core PUBLIC # how that is enforced rather than merely intended. target_link_libraries(MeshMC_logic Qt${QT_VERSION_MAJOR}::Widgets) +# Application decides at startup whether to show MainWindow or the QML shell, +# so it needs the shell compiled in either way. See useQmlShell(). +target_link_libraries(MeshMC_logic MeshMC_qml) +if(MeshMC_QML_UI) + target_compile_definitions(MeshMC_logic PRIVATE MESHMC_QML_UI_DEFAULT) +endif() + target_link_libraries(MeshMC_logic Qt6::OpenGL) # Plugin system needs dlopen/dlsym on Unix @@ -1391,8 +1404,15 @@ add_unit_test(QmlModule qt_import_qml_plugins(QmlModule_test) add_executable(${MeshMC_Name} MACOSX_BUNDLE WIN32 main.cpp ${MESHMC_RCS}) -target_link_libraries(${MeshMC_Name} MeshMC_logic) -target_link_libraries(${MeshMC_Name} MeshMC_qml) +# Keyword signature: qt_import_qml_plugins below links with PRIVATE, and CMake +# refuses to mix the two forms on one target. +target_link_libraries(${MeshMC_Name} PRIVATE MeshMC_logic) + +# Same reason as QmlModule_test: MeshMC_qml is a static QML module with a +# separate plugin, and a plain add_executable does not import it. Without this +# the launcher links and then fails at runtime with 'plugin "MeshMC_qmlplugin" +# not found' the moment the QML shell loads. +qt_import_qml_plugins(${MeshMC_Name}) # Export all symbols so dlopen()'ed .mmco plugins can resolve launcher symbols at runtime set_target_properties(${MeshMC_Name} PROPERTIES ENABLE_EXPORTS ON) diff --git a/launcher/qml/CMakeLists.txt b/launcher/qml/CMakeLists.txt index 4fc84bd3..a1d84440 100644 --- a/launcher/qml/CMakeLists.txt +++ b/launcher/qml/CMakeLists.txt @@ -28,7 +28,16 @@ qt_add_qml_module(MeshMC_qml Main.qml ) +# The C++ that drives the QML: the engine owner and whatever it hands over. +target_sources(MeshMC_qml PRIVATE + QmlShell.h + QmlShell.cpp +) + +# The QML side reads the core's models. It does not see QtWidgets: MeshMC_core +# cannot link them, and nothing here asks for them. target_link_libraries(MeshMC_qml PUBLIC + MeshMC_core Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::QuickControls2 ) diff --git a/launcher/qml/Main.qml b/launcher/qml/Main.qml index b2dde40c..397f2cb7 100644 --- a/launcher/qml/Main.qml +++ b/launcher/qml/Main.qml @@ -3,22 +3,79 @@ // SPDX-License-Identifier: Apache-2.0 import QtQuick +import QtQuick.Controls /* - * Placeholder root of the QML user interface. + * Root of the QML user interface. * - * At this stage the launcher still runs the QtWidgets MainWindow; this exists - * so that the QML module, its resource prefix and the qmlcachegen step are - * built and covered by a test from the first commit onwards, rather than - * appearing all at once later. QmlModule_test instantiates it headlessly. + * Deliberately unstyled for now: it exists to prove the path from the core's + * models to the screen with real data, before the design system is layered on. + * Everything it shows arrives as a required property set by QmlShell, so a + * missing model is a load error rather than an empty window. */ -Item { +ApplicationWindow { id: root - // Read by QmlModule_test to prove the component really instantiated - // rather than silently resolving to a default-constructed Item. + /* The instance list, straight from the core (InstanceList). Its named + * roles -- name, iconKey, group, instanceId, isRunning... -- are what the + * delegate binds to. */ + required property var instanceModel + + // Read by QmlModule_test to prove this component, and not some default, + // was instantiated. readonly property string moduleName: "MeshMC" - implicitWidth: 960 - implicitHeight: 600 + width: 1100 + height: 700 + minimumWidth: 720 + minimumHeight: 480 + title: "MeshMC" + + GridView { + id: grid + + anchors.fill: parent + anchors.margins: 16 + model: root.instanceModel + cellWidth: 168 + cellHeight: 96 + clip: true + + delegate: Item { + id: tile + + required property string name + required property string group + + width: grid.cellWidth - 8 + height: grid.cellHeight - 8 + + Column { + anchors.centerIn: parent + spacing: 4 + + Label { + anchors.horizontalCenter: parent.horizontalCenter + width: tile.width - 16 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + text: tile.name + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + opacity: 0.6 + font.pixelSize: 11 + text: tile.group + visible: text.length > 0 + } + } + } + + Label { + anchors.centerIn: parent + visible: grid.count === 0 + opacity: 0.6 + text: qsTr("No instances yet") + } + } } diff --git a/launcher/qml/QmlModule_test.cpp b/launcher/qml/QmlModule_test.cpp index f46b3671..c3397e92 100644 --- a/launcher/qml/QmlModule_test.cpp +++ b/launcher/qml/QmlModule_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -58,7 +59,13 @@ class QmlModuleTest : public QObject qPrintable(QStringLiteral("component not ready: %1") .arg(component.errorString()))); - std::unique_ptr root(component.create()); + /* The root requires the instance model; an empty stand-in is enough + * to prove the component loads, and a required property left unset + * would itself be a load error worth catching here. */ + QStandardItemModel instances; + std::unique_ptr root(component.createWithInitialProperties( + {{QStringLiteral("instanceModel"), + QVariant::fromValue(&instances)}})); QVERIFY2(root != nullptr, qPrintable(component.errorString())); /* Guards against the component resolving to something default diff --git a/launcher/qml/QmlShell.cpp b/launcher/qml/QmlShell.cpp new file mode 100644 index 00000000..5192ec58 --- /dev/null +++ b/launcher/qml/QmlShell.cpp @@ -0,0 +1,119 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "qml/QmlShell.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "InstanceList.h" +#include "core/LauncherContext.h" + +namespace +{ + /* Spelled out rather than loadFromModule(), which is Qt 6.5+; the floor is + * 6.4. The module sets RESOURCE_PREFIX "/qt/qml" so this path is stable. */ + const QUrl kRootUrl(QStringLiteral("qrc:/qt/qml/MeshMC/Main.qml")); +} // namespace + +QmlShell::QmlShell(QObject* parent) : QObject(parent) {} + +QmlShell::~QmlShell() = default; + +QObject* QmlShell::expose(QObject* object) +{ + if (object) { + QQmlEngine::setObjectOwnership(object, QQmlEngine::CppOwnership); + } + return object; +} + +QVariantMap QmlShell::rootProperties() const +{ + QVariantMap props; + props.insert(QStringLiteral("instanceModel"), + QVariant::fromValue(expose(LAUNCHER->instances().get()))); + return props; +} + +bool QmlShell::show(bool minimized) +{ + if (m_window) { + m_window->showNormal(); + m_window->raise(); + m_window->requestActivate(); + return true; + } + + m_engine = std::make_unique(); + m_engine->addImportPath(QStringLiteral("qrc:/qt/qml")); + m_engine->setInitialProperties(rootProperties()); + m_engine->load(kRootUrl); + + const auto roots = m_engine->rootObjects(); + m_window = roots.isEmpty() ? nullptr : qobject_cast(roots.first()); + if (!m_window) { + qCritical() << "QML shell: the root object at" << kRootUrl + << "failed to load or is not a window"; + m_engine.reset(); + return false; + } + + connect(m_window, &QWindow::visibleChanged, this, [this](bool visible) { + if (!visible) + emit closed(); + }); + + if (minimized) + m_window->showMinimized(); + else + m_window->show(); + + scheduleSnapshotIfRequested(); + return true; +} + +void QmlShell::scheduleSnapshotIfRequested() +{ + /* MESHMC_QML_SNAPSHOT= renders the real window, with the real + * models, into an image and exits. Combined with QT_QPA_PLATFORM=offscreen + * it never touches a display, so it can run on a CI runner and be diffed: + * this is what visual regression checks of the QML UI are built on. + * + * The delay lets layouts settle and asynchronously loaded images arrive; + * grabWindow() then forces a render of whatever is current. */ + const QString path = qEnvironmentVariable("MESHMC_QML_SNAPSHOT"); + if (path.isEmpty()) + return; + + QTimer::singleShot(750, this, [this, path]() { + const QImage image = m_window->grabWindow(); + const bool saved = !image.isNull() && image.save(path); + if (saved) + qInfo() << "QML shell: snapshot written to" << path << image.size(); + else + qCritical() << "QML shell: could not write snapshot to" << path; + QCoreApplication::exit(saved ? 0 : 1); + }); +} diff --git a/launcher/qml/QmlShell.h b/launcher/qml/QmlShell.h new file mode 100644 index 00000000..2c2a0918 --- /dev/null +++ b/launcher/qml/QmlShell.h @@ -0,0 +1,68 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include + +class QQmlApplicationEngine; +class QQuickWindow; + +/* + * The QML user interface: owns the engine, hands the core's models to it and + * loads the root window. + * + * Everything the QML side sees is passed in as a required property of the + * root window rather than set as a context property. That keeps each object's + * type visible to the QML tooling, makes a missing one a load error instead of + * a silent undefined, and leaves exactly one place -- expose() -- where + * ownership is decided. + */ +class QmlShell : public QObject +{ + Q_OBJECT + + public: + explicit QmlShell(QObject* parent = nullptr); + ~QmlShell() override; + + /* Loads the root window on first call; raises it on later ones. Returns + * false if the QML failed to load, in which case the reasons have already + * been logged. */ + bool show(bool minimized = false); + + /* Hands a C++-owned object to QML. The engine takes ownership of any + * QObject without a parent that crosses into JavaScript, and would delete + * core models out from under the rest of the launcher; this pins them. */ + static QObject* expose(QObject* object); + + signals: + /* Emitted when the user closes the root window. */ + void closed(); + + private: + QVariantMap rootProperties() const; + void scheduleSnapshotIfRequested(); + + std::unique_ptr m_engine; + QQuickWindow* m_window = nullptr; +}; From d6ca191e90767f2e2b9bbbc18cb039dcb70a37eb Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:50:06 +0300 Subject: [PATCH 09/64] [phase-06] Define the colour tokens both themes are built from ThemePalette is the value type the QML theme reads: 35 semantic colour tokens (surfaces, text, accent, lines, status, interaction, overlays), each a Q_PROPERTY so QML can bind to it, with Mesh Dark and Mesh Light as the two shipped palettes. Names describe a role, never a hue -- "accentText", not "cyan600" -- so a theme can change a colour without every use of it lying. It is core (QtCore/QtGui only) and its test links MeshMC_core alone, which proves it. The brand inputs came from the repository rather than taste: the cyan accent and the rose from the logo SVG, the green from the legacy GreenDark theme. Two of those three failed WCAG AA as they stand, and the test is what caught it: - The logo's rose (#FF003C) as danger text on its own tinted background is 4.42:1 on Mesh Dark -- just under 4.5 -- and about 3.4:1 on Mesh Light. It is #FF5C79 on dark and #B80035 on light. - The legacy green (#96DB59) is far too light for text on Mesh Light; that theme uses #1E6823. - The light theme's accent (#00798F) is 4.49:1 as text on the canvas, so accentText there is one step darker (#005F73) while the accent fill keeps the brand value. Every ratio is measured with theme/Contrast and asserted, never typed into a table: primary and secondary text >= 4.5 on canvas, surface and raised surface; tertiary >= 3.0 (it is for large or supplementary text, and says so); text on accent, on selection, on tooltips and on each status tint >= 4.5; and borderStrong and focusRing >= 3.0 against canvas and surface (WCAG 1.4.11, non-text contrast). The test prints the measured table so the numbers quoted anywhere else can come from it. The one exception is deliberate and documented: `border` is an 8% hairline for decoration and is not held to 3:1. Anything that has to be perceived as a boundary uses borderStrong. Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 8 ++ launcher/theme/ThemePalette.cpp | 176 ++++++++++++++++++++++++++ launcher/theme/ThemePalette.h | 182 +++++++++++++++++++++++++++ launcher/theme/ThemePalette_test.cpp | 170 +++++++++++++++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 launcher/theme/ThemePalette.cpp create mode 100644 launcher/theme/ThemePalette.h create mode 100644 launcher/theme/ThemePalette_test.cpp diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index c25ef382..682b5d7b 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -23,6 +23,8 @@ set(CORE_SOURCES # same as the block above -- no QtWidgets, no ui/. theme/Contrast.h theme/Contrast.cpp + theme/ThemePalette.h + theme/ThemePalette.cpp modplatform/BlockedMod.h # LOGIC - Base classes and infrastructure @@ -1245,6 +1247,12 @@ add_unit_test(Contrast LIBS MeshMC_logic ) +# Links the core alone, not MeshMC_logic: the palette is core and this proves it. +add_unit_test(ThemePalette + SOURCES theme/ThemePalette_test.cpp + LIBS MeshMC_core + ) + add_unit_test(IconTheme SOURCES ui/themes/IconTheme_test.cpp LIBS MeshMC_logic diff --git a/launcher/theme/ThemePalette.cpp b/launcher/theme/ThemePalette.cpp new file mode 100644 index 00000000..ac0ad160 --- /dev/null +++ b/launcher/theme/ThemePalette.cpp @@ -0,0 +1,176 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "theme/ThemePalette.h" + +bool ThemePalette::operator==(const ThemePalette& other) const +{ + return canvas == other.canvas && surface == other.surface && + surfaceRaised == other.surfaceRaised && + surfaceOverlay == other.surfaceOverlay && + surfaceSunken == other.surfaceSunken && + + textPrimary == other.textPrimary && + textSecondary == other.textSecondary && + textTertiary == other.textTertiary && + textDisabled == other.textDisabled && + textOnAccent == other.textOnAccent && + + accent == other.accent && accentHover == other.accentHover && + accentPressed == other.accentPressed && + accentSubtle == other.accentSubtle && + accentText == other.accentText && + + border == other.border && borderStrong == other.borderStrong && + divider == other.divider && focusRing == other.focusRing && + + success == other.success && successSubtle == other.successSubtle && + warning == other.warning && warningSubtle == other.warningSubtle && + danger == other.danger && dangerSubtle == other.dangerSubtle && + info == other.info && infoSubtle == other.infoSubtle && + + hoverOverlay == other.hoverOverlay && + pressedOverlay == other.pressedOverlay && + selection == other.selection && + selectionText == other.selectionText && + + scrim == other.scrim && shadow == other.shadow && + tooltipBackground == other.tooltipBackground && + tooltipText == other.tooltipText; +} + +bool ThemePalette::operator!=(const ThemePalette& other) const +{ + return !(*this == other); +} + +ThemePalette ThemePalette::meshDark() +{ + ThemePalette p; + + p.canvas = QColor(0x0B, 0x0E, 0x13); + p.surface = QColor(0x12, 0x16, 0x1E); + p.surfaceRaised = QColor(0x1B, 0x21, 0x2C); + p.surfaceOverlay = QColor(0x24, 0x2C, 0x39); + p.surfaceSunken = QColor(0x07, 0x09, 0x11); + + p.textPrimary = QColor(0xF3, 0xF5, 0xF8); + p.textSecondary = QColor(0xB7, 0xC0, 0xCC); + p.textTertiary = QColor(0x87, 0x91, 0xA1); + p.textDisabled = QColor(0x5A, 0x64, 0x72); + p.textOnAccent = QColor(0x00, 0x23, 0x2A); + + // The logo's cyan, used as-is: it is already bright enough to carry + // 4.5:1 as text/icon colour on this theme's dark surfaces. + p.accent = QColor(0x00, 0xE5, 0xFF); + p.accentHover = QColor(0x3E, 0xEB, 0xFF); + p.accentPressed = QColor(0x00, 0xB3, 0xC9); + p.accentSubtle = QColor(0x00, 0xE5, 0xFF, 41); // ~16% opacity + p.accentText = p.accent; + + p.border = QColor(0xFF, 0xFF, 0xFF, 20); // ~8% opacity, see header comment + p.borderStrong = QColor(0x69, 0x74, 0x8A); + p.divider = QColor(0xFF, 0xFF, 0xFF, 20); // ~8% opacity + p.focusRing = p.accent; + + // success keeps the legacy theme's green close to as-is -- against a + // dark surface it already clears the ratio below with room to spare. + p.success = QColor(0x96, 0xDB, 0x59); + p.successSubtle = QColor(0x96, 0xDB, 0x59, 41); // ~16% opacity + p.warning = QColor(0xFF, 0xB4, 0x54); + p.warningSubtle = QColor(0xFF, 0xB4, 0x54, 41); // ~16% opacity + // danger is the logo's rose, lightened from #FF003C: the raw brand hue + // (relative luminance 0.216) falls just short (4.42:1, see the report) + // against its own subtle background over a dark surface, so it is + // pushed lighter until it clears 4.5:1. + p.danger = QColor(0xFF, 0x5C, 0x79); + p.dangerSubtle = QColor(0xFF, 0x5C, 0x79, 41); // ~16% opacity + p.info = QColor(0x5A, 0xB8, 0xFF); + p.infoSubtle = QColor(0x5A, 0xB8, 0xFF, 41); // ~16% opacity + + p.hoverOverlay = QColor(0xFF, 0xFF, 0xFF, 15); // ~6% opacity + p.pressedOverlay = QColor(0xFF, 0xFF, 0xFF, 31); // ~12% opacity + p.selection = QColor(0x0F, 0x46, 0x50); + p.selectionText = p.textPrimary; + + p.scrim = QColor(0x00, 0x00, 0x00, 140); // ~55% opacity + p.shadow = QColor(0x00, 0x00, 0x00, 89); // ~35% opacity + p.tooltipBackground = QColor(0x20, 0x26, 0x32); + p.tooltipText = p.textPrimary; + + return p; +} + +ThemePalette ThemePalette::meshLight() +{ + ThemePalette p; + + p.canvas = QColor(0xEE, 0xF1, 0xF5); + p.surface = QColor(0xF7, 0xF9, 0xFC); + p.surfaceRaised = QColor(0xFB, 0xFC, 0xFE); + p.surfaceOverlay = QColor(0xFF, 0xFF, 0xFF); + p.surfaceSunken = QColor(0xE3, 0xE7, 0xED); + + p.textPrimary = QColor(0x12, 0x16, 0x1D); + p.textSecondary = QColor(0x45, 0x4C, 0x58); + p.textTertiary = QColor(0x5C, 0x64, 0x72); + p.textDisabled = QColor(0x9A, 0xA2, 0xAF); + p.textOnAccent = QColor(0xFF, 0xFF, 0xFF); + + // The logo's cyan, darkened for a light surface (as specified). + p.accent = QColor(0x00, 0x79, 0x8F); + p.accentHover = QColor(0x00, 0x63, 0x7A); + p.accentPressed = QColor(0x00, 0x4E, 0x61); + p.accentSubtle = QColor(0x00, 0x79, 0x8F, 31); // ~12% opacity + // Darkened further than the surface `accent`: #00798F on its own is + // 4.49:1 against canvas, just under the 4.5:1 bar (see the report), so + // accentText goes one step darker along the same hue. + p.accentText = QColor(0x00, 0x5F, 0x73); + + p.border = QColor(0x00, 0x00, 0x00, 20); // ~8% opacity, see header comment + p.borderStrong = QColor(0x6B, 0x72, 0x80); + p.divider = QColor(0x00, 0x00, 0x00, 20); // ~8% opacity + p.focusRing = p.accent; + + // Every status colour below is the brand/legacy hue driven dark enough + // to clear 4.5:1 against its own subtle tint over a near-white surface + // -- the light-theme mirror of what dark theme needed brightened. + p.success = QColor(0x1E, 0x68, 0x23); // hue of the legacy #96DB59, darkened + p.successSubtle = QColor(0x1E, 0x68, 0x23, 31); // ~12% opacity + p.warning = QColor(0x8A, 0x53, 0x00); + p.warningSubtle = QColor(0x8A, 0x53, 0x00, 31); // ~12% opacity + p.danger = QColor(0xB8, 0x00, 0x35); // hue of the logo's #FF003C, darkened + p.dangerSubtle = QColor(0xB8, 0x00, 0x35, 31); // ~12% opacity + p.info = QColor(0x0A, 0x58, 0xAF); + p.infoSubtle = QColor(0x0A, 0x58, 0xAF, 31); // ~12% opacity + + p.hoverOverlay = QColor(0x00, 0x00, 0x00, 13); // ~5% opacity + p.pressedOverlay = QColor(0x00, 0x00, 0x00, 26); // ~10% opacity + p.selection = QColor(0xD6, 0xEE, 0xF2); + p.selectionText = QColor(0x00, 0x40, 0x4D); + + p.scrim = QColor(0x00, 0x00, 0x00, 115); // ~45% opacity + p.shadow = QColor(0x00, 0x00, 0x00, 51); // ~20% opacity + // Inverted relative to the theme, like most tooltips: a dark chip reads + // clearly no matter which surface it floats over. + p.tooltipBackground = p.textPrimary; + p.tooltipText = QColor(0xF5, 0xF7, 0xFA); + + return p; +} diff --git a/launcher/theme/ThemePalette.h b/launcher/theme/ThemePalette.h new file mode 100644 index 00000000..b17cb580 --- /dev/null +++ b/launcher/theme/ThemePalette.h @@ -0,0 +1,182 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +/* + * The semantic colour tokens for the token-based QML theme -- "modern + * launcher": dark-first, soft, with a strong accent. + * + * Every field is named for what it means (textPrimary, danger, accent...), + * never for what it looks like -- there is no "cyan500" here, because a QML + * component should ask for the role a colour plays, not memorise which raw + * hue currently fills it. That is also why meshDark() and meshLight() are + * the same token set with different values rather than two unrelated + * colour lists: any component that binds to a token once works under + * either theme without caring which one is active. + * + * Q_GADGET rather than QObject: a palette carries no identity and emits no + * signals, it is a value copied around the way a QColor is, and MEMBER + * properties let QML read every token without a getter written per field. + * + * Nothing in this header hand-types a contrast ratio. What each token has + * to clear against what it sits on is asserted in ThemePalette_test.cpp + * against Contrast::ratio() and Contrast::compositeOver(), not transcribed + * into a comment where it could quietly go stale. + */ +class ThemePalette +{ + Q_GADGET + + Q_PROPERTY(QColor canvas MEMBER canvas) + Q_PROPERTY(QColor surface MEMBER surface) + Q_PROPERTY(QColor surfaceRaised MEMBER surfaceRaised) + Q_PROPERTY(QColor surfaceOverlay MEMBER surfaceOverlay) + Q_PROPERTY(QColor surfaceSunken MEMBER surfaceSunken) + + Q_PROPERTY(QColor textPrimary MEMBER textPrimary) + Q_PROPERTY(QColor textSecondary MEMBER textSecondary) + Q_PROPERTY(QColor textTertiary MEMBER textTertiary) + Q_PROPERTY(QColor textDisabled MEMBER textDisabled) + Q_PROPERTY(QColor textOnAccent MEMBER textOnAccent) + + Q_PROPERTY(QColor accent MEMBER accent) + Q_PROPERTY(QColor accentHover MEMBER accentHover) + Q_PROPERTY(QColor accentPressed MEMBER accentPressed) + Q_PROPERTY(QColor accentSubtle MEMBER accentSubtle) + Q_PROPERTY(QColor accentText MEMBER accentText) + + Q_PROPERTY(QColor border MEMBER border) + Q_PROPERTY(QColor borderStrong MEMBER borderStrong) + Q_PROPERTY(QColor divider MEMBER divider) + Q_PROPERTY(QColor focusRing MEMBER focusRing) + + Q_PROPERTY(QColor success MEMBER success) + Q_PROPERTY(QColor successSubtle MEMBER successSubtle) + Q_PROPERTY(QColor warning MEMBER warning) + Q_PROPERTY(QColor warningSubtle MEMBER warningSubtle) + Q_PROPERTY(QColor danger MEMBER danger) + Q_PROPERTY(QColor dangerSubtle MEMBER dangerSubtle) + Q_PROPERTY(QColor info MEMBER info) + Q_PROPERTY(QColor infoSubtle MEMBER infoSubtle) + + Q_PROPERTY(QColor hoverOverlay MEMBER hoverOverlay) + Q_PROPERTY(QColor pressedOverlay MEMBER pressedOverlay) + Q_PROPERTY(QColor selection MEMBER selection) + Q_PROPERTY(QColor selectionText MEMBER selectionText) + + Q_PROPERTY(QColor scrim MEMBER scrim) + Q_PROPERTY(QColor shadow MEMBER shadow) + Q_PROPERTY(QColor tooltipBackground MEMBER tooltipBackground) + Q_PROPERTY(QColor tooltipText MEMBER tooltipText) + + public: + /* Elevation, darkest/most-recessed to lightest/most-elevated in both + * themes: surfaceSunken sits below canvas (inset fields), surface holds + * ordinary panels, surfaceRaised is for cards, surfaceOverlay is for + * popovers and modals -- the layer furthest off the canvas. */ + QColor canvas; + QColor surface; + QColor surfaceRaised; + QColor surfaceOverlay; + QColor surfaceSunken; + + QColor textPrimary; + QColor textSecondary; + + /* WCAG AA only requires 3:1 for text this size or larger (18pt+, or + * 14pt+ bold) -- callers must not reach for textTertiary on ordinary + * body copy, since it is only guaranteed to clear the large-text + * minimum, not the normal-text one. */ + QColor textTertiary; + + /* No contrast minimum applies: a disabled control is meant to read as + * unavailable, not as dim body text held to the same bar. */ + QColor textDisabled; + + /* Text painted on top of a filled `accent` surface, e.g. a primary + * button's label. */ + QColor textOnAccent; + + QColor accent; + QColor accentHover; + QColor accentPressed; + + /* Tinted background, not a solid fill -- e.g. a selected filter chip. + * Translucent, so it always reads correctly composited over whatever + * surface it is painted on rather than only over one hard-coded colour. */ + QColor accentSubtle; + + /* The accent hue used AS text/icon colour directly on canvas or + * surface (a link, an active-tab label) -- kept separate from `accent` + * because a colour bright enough to read as a strong filled surface is + * not necessarily the shade that clears 4.5:1 as text on that theme's + * background, and vice versa. */ + QColor accentText; + + /* Decorative hairline. It is intentionally allowed to fall under the + * WCAG 1.4.11 non-text minimum of 3:1 (see the measured ratio in + * ThemePalette_test.cpp) because it only separates two surfaces that + * already read as distinct regions on their own, rather than carrying + * information by itself. Anything that must stay perceivable on its + * own -- an input outline, a required separator -- uses borderStrong + * instead, which the test does hold to 3:1. */ + QColor border; + QColor borderStrong; + QColor divider; + QColor focusRing; + + QColor success; + QColor successSubtle; + QColor warning; + QColor warningSubtle; + QColor danger; + QColor dangerSubtle; + QColor info; + QColor infoSubtle; + + QColor hoverOverlay; + QColor pressedOverlay; + + /* Solid fills, not translucent overlays -- a selected row's background + * and a tooltip's background need to carry their own contrast without + * depending on what happens to be behind them. */ + QColor selection; + QColor selectionText; + + /* Translucent black/white, meant to sit behind a modal (scrim) or + * under a raised surface (shadow). Neither is measured for text + * contrast -- nothing is ever read directly off them. */ + QColor scrim; + QColor shadow; + QColor tooltipBackground; + QColor tooltipText; + + static ThemePalette meshDark(); + static ThemePalette meshLight(); + + bool operator==(const ThemePalette& other) const; + bool operator!=(const ThemePalette& other) const; +}; + +Q_DECLARE_METATYPE(ThemePalette) diff --git a/launcher/theme/ThemePalette_test.cpp b/launcher/theme/ThemePalette_test.cpp new file mode 100644 index 00000000..62358f2f --- /dev/null +++ b/launcher/theme/ThemePalette_test.cpp @@ -0,0 +1,170 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "theme/Contrast.h" +#include "theme/ThemePalette.h" + +/* + * Behavioural contract for ThemePalette. + * + * Every ratio a token has to clear is computed here from Contrast::ratio() + * and Contrast::compositeOver(), the same way design notes should read + * them -- never typed by hand, because a hand-typed ratio is exactly what + * has gone stale on this project before (see Contrast_test.cpp). Every + * checked pair is also printed with qInfo() so the numbers going into + * design notes come from running this test, not from eyeballing hex codes. + */ + +namespace +{ + +/// One row of the printed ratio table plus the assertion it backs. Kept as +/// a free function rather than repeating QVERIFY2 at each call site, since +/// the same "measure, print, assert" shape recurs for every token pair +/// below. +void expectRatio(const QColor& foreground, const QColor& background, + qreal minimum, const QString& label) +{ + const qreal measured = Contrast::ratio(foreground, background); + qInfo("%-52s %6.3f (>= %.2f)", qPrintable(label), measured, minimum); + QVERIFY2(measured >= minimum, + qPrintable(QString("%1: measured %2, need >= %3") + .arg(label) + .arg(measured) + .arg(minimum))); +} + +/// Runs every contrast requirement from the ThemePalette task against one +/// theme. Shared between test_meshDark() and test_meshLight() so the two +/// themes are held to identical requirements rather than two hand-copied +/// lists that could drift apart. +void checkTheme(const ThemePalette& p, const QString& themeName) +{ + qInfo().noquote() << "----" << themeName << "----"; + + const QList> textSurfaces = { + { "canvas", p.canvas }, + { "surface", p.surface }, + { "surfaceRaised", p.surfaceRaised }, + }; + + for (const auto& s : textSurfaces) { + expectRatio(p.textPrimary, s.second, 4.5, + themeName + " textPrimary on " + s.first); + expectRatio(p.textSecondary, s.second, 4.5, + themeName + " textSecondary on " + s.first); + // Large/secondary text only -- see the comment on textTertiary in + // ThemePalette.h -- so 3.0 rather than the 4.5 used just above. + expectRatio(p.textTertiary, s.second, 3.0, + themeName + " textTertiary on " + s.first); + } + + expectRatio(p.textOnAccent, p.accent, 4.5, + themeName + " textOnAccent on accent"); + expectRatio(p.accentText, p.canvas, 4.5, + themeName + " accentText on canvas"); + expectRatio(p.accentText, p.surface, 4.5, + themeName + " accentText on surface"); + + // selection and tooltipBackground are opaque solid fills by design + // (see ThemePalette.h), so they carry their own contrast without + // needing to be composited onto anything first. + expectRatio(p.selectionText, p.selection, 4.5, + themeName + " selectionText on selection"); + expectRatio(p.tooltipText, p.tooltipBackground, 4.5, + themeName + " tooltipText on tooltipBackground"); + + // Every "Subtle" token is a translucent tint (see ThemePalette.h), so + // it has no contrast of its own until it is flattened onto the surface + // it is drawn over -- Contrast::compositeOver() does that flattening + // before the status colour is measured against it as text. + const QList> statusOnSubtle = { + { p.success, p.successSubtle }, + { p.warning, p.warningSubtle }, + { p.danger, p.dangerSubtle }, + { p.info, p.infoSubtle }, + }; + const QList statusNames = { "success", "warning", "danger", + "info" }; + for (int i = 0; i < statusOnSubtle.size(); ++i) { + const QColor subtleOverSurface = + Contrast::compositeOver(statusOnSubtle[i].second, p.surface); + expectRatio(statusOnSubtle[i].first, subtleOverSurface, 4.5, + themeName + " " + statusNames[i] + " on " + + statusNames[i] + "Subtle over surface"); + } + + // WCAG 1.4.11 non-text contrast: focusRing and borderStrong must stay + // perceivable against both backgrounds a control might sit on. + const QList> nonTextSurfaces = { + { "canvas", p.canvas }, + { "surface", p.surface }, + }; + for (const auto& s : nonTextSurfaces) { + expectRatio(p.borderStrong, s.second, 3.0, + themeName + " borderStrong on " + s.first); + expectRatio(p.focusRing, s.second, 3.0, + themeName + " focusRing on " + s.first); + + // border is decorative and explicitly allowed to fall under 3:1 + // (see ThemePalette.h) -- measured and printed for visibility, but + // not asserted on. + const QColor borderOverSurface = + Contrast::compositeOver(p.border, s.second); + const qreal borderRatio = + Contrast::ratio(borderOverSurface, s.second); + qInfo("%-52s %6.3f (decorative, no minimum)", + qPrintable(themeName + " border on " + s.first), borderRatio); + } +} + +} // namespace + +class ThemePaletteTest : public QObject +{ + Q_OBJECT + + private slots: + void test_meshDark() { checkTheme(ThemePalette::meshDark(), "meshDark"); } + + void test_meshLight() + { + checkTheme(ThemePalette::meshLight(), "meshLight"); + } + + /// The two themes have to disagree somewhere, and a palette has to + /// agree with an identical copy of itself -- the two ends of what + /// operator==/operator!= are for. + void test_equality() + { + const ThemePalette dark = ThemePalette::meshDark(); + const ThemePalette light = ThemePalette::meshLight(); + QVERIFY(dark != light); + + const ThemePalette darkCopy = dark; + QVERIFY(darkCopy == dark); + QVERIFY(!(darkCopy != dark)); + } +}; + +QTEST_GUILESS_MAIN(ThemePaletteTest) + +#include "ThemePalette_test.moc" From cc983a741ccebbd8c75f717c7ce29419715239c1 Mon Sep 17 00:00:00 2001 From: grxtor Date: Tue, 22 Sep 2026 23:56:51 +0300 Subject: [PATCH 10/64] [phase-08] Show instances sorted, grouped and with their icons The QML window now shows what the widget grid shows, in the same order. InstanceFilterModel (core) replaces the widget layer's InstanceProxyModel for QML. Its ordering is ported faithfully -- groups compared locale-aware, names within a group compared with QCollator's numeric mode so "Pack 2" comes before "Pack 10", and the InstSortMode "LastLaunch" override honoured -- because a user switching interfaces must not find their instances reshuffled. On top it adds what QML needs: a live filterText, a group filter and a count for empty states. It finds the name/group/lastLaunch roles by name from the source model's roleNames() rather than hard-coding integers, which is what lets the test drive it with a stand-in model on different role numbers. It calls sort(0) itself, after resolving those roles. A QSortFilterProxyModel does not sort until asked, so leaving that to callers meant one forgotten call showed instances in discovery order -- and sorting before the roles resolve would order by the wrong ones. The old proxy also turned Qt::DecorationRole into a QIcon through APPLICATION->icons(). The core cannot reach APPLICATION, so iconKey is now forwarded as the plain string InstanceList already stores, and turning it into a picture is the image provider's job. IdSelectionModel keeps the selection as a set of instance ids. QML has no QItemSelectionModel, and neither rows nor persistent indexes survive a filtering proxy: a persistent index is invalidated the moment its row drops out of the filter, which is exactly what happens while typing a search. InstanceIconProvider serves image://instanceicon/. It is a Pixmap-type provider on purpose: Qt only guarantees those run on the GUI thread, and QIcon and QPixmap must not be touched anywhere else. requestedSize already arrives in device pixels and is not scaled again; a "?rev=N" suffix is ignored so callers can bust QML's image cache when an icon changes; unknown keys fall back through IconList to the default icon, and to a plain pixmap if even that fails, so QML never receives a null image. Its test initialises multimc.qrc and the icon theme search path explicitly -- built-in icons resolve through QIcon::fromTheme -- because a test in which every key quietly falls back to the default proves nothing. QmlShell owns the filter and the selection and hands both to Main.qml as required properties; they are declared before the engine so they outlive it. Verified by rendering the real launcher offscreen against a scratch data directory: all nine instances, their own icons, ungrouped first then "Modpacks" and "Vanilla", and "Pack 2" ahead of "Pack 10". Co-Authored-By: Claude Opus 5.5 Signed-off-by: grxtor --- launcher/CMakeLists.txt | 25 +++ launcher/models/IdSelectionModel.cpp | 119 +++++++++++ launcher/models/IdSelectionModel.h | 86 ++++++++ launcher/models/IdSelectionModel_test.cpp | 207 +++++++++++++++++++ launcher/models/InstanceFilterModel.cpp | 169 +++++++++++++++ launcher/models/InstanceFilterModel.h | 102 +++++++++ launcher/models/InstanceFilterModel_test.cpp | 204 ++++++++++++++++++ launcher/qml/CMakeLists.txt | 2 + launcher/qml/InstanceIconProvider.cpp | 69 +++++++ launcher/qml/InstanceIconProvider.h | 94 +++++++++ launcher/qml/InstanceIconProvider_test.cpp | 187 +++++++++++++++++ launcher/qml/Main.qml | 14 +- launcher/qml/QmlModule_test.cpp | 5 +- launcher/qml/QmlShell.cpp | 19 +- launcher/qml/QmlShell.h | 7 + 15 files changed, 1306 insertions(+), 3 deletions(-) create mode 100644 launcher/models/IdSelectionModel.cpp create mode 100644 launcher/models/IdSelectionModel.h create mode 100644 launcher/models/IdSelectionModel_test.cpp create mode 100644 launcher/models/InstanceFilterModel.cpp create mode 100644 launcher/models/InstanceFilterModel.h create mode 100644 launcher/models/InstanceFilterModel_test.cpp create mode 100644 launcher/qml/InstanceIconProvider.cpp create mode 100644 launcher/qml/InstanceIconProvider.h create mode 100644 launcher/qml/InstanceIconProvider_test.cpp diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 682b5d7b..de1020ca 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -25,6 +25,13 @@ set(CORE_SOURCES theme/Contrast.cpp theme/ThemePalette.h theme/ThemePalette.cpp + + # What the QML instance grid needs in place of the widget proxy and + # QItemSelectionModel. Core, like the blocks above: no QtWidgets, no ui/. + models/InstanceFilterModel.h + models/InstanceFilterModel.cpp + models/IdSelectionModel.h + models/IdSelectionModel.cpp modplatform/BlockedMod.h # LOGIC - Base classes and infrastructure @@ -1253,6 +1260,16 @@ add_unit_test(ThemePalette LIBS MeshMC_core ) +add_unit_test(InstanceFilterModel + SOURCES models/InstanceFilterModel_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(IdSelectionModel + SOURCES models/IdSelectionModel_test.cpp + LIBS MeshMC_core + ) + add_unit_test(IconTheme SOURCES ui/themes/IconTheme_test.cpp LIBS MeshMC_logic @@ -1411,6 +1428,14 @@ add_unit_test(QmlModule # links and then fails at runtime with 'plugin "MeshMC_qmlplugin" not found'. qt_import_qml_plugins(QmlModule_test) +# MeshMC_logic rather than the core: the built-in icons are compiled from +# multimc.qrc into MeshMC_logic, and a test where every key falls back to the +# default icon would prove nothing. +add_unit_test(InstanceIconProvider + SOURCES qml/InstanceIconProvider_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + add_executable(${MeshMC_Name} MACOSX_BUNDLE WIN32 main.cpp ${MESHMC_RCS}) # Keyword signature: qt_import_qml_plugins below links with PRIVATE, and CMake # refuses to mix the two forms on one target. diff --git a/launcher/models/IdSelectionModel.cpp b/launcher/models/IdSelectionModel.cpp new file mode 100644 index 00000000..d988dcdb --- /dev/null +++ b/launcher/models/IdSelectionModel.cpp @@ -0,0 +1,119 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "IdSelectionModel.h" + +IdSelectionModel::IdSelectionModel(QObject* parent) : QObject(parent) {} + +int IdSelectionModel::count() const +{ + return m_selected.count(); +} + +QStringList IdSelectionModel::selectedIds() const +{ + return QStringList(m_selected.values()); +} + +QString IdSelectionModel::currentId() const +{ + return m_currentId; +} + +bool IdSelectionModel::setCurrentIdInternal(const QString& id) +{ + if (m_currentId == id) { + return false; + } + m_currentId = id; + Q_EMIT currentIdChanged(); + return true; +} + +void IdSelectionModel::select(const QString& id) +{ + // Empty is the "nothing selected" sentinel currentId() uses; selecting + // it would make an empty selection indistinguishable from one that + // contains it. + if (id.isEmpty() || m_selected.contains(id)) { + return; + } + m_selected.insert(id); + setCurrentIdInternal(id); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::deselect(const QString& id) +{ + if (!m_selected.remove(id)) { + return; + } + if (m_currentId == id) { + setCurrentIdInternal(QString()); + } + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::toggle(const QString& id) +{ + if (m_selected.contains(id)) { + deselect(id); + } else { + select(id); + } +} + +void IdSelectionModel::clear() +{ + if (m_selected.isEmpty() && m_currentId.isEmpty()) { + return; + } + m_selected.clear(); + setCurrentIdInternal(QString()); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::selectOnly(const QString& id) +{ + if (id.isEmpty()) { + clear(); + return; + } + if (m_selected.size() == 1 && m_selected.contains(id) && + m_currentId == id) { + return; + } + m_selected.clear(); + m_selected.insert(id); + setCurrentIdInternal(id); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +bool IdSelectionModel::isSelected(const QString& id) const +{ + return m_selected.contains(id); +} diff --git a/launcher/models/IdSelectionModel.h b/launcher/models/IdSelectionModel.h new file mode 100644 index 00000000..9961b416 --- /dev/null +++ b/launcher/models/IdSelectionModel.h @@ -0,0 +1,86 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +/* + * Selection for the QML instance grid, keyed by instance id string rather + * than by QModelIndex/QPersistentModelIndex. + * + * QML has no QItemSelectionModel to begin with, but even a hand-rolled + * index-based equivalent would be the wrong tool here: the grid's model is + * InstanceFilterModel, a QSortFilterProxyModel, and its row numbers move + * every time the filter text, the group filter or the sort mode changes. A + * plain QModelIndex would silently point at a different instance after a + * reorder; a QPersistentModelIndex survives a reorder but is invalidated + * outright whenever the row it names drops out of the filter -- which is + * exactly what happens while the user is typing into the search box that + * owns filterText. An instance id has neither problem: it names the + * instance itself, not a position in some particular view of it, so it + * keeps meaning the same thing across every reorder and refilter. + */ +class IdSelectionModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY( + QStringList selectedIds READ selectedIds NOTIFY selectedIdsChanged) + Q_PROPERTY(QString currentId READ currentId NOTIFY currentIdChanged) + + public: + explicit IdSelectionModel(QObject* parent = nullptr); + + int count() const; + /// Unordered -- backed by a QSet, not insertion order. + QStringList selectedIds() const; + /// The most recently selected id, or empty when nothing is selected. + QString currentId() const; + + Q_INVOKABLE void select(const QString& id); + Q_INVOKABLE void deselect(const QString& id); + Q_INVOKABLE void toggle(const QString& id); + Q_INVOKABLE void clear(); + /// Replaces the whole selection with just @p id. An empty id clears the + /// selection instead, matching clear() rather than selecting "nothing". + Q_INVOKABLE void selectOnly(const QString& id); + Q_INVOKABLE bool isSelected(const QString& id) const; + + signals: + /// Fires alongside whichever of the signals below also fire, so a + /// binding that only cares "did anything change" does not need to listen + /// to all three. + void changed(); + void countChanged(); + void selectedIdsChanged(); + void currentIdChanged(); + + private: + /// Returns whether @p id actually replaced the previous current id, and + /// emits currentIdChanged() when it did. + bool setCurrentIdInternal(const QString& id); + + QSet m_selected; + QString m_currentId; +}; diff --git a/launcher/models/IdSelectionModel_test.cpp b/launcher/models/IdSelectionModel_test.cpp new file mode 100644 index 00000000..9323789e --- /dev/null +++ b/launcher/models/IdSelectionModel_test.cpp @@ -0,0 +1,207 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "models/IdSelectionModel.h" + +class IdSelectionModelTest : public QObject +{ + Q_OBJECT + + private slots: + void test_select_addsIdAndBecomesCurrent() + { + IdSelectionModel selection; + QSignalSpy changed(&selection, &IdSelectionModel::changed); + QSignalSpy countChanged(&selection, &IdSelectionModel::countChanged); + QSignalSpy idsChanged(&selection, &IdSelectionModel::selectedIdsChanged); + QSignalSpy currentChanged(&selection, &IdSelectionModel::currentIdChanged); + + selection.select("a"); + + QVERIFY(selection.isSelected("a")); + QCOMPARE(selection.count(), 1); + QCOMPARE(selection.selectedIds(), QStringList{ "a" }); + QCOMPARE(selection.currentId(), QString("a")); + QCOMPARE(changed.count(), 1); + QCOMPARE(countChanged.count(), 1); + QCOMPARE(idsChanged.count(), 1); + QCOMPARE(currentChanged.count(), 1); + } + + void test_select_alreadySelected_emitsNothing() + { + IdSelectionModel selection; + selection.select("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.select("a"); + + QCOMPARE(changed.count(), 0); + QCOMPARE(selection.count(), 1); + } + + void test_select_emptyId_isIgnored() + { + IdSelectionModel selection; + QSignalSpy changed(&selection, &IdSelectionModel::changed); + + selection.select(QString()); + + QCOMPARE(changed.count(), 0); + QCOMPARE(selection.count(), 0); + } + + void test_deselect_removesId() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.deselect("a"); + + QVERIFY(!selection.isSelected("a")); + QVERIFY(selection.isSelected("b")); + QCOMPARE(selection.count(), 1); + QCOMPARE(changed.count(), 1); + } + + void test_deselect_notSelected_emitsNothing() + { + IdSelectionModel selection; + selection.select("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.deselect("z"); + + QCOMPARE(changed.count(), 0); + } + + /// Deselecting the current id clears currentId rather than leaving it + /// pointing at an id that is no longer selected. + void test_deselect_currentId_clearsCurrentId() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + QCOMPARE(selection.currentId(), QString("b")); + + selection.deselect("b"); + + QCOMPARE(selection.currentId(), QString()); + } + + /// Deselecting an id that is not the current one leaves currentId alone. + void test_deselect_otherId_leavesCurrentIdAlone() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + selection.deselect("a"); + + QCOMPARE(selection.currentId(), QString("b")); + } + + void test_toggle_flipsSelection() + { + IdSelectionModel selection; + selection.toggle("a"); + QVERIFY(selection.isSelected("a")); + + selection.toggle("a"); + QVERIFY(!selection.isSelected("a")); + QCOMPARE(selection.count(), 0); + } + + void test_clear_removesEverything() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.clear(); + + QCOMPARE(selection.count(), 0); + QCOMPARE(selection.currentId(), QString()); + QCOMPARE(changed.count(), 1); + } + + void test_clear_alreadyEmpty_emitsNothing() + { + IdSelectionModel selection; + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.clear(); + + QCOMPARE(changed.count(), 0); + } + + void test_selectOnly_replacesWholeSelection() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + selection.selectOnly("c"); + + QCOMPARE(selection.count(), 1); + QVERIFY(selection.isSelected("c")); + QVERIFY(!selection.isSelected("a")); + QVERIFY(!selection.isSelected("b")); + QCOMPARE(selection.currentId(), QString("c")); + } + + void test_selectOnly_sameSingleSelection_emitsNothing() + { + IdSelectionModel selection; + selection.selectOnly("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.selectOnly("a"); + + QCOMPARE(changed.count(), 0); + } + + /// An empty id clears the selection instead of trying to select nothing. + void test_selectOnly_emptyId_clearsSelection() + { + IdSelectionModel selection; + selection.select("a"); + + selection.selectOnly(QString()); + + QCOMPARE(selection.count(), 0); + QCOMPARE(selection.currentId(), QString()); + } + + void test_isSelected_falseForUnknownId() + { + IdSelectionModel selection; + QVERIFY(!selection.isSelected("nope")); + } +}; + +QTEST_GUILESS_MAIN(IdSelectionModelTest) + +#include "IdSelectionModel_test.moc" diff --git a/launcher/models/InstanceFilterModel.cpp b/launcher/models/InstanceFilterModel.cpp new file mode 100644 index 00000000..fe7347cb --- /dev/null +++ b/launcher/models/InstanceFilterModel.cpp @@ -0,0 +1,169 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceFilterModel.h" + +#include + +#include "core/LauncherContext.h" +#include "settings/SettingsObject.h" + +namespace +{ + /* First role in @p model's roleNames() named @p name, or @p fallback if + * @p model is null or names no role that way. */ + int roleByName(const QAbstractItemModel* model, const QByteArray& name, + int fallback) + { + if (!model) { + return fallback; + } + const auto roles = model->roleNames(); + for (auto it = roles.constBegin(); it != roles.constEnd(); ++it) { + if (it.value() == name) { + return it.key(); + } + } + return fallback; + } +} // namespace + +InstanceFilterModel::InstanceFilterModel(QObject* parent) + : QSortFilterProxyModel(parent) +{ + m_naturalSort.setNumericMode(true); + m_naturalSort.setCaseSensitivity(Qt::CaseInsensitive); + // FIXME: same as the widget proxy -- use loaded translation as source of + // locale instead, hook this up to translation changes. + m_naturalSort.setLocale(QLocale::system()); + + // rowCount() changes on all four of these; simplest to treat them all as + // "the filtered count might have moved" rather than track it by hand. + connect(this, &QAbstractItemModel::rowsInserted, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::layoutChanged, this, + &InstanceFilterModel::countChanged); +} + +QString InstanceFilterModel::filterText() const +{ + return m_filterText; +} + +void InstanceFilterModel::setFilterText(const QString& text) +{ + if (m_filterText == text) { + return; + } + m_filterText = text; + Q_EMIT filterTextChanged(); + invalidateFilter(); +} + +QString InstanceFilterModel::group() const +{ + return m_group; +} + +void InstanceFilterModel::setGroup(const QString& group) +{ + if (m_group == group) { + return; + } + m_group = group; + Q_EMIT groupChanged(); + invalidateFilter(); +} + +int InstanceFilterModel::count() const +{ + return rowCount(); +} + +void InstanceFilterModel::setSourceModel(QAbstractItemModel* sourceModel) +{ + QSortFilterProxyModel::setSourceModel(sourceModel); + m_nameRole = roleByName(sourceModel, "name", Qt::DisplayRole); + m_groupRole = roleByName(sourceModel, "group", Qt::UserRole); + m_lastLaunchRole = roleByName(sourceModel, "lastLaunch", -1); + + /* A QSortFilterProxyModel does not sort until something asks it to, and a + * caller that forgets sort(0) shows instances in discovery order. Asked + * for here, after the roles above are resolved, so the very first + * ordering already uses them rather than the defaults. */ + sort(0); +} + +bool InstanceFilterModel::filterAcceptsRow( + int sourceRow, const QModelIndex& sourceParent) const +{ + if (!sourceModel()) { + return false; + } + const QModelIndex index = + sourceModel()->index(sourceRow, 0, sourceParent); + if (!m_group.isEmpty() && + index.data(m_groupRole).toString() != m_group) { + return false; + } + if (!m_filterText.isEmpty() && + !index.data(m_nameRole).toString().contains(m_filterText, + Qt::CaseInsensitive)) { + return false; + } + return true; +} + +bool InstanceFilterModel::lessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + const QString leftGroup = left.data(m_groupRole).toString(); + const QString rightGroup = right.data(m_groupRole).toString(); + if (leftGroup == rightGroup) { + return subSortLessThan(left, right); + } else { + // FIXME: ported as-is from the widget proxy -- real group ordering + // happens in InstanceView::updateGeometries() on that side, not + // here. Carrying the same gap over to both UIs beats fixing it in + // only one of them. + auto result = leftGroup.localeAwareCompare(rightGroup); + if (result == 0) { + return subSortLessThan(left, right); + } + return result < 0; + } +} + +bool InstanceFilterModel::subSortLessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + auto* context = LauncherContext::instance(); + if (m_lastLaunchRole >= 0 && context && + context->settings()->get("InstSortMode").toString() == + "LastLaunch") { + return left.data(m_lastLaunchRole).toLongLong() > + right.data(m_lastLaunchRole).toLongLong(); + } + return m_naturalSort.compare(left.data(m_nameRole).toString(), + right.data(m_nameRole).toString()) < 0; +} diff --git a/launcher/models/InstanceFilterModel.h b/launcher/models/InstanceFilterModel.h new file mode 100644 index 00000000..ce37dac8 --- /dev/null +++ b/launcher/models/InstanceFilterModel.h @@ -0,0 +1,102 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +/* + * QML replacement for the widget grid's proxy + * (ui/instanceview/InstanceProxyModel.h). QML has no QItemSelectionModel and + * cannot reach into the widget layer, so this is a second proxy rather than a + * shared one -- but it reproduces that proxy's ordering exactly (grouped, + * locale-sorted groups, natural-sort names within a group, with the same + * "InstSortMode" last-launch override) so that switching between the widget + * grid and the QML one never reorders anyone's instances. + * + * QtCore/QtGui only, same rule as the rest of the core: no QtWidgets, nothing + * from ui/. In particular this cannot resolve icons the way the old proxy did + * (APPLICATION->icons()->getIcon() in its data() override) -- APPLICATION is + * a QApplication and off limits here. iconKey is forwarded as the plain + * string InstanceList already exposes it as; whatever turns that into a + * picture is the QML delegate's problem, not this proxy's. + * + * The name, group and last-launch roles are looked up by NAME from the + * source model's roleNames() rather than assumed to be + * Qt::DisplayRole/GroupRole/LastLaunchRole, so this works against + * InstanceList's real role numbers (see InstanceList.h -- GroupRole is + * pinned to Qt::UserRole, the QML-only roles start at Qt::UserRole + 10) and + * against any other model that merely exposes the same names (see + * InstanceFilterModel_test.cpp, which deliberately numbers them + * differently). + */ +class InstanceFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY + filterTextChanged) + Q_PROPERTY(QString group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + + public: + explicit InstanceFilterModel(QObject* parent = nullptr); + + QString filterText() const; + void setFilterText(const QString& text); + + QString group() const; + void setGroup(const QString& group); + + /// Row count after filtering -- what QML needs to decide an empty state. + int count() const; + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + signals: + void filterTextChanged(); + void groupChanged(); + void countChanged(); + + protected: + bool filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, + const QModelIndex& right) const override; + + private: + bool subSortLessThan(const QModelIndex& left, + const QModelIndex& right) const; + + QCollator m_naturalSort; + QString m_filterText; + QString m_group; + + /* Resolved in setSourceModel(). The fallbacks are the roles Qt itself + * gives the same meaning by convention, so a source model that never + * names its roles still filters and sorts by something sane instead of + * silently matching nothing. -1 for lastLaunch means "this source has no + * such role", which makes the LastLaunch sort mode fall back to name + * sorting instead of comparing garbage. */ + int m_nameRole = Qt::DisplayRole; + int m_groupRole = Qt::UserRole; + int m_lastLaunchRole = -1; +}; diff --git a/launcher/models/InstanceFilterModel_test.cpp b/launcher/models/InstanceFilterModel_test.cpp new file mode 100644 index 00000000..0178a798 --- /dev/null +++ b/launcher/models/InstanceFilterModel_test.cpp @@ -0,0 +1,204 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "models/InstanceFilterModel.h" + +namespace +{ + +/* + * Stand-in for InstanceList: exposes the same role NAMES (name, group, + * instanceId, lastLaunch) but deliberately different role NUMBERS. A test + * that passes against this only passes because InstanceFilterModel resolves + * roles by name, not because it happens to reuse InstanceList's own numbers. + */ +class FakeInstanceModel : public QAbstractListModel +{ + public: + struct Row { + QString id; + QString name; + QString group; + qint64 lastLaunch = 0; + }; + + enum Roles { IdRole = Qt::UserRole + 1, NameRole, GroupRole, LastLaunchRole }; + + explicit FakeInstanceModel(QList rows, QObject* parent = nullptr) + : QAbstractListModel(parent), m_rows(std::move(rows)) + { + } + + int rowCount(const QModelIndex& parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : m_rows.count(); + } + + QVariant data(const QModelIndex& index, int role) const override + { + if (!index.isValid() || index.row() >= m_rows.count()) { + return QVariant(); + } + const Row& row = m_rows.at(index.row()); + switch (role) { + case IdRole: + return row.id; + case NameRole: + return row.name; + case GroupRole: + return row.group; + case LastLaunchRole: + return row.lastLaunch; + default: + return QVariant(); + } + } + + QHash roleNames() const override + { + return { + { IdRole, "instanceId" }, + { NameRole, "name" }, + { GroupRole, "group" }, + { LastLaunchRole, "lastLaunch" }, + }; + } + + private: + QList m_rows; +}; + +} // namespace + +class InstanceFilterModelTest : public QObject +{ + Q_OBJECT + + private slots: + /// "Pack 2" has to sort before "Pack 10" -- a plain string compare would + /// put "Pack 10" first, which is the exact bug QCollator numeric mode + /// exists to avoid. + void test_naturalSort_ordersNumericSuffixesNumerically() + { + FakeInstanceModel source({ + { "b", "Pack 10", "", 0 }, + { "a", "Pack 2", "", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack 2")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack 10")); + } + + /// Same-group rows fall through to natural sort; different-group rows + /// sort by group first, mirroring the widget proxy's lessThan(). + void test_lessThan_groupsSortBeforeName() + { + FakeInstanceModel source({ + { "a", "Alpha", "Zeta Group", 0 }, + { "b", "Zulu", "Alpha Group", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + + // "Alpha Group" sorts before "Zeta Group", so "Zulu" (in Alpha + // Group) comes first even though "Alpha" < "Zulu" by name. + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Zulu")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Alpha")); + } + + void test_filterText_matchesNameCaseInsensitively_andUpdatesCount() + { + FakeInstanceModel source({ + { "a", "Pack Alpha", "", 0 }, + { "b", "Pack Beta", "", 0 }, + { "c", "Something Else", "", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + QCOMPARE(filter.count(), 3); + + QSignalSpy countSpy(&filter, &InstanceFilterModel::countChanged); + filter.setFilterText("PACK"); + QVERIFY(!countSpy.isEmpty()); + QCOMPARE(filter.count(), 2); + + filter.setFilterText("pack alpha"); + QCOMPARE(filter.count(), 1); + QCOMPARE( + filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack Alpha")); + + filter.setFilterText(QString()); + QCOMPARE(filter.count(), 3); + } + + /// Empty group means "no filter"; a non-empty one keeps only that group. + void test_group_emptyMeansAllGroups_otherwiseOnlyThatGroup() + { + FakeInstanceModel source({ + { "a", "One", "Modpacks", 0 }, + { "b", "Two", "Vanilla", 0 }, + { "c", "Three", "Modpacks", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + QCOMPARE(filter.count(), 3); + + filter.setGroup("Modpacks"); + QCOMPARE(filter.count(), 2); + for (int i = 0; i < filter.rowCount(); ++i) { + QCOMPARE( + filter.index(i, 0).data(FakeInstanceModel::GroupRole).toString(), + QString("Modpacks")); + } + + filter.setGroup(QString()); + QCOMPARE(filter.count(), 3); + } + + /// QML delegates reach instanceId/name/group/lastLaunch through the + /// proxy by name, so the proxy's roleNames() has to be the source + /// model's, not QSortFilterProxyModel's own default. + void test_roleNames_areForwardedFromSourceModel() + { + FakeInstanceModel source({}); + InstanceFilterModel filter; + filter.setSourceModel(&source); + + QCOMPARE(filter.roleNames(), source.roleNames()); + } +}; + +QTEST_GUILESS_MAIN(InstanceFilterModelTest) + +#include "InstanceFilterModel_test.moc" diff --git a/launcher/qml/CMakeLists.txt b/launcher/qml/CMakeLists.txt index a1d84440..617472cb 100644 --- a/launcher/qml/CMakeLists.txt +++ b/launcher/qml/CMakeLists.txt @@ -32,6 +32,8 @@ qt_add_qml_module(MeshMC_qml target_sources(MeshMC_qml PRIVATE QmlShell.h QmlShell.cpp + InstanceIconProvider.h + InstanceIconProvider.cpp ) # The QML side reads the core's models. It does not see QtWidgets: MeshMC_core diff --git a/launcher/qml/InstanceIconProvider.cpp b/launcher/qml/InstanceIconProvider.cpp new file mode 100644 index 00000000..e0c98bd2 --- /dev/null +++ b/launcher/qml/InstanceIconProvider.cpp @@ -0,0 +1,69 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceIconProvider.h" + +#include + +namespace +{ +// Only a fallback for an unrecognised or zero requestedSize; the exact value +// is not load-bearing anywhere else. +constexpr int kDefaultIconExtent = 64; +} + +InstanceIconProvider::InstanceIconProvider(std::shared_ptr icons) + : QQuickImageProvider(QQuickImageProvider::Pixmap), m_icons(std::move(icons)) +{ +} + +QPixmap InstanceIconProvider::requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) +{ + // Cache-busting query ("?rev=N") plays no part in the lookup -- see + // the header for why it exists at all. + const int queryStart = id.indexOf(QLatin1Char('?')); + const QString key = queryStart < 0 ? id : id.left(queryStart); + + // requestedSize is already in device pixels -- QML applied the device + // pixel ratio before calling here -- so it is used as-is, never an + // invalid or empty one. + const QSize wanted = requestedSize.isEmpty() + ? QSize(kDefaultIconExtent, kDefaultIconExtent) + : requestedSize; + + QPixmap pixmap; + if (m_icons) { + // getIcon() already falls back to the "grass" builtin for an unknown + // key (and for "default"); nothing extra to do for that here. + pixmap = m_icons->getIcon(key).pixmap(wanted); + } + + if (pixmap.isNull()) { + // Reached only if no IconList was given, or even "grass" could not + // be found (e.g. the icon theme resource was never registered). A + // null QPixmap here would reach QML as a broken image, so this + // draws something instead of ever returning one. + pixmap = QPixmap(wanted); + pixmap.fill(Qt::gray); + } + + *size = pixmap.size(); + return pixmap; +} diff --git a/launcher/qml/InstanceIconProvider.h b/launcher/qml/InstanceIconProvider.h new file mode 100644 index 00000000..6c03d13a --- /dev/null +++ b/launcher/qml/InstanceIconProvider.h @@ -0,0 +1,94 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include + +/* + * Bridges IconList (QIcon, launcher/icons/IconList.h) into QML, which has no + * way to display a QIcon. A delegate writes + * + * Image { source: "image://instanceicon/" + iconKey } + * + * where iconKey is the string role InstanceList::roleNames() exposes for + * Qt::DecorationRole (see InstanceList.h/.cpp) -- the same key + * InstanceProxyModel::data() already resolves through IconList::getIcon() + * for the QtWidgets grid. + * + * THREADING. This derives from QQuickImageProvider with ImageType::Pixmap, + * not QQuickAsyncImageProvider and not ImageType::Image. Qt Quick only + * guarantees a Pixmap-type provider's requestPixmap() is called on the GUI + * thread; ImageType::Image can be serviced from a loader thread once the + * engine's threaded image loading kicks in, and QQuickAsyncImageProvider is + * explicitly meant to do its work off-thread. QIcon and QPixmap are GUI-only + * types -- constructing, copying or painting either off the GUI thread is + * undefined behaviour -- so Pixmap is the only one of the three that keeps + * this code where it is safe to touch them. + * + * SIZE. requestedSize arrives already in device pixels; QML has already + * folded in the device pixel ratio before calling requestPixmap(), so it is + * used as-is here, with no extra multiplication. An invalid or empty + * requestedSize (the default QSize(), or an explicit zero) falls back to a + * fixed default extent instead. *size is always set to the pixmap actually + * returned, which is not necessarily requestedSize. + * + * CACHE-BUSTING. QML caches a resolved image:/// URL by id, so + * once IconList::iconUpdated(key) fires there is otherwise no way to make an + * already-bound Image re-fetch the same key. id may carry a "?rev=N" + * query string; requestPixmap() strips everything from the first '?' onward + * before looking the key up, so N has no effect on which icon comes back -- + * it exists purely so a caller can change it to make QML treat the URL as + * new. Bumping N when iconUpdated fires is left to whoever wires this + * provider into the engine. + * + * UNKNOWN KEYS. An unknown or empty key is not special-cased here: + * IconList::getIcon() already falls back to the "grass" builtin for those + * (and for the literal key "default"), which is the same fallback + * InstanceProxyModel::data() relies on today. Only if that also somehow + * comes back null (no IconList given, or "grass" itself unavailable) does + * this provider draw a plain fallback pixmap of its own, so a caller never + * receives a null QPixmap. + * + * OWNERSHIP. Takes the IconList as a std::shared_ptr rather than a raw + * pointer because that is how the rest of the launcher already holds it -- + * Application::icons() returns std::shared_ptr, not a + * QObject-parented instance -- so this provider shares the same object + * instead of assuming a global. It reaches for neither APPLICATION nor + * LAUNCHER: the list is injected by whoever constructs the provider, which + * is what makes it constructible with a throwaway IconList in a test. + */ +class InstanceIconProvider : public QQuickImageProvider +{ + public: + explicit InstanceIconProvider(std::shared_ptr icons); + + QPixmap requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) override; + + private: + std::shared_ptr m_icons; +}; diff --git a/launcher/qml/InstanceIconProvider_test.cpp b/launcher/qml/InstanceIconProvider_test.cpp new file mode 100644 index 00000000..2c901677 --- /dev/null +++ b/launcher/qml/InstanceIconProvider_test.cpp @@ -0,0 +1,187 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "InstanceIconProvider.h" + +namespace +{ +/* The same four directories Application.cpp (and IconTheme_test.cpp) pass to + * IconList. All four are needed for "grass" specifically: of the built-in + * instance icons, only scalable/instances/ ships an unsuffixed "grass.svg" + * -- the raster sizes only have "grass_legacy.png" -- so leaving scalable + * out would make every lookup below fall through to the fallback path and + * prove nothing. */ +const QStringList kBuiltinIconDirs = { + QStringLiteral(":/icons/multimc/32x32/instances/"), + QStringLiteral(":/icons/multimc/50x50/instances/"), + QStringLiteral(":/icons/multimc/128x128/instances/"), + QStringLiteral(":/icons/multimc/scalable/instances/"), +}; + +// Mirrors InstanceIconProvider's own fallback extent. +constexpr int kDefaultIconExtent = 64; +} // namespace + +/* + * Unit test for InstanceIconProvider. + * + * Runs against a real IconList rather than a mock: the point of this + * provider is the "?rev=N" bridge and the unknown-key/invalid-size + * fallbacks, and a mock would only prove it calls a method, not that the + * right pixels come back. Getting a real "grass" builtin icon to resolve + * here needs the same two things application start-up does, and both are + * easy to skip silently: + * + * - Q_INIT_RESOURCE(multimc) in main(), because the compiled "multimc" + * resource lives inside the MeshMC_logic static library and nothing else + * in this test binary forces the linker to keep those object files. + * - QIcon::setThemeName("multimc") over a search path that includes + * ":/icons", because IconList's Builtin icons resolve through + * QIcon::fromTheme() rather than loading the file directly -- see + * MMCIcon::icon() -- which is exactly what ThemeManager::setIconTheme() + * sets up for the real application. + * + * Skip either one and every lookup below quietly takes the "unknown key" + * path instead, which would pass without testing the thing it claims to. + */ +class InstanceIconProviderTest : public QObject +{ + Q_OBJECT + + private slots: + void initTestCase() + { + // Same as ThemeManager::setIconTheme(), restated rather than called + // so this measures IconList/Qt, not that wrapper. ":/icons" goes + // first so the bundled theme wins over a same-named one on the host. + QStringList searchPaths = QIcon::themeSearchPaths(); + searchPaths.prepend(QStringLiteral(":/icons")); + QIcon::setThemeSearchPaths(searchPaths); + QIcon::setThemeName(QStringLiteral("multimc")); + } + + void cleanupTestCase() + { + QIcon::setThemeName(QString()); + } + + void init() + { + m_tempDir = std::make_unique(); + QVERIFY(m_tempDir->isValid()); + m_icons = std::make_shared(kBuiltinIconDirs, m_tempDir->path()); + } + + void cleanup() + { + m_icons.reset(); + m_tempDir.reset(); + } + + void knownBuiltinKeyReturnsRequestedSize() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("grass"), &size, QSize(64, 64)); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(64, 64)); + QCOMPARE(pixmap.size(), QSize(64, 64)); + } + + void revisionQueryIsIgnored() + { + InstanceIconProvider provider(m_icons); + + QSize plainSize; + QSize revisionedSize; + const QPixmap plain = provider.requestPixmap( + QStringLiteral("grass"), &plainSize, QSize(48, 48)); + const QPixmap revisioned = provider.requestPixmap( + QStringLiteral("grass?rev=7"), &revisionedSize, QSize(48, 48)); + + QCOMPARE(revisionedSize, plainSize); + QCOMPARE(revisioned.toImage(), plain.toImage()); + } + + void unknownKeyFallsBackToNonNullPixmap() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("this-key-does-not-exist"), &size, QSize(32, 32)); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(32, 32)); + } + + void invalidRequestedSizeUsesDefault() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("grass"), &size, QSize()); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(kDefaultIconExtent, kDefaultIconExtent)); + } + + private: + std::unique_ptr m_tempDir; + std::shared_ptr m_icons; +}; + +int main(int argc, char* argv[]) +{ + /* Same reasoning as QmlModule_test.cpp: a Pixmap-type QQuickImageProvider + * needs a QGuiApplication, and forcing offscreen keeps this runnable on a + * headless runner without depending on the harness to set + * QT_QPA_PLATFORM for us. */ + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QGuiApplication app(argc, argv); + + /* The compiled "multimc" resource sits in MeshMC_logic, a static + * library; without a symbol reference into it, the linker drops the + * object that registers it, and every builtinPaths lookup below would + * silently come back empty. main.cpp does the same for the launcher + * itself. */ + Q_INIT_RESOURCE(multimc); + + InstanceIconProviderTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "InstanceIconProvider_test.moc" diff --git a/launcher/qml/Main.qml b/launcher/qml/Main.qml index 397f2cb7..1357e29c 100644 --- a/launcher/qml/Main.qml +++ b/launcher/qml/Main.qml @@ -21,6 +21,10 @@ ApplicationWindow { * delegate binds to. */ required property var instanceModel + /* Selection by instance id (IdSelectionModel). Ids rather than rows, + * because rows move whenever the filter or the sort changes. */ + required property var selection + // Read by QmlModule_test to prove this component, and not some default, // was instantiated. readonly property string moduleName: "MeshMC" @@ -38,7 +42,7 @@ ApplicationWindow { anchors.margins: 16 model: root.instanceModel cellWidth: 168 - cellHeight: 96 + cellHeight: 128 clip: true delegate: Item { @@ -46,6 +50,7 @@ ApplicationWindow { required property string name required property string group + required property string iconKey width: grid.cellWidth - 8 height: grid.cellHeight - 8 @@ -54,6 +59,13 @@ ApplicationWindow { anchors.centerIn: parent spacing: 4 + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 40 + height: 40 + sourceSize: Qt.size(40, 40) + source: "image://instanceicon/" + tile.iconKey + } Label { anchors.horizontalCenter: parent.horizontalCenter width: tile.width - 16 diff --git a/launcher/qml/QmlModule_test.cpp b/launcher/qml/QmlModule_test.cpp index c3397e92..645209dd 100644 --- a/launcher/qml/QmlModule_test.cpp +++ b/launcher/qml/QmlModule_test.cpp @@ -63,9 +63,12 @@ class QmlModuleTest : public QObject * to prove the component loads, and a required property left unset * would itself be a load error worth catching here. */ QStandardItemModel instances; + QObject selection; std::unique_ptr root(component.createWithInitialProperties( {{QStringLiteral("instanceModel"), - QVariant::fromValue(&instances)}})); + QVariant::fromValue(&instances)}, + {QStringLiteral("selection"), + QVariant::fromValue(&selection)}})); QVERIFY2(root != nullptr, qPrintable(component.errorString())); /* Guards against the component resolving to something default diff --git a/launcher/qml/QmlShell.cpp b/launcher/qml/QmlShell.cpp index 5192ec58..83754c67 100644 --- a/launcher/qml/QmlShell.cpp +++ b/launcher/qml/QmlShell.cpp @@ -28,6 +28,9 @@ #include #include "InstanceList.h" +#include "models/IdSelectionModel.h" +#include "models/InstanceFilterModel.h" +#include "qml/InstanceIconProvider.h" #include "core/LauncherContext.h" namespace @@ -53,7 +56,9 @@ QVariantMap QmlShell::rootProperties() const { QVariantMap props; props.insert(QStringLiteral("instanceModel"), - QVariant::fromValue(expose(LAUNCHER->instances().get()))); + QVariant::fromValue(expose(m_instances.get()))); + props.insert(QStringLiteral("selection"), + QVariant::fromValue(expose(m_selection.get()))); return props; } @@ -66,8 +71,20 @@ bool QmlShell::show(bool minimized) return true; } + /* The grid shows the core's instance list through a filtering, naturally + * sorted proxy -- the same ordering the widget grid used -- and keeps its + * selection by instance id, since rows move under the proxy. */ + m_instances = std::make_unique(); + m_instances->setSourceModel(LAUNCHER->instances().get()); + m_selection = std::make_unique(); + m_engine = std::make_unique(); m_engine->addImportPath(QStringLiteral("qrc:/qt/qml")); + + /* The engine takes ownership of image providers. Registered before load() + * so the first frame already has icons rather than broken images. */ + m_engine->addImageProvider(QStringLiteral("instanceicon"), + new InstanceIconProvider(LAUNCHER->icons())); m_engine->setInitialProperties(rootProperties()); m_engine->load(kRootUrl); diff --git a/launcher/qml/QmlShell.h b/launcher/qml/QmlShell.h index 2c2a0918..91fb815a 100644 --- a/launcher/qml/QmlShell.h +++ b/launcher/qml/QmlShell.h @@ -24,6 +24,8 @@ #include +class IdSelectionModel; +class InstanceFilterModel; class QQmlApplicationEngine; class QQuickWindow; @@ -63,6 +65,11 @@ class QmlShell : public QObject QVariantMap rootProperties() const; void scheduleSnapshotIfRequested(); + /* Declared before the engine so they are destroyed after it: QML holds + * pointers to both until the engine is gone. */ + std::unique_ptr m_instances; + std::unique_ptr m_selection; + std::unique_ptr m_engine; QQuickWindow* m_window = nullptr; }; From 8a616ff7d52dae5d92ea2297fda1e2208685e48a Mon Sep 17 00:00:00 2001 From: grxtor Date: Wed, 23 Sep 2026 00:05:26 +0300 Subject: [PATCH 11/64] [phase-07] Add MeshMC.Theme, the single source of design tokens Every size, colour, radius and duration the QML interface uses comes from here, so nothing on screen hard-codes one. After `import MeshMC.Theme`: Theme.palette. the 35 ThemePalette colours (already WCAG-checked) Theme.dark / .mode "system" | "dark" | "light", writable Theme.space 2 4 8 12 16 24 32 Theme.radius 4 8 12 16 pill Theme.control 28 36 44 heights Theme.icon 16 20 24 Theme.motion 120 180 260 ms, OutCubic Theme.font Inter / JetBrains Mono (bundled later; Qt falls back) Theme.type.