diff --git a/cmake/MacOSXBundleInfo.plist.in b/cmake/MacOSXBundleInfo.plist.in index eb40bacfd..1035efec3 100644 --- a/cmake/MacOSXBundleInfo.plist.in +++ b/cmake/MacOSXBundleInfo.plist.in @@ -85,6 +85,14 @@ curseforge + + CFBundleURLName + Modrinth + CFBundleURLSchemes + + modrinth + + CFBundleURLName ${Launcher_Name} diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 7d4430fd2..d1bdb7262 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -561,6 +561,8 @@ set(MODRINTH_SOURCES modplatform/modrinth/ModrinthInstanceCreationTask.h modplatform/modrinth/ModrinthPackExportTask.cpp modplatform/modrinth/ModrinthPackExportTask.h + modplatform/modrinth/ModrinthUrl.cpp + modplatform/modrinth/ModrinthUrl.h ) set(PACKWIZ_SOURCES diff --git a/launcher/modplatform/modrinth/ModrinthUrl.cpp b/launcher/modplatform/modrinth/ModrinthUrl.cpp new file mode 100644 index 000000000..003aa6dd3 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthUrl.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * 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, version 3. + * + * 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 . + */ + +#include "ModrinthUrl.h" + +namespace Modrinth { + +auto parseModpackLink(const QUrl& url) -> std::optional +{ + if (url.scheme().compare("modrinth", Qt::CaseInsensitive) != 0) { + return std::nullopt; + } + + if (url.host().compare("modpack", Qt::CaseInsensitive) != 0) { + return std::nullopt; + } + + const auto segments = QUrl::fromPercentEncoding(url.path().toUtf8()).split('/', Qt::SkipEmptyParts); + if (segments.size() != 1) { + return std::nullopt; + } + + const auto slug = segments.constFirst().trimmed(); + if (slug.isEmpty()) { + return std::nullopt; + } + + return ParsedModpackLink{ slug }; +} + +} // namespace Modrinth diff --git a/launcher/modplatform/modrinth/ModrinthUrl.h b/launcher/modplatform/modrinth/ModrinthUrl.h new file mode 100644 index 000000000..daf39fa45 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthUrl.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * 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, version 3. + * + * 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 . + */ + +#pragma once + +#include + +#include + +namespace Modrinth { + +struct ParsedModpackLink { + QString slug; +}; + +auto parseModpackLink(const QUrl& url) -> std::optional; + +} // namespace Modrinth diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index 1bdcd3f68..b057f5e9d 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -125,6 +125,7 @@ #include "modplatform/ModIndex.h" #include "modplatform/flame/FlameAPI.h" #include "modplatform/flame/FlameModIndex.h" +#include "modplatform/modrinth/ModrinthUrl.h" #include "KonamiCode.h" @@ -896,7 +897,7 @@ void MainWindow::on_actionCopyInstance_triggered() runModalTask(task.get()); } -void MainWindow::addInstance(const QString& url, const QMap& extra_info) +void MainWindow::addInstance(const QString& url, const QMap& extra_info, const QString& modrinthSlug) { QString groupName; do { @@ -917,6 +918,9 @@ void MainWindow::addInstance(const QString& url, const QMap& e } NewInstanceDialog newInstDlg(groupName, url, extra_info, this); + if (!modrinthSlug.isEmpty()) { + newInstDlg.openModrinthModpack(modrinthSlug); + } if (!newInstDlg.exec()) return; @@ -950,6 +954,19 @@ void MainWindow::processURLs(QList urls) QMap extra_info; QUrl local_url; if (!url.isLocalFile()) { // download the remote resource and identify + if (url.scheme().compare("modrinth", Qt::CaseInsensitive) == 0) { + if (auto modpackLink = Modrinth::parseModpackLink(url)) { + addInstance(QString(), {}, modpackLink->slug); + } else { + CustomMessageBox::selectable( + this, tr("Error"), + tr("Unsupported Modrinth link.\n\nPrism Launcher currently only supports modpack links such as " + "modrinth://modpack/fabulously-optimized."), + QMessageBox::Critical) + ->show(); + } + continue; + } const bool isExternalURLImport = (url.host().toLower() == "import") || (url.path().startsWith("/import", Qt::CaseInsensitive)); diff --git a/launcher/ui/MainWindow.h b/launcher/ui/MainWindow.h index 80860deef..776e6bf90 100644 --- a/launcher/ui/MainWindow.h +++ b/launcher/ui/MainWindow.h @@ -221,7 +221,8 @@ class MainWindow : public QMainWindow { private: void retranslateUi(); - void addInstance(const QString& url = QString(), const QMap& extra_info = {}); + void addInstance(const QString& url = QString(), const QMap& extra_info = {}, + const QString& modrinthSlug = QString()); void activateInstance(BaseInstance* instance); void setCatBackground(bool enabled); void updateInstanceToolIcon(QString new_icon); diff --git a/launcher/ui/dialogs/NewInstanceDialog.cpp b/launcher/ui/dialogs/NewInstanceDialog.cpp index 8cf094527..8e41500b2 100644 --- a/launcher/ui/dialogs/NewInstanceDialog.cpp +++ b/launcher/ui/dialogs/NewInstanceDialog.cpp @@ -250,6 +250,23 @@ void NewInstanceDialog::setSuggestedIcon(const QString& key) ui->iconButton->setIcon(icon); } +void NewInstanceDialog::openModrinthModpack(const QString& slug) +{ + auto trimmedSlug = slug.trimmed(); + if (trimmedSlug.isEmpty()) { + return; + } + + auto* page = dynamic_cast(m_container->getPage("modrinth")); + if (!page) { + return; + } + + m_searchTerm = trimmedSlug; + page->openProjectBySlug(trimmedSlug); + m_container->selectPage(page->id()); +} + InstanceTask* NewInstanceDialog::extractTask() { InstanceTask* extracted = creationTask.release(); diff --git a/launcher/ui/dialogs/NewInstanceDialog.h b/launcher/ui/dialogs/NewInstanceDialog.h index e97c9f543..b08ed92b7 100644 --- a/launcher/ui/dialogs/NewInstanceDialog.h +++ b/launcher/ui/dialogs/NewInstanceDialog.h @@ -65,6 +65,7 @@ class NewInstanceDialog : public QDialog, public BasePageProvider { void setSuggestedPack(const QString& name, QString version, InstanceTask* task = nullptr); void setSuggestedIconFromFile(const QString& path, const QString& name); void setSuggestedIcon(const QString& key); + void openModrinthModpack(const QString& slug); InstanceTask* extractTask(); diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp index 4798583bd..a92e6bb6b 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -56,6 +56,7 @@ #include #include #include +#include ModrinthPage::ModrinthPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), m_ui(new Ui::ModrinthPage), m_dialog(dialog), m_fetch_progress(this, false) @@ -328,6 +329,44 @@ void ModrinthPage::suggestCurrent() } } +void ModrinthPage::openProjectBySlug(QString slug) +{ + m_requestedProjectSlug = std::move(slug).trimmed(); + + if (m_requestedProjectSlug.isEmpty()) { + return; + } + + setSearchTerm(m_requestedProjectSlug); +} + +void ModrinthPage::selectRequestedProject() +{ + if (m_requestedProjectSlug.isEmpty()) { + return; + } + + const auto requestedSlug = m_requestedProjectSlug; + m_requestedProjectSlug.clear(); + + for (int row = 0; row < m_model->rowCount({}); row++) { + const auto index = m_model->index(row, 0); + const auto pack = m_model->data(index, Qt::UserRole).value(); + + if (pack && pack->slug.compare(requestedSlug, Qt::CaseInsensitive) == 0) { + m_ui->packView->setCurrentIndex(index); + m_ui->packView->scrollTo(index); + return; + } + } + + if (m_model->rowCount({}) == 1) { + const auto index = m_model->index(0, 0); + m_ui->packView->setCurrentIndex(index); + m_ui->packView->scrollTo(index); + } +} + void ModrinthPage::triggerSearch() { m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); @@ -335,8 +374,17 @@ void ModrinthPage::triggerSearch() m_ui->packDescription->clear(); m_ui->versionSelectionBox->clear(); bool filterChanged = m_filterWidget->changed(); - m_model->searchWithTerm(m_ui->searchEdit->text(), m_ui->sortByBox->currentIndex(), m_filterWidget->getFilter(), filterChanged); + const auto searchTerm = m_requestedProjectSlug.isEmpty() ? m_ui->searchEdit->text() : "#" + m_requestedProjectSlug; + m_model->searchWithTerm(searchTerm, m_ui->sortByBox->currentIndex(), m_filterWidget->getFilter(), filterChanged); m_fetch_progress.watch(m_model->activeSearchJob().get()); + + if (!m_requestedProjectSlug.isEmpty()) { + if (m_model->hasActiveSearchJob()) { + connect(m_model->activeSearchJob().get(), &Task::finished, this, [this] { selectRequestedProject(); }); + } else { + selectRequestedProject(); + } + } } void ModrinthPage::onVersionSelectionChanged(int index) diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h index 4ca41a3e0..264e92313 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h @@ -72,6 +72,7 @@ class ModrinthPage : public QWidget, public ModpackProviderBasePage { ModPlatform::IndexedPack::Ptr getCurrent() { return m_current; } void suggestCurrent(); + void openProjectBySlug(QString slug); void updateUI(); @@ -91,12 +92,15 @@ class ModrinthPage : public QWidget, public ModpackProviderBasePage { void createFilterWidget(); private: + void selectRequestedProject(); + Ui::ModrinthPage* m_ui; NewInstanceDialog* m_dialog; Modrinth::ModpackListModel* m_model; ModPlatform::IndexedPack::Ptr m_current; QString m_selectedVersion; + QString m_requestedProjectSlug; ProgressWidget m_fetch_progress; diff --git a/program_info/org.prismlauncher.PrismLauncher.desktop.in b/program_info/org.prismlauncher.PrismLauncher.desktop.in index 416ca1b6e..98037a78e 100644 --- a/program_info/org.prismlauncher.PrismLauncher.desktop.in +++ b/program_info/org.prismlauncher.PrismLauncher.desktop.in @@ -10,4 +10,4 @@ Icon=@Launcher_AppID@ Categories=Game;ActionGame;AdventureGame;Simulation;PackageManager; Keywords=game;minecraft;mc; StartupWMClass=@Launcher_CommonName@ -MimeType=application/zip;application/x-modrinth-modpack+zip;x-scheme-handler/curseforge;x-scheme-handler/prismlauncher;x-scheme-handler/@Launcher_APP_BINARY_NAME@; +MimeType=application/zip;application/x-modrinth-modpack+zip;x-scheme-handler/curseforge;x-scheme-handler/modrinth;x-scheme-handler/prismlauncher;x-scheme-handler/@Launcher_APP_BINARY_NAME@; diff --git a/program_info/win_install.nsi.in b/program_info/win_install.nsi.in index 83335ce9d..05431156f 100644 --- a/program_info/win_install.nsi.in +++ b/program_info/win_install.nsi.in @@ -394,6 +394,10 @@ Section "@Launcher_DisplayName@" WriteRegStr HKCU Software\Classes\curseforge "URL Protocol" "" WriteRegStr HKCU Software\Classes\curseforge\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' + ; Write the URL Handler into registry for modrinth + WriteRegStr HKCU Software\Classes\modrinth "URL Protocol" "" + WriteRegStr HKCU Software\Classes\modrinth\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' + ; Write the URL Handler into registry for prismlauncher WriteRegStr HKCU Software\Classes\@Launcher_APP_BINARY_NAME@ "URL Protocol" "" WriteRegStr HKCU Software\Classes\@Launcher_APP_BINARY_NAME@\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2165cd03d..d04fe2045 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,9 @@ ecm_add_test(Index_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}: ecm_add_test(Version_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test TEST_NAME Version) +ecm_add_test(ModrinthUrl_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test + TEST_NAME ModrinthUrl) + ecm_add_test(MetaComponentParse_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test TEST_NAME MetaComponentParse) diff --git a/tests/ModrinthUrl_test.cpp b/tests/ModrinthUrl_test.cpp new file mode 100644 index 000000000..967fb1872 --- /dev/null +++ b/tests/ModrinthUrl_test.cpp @@ -0,0 +1,49 @@ +#include + +#include + +class ModrinthUrlTest : public QObject { + Q_OBJECT + + private slots: + void parseModpackLink_data() + { + QTest::addColumn("link"); + QTest::addColumn("slug"); + + QTest::newRow("official") << "modrinth://modpack/fabulously-optimized" << "fabulously-optimized"; + QTest::newRow("trailing slash") << "modrinth://modpack/fabulously-optimized/" << "fabulously-optimized"; + QTest::newRow("case insensitive host") << "modrinth://MODPACK/fo" << "fo"; + } + + void parseModpackLink() + { + QFETCH(QString, link); + QFETCH(QString, slug); + + auto parsed = Modrinth::parseModpackLink(QUrl(link)); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->slug, slug); + } + + void rejectInvalidLinks_data() + { + QTest::addColumn("link"); + + QTest::newRow("wrong scheme") << "https://modrinth.com/modpack/fabulously-optimized"; + QTest::newRow("missing slug") << "modrinth://modpack/"; + QTest::newRow("unsupported resource") << "modrinth://mod/sodium"; + QTest::newRow("extra path") << "modrinth://modpack/fabulously-optimized/version/latest"; + } + + void rejectInvalidLinks() + { + QFETCH(QString, link); + + QVERIFY(!Modrinth::parseModpackLink(QUrl(link)).has_value()); + } +}; + +QTEST_GUILESS_MAIN(ModrinthUrlTest) + +#include "ModrinthUrl_test.moc"