summaryrefslogtreecommitdiff
path: root/meshmc/launcher/ui/pages/instance
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
commit31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch)
tree8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/ui/pages/instance
parent934382c8a1ce738589dee9ee0f14e1cec812770e (diff)
parentfad6a1066616b69d7f5fef01178efdf014c59537 (diff)
downloadProject-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.tar.gz
Project-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.zip
Add 'meshmc/' from commit 'fad6a1066616b69d7f5fef01178efdf014c59537'
git-subtree-dir: meshmc git-subtree-mainline: 934382c8a1ce738589dee9ee0f14e1cec812770e git-subtree-split: fad6a1066616b69d7f5fef01178efdf014c59537
Diffstat (limited to 'meshmc/launcher/ui/pages/instance')
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp56
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.h86
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.ui88
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp353
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h99
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui548
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp74
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h87
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui47
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.cpp337
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.h110
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.ui182
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.cpp407
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.h136
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.ui164
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.cpp42
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.h83
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.ui49
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp314
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.h105
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.ui150
-rw-r--r--meshmc/launcher/ui/pages/instance/ResourcePackPage.h45
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp458
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.h109
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui87
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.cpp734
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.h117
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.ui194
-rw-r--r--meshmc/launcher/ui/pages/instance/ShaderPackPage.h44
-rw-r--r--meshmc/launcher/ui/pages/instance/TexturePackPage.h44
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.cpp809
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.h131
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.ui303
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.cpp422
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.h120
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.ui161
36 files changed, 7295 insertions, 0 deletions
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp b/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp
new file mode 100644
index 0000000000..96087eedae
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp
@@ -0,0 +1,56 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "GameOptionsPage.h"
+#include "ui_GameOptionsPage.h"
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/gameoptions/GameOptions.h"
+
+GameOptionsPage::GameOptionsPage(MinecraftInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::GameOptionsPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ m_model = inst->gameOptionsModel();
+ ui->optionsView->setModel(m_model.get());
+ auto head = ui->optionsView->header();
+ if (head->count()) {
+ head->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ for (int i = 1; i < head->count(); i++) {
+ head->setSectionResizeMode(i, QHeaderView::Stretch);
+ }
+ }
+}
+
+GameOptionsPage::~GameOptionsPage()
+{
+ // m_model->save();
+}
+
+void GameOptionsPage::openedImpl()
+{
+ // m_model->observe();
+}
+
+void GameOptionsPage::closedImpl()
+{
+ // m_model->unobserve();
+}
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.h b/meshmc/launcher/ui/pages/instance/GameOptionsPage.h
new file mode 100644
index 0000000000..92e5296521
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.h
@@ -0,0 +1,86 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+#include <QString>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class GameOptionsPage;
+}
+
+class GameOptions;
+class MinecraftInstance;
+
+class GameOptionsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit GameOptionsPage(MinecraftInstance* inst, QWidget* parent = 0);
+ virtual ~GameOptionsPage();
+
+ void openedImpl() override;
+ void closedImpl() override;
+
+ virtual QString displayName() const override
+ {
+ return tr("Game Options");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("settings");
+ }
+ virtual QString id() const override
+ {
+ return "gameoptions";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Game-Options-management";
+ }
+
+ private: // data
+ Ui::GameOptionsPage* ui = nullptr;
+ std::shared_ptr<GameOptions> m_model;
+};
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui b/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui
new file mode 100644
index 0000000000..f0a5ce0ee1
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>GameOptionsPage</class>
+ <widget class="QWidget" name="GameOptionsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>706</width>
+ <height>575</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item row="0" column="0">
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0" colspan="2">
+ <widget class="QTreeView" name="optionsView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::SingleSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>optionsView</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp
new file mode 100644
index 0000000000..7d37415948
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp
@@ -0,0 +1,353 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "InstanceSettingsPage.h"
+#include "ui_InstanceSettingsPage.h"
+
+#include <QFileDialog>
+#include <QDialog>
+#include <QMessageBox>
+
+#include <sys.h>
+
+#include "ui/dialogs/VersionSelectDialog.h"
+#include "ui/widgets/CustomCommands.h"
+
+#include "JavaCommon.h"
+#include "Application.h"
+
+#include "java/JavaInstallList.h"
+#include "FileSystem.h"
+
+InstanceSettingsPage::InstanceSettingsPage(BaseInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::InstanceSettingsPage), m_instance(inst)
+{
+ m_settings = inst->settings();
+ ui->setupUi(this);
+ auto sysMB = Sys::getSystemRam() / Sys::mebibyte;
+ ui->maxMemSpinBox->setMaximum(sysMB);
+ connect(ui->openGlobalJavaSettingsButton, &QCommandLinkButton::clicked,
+ this, &InstanceSettingsPage::globalSettingsButtonClicked);
+ connect(APPLICATION, &Application::globalSettingsAboutToOpen, this,
+ &InstanceSettingsPage::applySettings);
+ connect(APPLICATION, &Application::globalSettingsClosed, this,
+ &InstanceSettingsPage::loadSettings);
+ loadSettings();
+}
+
+bool InstanceSettingsPage::shouldDisplay() const
+{
+ return !m_instance->isRunning();
+}
+
+InstanceSettingsPage::~InstanceSettingsPage()
+{
+ delete ui;
+}
+
+void InstanceSettingsPage::globalSettingsButtonClicked(bool)
+{
+ switch (ui->settingsTabs->currentIndex()) {
+ case 0:
+ APPLICATION->ShowGlobalSettings(this, "java-settings");
+ return;
+ case 1:
+ APPLICATION->ShowGlobalSettings(this, "minecraft-settings");
+ return;
+ case 2:
+ APPLICATION->ShowGlobalSettings(this, "custom-commands");
+ return;
+ }
+}
+
+bool InstanceSettingsPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void InstanceSettingsPage::applySettings()
+{
+ SettingsObject::Lock lock(m_settings);
+
+ // Console
+ bool console = ui->consoleSettingsBox->isChecked();
+ m_settings->set("OverrideConsole", console);
+ if (console) {
+ m_settings->set("ShowConsole", ui->showConsoleCheck->isChecked());
+ m_settings->set("AutoCloseConsole",
+ ui->autoCloseConsoleCheck->isChecked());
+ m_settings->set("ShowConsoleOnError",
+ ui->showConsoleErrorCheck->isChecked());
+ } else {
+ m_settings->reset("ShowConsole");
+ m_settings->reset("AutoCloseConsole");
+ m_settings->reset("ShowConsoleOnError");
+ }
+
+ // Window Size
+ bool window = ui->windowSizeGroupBox->isChecked();
+ m_settings->set("OverrideWindow", window);
+ if (window) {
+ m_settings->set("LaunchMaximized", ui->maximizedCheckBox->isChecked());
+ m_settings->set("MinecraftWinWidth", ui->windowWidthSpinBox->value());
+ m_settings->set("MinecraftWinHeight", ui->windowHeightSpinBox->value());
+ } else {
+ m_settings->reset("LaunchMaximized");
+ m_settings->reset("MinecraftWinWidth");
+ m_settings->reset("MinecraftWinHeight");
+ }
+
+ // Memory
+ bool memory = ui->memoryGroupBox->isChecked();
+ m_settings->set("OverrideMemory", memory);
+ if (memory) {
+ int min = ui->minMemSpinBox->value();
+ int max = ui->maxMemSpinBox->value();
+ if (min < max) {
+ m_settings->set("MinMemAlloc", min);
+ m_settings->set("MaxMemAlloc", max);
+ } else {
+ m_settings->set("MinMemAlloc", max);
+ m_settings->set("MaxMemAlloc", min);
+ }
+ m_settings->set("PermGen", ui->permGenSpinBox->value());
+ } else {
+ m_settings->reset("MinMemAlloc");
+ m_settings->reset("MaxMemAlloc");
+ m_settings->reset("PermGen");
+ }
+
+ // Java Install Settings
+ bool javaInstall = ui->javaSettingsGroupBox->isChecked();
+ m_settings->set("OverrideJavaLocation", javaInstall);
+ if (javaInstall) {
+ m_settings->set("JavaPath", ui->javaPathTextBox->text());
+ } else {
+ m_settings->reset("JavaPath");
+ }
+
+ // Java arguments
+ bool javaArgs = ui->javaArgumentsGroupBox->isChecked();
+ m_settings->set("OverrideJavaArgs", javaArgs);
+ if (javaArgs) {
+ m_settings->set("JvmArgs",
+ ui->jvmArgsTextBox->toPlainText().replace("\n", " "));
+ JavaCommon::checkJVMArgs(m_settings->get("JvmArgs").toString(),
+ this->parentWidget());
+ } else {
+ m_settings->reset("JvmArgs");
+ }
+
+ // old generic 'override both' is removed.
+ m_settings->reset("OverrideJava");
+
+ // Custom Commands
+ bool custcmd = ui->customCommands->checked();
+ m_settings->set("OverrideCommands", custcmd);
+ if (custcmd) {
+ m_settings->set("PreLaunchCommand",
+ ui->customCommands->prelaunchCommand());
+ m_settings->set("WrapperCommand", ui->customCommands->wrapperCommand());
+ m_settings->set("PostExitCommand",
+ ui->customCommands->postexitCommand());
+ } else {
+ m_settings->reset("PreLaunchCommand");
+ m_settings->reset("WrapperCommand");
+ m_settings->reset("PostExitCommand");
+ }
+
+ // Workarounds
+ bool workarounds = ui->nativeWorkaroundsGroupBox->isChecked();
+ m_settings->set("OverrideNativeWorkarounds", workarounds);
+ if (workarounds) {
+ m_settings->set("UseNativeOpenAL",
+ ui->useNativeOpenALCheck->isChecked());
+ m_settings->set("UseNativeGLFW", ui->useNativeGLFWCheck->isChecked());
+ } else {
+ m_settings->reset("UseNativeOpenAL");
+ m_settings->reset("UseNativeGLFW");
+ }
+
+ // Game time
+ bool gameTime = ui->gameTimeGroupBox->isChecked();
+ m_settings->set("OverrideGameTime", gameTime);
+ if (gameTime) {
+ m_settings->set("ShowGameTime", ui->showGameTime->isChecked());
+ m_settings->set("RecordGameTime", ui->recordGameTime->isChecked());
+ } else {
+ m_settings->reset("ShowGameTime");
+ m_settings->reset("RecordGameTime");
+ }
+
+ // Join server on launch
+ bool joinServerOnLaunch = ui->serverJoinGroupBox->isChecked();
+ m_settings->set("JoinServerOnLaunch", joinServerOnLaunch);
+ if (joinServerOnLaunch) {
+ m_settings->set("JoinServerOnLaunchAddress",
+ ui->serverJoinAddress->text());
+ } else {
+ m_settings->reset("JoinServerOnLaunchAddress");
+ }
+}
+
+void InstanceSettingsPage::loadSettings()
+{
+ // Console
+ ui->consoleSettingsBox->setChecked(
+ m_settings->get("OverrideConsole").toBool());
+ ui->showConsoleCheck->setChecked(m_settings->get("ShowConsole").toBool());
+ ui->autoCloseConsoleCheck->setChecked(
+ m_settings->get("AutoCloseConsole").toBool());
+ ui->showConsoleErrorCheck->setChecked(
+ m_settings->get("ShowConsoleOnError").toBool());
+
+ // Window Size
+ ui->windowSizeGroupBox->setChecked(
+ m_settings->get("OverrideWindow").toBool());
+ ui->maximizedCheckBox->setChecked(
+ m_settings->get("LaunchMaximized").toBool());
+ ui->windowWidthSpinBox->setValue(
+ m_settings->get("MinecraftWinWidth").toInt());
+ ui->windowHeightSpinBox->setValue(
+ m_settings->get("MinecraftWinHeight").toInt());
+
+ // Memory
+ ui->memoryGroupBox->setChecked(m_settings->get("OverrideMemory").toBool());
+ int min = m_settings->get("MinMemAlloc").toInt();
+ int max = m_settings->get("MaxMemAlloc").toInt();
+ if (min < max) {
+ ui->minMemSpinBox->setValue(min);
+ ui->maxMemSpinBox->setValue(max);
+ } else {
+ ui->minMemSpinBox->setValue(max);
+ ui->maxMemSpinBox->setValue(min);
+ }
+ ui->permGenSpinBox->setValue(m_settings->get("PermGen").toInt());
+ bool permGenVisible = m_settings->get("PermGenVisible").toBool();
+ ui->permGenSpinBox->setVisible(permGenVisible);
+ ui->labelPermGen->setVisible(permGenVisible);
+ ui->labelPermgenNote->setVisible(permGenVisible);
+
+ // Java Settings
+ bool overrideJava = m_settings->get("OverrideJava").toBool();
+ bool overrideLocation =
+ m_settings->get("OverrideJavaLocation").toBool() || overrideJava;
+ bool overrideArgs =
+ m_settings->get("OverrideJavaArgs").toBool() || overrideJava;
+
+ ui->javaSettingsGroupBox->setChecked(overrideLocation);
+ ui->javaPathTextBox->setText(m_settings->get("JavaPath").toString());
+
+ ui->javaArgumentsGroupBox->setChecked(overrideArgs);
+ ui->jvmArgsTextBox->setPlainText(m_settings->get("JvmArgs").toString());
+
+ // Custom commands
+ ui->customCommands->initialize(
+ true, m_settings->get("OverrideCommands").toBool(),
+ m_settings->get("PreLaunchCommand").toString(),
+ m_settings->get("WrapperCommand").toString(),
+ m_settings->get("PostExitCommand").toString());
+
+ // Workarounds
+ ui->nativeWorkaroundsGroupBox->setChecked(
+ m_settings->get("OverrideNativeWorkarounds").toBool());
+ ui->useNativeGLFWCheck->setChecked(
+ m_settings->get("UseNativeGLFW").toBool());
+ ui->useNativeOpenALCheck->setChecked(
+ m_settings->get("UseNativeOpenAL").toBool());
+
+ // Miscellanous
+ ui->gameTimeGroupBox->setChecked(
+ m_settings->get("OverrideGameTime").toBool());
+ ui->showGameTime->setChecked(m_settings->get("ShowGameTime").toBool());
+ ui->recordGameTime->setChecked(m_settings->get("RecordGameTime").toBool());
+
+ ui->serverJoinGroupBox->setChecked(
+ m_settings->get("JoinServerOnLaunch").toBool());
+ ui->serverJoinAddress->setText(
+ m_settings->get("JoinServerOnLaunchAddress").toString());
+}
+
+void InstanceSettingsPage::on_javaDetectBtn_clicked()
+{
+ JavaInstallPtr java;
+
+ VersionSelectDialog vselect(APPLICATION->javalist().get(),
+ tr("Select a Java version"), this, true);
+ vselect.setResizeOn(2);
+ vselect.exec();
+
+ if (vselect.result() == QDialog::Accepted && vselect.selectedVersion()) {
+ java =
+ std::dynamic_pointer_cast<JavaInstall>(vselect.selectedVersion());
+ ui->javaPathTextBox->setText(java->path);
+ bool visible = java->id.requiresPermGen() &&
+ m_settings->get("OverrideMemory").toBool();
+ ui->permGenSpinBox->setVisible(visible);
+ ui->labelPermGen->setVisible(visible);
+ ui->labelPermgenNote->setVisible(visible);
+ m_settings->set("PermGenVisible", visible);
+ }
+}
+
+void InstanceSettingsPage::on_javaBrowseBtn_clicked()
+{
+ QString raw_path =
+ QFileDialog::getOpenFileName(this, tr("Find Java executable"));
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (raw_path.isEmpty()) {
+ return;
+ }
+ QString cooked_path = FS::NormalizePath(raw_path);
+
+ QFileInfo javaInfo(cooked_path);
+ if (!javaInfo.exists() || !javaInfo.isExecutable()) {
+ return;
+ }
+ ui->javaPathTextBox->setText(cooked_path);
+
+ // custom Java could be anything... enable perm gen option
+ ui->permGenSpinBox->setVisible(true);
+ ui->labelPermGen->setVisible(true);
+ ui->labelPermgenNote->setVisible(true);
+ m_settings->set("PermGenVisible", true);
+}
+
+void InstanceSettingsPage::on_javaTestBtn_clicked()
+{
+ if (checker) {
+ return;
+ }
+ checker.reset(new JavaCommon::TestCheck(
+ this, ui->javaPathTextBox->text(),
+ ui->jvmArgsTextBox->toPlainText().replace("\n", " "),
+ ui->minMemSpinBox->value(), ui->maxMemSpinBox->value(),
+ ui->permGenSpinBox->value()));
+ connect(checker.get(), SIGNAL(finished()), SLOT(checkerFinished()));
+ checker->run();
+}
+
+void InstanceSettingsPage::checkerFinished()
+{
+ checker.reset();
+}
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h
new file mode 100644
index 0000000000..7e388c45b8
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h
@@ -0,0 +1,99 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+
+#include "java/JavaChecker.h"
+#include "BaseInstance.h"
+#include <QObjectPtr.h>
+#include "ui/pages/BasePage.h"
+#include "JavaCommon.h"
+#include "Application.h"
+
+class JavaChecker;
+namespace Ui
+{
+ class InstanceSettingsPage;
+}
+
+class InstanceSettingsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit InstanceSettingsPage(BaseInstance* inst, QWidget* parent = 0);
+ virtual ~InstanceSettingsPage();
+ virtual QString displayName() const override
+ {
+ return tr("Settings");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("instance-settings");
+ }
+ virtual QString id() const override
+ {
+ return "settings";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Instance-settings";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_javaDetectBtn_clicked();
+ void on_javaTestBtn_clicked();
+ void on_javaBrowseBtn_clicked();
+
+ void applySettings();
+ void loadSettings();
+
+ void checkerFinished();
+
+ void globalSettingsButtonClicked(bool checked);
+
+ private:
+ Ui::InstanceSettingsPage* ui;
+ BaseInstance* m_instance;
+ SettingsObjectPtr m_settings;
+ unique_qobject_ptr<JavaCommon::TestCheck> checker;
+};
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui
new file mode 100644
index 0000000000..729f8e2a6c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui
@@ -0,0 +1,548 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>InstanceSettingsPage</class>
+ <widget class="QWidget" name="InstanceSettingsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>691</width>
+ <height>581</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QCommandLinkButton" name="openGlobalJavaSettingsButton">
+ <property name="text">
+ <string>Open Global Settings</string>
+ </property>
+ <property name="description">
+ <string>The settings here are overrides for global settings.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTabWidget" name="settingsTabs">
+ <property name="tabShape">
+ <enum>QTabWidget::Rounded</enum>
+ </property>
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="minecraftTab">
+ <attribute name="title">
+ <string notr="true">Java</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <widget class="QGroupBox" name="javaSettingsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Java insta&amp;llation</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0" colspan="3">
+ <widget class="QLineEdit" name="javaPathTextBox"/>
+ </item>
+ <item row="1" column="0">
+ <widget class="QPushButton" name="javaDetectBtn">
+ <property name="text">
+ <string>Auto-detect...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QPushButton" name="javaBrowseBtn">
+ <property name="text">
+ <string>Browse...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QPushButton" name="javaTestBtn">
+ <property name="text">
+ <string>Test</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="memoryGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Memor&amp;y</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelMinMem">
+ <property name="text">
+ <string>Minimum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="maxMemSpinBox">
+ <property name="toolTip">
+ <string>The maximum amount of memory Minecraft is allowed to use.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>1024</number>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="minMemSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory Minecraft is started with.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>256</number>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QSpinBox" name="permGenSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory available to store loaded Java classes.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>64</number>
+ </property>
+ <property name="maximum">
+ <number>999999999</number>
+ </property>
+ <property name="singleStep">
+ <number>8</number>
+ </property>
+ <property name="value">
+ <number>64</number>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelPermGen">
+ <property name="text">
+ <string notr="true">PermGen:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelMaxMem">
+ <property name="text">
+ <string>Maximum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0" colspan="2">
+ <widget class="QLabel" name="labelPermgenNote">
+ <property name="text">
+ <string>Note: Permgen is set automatically by Java 8 and later</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="javaArgumentsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Java argumen&amp;ts</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_5">
+ <item row="1" column="1">
+ <widget class="QPlainTextEdit" name="jvmArgsTextBox"/>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="javaTab">
+ <attribute name="title">
+ <string>Game windows</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QGroupBox" name="windowSizeGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Game Window</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <item>
+ <widget class="QCheckBox" name="maximizedCheckBox">
+ <property name="text">
+ <string>Start Minecraft maximized?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayoutWindowSize">
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelWindowHeight">
+ <property name="text">
+ <string>Window height:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelWindowWidth">
+ <property name="text">
+ <string>Window width:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="windowWidthSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>1</number>
+ </property>
+ <property name="value">
+ <number>854</number>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="windowHeightSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="value">
+ <number>480</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="consoleSettingsBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Conso&amp;le Settings</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QCheckBox" name="showConsoleCheck">
+ <property name="text">
+ <string>Show console while the game is running?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="autoCloseConsoleCheck">
+ <property name="text">
+ <string>Automatically close console when the game quits?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="showConsoleErrorCheck">
+ <property name="text">
+ <string>Show console when the game crashes?</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacerMinecraft_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>88</width>
+ <height>125</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string>Custom commands</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_6">
+ <item>
+ <widget class="CustomCommands" name="customCommands" native="true"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="workaroundsPage">
+ <attribute name="title">
+ <string>Workarounds</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_8">
+ <item>
+ <widget class="QGroupBox" name="nativeWorkaroundsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Native libraries</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_7">
+ <item>
+ <widget class="QCheckBox" name="useNativeGLFWCheck">
+ <property name="text">
+ <string>Use system installation of GLFW</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="useNativeOpenALCheck">
+ <property name="text">
+ <string>Use system installation of OpenAL</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="miscellaneousPage">
+ <attribute name="title">
+ <string>Miscellaneous</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_9">
+ <item>
+ <widget class="QGroupBox" name="gameTimeGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Override global game time settings</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_10">
+ <item>
+ <widget class="QCheckBox" name="showGameTime">
+ <property name="text">
+ <string>Show time spent playing this instance</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="recordGameTime">
+ <property name="text">
+ <string>Record time spent playing this instance</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="serverJoinGroupBox">
+ <property name="title">
+ <string>Set a server to join on launch</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_11">
+ <item>
+ <layout class="QGridLayout" name="serverJoinLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="serverJoinAddressLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Server address:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="serverJoinAddress"/>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacerMiscellaneous">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>CustomCommands</class>
+ <extends>QWidget</extends>
+ <header>ui/widgets/CustomCommands.h</header>
+ <container>1</container>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>openGlobalJavaSettingsButton</tabstop>
+ <tabstop>settingsTabs</tabstop>
+ <tabstop>javaSettingsGroupBox</tabstop>
+ <tabstop>javaPathTextBox</tabstop>
+ <tabstop>javaDetectBtn</tabstop>
+ <tabstop>javaBrowseBtn</tabstop>
+ <tabstop>javaTestBtn</tabstop>
+ <tabstop>memoryGroupBox</tabstop>
+ <tabstop>minMemSpinBox</tabstop>
+ <tabstop>maxMemSpinBox</tabstop>
+ <tabstop>permGenSpinBox</tabstop>
+ <tabstop>javaArgumentsGroupBox</tabstop>
+ <tabstop>jvmArgsTextBox</tabstop>
+ <tabstop>windowSizeGroupBox</tabstop>
+ <tabstop>maximizedCheckBox</tabstop>
+ <tabstop>windowWidthSpinBox</tabstop>
+ <tabstop>windowHeightSpinBox</tabstop>
+ <tabstop>consoleSettingsBox</tabstop>
+ <tabstop>showConsoleCheck</tabstop>
+ <tabstop>autoCloseConsoleCheck</tabstop>
+ <tabstop>showConsoleErrorCheck</tabstop>
+ <tabstop>nativeWorkaroundsGroupBox</tabstop>
+ <tabstop>useNativeGLFWCheck</tabstop>
+ <tabstop>useNativeOpenALCheck</tabstop>
+ <tabstop>showGameTime</tabstop>
+ <tabstop>recordGameTime</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp
new file mode 100644
index 0000000000..7d12fad0e7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp
@@ -0,0 +1,74 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LegacyUpgradePage.h"
+#include "ui_LegacyUpgradePage.h"
+
+#include "InstanceList.h"
+#include "minecraft/legacy/LegacyInstance.h"
+#include "minecraft/legacy/LegacyUpgradeTask.h"
+#include "Application.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/ProgressDialog.h"
+
+LegacyUpgradePage::LegacyUpgradePage(InstancePtr inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::LegacyUpgradePage), m_inst(inst)
+{
+ ui->setupUi(this);
+}
+
+LegacyUpgradePage::~LegacyUpgradePage()
+{
+ delete ui;
+}
+
+void LegacyUpgradePage::runModalTask(Task* task)
+{
+ connect(task, &Task::failed, [this](QString reason) {
+ CustomMessageBox::selectable(this, tr("Error"), reason,
+ QMessageBox::Warning)
+ ->show();
+ });
+ ProgressDialog loadDialog(this);
+ loadDialog.setSkipButton(true, tr("Abort"));
+ if (loadDialog.execWithTask(task) == QDialog::Accepted) {
+ m_container->requestClose();
+ }
+}
+
+void LegacyUpgradePage::on_upgradeButton_clicked()
+{
+ QString newName = tr("%1 (Migrated)").arg(m_inst->name());
+ auto upgradeTask = new LegacyUpgradeTask(m_inst);
+ upgradeTask->setName(newName);
+ upgradeTask->setGroup(
+ APPLICATION->instances()->getInstanceGroup(m_inst->id()));
+ upgradeTask->setIcon(m_inst->iconKey());
+ unique_qobject_ptr<Task> task(
+ APPLICATION->instances()->wrapInstanceTask(upgradeTask));
+ runModalTask(task.get());
+}
+
+bool LegacyUpgradePage::shouldDisplay() const
+{
+ return !m_inst->isRunning();
+}
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h
new file mode 100644
index 0000000000..bba6c35b5f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h
@@ -0,0 +1,87 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+
+#include "minecraft/legacy/LegacyInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class LegacyUpgradePage;
+}
+
+class LegacyUpgradePage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit LegacyUpgradePage(InstancePtr inst, QWidget* parent = 0);
+ virtual ~LegacyUpgradePage();
+ virtual QString displayName() const override
+ {
+ return tr("Upgrade");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("checkupdate");
+ }
+ virtual QString id() const override
+ {
+ return "upgrade";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Legacy-upgrade";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_upgradeButton_clicked();
+
+ private:
+ void runModalTask(Task* task);
+
+ private:
+ Ui::LegacyUpgradePage* ui;
+ InstancePtr m_inst;
+};
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui
new file mode 100644
index 0000000000..3897ce3758
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LegacyUpgradePage</class>
+ <widget class="QWidget" name="LegacyUpgradePage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>546</width>
+ <height>405</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTextBrowser" name="textBrowser">
+ <property name="html">
+ <string>&lt;html&gt;&lt;body&gt;&lt;h1&gt;Upgrade is required&lt;/h1&gt;&lt;p&gt;MeshMC now supports old Minecraft versions and all the required features in the new (OneSix) instance format. As a consequence, the old (Legacy) format has been entirely disabled and old instances need to be upgraded.&lt;/p&gt;&lt;p&gt;The upgrade will create a new instance with the same contents as the current one, in the new format. The original instance will remain untouched, in case anything goes wrong in the process.&lt;/p&gt;&lt;p&gt;Please report any issues on our &lt;a href=&quot;https://github.com/Project-Tick/MeshMC/issues&quot;&gt;github issues page&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;There is also a &lt;a href=&quot;https://discord.gg/GtPmv93&quot;&gt;discord channel for testing here&lt;/a&gt;.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCommandLinkButton" name="upgradeButton">
+ <property name="text">
+ <string>Upgrade the instance</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.cpp b/meshmc/launcher/ui/pages/instance/LogPage.cpp
new file mode 100644
index 0000000000..8f4f4c11a4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.cpp
@@ -0,0 +1,337 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LogPage.h"
+#include "ui_LogPage.h"
+
+#include "Application.h"
+
+#include <QIcon>
+#include <QScrollBar>
+#include <QShortcut>
+
+#include "launch/LaunchTask.h"
+#include "settings/Setting.h"
+
+#include "ui/GuiUtil.h"
+#include "ui/ColorCache.h"
+
+#include <BuildConfig.h>
+
+class LogFormatProxyModel : public QIdentityProxyModel
+{
+ public:
+ LogFormatProxyModel(QObject* parent = nullptr) : QIdentityProxyModel(parent)
+ {
+ }
+ QVariant data(const QModelIndex& index, int role) const override
+ {
+ switch (role) {
+ case Qt::FontRole:
+ return m_font;
+ case Qt::ForegroundRole: {
+ MessageLevel::Enum level =
+ (MessageLevel::Enum)QIdentityProxyModel::data(
+ index, LogModel::LevelRole)
+ .toInt();
+ return m_colors->getFront(level);
+ }
+ case Qt::BackgroundRole: {
+ MessageLevel::Enum level =
+ (MessageLevel::Enum)QIdentityProxyModel::data(
+ index, LogModel::LevelRole)
+ .toInt();
+ return m_colors->getBack(level);
+ }
+ default:
+ return QIdentityProxyModel::data(index, role);
+ }
+ }
+
+ void setFont(QFont font)
+ {
+ m_font = font;
+ }
+
+ void setColors(LogColorCache* colors)
+ {
+ m_colors.reset(colors);
+ }
+
+ QModelIndex find(const QModelIndex& start, const QString& value,
+ bool reverse) const
+ {
+ QModelIndex parentIndex = parent(start);
+ auto compare = [&](int r) -> QModelIndex {
+ QModelIndex idx = index(r, start.column(), parentIndex);
+ if (!idx.isValid() || idx == start) {
+ return QModelIndex();
+ }
+ QVariant v = data(idx, Qt::DisplayRole);
+ QString t = v.toString();
+ if (t.contains(value, Qt::CaseInsensitive))
+ return idx;
+ return QModelIndex();
+ };
+ if (reverse) {
+ int from = start.row();
+ int to = 0;
+
+ for (int i = 0; i < 2; ++i) {
+ for (int r = from; (r >= to); --r) {
+ auto idx = compare(r);
+ if (idx.isValid())
+ return idx;
+ }
+ // prepare for the next iteration
+ from = rowCount() - 1;
+ to = start.row();
+ }
+ } else {
+ int from = start.row();
+ int to = rowCount(parentIndex);
+
+ for (int i = 0; i < 2; ++i) {
+ for (int r = from; (r < to); ++r) {
+ auto idx = compare(r);
+ if (idx.isValid())
+ return idx;
+ }
+ // prepare for the next iteration
+ from = 0;
+ to = start.row();
+ }
+ }
+ return QModelIndex();
+ }
+
+ private:
+ QFont m_font;
+ std::unique_ptr<LogColorCache> m_colors;
+};
+
+LogPage::LogPage(InstancePtr instance, QWidget* parent)
+ : QWidget(parent), ui(new Ui::LogPage), m_instance(instance)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+
+ m_proxy = new LogFormatProxyModel(this);
+ // set up text colors in the log proxy and adapt them to the current theme
+ // foreground and background
+ {
+ auto origForeground =
+ ui->text->palette().color(ui->text->foregroundRole());
+ auto origBackground =
+ ui->text->palette().color(ui->text->backgroundRole());
+ m_proxy->setColors(new LogColorCache(origForeground, origBackground));
+ }
+
+ // set up fonts in the log proxy
+ {
+ QString fontFamily =
+ APPLICATION->settings()->get("ConsoleFont").toString();
+ bool conversionOk = false;
+ int fontSize = APPLICATION->settings()
+ ->get("ConsoleFontSize")
+ .toInt(&conversionOk);
+ if (!conversionOk) {
+ fontSize = 11;
+ }
+ m_proxy->setFont(QFont(fontFamily, fontSize));
+ }
+
+ ui->text->setModel(m_proxy);
+
+ // set up instance and launch process recognition
+ {
+ auto launchTask = m_instance->getLaunchTask();
+ if (launchTask) {
+ setInstanceLaunchTaskChanged(launchTask, true);
+ }
+ connect(m_instance.get(), &BaseInstance::launchTaskChanged, this,
+ &LogPage::onInstanceLaunchTaskChanged);
+ }
+
+ auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this);
+ connect(findShortcut, SIGNAL(activated()), SLOT(findActivated()));
+ auto findNextShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindNext), this);
+ connect(findNextShortcut, SIGNAL(activated()), SLOT(findNextActivated()));
+ connect(ui->searchBar, SIGNAL(returnPressed()),
+ SLOT(on_findButton_clicked()));
+ auto findPreviousShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindPrevious), this);
+ connect(findPreviousShortcut, SIGNAL(activated()),
+ SLOT(findPreviousActivated()));
+}
+
+LogPage::~LogPage()
+{
+ delete ui;
+}
+
+void LogPage::modelStateToUI()
+{
+ if (m_model->wrapLines()) {
+ ui->text->setWordWrap(true);
+ ui->wrapCheckbox->setCheckState(Qt::Checked);
+ } else {
+ ui->text->setWordWrap(false);
+ ui->wrapCheckbox->setCheckState(Qt::Unchecked);
+ }
+ if (m_model->suspended()) {
+ ui->trackLogCheckbox->setCheckState(Qt::Unchecked);
+ } else {
+ ui->trackLogCheckbox->setCheckState(Qt::Checked);
+ }
+}
+
+void LogPage::UIToModelState()
+{
+ if (!m_model) {
+ return;
+ }
+ m_model->setLineWrap(ui->wrapCheckbox->checkState() == Qt::Checked);
+ m_model->suspend(ui->trackLogCheckbox->checkState() != Qt::Checked);
+}
+
+void LogPage::setInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc,
+ bool initial)
+{
+ m_process = proc;
+ if (m_process) {
+ m_model = proc->getLogModel();
+ m_proxy->setSourceModel(m_model.get());
+ if (initial) {
+ modelStateToUI();
+ } else {
+ UIToModelState();
+ }
+ } else {
+ m_proxy->setSourceModel(nullptr);
+ m_model.reset();
+ }
+}
+
+void LogPage::onInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc)
+{
+ setInstanceLaunchTaskChanged(proc, false);
+}
+
+bool LogPage::apply()
+{
+ return true;
+}
+
+bool LogPage::shouldDisplay() const
+{
+ return m_instance->isRunning() || m_proxy->rowCount() > 0;
+}
+
+void LogPage::on_btnPaste_clicked()
+{
+ if (!m_model)
+ return;
+
+ // FIXME: turn this into a proper task and move the upload logic out of
+ // GuiUtil!
+ m_model->append(
+ MessageLevel::MeshMC,
+ QString("%2: Log upload triggered at: %1")
+ .arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date),
+ BuildConfig.MESHMC_NAME));
+ auto url = GuiUtil::uploadPaste(m_model->toPlainText(), this);
+ if (!url.isEmpty()) {
+ m_model->append(MessageLevel::MeshMC,
+ QString("%2: Log uploaded to: %1")
+ .arg(url, BuildConfig.MESHMC_NAME));
+ } else {
+ m_model->append(
+ MessageLevel::Error,
+ QString("%1: Log upload failed!").arg(BuildConfig.MESHMC_NAME));
+ }
+}
+
+void LogPage::on_btnCopy_clicked()
+{
+ if (!m_model)
+ return;
+ m_model->append(
+ MessageLevel::MeshMC,
+ QString("Clipboard copy at: %1")
+ .arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date)));
+ GuiUtil::setClipboardText(m_model->toPlainText());
+}
+
+void LogPage::on_btnClear_clicked()
+{
+ if (!m_model)
+ return;
+ m_model->clear();
+ m_container->refreshContainer();
+}
+
+void LogPage::on_btnBottom_clicked()
+{
+ ui->text->scrollToBottom();
+}
+
+void LogPage::on_trackLogCheckbox_clicked(bool checked)
+{
+ if (!m_model)
+ return;
+ m_model->suspend(!checked);
+}
+
+void LogPage::on_wrapCheckbox_clicked(bool checked)
+{
+ ui->text->setWordWrap(checked);
+ if (!m_model)
+ return;
+ m_model->setLineWrap(checked);
+}
+
+void LogPage::on_findButton_clicked()
+{
+ auto modifiers = QApplication::keyboardModifiers();
+ bool reverse = modifiers & Qt::ShiftModifier;
+ ui->text->findNext(ui->searchBar->text(), reverse);
+}
+
+void LogPage::findNextActivated()
+{
+ ui->text->findNext(ui->searchBar->text(), false);
+}
+
+void LogPage::findPreviousActivated()
+{
+ ui->text->findNext(ui->searchBar->text(), true);
+}
+
+void LogPage::findActivated()
+{
+ // focus the search bar if it doesn't have focus
+ if (!ui->searchBar->hasFocus()) {
+ ui->searchBar->setFocus();
+ ui->searchBar->selectAll();
+ }
+}
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.h b/meshmc/launcher/ui/pages/instance/LogPage.h
new file mode 100644
index 0000000000..1c9b39fc0e
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.h
@@ -0,0 +1,110 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+
+#include "BaseInstance.h"
+#include "launch/LaunchTask.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class LogPage;
+}
+class QTextCharFormat;
+class LogFormatProxyModel;
+
+class LogPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit LogPage(InstancePtr instance, QWidget* parent = 0);
+ virtual ~LogPage();
+ virtual QString displayName() const override
+ {
+ return tr("Minecraft Log");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("log");
+ }
+ virtual QString id() const override
+ {
+ return "console";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Minecraft-Logs";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_btnPaste_clicked();
+ void on_btnCopy_clicked();
+ void on_btnClear_clicked();
+ void on_btnBottom_clicked();
+
+ void on_trackLogCheckbox_clicked(bool checked);
+ void on_wrapCheckbox_clicked(bool checked);
+
+ void on_findButton_clicked();
+ void findActivated();
+ void findNextActivated();
+ void findPreviousActivated();
+
+ void onInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc);
+
+ private:
+ void modelStateToUI();
+ void UIToModelState();
+ void setInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc,
+ bool initial);
+
+ private:
+ Ui::LogPage* ui;
+ InstancePtr m_instance;
+ shared_qobject_ptr<LaunchTask> m_process;
+
+ LogFormatProxyModel* m_proxy;
+ shared_qobject_ptr<LogModel> m_model;
+};
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.ui b/meshmc/launcher/ui/pages/instance/LogPage.ui
new file mode 100644
index 0000000000..ccfc15517b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.ui
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LogPage</class>
+ <widget class="QWidget" name="LogPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>825</width>
+ <height>782</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="1" column="0" colspan="5">
+ <widget class="LogView" name="text">
+ <property name="undoRedoEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="plainText">
+ <string notr="true"/>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ <property name="centerOnScroll">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="5">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QCheckBox" name="trackLogCheckbox">
+ <property name="text">
+ <string>Keep updating</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="wrapCheckbox">
+ <property name="text">
+ <string>Wrap lines</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnCopy">
+ <property name="toolTip">
+ <string>Copy the whole log into the clipboard</string>
+ </property>
+ <property name="text">
+ <string>&amp;Copy</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnPaste">
+ <property name="toolTip">
+ <string>Upload the log to paste.ee - it will stay online for a month</string>
+ </property>
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnClear">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Clear</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Search:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="2">
+ <widget class="QPushButton" name="findButton">
+ <property name="text">
+ <string>Find</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="searchBar"/>
+ </item>
+ <item row="2" column="4">
+ <widget class="QPushButton" name="btnBottom">
+ <property name="toolTip">
+ <string>Scroll all the way to bottom</string>
+ </property>
+ <property name="text">
+ <string>Bottom</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="3">
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>LogView</class>
+ <extends>QPlainTextEdit</extends>
+ <header>ui/widgets/LogView.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>trackLogCheckbox</tabstop>
+ <tabstop>wrapCheckbox</tabstop>
+ <tabstop>btnCopy</tabstop>
+ <tabstop>btnPaste</tabstop>
+ <tabstop>btnClear</tabstop>
+ <tabstop>text</tabstop>
+ <tabstop>searchBar</tabstop>
+ <tabstop>findButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp b/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp
new file mode 100644
index 0000000000..b894534f5f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp
@@ -0,0 +1,407 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+#include <QMessageBox>
+#include <QEvent>
+#include <QKeyEvent>
+#include <QAbstractItemModel>
+#include <QMenu>
+#include <QSortFilterProxyModel>
+
+#include "Application.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/GuiUtil.h"
+
+#include "DesktopServices.h"
+
+#include "minecraft/mod/ModFolderModel.h"
+#include "minecraft/mod/Mod.h"
+#include "minecraft/VersionFilterData.h"
+#include "minecraft/PackProfile.h"
+
+#include "Version.h"
+
+namespace
+{
+ // FIXME: wasteful
+ void RemoveThePrefix(QString& string)
+ {
+ QRegularExpression regex(
+ QStringLiteral("^(([Tt][Hh][eE])|([Tt][eE][Hh])) +"));
+ string.remove(regex);
+ string = string.trimmed();
+ }
+} // namespace
+
+class ModSortProxy : public QSortFilterProxyModel
+{
+ public:
+ explicit ModSortProxy(QObject* parent = 0) : QSortFilterProxyModel(parent)
+ {
+ }
+
+ protected:
+ bool filterAcceptsRow(int source_row,
+ const QModelIndex& source_parent) const override
+ {
+ ModFolderModel* model = qobject_cast<ModFolderModel*>(sourceModel());
+ if (!model) {
+ return false;
+ }
+ const auto& mod = model->at(source_row);
+ if (mod.name().contains(filterRegularExpression())) {
+ return true;
+ }
+ if (mod.description().contains(filterRegularExpression())) {
+ return true;
+ }
+ for (auto& author : mod.authors()) {
+ if (author.contains(filterRegularExpression())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ bool lessThan(const QModelIndex& source_left,
+ const QModelIndex& source_right) const override
+ {
+ ModFolderModel* model = qobject_cast<ModFolderModel*>(sourceModel());
+ if (!model || !source_left.isValid() || !source_right.isValid() ||
+ source_left.column() != source_right.column()) {
+ return QSortFilterProxyModel::lessThan(source_left, source_right);
+ }
+
+ // we are now guaranteed to have two valid indexes in the same column...
+ // we love the provided invariants unconditionally and proceed.
+
+ auto column = (ModFolderModel::Columns)source_left.column();
+ bool invert = false;
+ switch (column) {
+ // GH-2550 - sort by enabled/disabled
+ case ModFolderModel::ActiveColumn: {
+ auto dataL = source_left.data(Qt::CheckStateRole).toBool();
+ auto dataR = source_right.data(Qt::CheckStateRole).toBool();
+ if (dataL != dataR) {
+ return dataL > dataR;
+ }
+ // fallthrough
+ invert = sortOrder() == Qt::DescendingOrder;
+ }
+ // GH-2722 - sort mod names in a way that discards "The" prefixes
+ case ModFolderModel::NameColumn: {
+ auto dataL =
+ model
+ ->data(model->index(source_left.row(),
+ ModFolderModel::NameColumn))
+ .toString();
+ RemoveThePrefix(dataL);
+ auto dataR =
+ model
+ ->data(model->index(source_right.row(),
+ ModFolderModel::NameColumn))
+ .toString();
+ RemoveThePrefix(dataR);
+
+ auto less = dataL.compare(dataR, sortCaseSensitivity());
+ if (less != 0) {
+ return invert ? (less > 0) : (less < 0);
+ }
+ // fallthrough
+ invert = sortOrder() == Qt::DescendingOrder;
+ }
+ // GH-2762 - sort versions by parsing them as versions
+ case ModFolderModel::VersionColumn: {
+ auto dataL = Version(
+ model
+ ->data(model->index(source_left.row(),
+ ModFolderModel::VersionColumn))
+ .toString());
+ auto dataR = Version(
+ model
+ ->data(model->index(source_right.row(),
+ ModFolderModel::VersionColumn))
+ .toString());
+ return invert ? (dataL > dataR) : (dataL < dataR);
+ }
+ default: {
+ return QSortFilterProxyModel::lessThan(source_left,
+ source_right);
+ }
+ }
+ }
+};
+
+ModFolderPage::ModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ModFolderPage)
+{
+ ui->setupUi(this);
+ ui->actionsToolbar->insertSpacer(ui->actionView_configs);
+
+ m_inst = inst;
+ on_RunningState_changed(m_inst && m_inst->isRunning());
+ m_mods = mods;
+ m_id = id;
+ m_displayName = displayName;
+ m_iconName = iconName;
+ m_helpName = helpPage;
+ m_fileSelectionFilter = "%1 (*.zip *.jar)";
+ m_filterModel = new ModSortProxy(this);
+ m_filterModel->setDynamicSortFilter(true);
+ m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSourceModel(m_mods.get());
+ m_filterModel->setFilterKeyColumn(-1);
+ ui->modTreeView->setModel(m_filterModel);
+ ui->modTreeView->installEventFilter(this);
+ ui->modTreeView->sortByColumn(1, Qt::AscendingOrder);
+ ui->modTreeView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->modTreeView, &ModListView::customContextMenuRequested, this,
+ &ModFolderPage::ShowContextMenu);
+ connect(ui->modTreeView, &ModListView::activated, this,
+ &ModFolderPage::modItemActivated);
+
+ auto smodel = ui->modTreeView->selectionModel();
+ connect(smodel, &QItemSelectionModel::currentChanged, this,
+ &ModFolderPage::modCurrent);
+ connect(ui->filterEdit, &QLineEdit::textChanged, this,
+ &ModFolderPage::on_filterTextChanged);
+ connect(m_inst, &BaseInstance::runningStatusChanged, this,
+ &ModFolderPage::on_RunningState_changed);
+}
+
+void ModFolderPage::modItemActivated(const QModelIndex&)
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Toggle);
+}
+
+QMenu* ModFolderPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->actionsToolbar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ModFolderPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->actionsToolbar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->modTreeView->mapToGlobal(pos));
+ delete menu;
+}
+
+void ModFolderPage::openedImpl()
+{
+ m_mods->startWatching();
+}
+
+void ModFolderPage::closedImpl()
+{
+ m_mods->stopWatching();
+}
+
+void ModFolderPage::on_filterTextChanged(const QString& newContents)
+{
+ m_viewFilter = newContents;
+ m_filterModel->setFilterFixedString(m_viewFilter);
+}
+
+CoreModFolderPage::CoreModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods,
+ QString id, QString iconName,
+ QString displayName, QString helpPage,
+ QWidget* parent)
+ : ModFolderPage(inst, mods, id, iconName, displayName, helpPage, parent)
+{
+}
+
+ModFolderPage::~ModFolderPage()
+{
+ m_mods->stopWatching();
+ delete ui;
+}
+
+void ModFolderPage::on_RunningState_changed(bool running)
+{
+ if (m_controlsEnabled == !running) {
+ return;
+ }
+ m_controlsEnabled = !running;
+ ui->actionAdd->setEnabled(m_controlsEnabled);
+ ui->actionDisable->setEnabled(m_controlsEnabled);
+ ui->actionEnable->setEnabled(m_controlsEnabled);
+ ui->actionRemove->setEnabled(m_controlsEnabled);
+}
+
+bool ModFolderPage::shouldDisplay() const
+{
+ return true;
+}
+
+bool CoreModFolderPage::shouldDisplay() const
+{
+ if (ModFolderPage::shouldDisplay()) {
+ auto inst = dynamic_cast<MinecraftInstance*>(m_inst);
+ if (!inst)
+ return true;
+ auto version = inst->getPackProfile();
+ if (!version)
+ return true;
+ if (!version->getComponent("net.minecraftforge")) {
+ return false;
+ }
+ if (!version->getComponent("net.minecraft")) {
+ return false;
+ }
+ if (version->getComponent("net.minecraft")->getReleaseDateTime() <
+ g_VersionFilterData.legacyCutoffDate) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool ModFolderPage::modListFilter(QKeyEvent* keyEvent)
+{
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionRemove_triggered();
+ return true;
+ case Qt::Key_Plus:
+ on_actionAdd_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(ui->modTreeView, keyEvent);
+}
+
+bool ModFolderPage::eventFilter(QObject* obj, QEvent* ev)
+{
+ if (ev->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, ev);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(ev);
+ if (obj == ui->modTreeView)
+ return modListFilter(keyEvent);
+ return QWidget::eventFilter(obj, ev);
+}
+
+void ModFolderPage::on_actionAdd_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto list = GuiUtil::BrowseForFiles(
+ m_helpName,
+ tr("Select %1", "Select whatever type of files the page contains. "
+ "Example: 'Loader Mods'")
+ .arg(m_displayName),
+ m_fileSelectionFilter.arg(m_displayName),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!list.empty()) {
+ for (auto filename : list) {
+ m_mods->installMod(filename);
+ }
+ }
+}
+
+void ModFolderPage::on_actionEnable_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Enable);
+}
+
+void ModFolderPage::on_actionDisable_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Disable);
+}
+
+void ModFolderPage::on_actionRemove_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->deleteMods(selection.indexes());
+}
+
+void ModFolderPage::on_actionView_configs_triggered()
+{
+ DesktopServices::openDirectory(m_inst->instanceConfigFolder(), true);
+}
+
+void ModFolderPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_mods->dir().absolutePath(), true);
+}
+
+void ModFolderPage::modCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (!current.isValid()) {
+ ui->frame->clear();
+ return;
+ }
+ auto sourceCurrent = m_filterModel->mapToSource(current);
+ int row = sourceCurrent.row();
+ Mod& m = m_mods->operator[](row);
+ ui->frame->updateWithMod(m);
+}
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.h b/meshmc/launcher/ui/pages/instance/ModFolderPage.h
new file mode 100644
index 0000000000..59e9cea937
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.h
@@ -0,0 +1,136 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "ui/pages/BasePage.h"
+
+#include <Application.h>
+
+class ModFolderModel;
+namespace Ui
+{
+ class ModFolderPage;
+}
+
+class ModFolderPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage = "", QWidget* parent = 0);
+ virtual ~ModFolderPage();
+
+ void setFilter(const QString& filter)
+ {
+ m_fileSelectionFilter = filter;
+ }
+
+ virtual QString displayName() const override
+ {
+ return m_displayName;
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon(m_iconName);
+ }
+ virtual QString id() const override
+ {
+ return m_id;
+ }
+ virtual QString helpPage() const override
+ {
+ return m_helpName;
+ }
+ virtual bool shouldDisplay() const override;
+
+ virtual void openedImpl() override;
+ virtual void closedImpl() override;
+
+ protected:
+ bool eventFilter(QObject* obj, QEvent* ev) override;
+ bool modListFilter(QKeyEvent* ev);
+ QMenu* createPopupMenu() override;
+
+ protected:
+ BaseInstance* m_inst = nullptr;
+
+ protected:
+ Ui::ModFolderPage* ui = nullptr;
+ std::shared_ptr<ModFolderModel> m_mods;
+ QSortFilterProxyModel* m_filterModel = nullptr;
+ QString m_iconName;
+ QString m_id;
+ QString m_displayName;
+ QString m_helpName;
+ QString m_fileSelectionFilter;
+ QString m_viewFilter;
+ bool m_controlsEnabled = true;
+
+ public slots:
+ void modCurrent(const QModelIndex& current, const QModelIndex& previous);
+
+ private slots:
+ void modItemActivated(const QModelIndex& index);
+ void on_filterTextChanged(const QString& newContents);
+ void on_RunningState_changed(bool running);
+ void on_actionAdd_triggered();
+ void on_actionRemove_triggered();
+ void on_actionEnable_triggered();
+ void on_actionDisable_triggered();
+ void on_actionView_Folder_triggered();
+ void on_actionView_configs_triggered();
+ void ShowContextMenu(const QPoint& pos);
+};
+
+class CoreModFolderPage : public ModFolderPage
+{
+ public:
+ explicit CoreModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage = "", QWidget* parent = 0);
+ virtual ~CoreModFolderPage() {}
+ virtual bool shouldDisplay() const;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.ui b/meshmc/launcher/ui/pages/instance/ModFolderPage.ui
new file mode 100644
index 0000000000..0fb51e84fc
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.ui
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ModFolderPage</class>
+ <widget class="QMainWindow" name="ModFolderPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>1042</width>
+ <height>501</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QGridLayout" name="gridLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item row="4" column="1" colspan="3">
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="filterEdit">
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="filterLabel">
+ <property name="text">
+ <string>Filter:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="1" colspan="3">
+ <widget class="MCModInfoFrame" name="frame">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1" colspan="3">
+ <widget class="ModListView" name="modTreeView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="dragDropMode">
+ <enum>QAbstractItemView::DropOnly</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="actionsToolbar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="separator"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionEnable"/>
+ <addaction name="actionDisable"/>
+ <addaction name="actionView_configs"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>&amp;Add</string>
+ </property>
+ <property name="toolTip">
+ <string>Add mods</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>&amp;Remove</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove selected mods</string>
+ </property>
+ </action>
+ <action name="actionEnable">
+ <property name="text">
+ <string>&amp;Enable</string>
+ </property>
+ <property name="toolTip">
+ <string>Enable selected mods</string>
+ </property>
+ </action>
+ <action name="actionDisable">
+ <property name="text">
+ <string>&amp;Disable</string>
+ </property>
+ <property name="toolTip">
+ <string>Disable selected mods</string>
+ </property>
+ </action>
+ <action name="actionView_configs">
+ <property name="text">
+ <string>View &amp;Configs</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the 'config' folder in the system file manager.</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View &amp;Folder</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>ModListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/ModListView.h</header>
+ </customwidget>
+ <customwidget>
+ <class>MCModInfoFrame</class>
+ <extends>QFrame</extends>
+ <header>ui/widgets/MCModInfoFrame.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>modTreeView</tabstop>
+ <tabstop>filterEdit</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.cpp b/meshmc/launcher/ui/pages/instance/NotesPage.cpp
new file mode 100644
index 0000000000..8cbb56637a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.cpp
@@ -0,0 +1,42 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "NotesPage.h"
+#include "ui_NotesPage.h"
+#include <QTabBar>
+
+NotesPage::NotesPage(BaseInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::NotesPage), m_inst(inst)
+{
+ ui->setupUi(this);
+ ui->noteEditor->setText(m_inst->notes());
+}
+
+NotesPage::~NotesPage()
+{
+ delete ui;
+}
+
+bool NotesPage::apply()
+{
+ m_inst->setNotes(ui->noteEditor->toPlainText());
+ return true;
+}
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.h b/meshmc/launcher/ui/pages/instance/NotesPage.h
new file mode 100644
index 0000000000..07265ffa76
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.h
@@ -0,0 +1,83 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+
+#include "BaseInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class NotesPage;
+}
+
+class NotesPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit NotesPage(BaseInstance* inst, QWidget* parent = 0);
+ virtual ~NotesPage();
+ virtual QString displayName() const override
+ {
+ return tr("Notes");
+ }
+ virtual QIcon icon() const override
+ {
+ auto icon = APPLICATION->getThemedIcon("notes");
+ if (icon.isNull())
+ icon = APPLICATION->getThemedIcon("news");
+ return icon;
+ }
+ virtual QString id() const override
+ {
+ return "notes";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Notes";
+ }
+
+ private:
+ Ui::NotesPage* ui;
+ BaseInstance* m_inst;
+};
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.ui b/meshmc/launcher/ui/pages/instance/NotesPage.ui
new file mode 100644
index 0000000000..67cb261c1b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.ui
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NotesPage</class>
+ <widget class="QWidget" name="NotesPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>731</width>
+ <height>538</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTextEdit" name="noteEditor">
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="tabChangesFocus">
+ <bool>true</bool>
+ </property>
+ <property name="acceptRichText">
+ <bool>false</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextEditable|Qt::TextEditorInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>noteEditor</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp b/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp
new file mode 100644
index 0000000000..c1c357f622
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp
@@ -0,0 +1,314 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 "OtherLogsPage.h"
+#include "ui_OtherLogsPage.h"
+
+#include <QMessageBox>
+
+#include "ui/GuiUtil.h"
+
+#include "RecursiveFileSystemWatcher.h"
+#include <GZip.h>
+#include <FileSystem.h>
+#include <QShortcut>
+
+OtherLogsPage::OtherLogsPage(QString path, IPathMatcher::Ptr fileFilter,
+ QWidget* parent)
+ : QWidget(parent), ui(new Ui::OtherLogsPage), m_path(path),
+ m_fileFilter(fileFilter), m_watcher(new RecursiveFileSystemWatcher(this))
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+
+ m_watcher->setMatcher(fileFilter);
+ m_watcher->setRootDir(QDir::current().absoluteFilePath(m_path));
+
+ connect(m_watcher, &RecursiveFileSystemWatcher::filesChanged, this,
+ &OtherLogsPage::populateSelectLogBox);
+ populateSelectLogBox();
+
+ auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this);
+ connect(findShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findActivated);
+
+ auto findNextShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindNext), this);
+ connect(findNextShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findNextActivated);
+
+ auto findPreviousShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindPrevious), this);
+ connect(findPreviousShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findPreviousActivated);
+
+ connect(ui->searchBar, &QLineEdit::returnPressed, this,
+ &OtherLogsPage::on_findButton_clicked);
+}
+
+OtherLogsPage::~OtherLogsPage()
+{
+ delete ui;
+}
+
+void OtherLogsPage::openedImpl()
+{
+ m_watcher->enable();
+}
+void OtherLogsPage::closedImpl()
+{
+ m_watcher->disable();
+}
+
+void OtherLogsPage::populateSelectLogBox()
+{
+ ui->selectLogBox->clear();
+ ui->selectLogBox->addItems(m_watcher->files());
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ ui->selectLogBox->setCurrentIndex(-1);
+ } else {
+ const int index = ui->selectLogBox->findText(m_currentFile);
+ if (index != -1) {
+ ui->selectLogBox->setCurrentIndex(index);
+ setControlsEnabled(true);
+ } else {
+ setControlsEnabled(false);
+ }
+ }
+}
+
+void OtherLogsPage::on_selectLogBox_currentIndexChanged(const int index)
+{
+ QString file;
+ if (index != -1) {
+ file = ui->selectLogBox->itemText(index);
+ }
+
+ if (file.isEmpty() || !QFile::exists(FS::PathCombine(m_path, file))) {
+ m_currentFile = QString();
+ ui->text->clear();
+ setControlsEnabled(false);
+ } else {
+ m_currentFile = file;
+ on_btnReload_clicked();
+ setControlsEnabled(true);
+ }
+}
+
+void OtherLogsPage::on_btnReload_clicked()
+{
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ return;
+ }
+ QFile file(FS::PathCombine(m_path, m_currentFile));
+ if (!file.open(QFile::ReadOnly)) {
+ setControlsEnabled(false);
+ ui->btnReload->setEnabled(true); // allow reload
+ m_currentFile = QString();
+ QMessageBox::critical(this, tr("Error"),
+ tr("Unable to open %1 for reading: %2")
+ .arg(m_currentFile, file.errorString()));
+ } else {
+ auto setPlainText = [&](const QString& text) {
+ QString fontFamily =
+ APPLICATION->settings()->get("ConsoleFont").toString();
+ bool conversionOk = false;
+ int fontSize = APPLICATION->settings()
+ ->get("ConsoleFontSize")
+ .toInt(&conversionOk);
+ if (!conversionOk) {
+ fontSize = 11;
+ }
+ QTextDocument* doc = ui->text->document();
+ doc->setDefaultFont(QFont(fontFamily, fontSize));
+ ui->text->setPlainText(text);
+ };
+ auto showTooBig = [&]() {
+ setPlainText(tr("The file (%1) is too big. You may want to open it "
+ "in a viewer optimized "
+ "for large files.")
+ .arg(file.fileName()));
+ };
+ if (file.size() > (1024ll * 1024ll * 12ll)) {
+ showTooBig();
+ return;
+ }
+ QString content;
+ if (file.fileName().endsWith(".gz")) {
+ QByteArray temp;
+ if (!GZip::unzip(file.readAll(), temp)) {
+ setPlainText(
+ tr("The file (%1) is not readable.").arg(file.fileName()));
+ return;
+ }
+ content = QString::fromUtf8(temp);
+ } else {
+ content = QString::fromUtf8(file.readAll());
+ }
+ if (content.size() >= 50000000ll) {
+ showTooBig();
+ return;
+ }
+ setPlainText(content);
+ }
+}
+
+void OtherLogsPage::on_btnPaste_clicked()
+{
+ GuiUtil::uploadPaste(ui->text->toPlainText(), this);
+}
+
+void OtherLogsPage::on_btnCopy_clicked()
+{
+ GuiUtil::setClipboardText(ui->text->toPlainText());
+}
+
+void OtherLogsPage::on_btnDelete_clicked()
+{
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ return;
+ }
+ if (QMessageBox::question(
+ this, tr("Delete"),
+ tr("Do you really want to delete %1?").arg(m_currentFile),
+ QMessageBox::Yes, QMessageBox::No) == QMessageBox::No) {
+ return;
+ }
+ QFile file(FS::PathCombine(m_path, m_currentFile));
+ if (!file.remove()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Unable to delete %1: %2")
+ .arg(m_currentFile, file.errorString()));
+ }
+}
+
+void OtherLogsPage::on_btnClean_clicked()
+{
+ auto toDelete = m_watcher->files();
+ if (toDelete.isEmpty()) {
+ return;
+ }
+ QMessageBox* messageBox = new QMessageBox(this);
+ messageBox->setWindowTitle(tr("Clean up"));
+ if (toDelete.size() > 5) {
+ messageBox->setText(tr("Do you really want to delete all log files?"));
+ messageBox->setDetailedText(toDelete.join('\n'));
+ } else {
+ messageBox->setText(tr("Do you really want to delete these files?\n%1")
+ .arg(toDelete.join('\n')));
+ }
+ messageBox->setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel);
+ messageBox->setDefaultButton(QMessageBox::Ok);
+ messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ messageBox->setIcon(QMessageBox::Question);
+ messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+
+ if (messageBox->exec() != QMessageBox::Ok) {
+ return;
+ }
+ QStringList failed;
+ for (auto item : toDelete) {
+ QFile file(FS::PathCombine(m_path, item));
+ if (!file.remove()) {
+ failed.push_back(item);
+ }
+ }
+ if (!failed.empty()) {
+ QMessageBox* messageBox = new QMessageBox(this);
+ messageBox->setWindowTitle(tr("Error"));
+ if (failed.size() > 5) {
+ messageBox->setText(tr("Couldn't delete some files!"));
+ messageBox->setDetailedText(failed.join('\n'));
+ } else {
+ messageBox->setText(
+ tr("Couldn't delete some files:\n%1").arg(failed.join('\n')));
+ }
+ messageBox->setStandardButtons(QMessageBox::Ok);
+ messageBox->setDefaultButton(QMessageBox::Ok);
+ messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ messageBox->setIcon(QMessageBox::Critical);
+ messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+ messageBox->exec();
+ }
+}
+
+void OtherLogsPage::setControlsEnabled(const bool enabled)
+{
+ ui->btnReload->setEnabled(enabled);
+ ui->btnDelete->setEnabled(enabled);
+ ui->btnCopy->setEnabled(enabled);
+ ui->btnPaste->setEnabled(enabled);
+ ui->text->setEnabled(enabled);
+ ui->btnClean->setEnabled(enabled);
+}
+
+// FIXME: HACK, use LogView instead?
+static void findNext(QPlainTextEdit* _this, const QString& what, bool reverse)
+{
+ _this->find(what, reverse ? QTextDocument::FindFlag::FindBackward
+ : QTextDocument::FindFlag(0));
+}
+
+void OtherLogsPage::on_findButton_clicked()
+{
+ auto modifiers = QApplication::keyboardModifiers();
+ bool reverse = modifiers & Qt::ShiftModifier;
+ findNext(ui->text, ui->searchBar->text(), reverse);
+}
+
+void OtherLogsPage::findNextActivated()
+{
+ findNext(ui->text, ui->searchBar->text(), false);
+}
+
+void OtherLogsPage::findPreviousActivated()
+{
+ findNext(ui->text, ui->searchBar->text(), true);
+}
+
+void OtherLogsPage::findActivated()
+{
+ // focus the search bar if it doesn't have focus
+ if (!ui->searchBar->hasFocus()) {
+ ui->searchBar->setFocus();
+ ui->searchBar->selectAll();
+ }
+}
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.h b/meshmc/launcher/ui/pages/instance/OtherLogsPage.h
new file mode 100644
index 0000000000..6201055493
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.h
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include <pathmatcher/IPathMatcher.h>
+
+namespace Ui
+{
+ class OtherLogsPage;
+}
+
+class RecursiveFileSystemWatcher;
+
+class OtherLogsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit OtherLogsPage(QString path, IPathMatcher::Ptr fileFilter,
+ QWidget* parent = 0);
+ ~OtherLogsPage();
+
+ QString id() const override
+ {
+ return "logs";
+ }
+ QString displayName() const override
+ {
+ return tr("Other logs");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("log");
+ }
+ QString helpPage() const override
+ {
+ return "Minecraft-Logs";
+ }
+ void openedImpl() override;
+ void closedImpl() override;
+
+ private slots:
+ void populateSelectLogBox();
+ void on_selectLogBox_currentIndexChanged(const int index);
+ void on_btnReload_clicked();
+ void on_btnPaste_clicked();
+ void on_btnCopy_clicked();
+ void on_btnDelete_clicked();
+ void on_btnClean_clicked();
+
+ void on_findButton_clicked();
+ void findActivated();
+ void findNextActivated();
+ void findPreviousActivated();
+
+ private:
+ void setControlsEnabled(const bool enabled);
+
+ private:
+ Ui::OtherLogsPage* ui;
+ QString m_path;
+ QString m_currentFile;
+ IPathMatcher::Ptr m_fileFilter;
+ RecursiveFileSystemWatcher* m_watcher;
+};
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui b/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui
new file mode 100644
index 0000000000..56ff3b6212
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OtherLogsPage</class>
+ <widget class="QWidget" name="OtherLogsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>657</width>
+ <height>538</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="searchBar"/>
+ </item>
+ <item row="2" column="2">
+ <widget class="QPushButton" name="findButton">
+ <property name="text">
+ <string>Find</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" colspan="4">
+ <widget class="QPlainTextEdit" name="text">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="4">
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="3" column="1">
+ <widget class="QPushButton" name="btnCopy">
+ <property name="toolTip">
+ <string>Copy the whole log into the clipboard</string>
+ </property>
+ <property name="text">
+ <string>&amp;Copy</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="3">
+ <widget class="QPushButton" name="btnDelete">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Delete</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="2">
+ <widget class="QPushButton" name="btnPaste">
+ <property name="toolTip">
+ <string>Upload the log to paste.ee - it will stay online for a month</string>
+ </property>
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="4">
+ <widget class="QPushButton" name="btnClean">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Clean</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QPushButton" name="btnReload">
+ <property name="text">
+ <string>Reload</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="5">
+ <widget class="QComboBox" name="selectLogBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Search:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>selectLogBox</tabstop>
+ <tabstop>btnReload</tabstop>
+ <tabstop>btnCopy</tabstop>
+ <tabstop>btnPaste</tabstop>
+ <tabstop>btnDelete</tabstop>
+ <tabstop>btnClean</tabstop>
+ <tabstop>text</tabstop>
+ <tabstop>searchBar</tabstop>
+ <tabstop>findButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ResourcePackPage.h b/meshmc/launcher/ui/pages/instance/ResourcePackPage.h
new file mode 100644
index 0000000000..6b7de04632
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ResourcePackPage.h
@@ -0,0 +1,45 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class ResourcePackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit ResourcePackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->resourcePackList(), "resourcepacks",
+ "resourcepacks", tr("Resource packs"), "Resource-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~ResourcePackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return !m_inst->traits().contains("no-texturepacks") &&
+ !m_inst->traits().contains("texturepacks");
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp
new file mode 100644
index 0000000000..96f09da33a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp
@@ -0,0 +1,458 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ScreenshotsPage.h"
+#include "ui_ScreenshotsPage.h"
+
+#include <QModelIndex>
+#include <QMutableListIterator>
+#include <QMap>
+#include <QSet>
+#include <QFileIconProvider>
+#include <QFileSystemModel>
+#include <QStyledItemDelegate>
+#include <QLineEdit>
+#include <QRegularExpression>
+#include <QEvent>
+#include <QPainter>
+#include <QClipboard>
+#include <QKeyEvent>
+#include <QMenu>
+
+#include <Application.h>
+
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/dialogs/CustomMessageBox.h"
+
+#include "net/NetJob.h"
+#include "screenshots/ImgurUpload.h"
+#include "screenshots/ImgurAlbumCreation.h"
+#include "tasks/SequentialTask.h"
+
+#include "RWStorage.h"
+#include <FileSystem.h>
+#include <DesktopServices.h>
+
+typedef RWStorage<QString, QIcon> SharedIconCache;
+typedef std::shared_ptr<SharedIconCache> SharedIconCachePtr;
+
+class ThumbnailingResult : public QObject
+{
+ Q_OBJECT
+ public slots:
+ inline void emitResultsReady(const QString& path)
+ {
+ emit resultsReady(path);
+ }
+ inline void emitResultsFailed(const QString& path)
+ {
+ emit resultsFailed(path);
+ }
+ signals:
+ void resultsReady(const QString& path);
+ void resultsFailed(const QString& path);
+};
+
+class ThumbnailRunnable : public QRunnable
+{
+ public:
+ ThumbnailRunnable(QString path, SharedIconCachePtr cache)
+ {
+ m_path = path;
+ m_cache = cache;
+ }
+ void run()
+ {
+ QFileInfo info(m_path);
+ if (info.isDir())
+ return;
+ if ((info.suffix().compare("png", Qt::CaseInsensitive) != 0))
+ return;
+ int tries = 5;
+ while (tries) {
+ if (!m_cache->stale(m_path))
+ return;
+ QImage image(m_path);
+ if (image.isNull()) {
+ QThread::msleep(500);
+ tries--;
+ continue;
+ }
+ QImage small;
+ if (image.width() > image.height())
+ small = image.scaledToWidth(512).scaledToWidth(
+ 256, Qt::SmoothTransformation);
+ else
+ small = image.scaledToHeight(512).scaledToHeight(
+ 256, Qt::SmoothTransformation);
+ QPoint offset((256 - small.width()) / 2,
+ (256 - small.height()) / 2);
+ QImage square(QSize(256, 256), QImage::Format_ARGB32);
+ square.fill(Qt::transparent);
+
+ QPainter painter(&square);
+ painter.drawImage(offset, small);
+ painter.end();
+
+ QIcon icon(QPixmap::fromImage(square));
+ m_cache->add(m_path, icon);
+ m_resultEmitter.emitResultsReady(m_path);
+ return;
+ }
+ m_resultEmitter.emitResultsFailed(m_path);
+ }
+ QString m_path;
+ SharedIconCachePtr m_cache;
+ ThumbnailingResult m_resultEmitter;
+};
+
+// this is about as elegant and well written as a bag of bricks with scribbles
+// done by insane asylum patients.
+class FilterModel : public QIdentityProxyModel
+{
+ Q_OBJECT
+ public:
+ explicit FilterModel(QObject* parent = 0) : QIdentityProxyModel(parent)
+ {
+ m_thumbnailingPool.setMaxThreadCount(4);
+ m_thumbnailCache = std::make_shared<SharedIconCache>();
+ m_thumbnailCache->add("placeholder", APPLICATION->getThemedIcon(
+ "screenshot-placeholder"));
+ connect(&watcher, SIGNAL(fileChanged(QString)),
+ SLOT(fileChanged(QString)));
+ // FIXME: the watched file set is not updated when files are removed
+ }
+ virtual ~FilterModel()
+ {
+ m_thumbnailingPool.waitForDone(500);
+ }
+ virtual QVariant data(const QModelIndex& proxyIndex,
+ int role = Qt::DisplayRole) const
+ {
+ auto model = sourceModel();
+ if (!model)
+ return QVariant();
+ if (role == Qt::DisplayRole || role == Qt::EditRole) {
+ QVariant result =
+ sourceModel()->data(mapToSource(proxyIndex), role);
+ return result.toString().remove(QRegularExpression("\\.png$"));
+ }
+ if (role == Qt::DecorationRole) {
+ QVariant result = sourceModel()->data(
+ mapToSource(proxyIndex), QFileSystemModel::FilePathRole);
+ QString filePath = result.toString();
+ QIcon temp;
+ if (!watched.contains(filePath)) {
+ ((QFileSystemWatcher&)watcher).addPath(filePath);
+ ((QSet<QString>&)watched).insert(filePath);
+ }
+ if (m_thumbnailCache->get(filePath, temp)) {
+ return temp;
+ }
+ if (!m_failed.contains(filePath)) {
+ ((FilterModel*)this)->thumbnailImage(filePath);
+ }
+ return (m_thumbnailCache->get("placeholder"));
+ }
+ return sourceModel()->data(mapToSource(proxyIndex), role);
+ }
+ virtual bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole)
+ {
+ auto model = sourceModel();
+ if (!model)
+ return false;
+ if (role != Qt::EditRole)
+ return false;
+ // FIXME: this is a workaround for a bug in QFileSystemModel, where it
+ // doesn't sort after renames
+ {
+ ((QFileSystemModel*)model)->setNameFilterDisables(true);
+ ((QFileSystemModel*)model)->setNameFilterDisables(false);
+ }
+ return model->setData(mapToSource(index), value.toString() + ".png",
+ role);
+ }
+
+ private:
+ void thumbnailImage(QString path)
+ {
+ auto runnable = new ThumbnailRunnable(path, m_thumbnailCache);
+ connect(&(runnable->m_resultEmitter), SIGNAL(resultsReady(QString)),
+ SLOT(thumbnailReady(QString)));
+ connect(&(runnable->m_resultEmitter), SIGNAL(resultsFailed(QString)),
+ SLOT(thumbnailFailed(QString)));
+ ((QThreadPool&)m_thumbnailingPool).start(runnable);
+ }
+ private slots:
+ void thumbnailReady(QString path)
+ {
+ emit layoutChanged();
+ }
+ void thumbnailFailed(QString path)
+ {
+ m_failed.insert(path);
+ }
+ void fileChanged(QString filepath)
+ {
+ m_thumbnailCache->setStale(filepath);
+ thumbnailImage(filepath);
+ // reinsert the path...
+ watcher.removePath(filepath);
+ watcher.addPath(filepath);
+ }
+
+ private:
+ SharedIconCachePtr m_thumbnailCache;
+ QThreadPool m_thumbnailingPool;
+ QSet<QString> m_failed;
+ QSet<QString> watched;
+ QFileSystemWatcher watcher;
+};
+
+class CenteredEditingDelegate : public QStyledItemDelegate
+{
+ public:
+ explicit CenteredEditingDelegate(QObject* parent = 0)
+ : QStyledItemDelegate(parent)
+ {
+ }
+ virtual ~CenteredEditingDelegate() {}
+ virtual QWidget* createEditor(QWidget* parent,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+ {
+ auto widget = QStyledItemDelegate::createEditor(parent, option, index);
+ auto foo = dynamic_cast<QLineEdit*>(widget);
+ if (foo) {
+ foo->setAlignment(Qt::AlignHCenter);
+ foo->setFrame(true);
+ foo->setMaximumWidth(192);
+ }
+ return widget;
+ }
+};
+
+ScreenshotsPage::ScreenshotsPage(QString path, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ScreenshotsPage)
+{
+ m_model.reset(new QFileSystemModel());
+ m_filterModel.reset(new FilterModel());
+ m_filterModel->setSourceModel(m_model.get());
+ m_model->setFilter(QDir::Files | QDir::Writable | QDir::Readable);
+ m_model->setReadOnly(false);
+ m_model->setNameFilters({"*.png"});
+ m_model->setNameFilterDisables(false);
+ m_folder = path;
+ m_valid = FS::ensureFolderPathExists(m_folder);
+
+ ui->setupUi(this);
+ ui->toolBar->insertSpacer(ui->actionView_Folder);
+
+ ui->listView->setIconSize(QSize(128, 128));
+ ui->listView->setGridSize(QSize(192, 160));
+ ui->listView->setSpacing(9);
+ // ui->listView->setUniformItemSizes(true);
+ ui->listView->setLayoutMode(QListView::Batched);
+ ui->listView->setViewMode(QListView::IconMode);
+ ui->listView->setResizeMode(QListView::Adjust);
+ ui->listView->installEventFilter(this);
+ ui->listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ ui->listView->setItemDelegate(new CenteredEditingDelegate(this));
+ ui->listView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->listView, &QListView::customContextMenuRequested, this,
+ &ScreenshotsPage::ShowContextMenu);
+ connect(ui->listView, SIGNAL(activated(QModelIndex)),
+ SLOT(onItemActivated(QModelIndex)));
+}
+
+bool ScreenshotsPage::eventFilter(QObject* obj, QEvent* evt)
+{
+ if (obj != ui->listView)
+ return QWidget::eventFilter(obj, evt);
+ if (evt->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, evt);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(evt);
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionDelete_triggered();
+ return true;
+ case Qt::Key_F2:
+ on_actionRename_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(obj, evt);
+}
+
+ScreenshotsPage::~ScreenshotsPage()
+{
+ delete ui;
+}
+
+void ScreenshotsPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->listView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* ScreenshotsPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ScreenshotsPage::onItemActivated(QModelIndex index)
+{
+ if (!index.isValid())
+ return;
+ auto info = m_model->fileInfo(index);
+ QString fileName = info.absoluteFilePath();
+ DesktopServices::openFile(info.absoluteFilePath());
+}
+
+void ScreenshotsPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_folder, true);
+}
+
+void ScreenshotsPage::on_actionUpload_triggered()
+{
+ auto selection = ui->listView->selectionModel()->selectedRows();
+ if (selection.isEmpty())
+ return;
+
+ QList<ScreenShot::Ptr> uploaded;
+ auto job =
+ NetJob::Ptr(new NetJob("Screenshot Upload", APPLICATION->network()));
+ if (selection.size() < 2) {
+ auto item = selection.at(0);
+ auto info = m_model->fileInfo(item);
+ auto screenshot = std::make_shared<ScreenShot>(info);
+ job->addNetAction(ImgurUpload::make(screenshot));
+
+ m_uploadActive = true;
+ ProgressDialog dialog(this);
+
+ if (dialog.execWithTask(job.get()) != QDialog::Accepted) {
+ CustomMessageBox::selectable(
+ this, tr("Failed to upload screenshots!"), tr("Unknown error"),
+ QMessageBox::Warning)
+ ->exec();
+ } else {
+ auto link = screenshot->m_url;
+ QClipboard* clipboard = QApplication::clipboard();
+ clipboard->setText(link);
+ CustomMessageBox::selectable(
+ this, tr("Upload finished"),
+ tr("The <a href=\"%1\">link to the uploaded screenshot</a> "
+ "has been placed in your clipboard.")
+ .arg(link),
+ QMessageBox::Information)
+ ->exec();
+ }
+
+ m_uploadActive = false;
+ return;
+ }
+
+ for (auto item : selection) {
+ auto info = m_model->fileInfo(item);
+ auto screenshot = std::make_shared<ScreenShot>(info);
+ uploaded.push_back(screenshot);
+ job->addNetAction(ImgurUpload::make(screenshot));
+ }
+ SequentialTask task;
+ auto albumTask =
+ NetJob::Ptr(new NetJob("Imgur Album Creation", APPLICATION->network()));
+ auto imgurAlbum = ImgurAlbumCreation::make(uploaded);
+ albumTask->addNetAction(imgurAlbum);
+ task.addTask(job);
+ task.addTask(albumTask);
+ m_uploadActive = true;
+ ProgressDialog prog(this);
+ if (prog.execWithTask(&task) != QDialog::Accepted) {
+ CustomMessageBox::selectable(this, tr("Failed to upload screenshots!"),
+ tr("Unknown error"), QMessageBox::Warning)
+ ->exec();
+ } else {
+ auto link = QString("https://imgur.com/a/%1").arg(imgurAlbum->id());
+ QClipboard* clipboard = QApplication::clipboard();
+ clipboard->setText(link);
+ CustomMessageBox::selectable(
+ this, tr("Upload finished"),
+ tr("The <a href=\"%1\">link to the uploaded album</a> has been "
+ "placed in your clipboard.")
+ .arg(link),
+ QMessageBox::Information)
+ ->exec();
+ }
+ m_uploadActive = false;
+}
+
+void ScreenshotsPage::on_actionDelete_triggered()
+{
+ auto mbox = CustomMessageBox::selectable(
+ this, tr("Are you sure?"),
+ tr("This will delete all selected screenshots."), QMessageBox::Warning,
+ QMessageBox::Yes | QMessageBox::No);
+ std::unique_ptr<QMessageBox> box(mbox);
+
+ if (box->exec() != QMessageBox::Yes)
+ return;
+
+ auto selected = ui->listView->selectionModel()->selectedIndexes();
+ for (auto item : selected) {
+ m_model->remove(item);
+ }
+}
+
+void ScreenshotsPage::on_actionRename_triggered()
+{
+ auto selection = ui->listView->selectionModel()->selectedIndexes();
+ if (selection.isEmpty())
+ return;
+ ui->listView->edit(selection[0]);
+ // TODO: mass renaming
+}
+
+void ScreenshotsPage::openedImpl()
+{
+ if (!m_valid) {
+ m_valid = FS::ensureFolderPathExists(m_folder);
+ }
+ if (m_valid) {
+ QString path = QDir(m_folder).absolutePath();
+ auto idx = m_model->setRootPath(path);
+ if (idx.isValid()) {
+ ui->listView->setModel(m_filterModel.get());
+ ui->listView->setRootIndex(m_filterModel->mapFromSource(idx));
+ } else {
+ ui->listView->setModel(nullptr);
+ }
+ }
+}
+
+#include "ScreenshotsPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h
new file mode 100644
index 0000000000..87d6dd3772
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h
@@ -0,0 +1,109 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QMainWindow>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+class QFileSystemModel;
+class QIdentityProxyModel;
+namespace Ui
+{
+ class ScreenshotsPage;
+}
+
+struct ScreenShot;
+class ScreenshotList;
+class ImgurAlbumCreation;
+
+class ScreenshotsPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ScreenshotsPage(QString path, QWidget* parent = 0);
+ virtual ~ScreenshotsPage();
+
+ virtual void openedImpl() override;
+
+ enum { NothingDone = 0x42 };
+
+ virtual bool eventFilter(QObject*, QEvent*) override;
+ virtual QString displayName() const override
+ {
+ return tr("Screenshots");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("screenshots");
+ }
+ virtual QString id() const override
+ {
+ return "screenshots";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Screenshots-management";
+ }
+ virtual bool apply() override
+ {
+ return !m_uploadActive;
+ }
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ private slots:
+ void on_actionUpload_triggered();
+ void on_actionDelete_triggered();
+ void on_actionRename_triggered();
+ void on_actionView_Folder_triggered();
+ void onItemActivated(QModelIndex);
+ void ShowContextMenu(const QPoint& pos);
+
+ private:
+ Ui::ScreenshotsPage* ui;
+ std::shared_ptr<QFileSystemModel> m_model;
+ std::shared_ptr<QIdentityProxyModel> m_filterModel;
+ QString m_folder;
+ bool m_valid = false;
+ bool m_uploadActive = false;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui
new file mode 100644
index 0000000000..ec46108775
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ScreenshotsPage</class>
+ <widget class="QMainWindow" name="ScreenshotsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QListView" name="listView">
+ <property name="selectionMode">
+ <enum>QAbstractItemView::ExtendedSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionUpload"/>
+ <addaction name="actionDelete"/>
+ <addaction name="actionRename"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionUpload">
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </action>
+ <action name="actionDelete">
+ <property name="text">
+ <string>Delete</string>
+ </property>
+ </action>
+ <action name="actionRename">
+ <property name="text">
+ <string>Rename</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View Folder</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.cpp b/meshmc/launcher/ui/pages/instance/ServersPage.cpp
new file mode 100644
index 0000000000..0538369f99
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.cpp
@@ -0,0 +1,734 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ServersPage.h"
+#include "ui_ServersPage.h"
+
+#include <FileSystem.h>
+#include <sstream>
+#include <io/stream_reader.h>
+#include <tag_string.h>
+#include <tag_primitive.h>
+#include <tag_list.h>
+#include <tag_compound.h>
+#include <minecraft/MinecraftInstance.h>
+
+#include <QFileSystemWatcher>
+#include <QMenu>
+
+static const int COLUMN_COUNT = 2; // 3 , TBD: latency and other nice things.
+
+struct Server {
+ // Types
+ enum class AcceptsTextures : int { ASK = 0, ALWAYS = 1, NEVER = 2 };
+
+ // Methods
+ Server()
+ {
+ m_name = QObject::tr("Minecraft Server");
+ }
+ Server(const QString& name, const QString& address)
+ {
+ m_name = name;
+ m_address = address;
+ }
+ Server(nbt::tag_compound& server)
+ {
+ std::string addressStr(server["ip"]);
+ m_address = QString::fromUtf8(addressStr.c_str());
+
+ std::string nameStr(server["name"]);
+ m_name = QString::fromUtf8(nameStr.c_str());
+
+ if (server["icon"]) {
+ std::string base64str(server["icon"]);
+ m_icon = QByteArray::fromBase64(base64str.c_str());
+ }
+
+ if (server.has_key("acceptTextures", nbt::tag_type::Byte)) {
+ bool value = server["acceptTextures"].as<nbt::tag_byte>().get();
+ if (value) {
+ m_acceptsTextures = AcceptsTextures::ALWAYS;
+ } else {
+ m_acceptsTextures = AcceptsTextures::NEVER;
+ }
+ }
+ }
+
+ void serialize(nbt::tag_compound& server)
+ {
+ server.insert("name", m_name.trimmed().toUtf8().toStdString());
+ server.insert("ip", m_address.trimmed().toUtf8().toStdString());
+ if (m_icon.size()) {
+ server.insert("icon", m_icon.toBase64().toStdString());
+ }
+ if (m_acceptsTextures != AcceptsTextures::ASK) {
+ server.insert(
+ "acceptTextures",
+ nbt::tag_byte(m_acceptsTextures == AcceptsTextures::ALWAYS));
+ }
+ }
+
+ // Data - persistent and user changeable
+ QString m_name;
+ QString m_address;
+ AcceptsTextures m_acceptsTextures = AcceptsTextures::ASK;
+
+ // Data - persistent and automatically updated
+ QByteArray m_icon;
+
+ // Data - temporary
+ bool m_checked = false;
+ bool m_up = false;
+ QString m_motd; // https://mctools.org/motd-creator
+ int m_ping = 0;
+ int m_currentPlayers = 0;
+ int m_maxPlayers = 0;
+};
+
+static std::unique_ptr<nbt::tag_compound>
+parseServersDat(const QString& filename)
+{
+ try {
+ QByteArray input = FS::read(filename);
+ std::istringstream foo(std::string(input.constData(), input.size()));
+ auto pair = nbt::io::read_compound(foo);
+
+ if (pair.first != "")
+ return nullptr;
+
+ if (pair.second == nullptr)
+ return nullptr;
+
+ return std::move(pair.second);
+ } catch (...) {
+ return nullptr;
+ }
+}
+
+static bool serializeServerDat(const QString& filename,
+ nbt::tag_compound* levelInfo)
+{
+ try {
+ if (!FS::ensureFilePathExists(filename)) {
+ return false;
+ }
+ std::ostringstream s;
+ nbt::io::write_tag("", *levelInfo, s);
+ QByteArray val(s.str().data(), (int)s.str().size());
+ FS::write(filename, val);
+ return true;
+ } catch (...) {
+ return false;
+ }
+}
+
+class ServersModel : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ enum Roles {
+ ServerPtrRole = Qt::UserRole,
+ };
+ explicit ServersModel(const QString& path, QObject* parent = 0)
+ : QAbstractListModel(parent)
+ {
+ m_path = path;
+ m_watcher = new QFileSystemWatcher(this);
+ connect(m_watcher, &QFileSystemWatcher::fileChanged, this,
+ &ServersModel::fileChanged);
+ connect(m_watcher, &QFileSystemWatcher::directoryChanged, this,
+ &ServersModel::dirChanged);
+ m_saveTimer.setSingleShot(true);
+ m_saveTimer.setInterval(5000);
+ connect(&m_saveTimer, &QTimer::timeout, this,
+ &ServersModel::save_internal);
+ }
+ virtual ~ServersModel() {};
+
+ void observe()
+ {
+ if (m_observed) {
+ return;
+ }
+ m_observed = true;
+
+ if (!m_loaded) {
+ load();
+ }
+
+ updateFSObserver();
+ }
+
+ void unobserve()
+ {
+ if (!m_observed) {
+ return;
+ }
+ m_observed = false;
+
+ updateFSObserver();
+ }
+
+ void lock()
+ {
+ if (m_locked) {
+ return;
+ }
+ saveNow();
+
+ m_locked = true;
+ updateFSObserver();
+ }
+
+ void unlock()
+ {
+ if (!m_locked) {
+ return;
+ }
+ m_locked = false;
+
+ updateFSObserver();
+ }
+
+ int addEmptyRow(int position)
+ {
+ if (m_locked) {
+ return -1;
+ }
+ if (position < 0 || position >= rowCount()) {
+ position = rowCount();
+ }
+ beginInsertRows(QModelIndex(), position, position);
+ m_servers.insert(position, Server());
+ endInsertRows();
+ scheduleSave();
+ return position;
+ }
+
+ bool removeRow(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ if (row < 0 || row >= rowCount()) {
+ return false;
+ }
+ beginRemoveRows(QModelIndex(), row, row);
+ m_servers.removeAt(row);
+ endRemoveRows(); // does absolutely nothing, the selected server stays
+ // as the next line...
+ scheduleSave();
+ return true;
+ }
+
+ bool moveUp(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ if (row <= 0) {
+ return false;
+ }
+ beginMoveRows(QModelIndex(), row, row, QModelIndex(), row - 1);
+ m_servers.swapItemsAt(row - 1, row);
+ endMoveRows();
+ scheduleSave();
+ return true;
+ }
+
+ bool moveDown(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ int count = rowCount();
+ if (row + 1 >= count) {
+ return false;
+ }
+ beginMoveRows(QModelIndex(), row, row, QModelIndex(), row + 2);
+ m_servers.swapItemsAt(row + 1, row);
+ endMoveRows();
+ scheduleSave();
+ return true;
+ }
+
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override
+ {
+ if (section < 0 || section >= COLUMN_COUNT)
+ return QVariant();
+
+ if (role == Qt::DisplayRole) {
+ switch (section) {
+ case 0:
+ return tr("Name");
+ case 1:
+ return tr("Address");
+ case 2:
+ return tr("Latency");
+ }
+ }
+
+ return QAbstractListModel::headerData(section, orientation, role);
+ }
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const override
+ {
+ if (!index.isValid())
+ return QVariant();
+
+ int row = index.row();
+ int column = index.column();
+ if (column < 0 || column >= COLUMN_COUNT)
+ return QVariant();
+
+ if (row < 0 || row >= m_servers.size())
+ return QVariant();
+
+ switch (column) {
+ case 0:
+ switch (role) {
+ case Qt::DecorationRole: {
+ auto& bytes = m_servers[row].m_icon;
+ if (bytes.size()) {
+ QPixmap px;
+ if (px.loadFromData(bytes))
+ return QIcon(px);
+ }
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ case Qt::DisplayRole:
+ return m_servers[row].m_name;
+ case ServerPtrRole:
+ return QVariant::fromValue<void*>(
+ (void*)&m_servers[row]);
+ default:
+ return QVariant();
+ }
+ case 1:
+ switch (role) {
+ case Qt::DisplayRole:
+ return m_servers[row].m_address;
+ default:
+ return QVariant();
+ }
+ case 2:
+ switch (role) {
+ case Qt::DisplayRole:
+ return m_servers[row].m_ping;
+ default:
+ return QVariant();
+ }
+ default:
+ return QVariant();
+ }
+ }
+
+ virtual int
+ rowCount(const QModelIndex& parent = QModelIndex()) const override
+ {
+ return m_servers.size();
+ }
+ int columnCount(const QModelIndex& parent) const override
+ {
+ return COLUMN_COUNT;
+ }
+
+ Server* at(int index)
+ {
+ if (index < 0 || index >= rowCount()) {
+ return nullptr;
+ }
+ return &m_servers[index];
+ }
+
+ void setName(int row, const QString& name)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_name == name) {
+ return;
+ }
+ server->m_name = name;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void setAddress(int row, const QString& address)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_address == address) {
+ return;
+ }
+ server->m_address = address;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void setAcceptsTextures(int row, Server::AcceptsTextures textures)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_acceptsTextures == textures) {
+ return;
+ }
+ server->m_acceptsTextures = textures;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void load()
+ {
+ cancelSave();
+ beginResetModel();
+ QList<Server> servers;
+ auto serversDat = parseServersDat(serversPath());
+ if (serversDat) {
+ if (serversDat->has_key("servers", nbt::tag_type::List)) {
+ auto& serversList =
+ serversDat->at("servers").as<nbt::tag_list>();
+ for (auto iter = serversList.begin(); iter != serversList.end();
+ iter++) {
+ auto& serverTag = (*iter).as<nbt::tag_compound>();
+ Server s(serverTag);
+ servers.append(s);
+ }
+ }
+ }
+ m_servers.swap(servers);
+ m_loaded = true;
+ endResetModel();
+ }
+
+ void saveNow()
+ {
+ if (saveIsScheduled()) {
+ save_internal();
+ }
+ }
+
+ public slots:
+ void dirChanged(const QString& path)
+ {
+ qDebug() << "Changed:" << path;
+ load();
+ }
+ void fileChanged(const QString& path)
+ {
+ qDebug() << "Changed:" << path;
+ }
+
+ private slots:
+ void save_internal()
+ {
+ cancelSave();
+ QString path = serversPath();
+ qDebug() << "Server list about to be saved to" << path;
+
+ nbt::tag_compound out;
+ nbt::tag_list list;
+ for (auto& server : m_servers) {
+ nbt::tag_compound serverNbt;
+ server.serialize(serverNbt);
+ list.push_back(std::move(serverNbt));
+ }
+ out.insert("servers", nbt::value(std::move(list)));
+
+ if (!serializeServerDat(path, &out)) {
+ qDebug() << "Failed to save server list:" << path
+ << "Will try again.";
+ scheduleSave();
+ }
+ }
+
+ private:
+ void scheduleSave()
+ {
+ if (!m_loaded) {
+ qDebug() << "Server list should never save if it didn't "
+ "successfully load, path:"
+ << m_path;
+ return;
+ }
+ if (!m_dirty) {
+ m_dirty = true;
+ qDebug() << "Server list save is scheduled for" << m_path;
+ }
+ m_saveTimer.start();
+ }
+
+ void cancelSave()
+ {
+ m_dirty = false;
+ m_saveTimer.stop();
+ }
+
+ bool saveIsScheduled() const
+ {
+ return m_dirty;
+ }
+
+ void updateFSObserver()
+ {
+ bool observingFS = m_watcher->directories().contains(m_path);
+ if (m_observed && m_locked) {
+ if (!observingFS) {
+ qWarning() << "Will watch" << m_path;
+ if (!m_watcher->addPath(m_path)) {
+ qWarning() << "Failed to start watching" << m_path;
+ }
+ }
+ } else {
+ if (observingFS) {
+ qWarning() << "Will stop watching" << m_path;
+ if (!m_watcher->removePath(m_path)) {
+ qWarning() << "Failed to stop watching" << m_path;
+ }
+ }
+ }
+ }
+
+ QString serversPath()
+ {
+ QFileInfo foo(FS::PathCombine(m_path, "servers.dat"));
+ return foo.filePath();
+ }
+
+ private:
+ bool m_loaded = false;
+ bool m_locked = false;
+ bool m_observed = false;
+ bool m_dirty = false;
+ QString m_path;
+ QList<Server> m_servers;
+ QFileSystemWatcher* m_watcher = nullptr;
+ QTimer m_saveTimer;
+};
+
+ServersPage::ServersPage(InstancePtr inst, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ServersPage)
+{
+ ui->setupUi(this);
+ m_inst = inst;
+ m_model = new ServersModel(inst->gameRoot(), this);
+ ui->serversView->setIconSize(QSize(64, 64));
+ ui->serversView->setModel(m_model);
+ ui->serversView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->serversView, &QTreeView::customContextMenuRequested, this,
+ &ServersPage::ShowContextMenu);
+
+ auto head = ui->serversView->header();
+ if (head->count()) {
+ head->setSectionResizeMode(0, QHeaderView::Stretch);
+ for (int i = 1; i < head->count(); i++) {
+ head->setSectionResizeMode(i, QHeaderView::ResizeToContents);
+ }
+ }
+
+ auto selectionModel = ui->serversView->selectionModel();
+ connect(selectionModel, &QItemSelectionModel::currentChanged, this,
+ &ServersPage::currentChanged);
+ connect(m_inst.get(), &MinecraftInstance::runningStatusChanged, this,
+ &ServersPage::on_RunningState_changed);
+ connect(ui->nameLine, &QLineEdit::textEdited, this,
+ &ServersPage::nameEdited);
+ connect(ui->addressLine, &QLineEdit::textEdited, this,
+ &ServersPage::addressEdited);
+ connect(ui->resourceComboBox, SIGNAL(currentIndexChanged(int)), this,
+ SLOT(resourceIndexChanged(int)));
+ connect(m_model, &QAbstractItemModel::rowsRemoved, this,
+ &ServersPage::rowsRemoved);
+
+ m_locked = m_inst->isRunning();
+ if (m_locked) {
+ m_model->lock();
+ }
+
+ updateState();
+}
+
+ServersPage::~ServersPage()
+{
+ m_model->saveNow();
+ delete ui;
+}
+
+void ServersPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->serversView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* ServersPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ServersPage::on_RunningState_changed(bool running)
+{
+ if (m_locked == running) {
+ return;
+ }
+ m_locked = running;
+ if (m_locked) {
+ m_model->lock();
+ } else {
+ m_model->unlock();
+ }
+ updateState();
+}
+
+void ServersPage::currentChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ int nextServer = -1;
+ if (!current.isValid()) {
+ nextServer = -1;
+ } else {
+ nextServer = current.row();
+ }
+ currentServer = nextServer;
+ updateState();
+}
+
+// WARNING: this is here because currentChanged is not accurate when removing
+// rows. the current item needs to be fixed up after removal.
+void ServersPage::rowsRemoved(const QModelIndex& parent, int first, int last)
+{
+ if (currentServer < first) {
+ // current was before the removal
+ return;
+ } else if (currentServer >= first && currentServer <= last) {
+ // current got removed...
+ return;
+ } else {
+ // current was past the removal
+ int count = last - first + 1;
+ currentServer -= count;
+ }
+}
+
+void ServersPage::nameEdited(const QString& name)
+{
+ m_model->setName(currentServer, name);
+}
+
+void ServersPage::addressEdited(const QString& address)
+{
+ m_model->setAddress(currentServer, address);
+}
+
+void ServersPage::resourceIndexChanged(int index)
+{
+ auto acceptsTextures = Server::AcceptsTextures(index);
+ m_model->setAcceptsTextures(currentServer, acceptsTextures);
+}
+
+void ServersPage::updateState()
+{
+ auto server = m_model->at(currentServer);
+
+ bool serverEditEnabled = server && !m_locked;
+ ui->addressLine->setEnabled(serverEditEnabled);
+ ui->nameLine->setEnabled(serverEditEnabled);
+ ui->resourceComboBox->setEnabled(serverEditEnabled);
+ ui->actionMove_Down->setEnabled(serverEditEnabled);
+ ui->actionMove_Up->setEnabled(serverEditEnabled);
+ ui->actionRemove->setEnabled(serverEditEnabled);
+ ui->actionJoin->setEnabled(serverEditEnabled);
+
+ if (server) {
+ ui->addressLine->setText(server->m_address);
+ ui->nameLine->setText(server->m_name);
+ ui->resourceComboBox->setCurrentIndex(int(server->m_acceptsTextures));
+ } else {
+ ui->addressLine->setText(QString());
+ ui->nameLine->setText(QString());
+ ui->resourceComboBox->setCurrentIndex(0);
+ }
+
+ ui->actionAdd->setDisabled(m_locked);
+}
+
+void ServersPage::openedImpl()
+{
+ m_model->observe();
+}
+
+void ServersPage::closedImpl()
+{
+ m_model->unobserve();
+}
+
+void ServersPage::on_actionAdd_triggered()
+{
+ int position = m_model->addEmptyRow(currentServer + 1);
+ if (position < 0) {
+ return;
+ }
+ // select the new row
+ ui->serversView->selectionModel()->setCurrentIndex(
+ m_model->index(position), QItemSelectionModel::SelectCurrent |
+ QItemSelectionModel::Clear |
+ QItemSelectionModel::Rows);
+ currentServer = position;
+}
+
+void ServersPage::on_actionRemove_triggered()
+{
+ m_model->removeRow(currentServer);
+}
+
+void ServersPage::on_actionMove_Up_triggered()
+{
+ if (m_model->moveUp(currentServer)) {
+ currentServer--;
+ }
+}
+
+void ServersPage::on_actionMove_Down_triggered()
+{
+ if (m_model->moveDown(currentServer)) {
+ currentServer++;
+ }
+}
+
+void ServersPage::on_actionJoin_triggered()
+{
+ const auto& address = m_model->at(currentServer)->m_address;
+ APPLICATION->launch(m_inst, true, nullptr,
+ std::make_shared<MinecraftServerTarget>(
+ MinecraftServerTarget::parse(address)));
+}
+
+#include "ServersPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.h b/meshmc/launcher/ui/pages/instance/ServersPage.h
new file mode 100644
index 0000000000..bd04b1c338
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.h
@@ -0,0 +1,117 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QMainWindow>
+#include <QString>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class ServersPage;
+}
+
+struct Server;
+class ServersModel;
+class MinecraftInstance;
+
+class ServersPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ServersPage(InstancePtr inst, QWidget* parent = 0);
+ virtual ~ServersPage();
+
+ void openedImpl() override;
+ void closedImpl() override;
+
+ virtual QString displayName() const override
+ {
+ return tr("Servers");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ virtual QString id() const override
+ {
+ return "servers";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Servers-management";
+ }
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ private:
+ void updateState();
+ void scheduleSave();
+ bool saveIsScheduled() const;
+
+ private slots:
+ void currentChanged(const QModelIndex& current,
+ const QModelIndex& previous);
+ void rowsRemoved(const QModelIndex& parent, int first, int last);
+
+ void on_actionAdd_triggered();
+ void on_actionRemove_triggered();
+ void on_actionMove_Up_triggered();
+ void on_actionMove_Down_triggered();
+ void on_actionJoin_triggered();
+
+ void on_RunningState_changed(bool running);
+
+ void nameEdited(const QString& name);
+ void addressEdited(const QString& address);
+ void resourceIndexChanged(int index);
+
+ void ShowContextMenu(const QPoint& pos);
+
+ private: // data
+ int currentServer = -1;
+ bool m_locked = true;
+ Ui::ServersPage* ui = nullptr;
+ ServersModel* m_model = nullptr;
+ InstancePtr m_inst = nullptr;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.ui b/meshmc/launcher/ui/pages/instance/ServersPage.ui
new file mode 100644
index 0000000000..e8f79cf2e4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.ui
@@ -0,0 +1,194 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ServersPage</class>
+ <widget class="QMainWindow" name="ServersPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>1318</width>
+ <height>879</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="serversView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::SingleSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <property name="leftMargin">
+ <number>6</number>
+ </property>
+ <property name="rightMargin">
+ <number>6</number>
+ </property>
+ <item row="0" column="0">
+ <widget class="QLabel" name="nameLabel">
+ <property name="text">
+ <string>&amp;Name</string>
+ </property>
+ <property name="buddy">
+ <cstring>nameLine</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="nameLine"/>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="addressLabel">
+ <property name="text">
+ <string>Address</string>
+ </property>
+ <property name="buddy">
+ <cstring>addressLine</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="addressLine"/>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="resourcesLabel">
+ <property name="text">
+ <string>Reso&amp;urces</string>
+ </property>
+ <property name="buddy">
+ <cstring>resourceComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QComboBox" name="resourceComboBox">
+ <item>
+ <property name="text">
+ <string>Ask to download</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Always download</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Never download</string>
+ </property>
+ </item>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionMove_Up"/>
+ <addaction name="actionMove_Down"/>
+ <addaction name="actionJoin"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>Add</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </action>
+ <action name="actionMove_Up">
+ <property name="text">
+ <string>Move Up</string>
+ </property>
+ </action>
+ <action name="actionMove_Down">
+ <property name="text">
+ <string>Move Down</string>
+ </property>
+ </action>
+ <action name="actionJoin">
+ <property name="text">
+ <string>Join</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>serversView</tabstop>
+ <tabstop>nameLine</tabstop>
+ <tabstop>addressLine</tabstop>
+ <tabstop>resourceComboBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ShaderPackPage.h b/meshmc/launcher/ui/pages/instance/ShaderPackPage.h
new file mode 100644
index 0000000000..b2bd61ccdd
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ShaderPackPage.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class ShaderPackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit ShaderPackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->shaderPackList(), "shaderpacks",
+ "shaderpacks", tr("Shader packs"), "Resource-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~ShaderPackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return true;
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/TexturePackPage.h b/meshmc/launcher/ui/pages/instance/TexturePackPage.h
new file mode 100644
index 0000000000..43fd03bd30
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/TexturePackPage.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class TexturePackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit TexturePackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->texturePackList(), "texturepacks",
+ "resourcepacks", tr("Texture packs"), "Texture-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~TexturePackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return m_inst->traits().contains("texturepacks");
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.cpp b/meshmc/launcher/ui/pages/instance/VersionPage.cpp
new file mode 100644
index 0000000000..5ad383b79c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.cpp
@@ -0,0 +1,809 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 "Application.h"
+
+#include <QMessageBox>
+#include <QLabel>
+#include <QEvent>
+#include <QKeyEvent>
+#include <QMenu>
+#include <QAbstractItemModel>
+#include <QMessageBox>
+#include <QListView>
+#include <QString>
+#include <QUrl>
+
+#include "VersionPage.h"
+#include "ui_VersionPage.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/VersionSelectDialog.h"
+#include "ui/dialogs/NewComponentDialog.h"
+#include "ui/dialogs/ProgressDialog.h"
+
+#include "ui/GuiUtil.h"
+
+#include "minecraft/PackProfile.h"
+#include "minecraft/auth/AccountList.h"
+#include "minecraft/mod/Mod.h"
+#include "icons/IconList.h"
+#include "Exception.h"
+#include "Version.h"
+#include "DesktopServices.h"
+
+#include "meta/Index.h"
+#include "meta/VersionList.h"
+
+class IconProxy : public QIdentityProxyModel
+{
+ Q_OBJECT
+ public:
+ IconProxy(QWidget* parentWidget) : QIdentityProxyModel(parentWidget)
+ {
+ connect(parentWidget, &QObject::destroyed, this,
+ &IconProxy::widgetGone);
+ m_parentWidget = parentWidget;
+ }
+
+ virtual QVariant data(const QModelIndex& proxyIndex,
+ int role = Qt::DisplayRole) const override
+ {
+ QVariant var = QIdentityProxyModel::data(proxyIndex, role);
+ int column = proxyIndex.column();
+ if (column == 0 && role == Qt::DecorationRole && m_parentWidget) {
+ if (!var.isNull()) {
+ auto string = var.toString();
+ if (string == "warning") {
+ return APPLICATION->getThemedIcon("status-yellow");
+ } else if (string == "error") {
+ return APPLICATION->getThemedIcon("status-bad");
+ }
+ }
+ return APPLICATION->getThemedIcon("status-good");
+ }
+ return var;
+ }
+ private slots:
+ void widgetGone()
+ {
+ m_parentWidget = nullptr;
+ }
+
+ private:
+ QWidget* m_parentWidget = nullptr;
+};
+
+QIcon VersionPage::icon() const
+{
+ return APPLICATION->icons()->getIcon(m_inst->iconKey());
+}
+bool VersionPage::shouldDisplay() const
+{
+ return true;
+}
+
+QMenu* VersionPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+VersionPage::VersionPage(MinecraftInstance* inst, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::VersionPage), m_inst(inst)
+{
+ ui->setupUi(this);
+
+ ui->toolBar->insertSpacer(ui->actionReload);
+
+ m_profile = m_inst->getPackProfile();
+
+ reloadPackProfile();
+
+ auto proxy = new IconProxy(ui->packageView);
+ proxy->setSourceModel(m_profile.get());
+
+ m_filterModel = new QSortFilterProxyModel();
+ m_filterModel->setDynamicSortFilter(true);
+ m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSourceModel(proxy);
+ m_filterModel->setFilterKeyColumn(-1);
+
+ ui->packageView->setModel(m_filterModel);
+ ui->packageView->installEventFilter(this);
+ ui->packageView->setSelectionMode(QAbstractItemView::SingleSelection);
+ ui->packageView->setContextMenuPolicy(Qt::CustomContextMenu);
+
+ connect(ui->packageView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &VersionPage::versionCurrent);
+ auto smodel = ui->packageView->selectionModel();
+ connect(smodel, &QItemSelectionModel::currentChanged, this,
+ &VersionPage::packageCurrent);
+
+ connect(m_profile.get(), &PackProfile::minecraftChanged, this,
+ &VersionPage::updateVersionControls);
+ controlsEnabled = !m_inst->isRunning();
+ updateVersionControls();
+ preselect(0);
+ connect(m_inst, &BaseInstance::runningStatusChanged, this,
+ &VersionPage::updateRunningStatus);
+ connect(ui->packageView, &ModListView::customContextMenuRequested, this,
+ &VersionPage::showContextMenu);
+ connect(ui->filterEdit, &QLineEdit::textChanged, this,
+ &VersionPage::onFilterTextChanged);
+}
+
+VersionPage::~VersionPage()
+{
+ delete ui;
+}
+
+void VersionPage::showContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->packageView->mapToGlobal(pos));
+ delete menu;
+}
+
+void VersionPage::packageCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (!current.isValid()) {
+ ui->frame->clear();
+ return;
+ }
+ int row = current.row();
+ auto patch = m_profile->getComponent(row);
+ auto severity = patch->getProblemSeverity();
+ switch (severity) {
+ case ProblemSeverity::Warning:
+ ui->frame->setModText(
+ tr("%1 possibly has issues.").arg(patch->getName()));
+ break;
+ case ProblemSeverity::Error:
+ ui->frame->setModText(tr("%1 has issues!").arg(patch->getName()));
+ break;
+ default:
+ case ProblemSeverity::None:
+ ui->frame->clear();
+ return;
+ }
+
+ auto& problems = patch->getProblems();
+ QString problemOut;
+ for (auto& problem : problems) {
+ if (problem.m_severity == ProblemSeverity::Error) {
+ problemOut += tr("Error: ");
+ } else if (problem.m_severity == ProblemSeverity::Warning) {
+ problemOut += tr("Warning: ");
+ }
+ problemOut += problem.m_description;
+ problemOut += "\n";
+ }
+ ui->frame->setModDescription(problemOut);
+}
+
+void VersionPage::updateRunningStatus(bool running)
+{
+ if (controlsEnabled == running) {
+ controlsEnabled = !running;
+ updateVersionControls();
+ }
+}
+
+void VersionPage::updateVersionControls()
+{
+ // FIXME: this is a dirty hack
+ auto minecraftVersion =
+ Version(m_profile->getComponentVersion("net.minecraft"));
+
+ bool supportsFabric = minecraftVersion >= Version("1.14");
+ ui->actionInstall_Fabric->setEnabled(controlsEnabled && supportsFabric);
+
+ bool supportsNeoForge = minecraftVersion >= Version("1.20.1");
+ ui->actionInstall_NeoForge->setEnabled(controlsEnabled && supportsNeoForge);
+
+ bool supportsQuilt = minecraftVersion >= Version("1.14");
+ ui->actionInstall_Quilt->setEnabled(controlsEnabled && supportsQuilt);
+
+ bool supportsLiteLoader = minecraftVersion <= Version("1.12.2");
+ ui->actionInstall_LiteLoader->setEnabled(controlsEnabled &&
+ supportsLiteLoader);
+
+ updateButtons();
+}
+
+void VersionPage::updateButtons(int row)
+{
+ if (row == -1)
+ row = currentRow();
+ auto patch = m_profile->getComponent(row);
+ ui->actionRemove->setEnabled(controlsEnabled && patch &&
+ patch->isRemovable());
+ ui->actionMove_down->setEnabled(controlsEnabled && patch &&
+ patch->isMoveable());
+ ui->actionMove_up->setEnabled(controlsEnabled && patch &&
+ patch->isMoveable());
+ ui->actionChange_version->setEnabled(controlsEnabled && patch &&
+ patch->isVersionChangeable());
+ ui->actionEdit->setEnabled(controlsEnabled && patch && patch->isCustom());
+ ui->actionCustomize->setEnabled(controlsEnabled && patch &&
+ patch->isCustomizable());
+ ui->actionRevert->setEnabled(controlsEnabled && patch &&
+ patch->isRevertible());
+ ui->actionDownload_All->setEnabled(controlsEnabled);
+ ui->actionAdd_Empty->setEnabled(controlsEnabled);
+ ui->actionReload->setEnabled(controlsEnabled);
+ ui->actionInstall_mods->setEnabled(controlsEnabled);
+ ui->actionReplace_Minecraft_jar->setEnabled(controlsEnabled);
+ ui->actionAdd_to_Minecraft_jar->setEnabled(controlsEnabled);
+}
+
+bool VersionPage::reloadPackProfile()
+{
+ try {
+ m_profile->reload(Net::Mode::Online);
+ return true;
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ return false;
+ } catch (...) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Couldn't load the instance profile."));
+ return false;
+ }
+}
+
+void VersionPage::on_actionReload_triggered()
+{
+ reloadPackProfile();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionRemove_triggered()
+{
+ if (ui->packageView->currentIndex().isValid()) {
+ // FIXME: use actual model, not reloading.
+ if (!m_profile->remove(ui->packageView->currentIndex().row())) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Couldn't remove file"));
+ }
+ }
+ updateButtons();
+ reloadPackProfile();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionInstall_mods_triggered()
+{
+ if (m_container) {
+ m_container->selectPage("mods");
+ }
+}
+
+void VersionPage::on_actionAdd_to_Minecraft_jar_triggered()
+{
+ auto list = GuiUtil::BrowseForFiles(
+ "jarmod", tr("Select jar mods"), tr("Minecraft.jar mods (*.zip *.jar)"),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!list.empty()) {
+ m_profile->installJarMods(list);
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionReplace_Minecraft_jar_triggered()
+{
+ auto jarPath = GuiUtil::BrowseForFile(
+ "jar", tr("Select jar"), tr("Minecraft.jar replacement (*.jar)"),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!jarPath.isEmpty()) {
+ m_profile->installCustomJar(jarPath);
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionMove_up_triggered()
+{
+ try {
+ m_profile->move(currentRow(), PackProfile::MoveUp);
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionMove_down_triggered()
+{
+ try {
+ m_profile->move(currentRow(), PackProfile::MoveDown);
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionChange_version_triggered()
+{
+ auto versionRow = currentRow();
+ if (versionRow == -1) {
+ return;
+ }
+ auto patch = m_profile->getComponent(versionRow);
+ auto name = patch->getName();
+ auto list = patch->getVersionList();
+ if (!list) {
+ return;
+ }
+ auto uid = list->uid();
+ // FIXME: this is a horrible HACK. Get version filtering information from
+ // the actual metadata...
+ if (uid == "net.minecraftforge") {
+ on_actionInstall_Forge_triggered();
+ return;
+ } else if (uid == "net.neoforged") {
+ on_actionInstall_NeoForge_triggered();
+ return;
+ } else if (uid == "com.mumfrey.liteloader") {
+ on_actionInstall_LiteLoader_triggered();
+ return;
+ }
+ VersionSelectDialog vselect(list.get(), tr("Change %1 version").arg(name),
+ this);
+ if (uid == "net.fabricmc.intermediary") {
+ vselect.setEmptyString(
+ tr("No intermediary mappings versions are currently available."));
+ vselect.setEmptyErrorString(tr("Couldn't load or download the "
+ "intermediary mappings version lists!"));
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ }
+ auto currentVersion = patch->getVersion();
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+ if (!vselect.exec() || !vselect.selectedVersion())
+ return;
+
+ qDebug() << "Change" << uid << "to"
+ << vselect.selectedVersion()->descriptor();
+ bool important = false;
+ if (uid == "net.minecraft") {
+ important = true;
+ }
+ m_profile->setComponentVersion(uid, vselect.selectedVersion()->descriptor(),
+ important);
+ m_profile->resolve(Net::Mode::Online);
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionDownload_All_triggered()
+{
+ if (!APPLICATION->accounts()->anyAccountIsValid()) {
+ CustomMessageBox::selectable(
+ this, tr("Error"),
+ tr("MeshMC cannot download Minecraft or update instances unless "
+ "you have at least "
+ "one account added.\nPlease add your Mojang or Minecraft "
+ "account."),
+ QMessageBox::Warning)
+ ->show();
+ return;
+ }
+
+ auto updateTask = m_inst->createUpdateTask(Net::Mode::Online);
+ if (!updateTask) {
+ return;
+ }
+ ProgressDialog tDialog(this);
+ connect(updateTask.get(), SIGNAL(failed(QString)),
+ SLOT(onGameUpdateError(QString)));
+ // FIXME: unused return value
+ tDialog.execWithTask(updateTask.get());
+ updateButtons();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionInstall_Forge_triggered()
+{
+ // Forge conflicts with Fabric, NeoForge, and Quilt
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with Fabric Loader. "
+ "Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with NeoForge. Please "
+ "remove NeoForge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with Quilt Loader. "
+ "Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("net.minecraftforge");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Forge version"), this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No Forge versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Forge version lists!"));
+
+ auto currentVersion = m_profile->getComponentVersion("net.minecraftforge");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.minecraftforge", vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ // m_profile->installVersion();
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_Fabric_triggered()
+{
+ // Fabric conflicts with Forge, NeoForge, and Quilt
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with Forge. "
+ "Please remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with NeoForge. "
+ "Please remove NeoForge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with Quilt "
+ "Loader. Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist =
+ APPLICATION->metadataIndex()->get("net.fabricmc.fabric-loader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Fabric Loader version"),
+ this);
+ vselect.setEmptyString(
+ tr("No Fabric Loader versions are currently available."));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Fabric Loader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("net.fabricmc.fabric-loader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.fabricmc.fabric-loader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_NeoForge_triggered()
+{
+ // NeoForge conflicts with Forge, Fabric, and Quilt
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Forge. Please "
+ "remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Fabric Loader. "
+ "Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Quilt Loader. "
+ "Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("net.neoforged");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select NeoForge version"),
+ this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No NeoForge versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the NeoForge version lists!"));
+
+ auto currentVersion = m_profile->getComponentVersion("net.neoforged");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.neoforged", vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_Quilt_triggered()
+{
+ // Quilt conflicts with Forge, Fabric, and NeoForge
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with Forge. "
+ "Please remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with Fabric "
+ "Loader. Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with NeoForge. "
+ "Please remove NeoForge first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("org.quiltmc.quilt-loader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Quilt Loader version"),
+ this);
+ vselect.setEmptyString(
+ tr("No Quilt Loader versions are currently available."));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Quilt Loader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("org.quiltmc.quilt-loader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("org.quiltmc.quilt-loader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionAdd_Empty_triggered()
+{
+ NewComponentDialog compdialog(QString(), QString(), this);
+ QStringList blacklist;
+ for (int i = 0; i < m_profile->rowCount(); i++) {
+ auto comp = m_profile->getComponent(i);
+ blacklist.push_back(comp->getID());
+ }
+ compdialog.setBlacklist(blacklist);
+ if (compdialog.exec()) {
+ qDebug() << "name:" << compdialog.name();
+ qDebug() << "uid:" << compdialog.uid();
+ m_profile->installEmpty(compdialog.uid(), compdialog.name());
+ }
+}
+
+void VersionPage::on_actionInstall_LiteLoader_triggered()
+{
+ auto vlist = APPLICATION->metadataIndex()->get("com.mumfrey.liteloader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select LiteLoader version"),
+ this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No LiteLoader versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the LiteLoader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("com.mumfrey.liteloader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("com.mumfrey.liteloader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ // m_profile->installVersion(vselect.selectedVersion());
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionLibrariesFolder_triggered()
+{
+ DesktopServices::openDirectory(m_inst->getLocalLibraryPath(), true);
+}
+
+void VersionPage::on_actionMinecraftFolder_triggered()
+{
+ DesktopServices::openDirectory(m_inst->gameRoot(), true);
+}
+
+void VersionPage::versionCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ currentIdx = current.row();
+ updateButtons(currentIdx);
+}
+
+void VersionPage::preselect(int row)
+{
+ if (row < 0) {
+ row = 0;
+ }
+ if (row >= m_profile->rowCount(QModelIndex())) {
+ row = m_profile->rowCount(QModelIndex()) - 1;
+ }
+ if (row < 0) {
+ return;
+ }
+ auto model_index = m_profile->index(row);
+ ui->packageView->selectionModel()->select(
+ model_index,
+ QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows);
+ updateButtons(row);
+}
+
+void VersionPage::onGameUpdateError(QString error)
+{
+ CustomMessageBox::selectable(this, tr("Error updating instance"), error,
+ QMessageBox::Warning)
+ ->show();
+}
+
+Component* VersionPage::current()
+{
+ auto row = currentRow();
+ if (row < 0) {
+ return nullptr;
+ }
+ return m_profile->getComponent(row);
+}
+
+int VersionPage::currentRow()
+{
+ if (ui->packageView->selectionModel()->selectedRows().isEmpty()) {
+ return -1;
+ }
+ return ui->packageView->selectionModel()->selectedRows().first().row();
+}
+
+void VersionPage::on_actionCustomize_triggered()
+{
+ auto version = currentRow();
+ if (version == -1) {
+ return;
+ }
+ auto patch = m_profile->getComponent(version);
+ if (!patch->getVersionFile()) {
+ // TODO: wait for the update task to finish here...
+ return;
+ }
+ if (!m_profile->customize(version)) {
+ // TODO: some error box here
+ }
+ updateButtons();
+ preselect(currentIdx);
+}
+
+void VersionPage::on_actionEdit_triggered()
+{
+ auto version = current();
+ if (!version) {
+ return;
+ }
+ auto filename = version->getFilename();
+ if (!QFileInfo::exists(filename)) {
+ qWarning() << "file" << filename
+ << "can't be opened for editing, doesn't exist!";
+ return;
+ }
+ APPLICATION->openJsonEditor(filename);
+}
+
+void VersionPage::on_actionRevert_triggered()
+{
+ auto version = currentRow();
+ if (version == -1) {
+ return;
+ }
+ if (!m_profile->revertToBase(version)) {
+ // TODO: some error box here
+ }
+ updateButtons();
+ preselect(currentIdx);
+ m_container->refreshContainer();
+}
+
+void VersionPage::onFilterTextChanged(const QString& newContents)
+{
+ m_filterModel->setFilterFixedString(newContents);
+}
+
+#include "VersionPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.h b/meshmc/launcher/ui/pages/instance/VersionPage.h
new file mode 100644
index 0000000000..989a4b735b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.h
@@ -0,0 +1,131 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * 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 <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/PackProfile.h"
+#include "ui/pages/BasePage.h"
+
+namespace Ui
+{
+ class VersionPage;
+}
+
+class VersionPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit VersionPage(MinecraftInstance* inst, QWidget* parent = 0);
+ virtual ~VersionPage();
+ virtual QString displayName() const override
+ {
+ return tr("Version");
+ }
+ virtual QIcon icon() const override;
+ virtual QString id() const override
+ {
+ return "version";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Instance-Version";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_actionChange_version_triggered();
+ void on_actionInstall_Forge_triggered();
+ void on_actionInstall_Fabric_triggered();
+ void on_actionInstall_NeoForge_triggered();
+ void on_actionInstall_Quilt_triggered();
+ void on_actionAdd_Empty_triggered();
+ void on_actionInstall_LiteLoader_triggered();
+ void on_actionReload_triggered();
+ void on_actionRemove_triggered();
+ void on_actionMove_up_triggered();
+ void on_actionMove_down_triggered();
+ void on_actionAdd_to_Minecraft_jar_triggered();
+ void on_actionReplace_Minecraft_jar_triggered();
+ void on_actionRevert_triggered();
+ void on_actionEdit_triggered();
+ void on_actionInstall_mods_triggered();
+ void on_actionCustomize_triggered();
+ void on_actionDownload_All_triggered();
+
+ void on_actionMinecraftFolder_triggered();
+ void on_actionLibrariesFolder_triggered();
+
+ void updateVersionControls();
+
+ private:
+ Component* current();
+ int currentRow();
+ void updateButtons(int row = -1);
+ void preselect(int row = 0);
+ int doUpdate();
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ /// FIXME: this shouldn't be necessary!
+ bool reloadPackProfile();
+
+ private:
+ Ui::VersionPage* ui;
+ QSortFilterProxyModel* m_filterModel;
+ std::shared_ptr<PackProfile> m_profile;
+ MinecraftInstance* m_inst;
+ int currentIdx = 0;
+ bool controlsEnabled = false;
+
+ public slots:
+ void versionCurrent(const QModelIndex& current,
+ const QModelIndex& previous);
+
+ private slots:
+ void updateRunningStatus(bool running);
+ void onGameUpdateError(QString error);
+ void packageCurrent(const QModelIndex& current,
+ const QModelIndex& previous);
+ void showContextMenu(const QPoint& pos);
+ void onFilterTextChanged(const QString& newContents);
+};
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.ui b/meshmc/launcher/ui/pages/instance/VersionPage.ui
new file mode 100644
index 0000000000..d6d0a74570
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.ui
@@ -0,0 +1,303 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>VersionPage</class>
+ <widget class="QMainWindow" name="VersionPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>961</width>
+ <height>1091</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="ModListView" name="packageView">
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <property name="sortingEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="headerHidden">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerVisible">
+ <bool>true</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="filterEdit">
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="filterLabel">
+ <property name="text">
+ <string>Filter:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="MCModInfoFrame" name="frame">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionChange_version"/>
+ <addaction name="actionMove_up"/>
+ <addaction name="actionMove_down"/>
+ <addaction name="actionRemove"/>
+ <addaction name="separator"/>
+ <addaction name="actionCustomize"/>
+ <addaction name="actionEdit"/>
+ <addaction name="actionRevert"/>
+ <addaction name="separator"/>
+ <addaction name="actionInstall_Forge"/>
+ <addaction name="actionInstall_Fabric"/>
+ <addaction name="actionInstall_NeoForge"/>
+ <addaction name="actionInstall_Quilt"/>
+ <addaction name="actionInstall_LiteLoader"/>
+ <addaction name="actionInstall_mods"/>
+ <addaction name="separator"/>
+ <addaction name="actionAdd_to_Minecraft_jar"/>
+ <addaction name="actionReplace_Minecraft_jar"/>
+ <addaction name="actionAdd_Empty"/>
+ <addaction name="separator"/>
+ <addaction name="actionMinecraftFolder"/>
+ <addaction name="actionLibrariesFolder"/>
+ <addaction name="separator"/>
+ <addaction name="actionReload"/>
+ <addaction name="actionDownload_All"/>
+ </widget>
+ <action name="actionChange_version">
+ <property name="text">
+ <string>Change version</string>
+ </property>
+ <property name="toolTip">
+ <string>Change version of the selected package.</string>
+ </property>
+ </action>
+ <action name="actionMove_up">
+ <property name="text">
+ <string>Move up</string>
+ </property>
+ <property name="toolTip">
+ <string>Make the selected package apply sooner.</string>
+ </property>
+ </action>
+ <action name="actionMove_down">
+ <property name="text">
+ <string>Move down</string>
+ </property>
+ <property name="toolTip">
+ <string>Make the selected package apply later.</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove selected package from the instance.</string>
+ </property>
+ </action>
+ <action name="actionCustomize">
+ <property name="text">
+ <string>Customize</string>
+ </property>
+ <property name="toolTip">
+ <string>Customize selected package.</string>
+ </property>
+ </action>
+ <action name="actionEdit">
+ <property name="text">
+ <string>Edit</string>
+ </property>
+ <property name="toolTip">
+ <string>Edit selected package.</string>
+ </property>
+ </action>
+ <action name="actionRevert">
+ <property name="text">
+ <string>Revert</string>
+ </property>
+ <property name="toolTip">
+ <string>Revert the selected package to default.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Forge">
+ <property name="text">
+ <string>Install Forge</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Minecraft Forge package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Fabric">
+ <property name="text">
+ <string>Install Fabric</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Fabric Loader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_LiteLoader">
+ <property name="text">
+ <string>Install LiteLoader</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the LiteLoader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_NeoForge">
+ <property name="text">
+ <string>Install NeoForge</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the NeoForge mod loader.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Quilt">
+ <property name="text">
+ <string>Install Quilt</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Quilt Loader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_mods">
+ <property name="text">
+ <string>Install mods</string>
+ </property>
+ <property name="toolTip">
+ <string>Install normal mods.</string>
+ </property>
+ </action>
+ <action name="actionAdd_to_Minecraft_jar">
+ <property name="text">
+ <string>Add to Minecraft.jar</string>
+ </property>
+ <property name="toolTip">
+ <string>Add a mod into the Minecraft jar file.</string>
+ </property>
+ </action>
+ <action name="actionReplace_Minecraft_jar">
+ <property name="text">
+ <string>Replace Minecraft.jar</string>
+ </property>
+ </action>
+ <action name="actionAdd_Empty">
+ <property name="text">
+ <string>Add Empty</string>
+ </property>
+ <property name="toolTip">
+ <string>Add an empty custom package.</string>
+ </property>
+ </action>
+ <action name="actionReload">
+ <property name="text">
+ <string>Reload</string>
+ </property>
+ <property name="toolTip">
+ <string>Reload all packages.</string>
+ </property>
+ </action>
+ <action name="actionDownload_All">
+ <property name="text">
+ <string>Download All</string>
+ </property>
+ <property name="toolTip">
+ <string>Download the files needed to launch the instance now.</string>
+ </property>
+ </action>
+ <action name="actionMinecraftFolder">
+ <property name="text">
+ <string>Open .minecraft</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the instance's .minecraft folder.</string>
+ </property>
+ </action>
+ <action name="actionLibrariesFolder">
+ <property name="text">
+ <string>Open libraries</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the instance's local libraries folder.</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>ModListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/ModListView.h</header>
+ </customwidget>
+ <customwidget>
+ <class>MCModInfoFrame</class>
+ <extends>QFrame</extends>
+ <header>ui/widgets/MCModInfoFrame.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.cpp b/meshmc/launcher/ui/pages/instance/WorldListPage.cpp
new file mode 100644
index 0000000000..3759b77823
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.cpp
@@ -0,0 +1,422 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2015-2021 MultiMC Contributors
+ *
+ * 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 "WorldListPage.h"
+#include "ui_WorldListPage.h"
+#include "minecraft/WorldList.h"
+
+#include <QEvent>
+#include <QMenu>
+#include <QKeyEvent>
+#include <QClipboard>
+#include <QMessageBox>
+#include <QTreeView>
+#include <QInputDialog>
+#include <QProcess>
+
+#include "tools/MCEditTool.h"
+#include "FileSystem.h"
+
+#include "ui/GuiUtil.h"
+#include "DesktopServices.h"
+
+#include "Application.h"
+
+class WorldListProxyModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ WorldListProxyModel(QObject* parent) : QSortFilterProxyModel(parent) {}
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const
+ {
+ QModelIndex sourceIndex = mapToSource(index);
+
+ if (index.column() == 0 && role == Qt::DecorationRole) {
+ WorldList* worlds = qobject_cast<WorldList*>(sourceModel());
+ auto iconFile =
+ worlds->data(sourceIndex, WorldList::IconFileRole).toString();
+ if (iconFile.isNull()) {
+ // NOTE: Minecraft uses the same placeholder for servers AND
+ // worlds
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ return QIcon(iconFile);
+ }
+
+ return sourceIndex.data(role);
+ }
+};
+
+WorldListPage::WorldListPage(BaseInstance* inst,
+ std::shared_ptr<WorldList> worlds, QWidget* parent)
+ : QMainWindow(parent), m_inst(inst), ui(new Ui::WorldListPage),
+ m_worlds(worlds)
+{
+ ui->setupUi(this);
+
+ ui->toolBar->insertSpacer(ui->actionRefresh);
+
+ WorldListProxyModel* proxy = new WorldListProxyModel(this);
+ proxy->setSortCaseSensitivity(Qt::CaseInsensitive);
+ proxy->setSourceModel(m_worlds.get());
+ ui->worldTreeView->setSortingEnabled(true);
+ ui->worldTreeView->setModel(proxy);
+ ui->worldTreeView->installEventFilter(this);
+ ui->worldTreeView->setContextMenuPolicy(Qt::CustomContextMenu);
+ ui->worldTreeView->setIconSize(QSize(64, 64));
+ connect(ui->worldTreeView, &QTreeView::customContextMenuRequested, this,
+ &WorldListPage::ShowContextMenu);
+
+ auto head = ui->worldTreeView->header();
+ head->setSectionResizeMode(0, QHeaderView::Stretch);
+ head->setSectionResizeMode(1, QHeaderView::ResizeToContents);
+
+ connect(ui->worldTreeView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &WorldListPage::worldChanged);
+ worldChanged(QModelIndex(), QModelIndex());
+}
+
+void WorldListPage::openedImpl()
+{
+ m_worlds->startWatching();
+}
+
+void WorldListPage::closedImpl()
+{
+ m_worlds->stopWatching();
+}
+
+WorldListPage::~WorldListPage()
+{
+ m_worlds->stopWatching();
+ delete ui;
+}
+
+void WorldListPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->worldTreeView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* WorldListPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+bool WorldListPage::shouldDisplay() const
+{
+ return true;
+}
+
+bool WorldListPage::worldListFilter(QKeyEvent* keyEvent)
+{
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionRemove_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(ui->worldTreeView, keyEvent);
+}
+
+bool WorldListPage::eventFilter(QObject* obj, QEvent* ev)
+{
+ if (ev->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, ev);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(ev);
+ if (obj == ui->worldTreeView)
+ return worldListFilter(keyEvent);
+ return QWidget::eventFilter(obj, ev);
+}
+
+void WorldListPage::on_actionRemove_triggered()
+{
+ auto proxiedIndex = getSelectedWorld();
+
+ if (!proxiedIndex.isValid())
+ return;
+
+ auto result = QMessageBox::question(
+ this, tr("Are you sure?"),
+ tr("This will remove the selected world permenantly.\n"
+ "The world will be gone forever (A LONG TIME).\n"
+ "\n"
+ "Do you want to continue?"));
+ if (result != QMessageBox::Yes) {
+ return;
+ }
+ m_worlds->stopWatching();
+ m_worlds->deleteWorld(proxiedIndex.row());
+ m_worlds->startWatching();
+}
+
+void WorldListPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_worlds->dir().absolutePath(), true);
+}
+
+void WorldListPage::on_actionDatapacks_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto fullPath = m_worlds->data(index, WorldList::FolderRole).toString();
+
+ DesktopServices::openDirectory(FS::PathCombine(fullPath, "datapacks"),
+ true);
+}
+
+void WorldListPage::on_actionReset_Icon_triggered()
+{
+ auto proxiedIndex = getSelectedWorld();
+
+ if (!proxiedIndex.isValid())
+ return;
+
+ if (m_worlds->resetIcon(proxiedIndex.row())) {
+ ui->actionReset_Icon->setEnabled(false);
+ }
+}
+
+QModelIndex WorldListPage::getSelectedWorld()
+{
+ auto index = ui->worldTreeView->selectionModel()->currentIndex();
+
+ auto proxy = (QSortFilterProxyModel*)ui->worldTreeView->model();
+ return proxy->mapToSource(index);
+}
+
+void WorldListPage::on_actionCopy_Seed_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+ int64_t seed = m_worlds->data(index, WorldList::SeedRole).toLongLong();
+ APPLICATION->clipboard()->setText(QString::number(seed));
+}
+
+void WorldListPage::on_actionMCEdit_triggered()
+{
+ if (m_mceditStarting)
+ return;
+
+ auto mcedit = APPLICATION->mcedit();
+
+ const QString mceditPath = mcedit->path();
+
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto fullPath = m_worlds->data(index, WorldList::FolderRole).toString();
+
+ auto program = mcedit->getProgramPath();
+ if (program.size()) {
+#ifdef Q_OS_WIN32
+ if (!QProcess::startDetached(program, {fullPath}, mceditPath)) {
+ mceditError();
+ }
+#else
+ m_mceditProcess.reset(new LoggedProcess());
+ m_mceditProcess->setDetachable(true);
+ connect(m_mceditProcess.get(), &LoggedProcess::stateChanged, this,
+ &WorldListPage::mceditState);
+ m_mceditProcess->start(program, {fullPath});
+ m_mceditProcess->setWorkingDirectory(mceditPath);
+ m_mceditStarting = true;
+#endif
+ } else {
+ QMessageBox::warning(
+ this->parentWidget(), tr("No MCEdit found or set up!"),
+ tr("You do not have MCEdit set up or it was moved.\nYou can set it "
+ "up in the global settings."));
+ }
+}
+
+void WorldListPage::mceditError()
+{
+ QMessageBox::warning(
+ this->parentWidget(), tr("MCEdit failed to start!"),
+ tr("MCEdit failed to start.\nIt may be necessary to reinstall it."));
+}
+
+void WorldListPage::mceditState(LoggedProcess::State state)
+{
+ bool failed = false;
+ switch (state) {
+ case LoggedProcess::NotRunning:
+ case LoggedProcess::Starting:
+ return;
+ case LoggedProcess::FailedToStart:
+ case LoggedProcess::Crashed:
+ case LoggedProcess::Aborted: {
+ failed = true;
+ }
+ case LoggedProcess::Running:
+ case LoggedProcess::Finished: {
+ m_mceditStarting = false;
+ break;
+ }
+ }
+ if (failed) {
+ mceditError();
+ }
+}
+
+void WorldListPage::worldChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ QModelIndex index = getSelectedWorld();
+ bool enable = index.isValid();
+ ui->actionCopy_Seed->setEnabled(enable);
+ ui->actionMCEdit->setEnabled(enable);
+ ui->actionRemove->setEnabled(enable);
+ ui->actionCopy->setEnabled(enable);
+ ui->actionRename->setEnabled(enable);
+ ui->actionDatapacks->setEnabled(enable);
+ bool hasIcon = !index.data(WorldList::IconFileRole).isNull();
+ ui->actionReset_Icon->setEnabled(enable && hasIcon);
+}
+
+void WorldListPage::on_actionAdd_triggered()
+{
+ auto list = GuiUtil::BrowseForFiles(displayName(),
+ tr("Select a Minecraft world zip"),
+ tr("Minecraft World Zip File (*.zip)"),
+ QString(), this->parentWidget());
+ if (!list.empty()) {
+ m_worlds->stopWatching();
+ for (auto filename : list) {
+ m_worlds->installWorld(QFileInfo(filename));
+ }
+ m_worlds->startWatching();
+ }
+}
+
+bool WorldListPage::isWorldSafe(QModelIndex)
+{
+ return !m_inst->isRunning();
+}
+
+bool WorldListPage::worldSafetyNagQuestion()
+{
+ if (!isWorldSafe(getSelectedWorld())) {
+ auto result = QMessageBox::question(
+ this, tr("Copy World"),
+ tr("Changing a world while Minecraft is running is potentially "
+ "unsafe.\nDo you wish to proceed?"));
+ if (result == QMessageBox::No) {
+ return false;
+ }
+ }
+ return true;
+}
+
+void WorldListPage::on_actionCopy_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto worldVariant = m_worlds->data(index, WorldList::ObjectRole);
+ auto world = (World*)worldVariant.value<void*>();
+ bool ok = false;
+ QString name = QInputDialog::getText(this, tr("World name"),
+ tr("Enter a new name for the copy."),
+ QLineEdit::Normal, world->name(), &ok);
+
+ if (ok && name.length() > 0) {
+ world->install(m_worlds->dir().absolutePath(), name);
+ }
+}
+
+void WorldListPage::on_actionRename_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto worldVariant = m_worlds->data(index, WorldList::ObjectRole);
+ auto world = (World*)worldVariant.value<void*>();
+
+ bool ok = false;
+ QString name = QInputDialog::getText(this, tr("World name"),
+ tr("Enter a new world name."),
+ QLineEdit::Normal, world->name(), &ok);
+
+ if (ok && name.length() > 0) {
+ world->rename(name);
+ }
+}
+
+void WorldListPage::on_actionRefresh_triggered()
+{
+ m_worlds->update();
+}
+
+#include "WorldListPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.h b/meshmc/launcher/ui/pages/instance/WorldListPage.h
new file mode 100644
index 0000000000..5716a32f55
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.h
@@ -0,0 +1,120 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2015-2021 MultiMC Contributors
+ *
+ * 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 <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include <LoggedProcess.h>
+
+class WorldList;
+namespace Ui
+{
+ class WorldListPage;
+}
+
+class WorldListPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit WorldListPage(BaseInstance* inst,
+ std::shared_ptr<WorldList> worlds,
+ QWidget* parent = 0);
+ virtual ~WorldListPage();
+
+ virtual QString displayName() const override
+ {
+ return tr("Worlds");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("worlds");
+ }
+ virtual QString id() const override
+ {
+ return "worlds";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Worlds";
+ }
+ virtual bool shouldDisplay() const override;
+
+ virtual void openedImpl() override;
+ virtual void closedImpl() override;
+
+ protected:
+ bool eventFilter(QObject* obj, QEvent* ev) override;
+ bool worldListFilter(QKeyEvent* ev);
+ QMenu* createPopupMenu() override;
+
+ protected:
+ BaseInstance* m_inst;
+
+ private:
+ QModelIndex getSelectedWorld();
+ bool isWorldSafe(QModelIndex index);
+ bool worldSafetyNagQuestion();
+ void mceditError();
+
+ private:
+ Ui::WorldListPage* ui;
+ std::shared_ptr<WorldList> m_worlds;
+ unique_qobject_ptr<LoggedProcess> m_mceditProcess;
+ bool m_mceditStarting = false;
+
+ private slots:
+ void on_actionCopy_Seed_triggered();
+ void on_actionMCEdit_triggered();
+ void on_actionRemove_triggered();
+ void on_actionAdd_triggered();
+ void on_actionCopy_triggered();
+ void on_actionRename_triggered();
+ void on_actionRefresh_triggered();
+ void on_actionView_Folder_triggered();
+ void on_actionDatapacks_triggered();
+ void on_actionReset_Icon_triggered();
+ void worldChanged(const QModelIndex& current, const QModelIndex& previous);
+ void mceditState(LoggedProcess::State state);
+
+ void ShowContextMenu(const QPoint& pos);
+};
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.ui b/meshmc/launcher/ui/pages/instance/WorldListPage.ui
new file mode 100644
index 0000000000..7c68bfaee4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.ui
@@ -0,0 +1,161 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WorldListPage</class>
+ <widget class="QMainWindow" name="WorldListPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="worldTreeView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="dragDropMode">
+ <enum>QAbstractItemView::DragDrop</enum>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <property name="itemsExpandable">
+ <bool>false</bool>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <property name="allColumnsShowFocus">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="separator"/>
+ <addaction name="actionRename"/>
+ <addaction name="actionCopy"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionMCEdit"/>
+ <addaction name="actionDatapacks"/>
+ <addaction name="actionReset_Icon"/>
+ <addaction name="separator"/>
+ <addaction name="actionCopy_Seed"/>
+ <addaction name="actionRefresh"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>Add</string>
+ </property>
+ </action>
+ <action name="actionRename">
+ <property name="text">
+ <string>Rename</string>
+ </property>
+ </action>
+ <action name="actionCopy">
+ <property name="text">
+ <string>Copy</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </action>
+ <action name="actionMCEdit">
+ <property name="text">
+ <string>MCEdit</string>
+ </property>
+ </action>
+ <action name="actionCopy_Seed">
+ <property name="text">
+ <string>Copy Seed</string>
+ </property>
+ </action>
+ <action name="actionRefresh">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View Folder</string>
+ </property>
+ </action>
+ <action name="actionReset_Icon">
+ <property name="text">
+ <string>Reset Icon</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove world icon to make the game re-generate it on next load.</string>
+ </property>
+ </action>
+ <action name="actionDatapacks">
+ <property name="text">
+ <string>Datapacks</string>
+ </property>
+ <property name="toolTip">
+ <string>Manage datapacks inside the world.</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>