Merge branch 'develop' of https://github.com/PrismLauncher/PrismLauncher into fix_login

This commit is contained in:
Trial97 2024-10-01 08:49:03 +03:00
commit b676a67b3c
No known key found for this signature in database
GPG Key ID: 55EF5DA53DB36318
52 changed files with 673 additions and 321 deletions

View File

@ -2,3 +2,6 @@
# tabs -> spaces
bbb3b3e6f6e3c0f95873f22e6d0a4aaf350f49d9
# (nix) alejandra -> nixfmt
4c81d8c53d09196426568c4a31a4e752ed05397a

View File

@ -39,6 +39,9 @@ on:
APPLE_NOTARIZE_PASSWORD:
description: Password used for notarizing macOS builds
required: false
CACHIX_AUTH_TOKEN:
description: Private token for authenticating against Cachix cache
required: false
GPG_PRIVATE_KEY:
description: Private key for AppImage signing
required: false
@ -631,3 +634,53 @@ jobs:
with:
bundle: "Prism Launcher.flatpak"
manifest-path: flatpak/org.prismlauncher.PrismLauncher.yml
nix:
name: Nix (${{ matrix.system }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
system: x86_64-linux
- os: macos-13
system: x86_64-darwin
- os: macos-14
system: aarch64-darwin
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v27
# For PRs
- name: Setup Nix Magic Cache
uses: DeterminateSystems/magic-nix-cache-action@v8
# For in-tree builds
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: prismlauncher
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Run flake checks
run: |
nix flake check --print-build-logs --show-trace
- name: Build debug package
if: ${{ inputs.build_type == 'Debug' }}
run: |
nix build --print-build-logs .#prismlauncher-debug
- name: Build release package
if: ${{ inputs.build_type != 'Debug' }}
run: |
nix build --print-build-logs .#prismlauncher

View File

@ -38,5 +38,6 @@ jobs:
APPLE_NOTARIZE_APPLE_ID: ${{ secrets.APPLE_NOTARIZE_APPLE_ID }}
APPLE_NOTARIZE_TEAM_ID: ${{ secrets.APPLE_NOTARIZE_TEAM_ID }}
APPLE_NOTARIZE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }}
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PRIVATE_KEY_ID: ${{ secrets.GPG_PRIVATE_KEY_ID }}

View File

@ -22,6 +22,7 @@ jobs:
APPLE_NOTARIZE_APPLE_ID: ${{ secrets.APPLE_NOTARIZE_APPLE_ID }}
APPLE_NOTARIZE_TEAM_ID: ${{ secrets.APPLE_NOTARIZE_TEAM_ID }}
APPLE_NOTARIZE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }}
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PRIVATE_KEY_ID: ${{ secrets.GPG_PRIVATE_KEY_ID }}

View File

@ -176,6 +176,8 @@ endif()
set(Launcher_NEWS_RSS_URL "https://prismlauncher.org/feed/feed.xml" CACHE STRING "URL to fetch Prism Launcher's news RSS feed from.")
set(Launcher_NEWS_OPEN_URL "https://prismlauncher.org/news" CACHE STRING "URL that gets opened when the user clicks 'More News'")
set(Launcher_HELP_URL "https://prismlauncher.org/wiki/help-pages/%1" CACHE STRING "URL (with arg %1 to be substituted with page-id) that gets opened when the user requests help")
set(Launcher_LOGIN_CALLBACK_URL "https://prismlauncher.org/successful-login" CACHE STRING "URL that gets opened when the user successfully logins.")
set(Launcher_FMLLIBS_BASE_URL "https://files.prismlauncher.org/fmllibs/" CACHE STRING "URL for FML Libraries.")
######## Set version numbers ########
set(Launcher_VERSION_MAJOR 9)
@ -205,6 +207,7 @@ set(Launcher_BUG_TRACKER_URL "https://github.com/PrismLauncher/PrismLauncher/iss
# Translations Platform URL
set(Launcher_TRANSLATIONS_URL "https://hosted.weblate.org/projects/prismlauncher/launcher/" CACHE STRING "URL for the translations platform.")
set(Launcher_TRANSLATION_FILES_URL "https://i18n.prismlauncher.org/" CACHE STRING "URL for the translations files.")
# Matrix Space
set(Launcher_MATRIX_URL "https://prismlauncher.org/matrix" CACHE STRING "URL to the Matrix Space")

View File

@ -116,16 +116,19 @@ Config::Config()
NEWS_RSS_URL = "@Launcher_NEWS_RSS_URL@";
NEWS_OPEN_URL = "@Launcher_NEWS_OPEN_URL@";
HELP_URL = "@Launcher_HELP_URL@";
LOGIN_CALLBACK_URL = "@Launcher_LOGIN_CALLBACK_URL@";
IMGUR_CLIENT_ID = "@Launcher_IMGUR_CLIENT_ID@";
MSA_CLIENT_ID = "@Launcher_MSA_CLIENT_ID@";
FLAME_API_KEY = "@Launcher_CURSEFORGE_API_KEY@";
META_URL = "@Launcher_META_URL@";
FMLLIBS_BASE_URL = "@Launcher_FMLLIBS_BASE_URL@";
GLFW_LIBRARY_NAME = "@Launcher_GLFW_LIBRARY_NAME@";
OPENAL_LIBRARY_NAME = "@Launcher_OPENAL_LIBRARY_NAME@";
BUG_TRACKER_URL = "@Launcher_BUG_TRACKER_URL@";
TRANSLATIONS_URL = "@Launcher_TRANSLATIONS_URL@";
TRANSLATION_FILES_URL = "@Launcher_TRANSLATION_FILES_URL@";
MATRIX_URL = "@Launcher_MATRIX_URL@";
DISCORD_URL = "@Launcher_DISCORD_URL@";
SUBREDDIT_URL = "@Launcher_SUBREDDIT_URL@";

View File

@ -133,6 +133,11 @@ class Config {
*/
QString HELP_URL;
/**
* URL that gets opened when the user succesfully logins.
*/
QString LOGIN_CALLBACK_URL;
/**
* Client ID you can get from Imgur when you register an application
*/
@ -165,8 +170,8 @@ class Config {
QString RESOURCE_BASE = "https://resources.download.minecraft.net/";
QString LIBRARY_BASE = "https://libraries.minecraft.net/";
QString IMGUR_BASE_URL = "https://api.imgur.com/3/";
QString FMLLIBS_BASE_URL = "https://files.prismlauncher.org/fmllibs/"; // FIXME: move into CMakeLists
QString TRANSLATIONS_BASE_URL = "https://i18n.prismlauncher.org/"; // FIXME: move into CMakeLists
QString FMLLIBS_BASE_URL;
QString TRANSLATION_FILES_URL;
QString MODPACKSCH_API_BASE_URL = "https://api.modpacks.ch/";

View File

@ -2,8 +2,10 @@
description = "A custom launcher for Minecraft that allows you to easily manage multiple installations of Minecraft at once (Fork of MultiMC)";
nixConfig = {
extra-substituters = [ "https://cache.garnix.io" ];
extra-trusted-public-keys = [ "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=" ];
extra-substituters = [ "https://prismlauncher.cachix.org" ];
extra-trusted-public-keys = [
"prismlauncher.cachix.org-1:9/n/FGyABA2jLUVfY+DEp4hKds/rwO+SCOtbOkDzd+c="
];
};
inputs = {
@ -118,5 +120,24 @@
# Only output them if they're available on the current system
lib.filterAttrs (_: lib.meta.availableOn pkgs.stdenv.hostPlatform) packages
);
# We put these under legacyPackages as they are meant for CI, not end user consumption
legacyPackages = forAllSystems (
system:
let
prismPackages = self.packages.${system};
legacyPackages = self.legacyPackages.${system};
in
{
prismlauncher-debug = prismPackages.prismlauncher.override {
prismlauncher-unwrapped = legacyPackages.prismlauncher-unwrapped-debug;
};
prismlauncher-unwrapped-debug = prismPackages.prismlauncher-unwrapped.overrideAttrs {
cmakeBuildType = "Debug";
dontStrip = true;
};
}
);
};
}

View File

@ -1,10 +0,0 @@
builds:
exclude:
# Currently broken on Garnix's end
- "*.x86_64-darwin.*"
include:
- "checks.x86_64-linux.*"
- "packages.x86_64-linux.*"
- "packages.aarch64-linux.*"
- "packages.x86_64-darwin.*"
- "packages.aarch64-darwin.*"

View File

@ -780,6 +780,9 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv)
// FTBApp instances
m_settings->registerSetting("FTBAppInstancesPath", "");
// Custom Technic Client ID
m_settings->registerSetting("TechnicClientID", "");
// Init page provider
{
m_globalSettingsProvider = std::make_shared<GenericPageProvider>(tr("Settings"));
@ -1022,7 +1025,8 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv)
}
// notify user if /tmp is mounted with `noexec` (#1693)
{
QString jvmArgs = m_settings->get("JvmArgs").toString();
if (jvmArgs.indexOf("java.io.tmpdir") == -1) { /* java.io.tmpdir is a valid workaround, so don't annoy */
bool is_tmp_noexec = false;
#if defined(Q_OS_LINUX)
@ -1042,7 +1046,11 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv)
if (is_tmp_noexec) {
auto infoMsg =
tr("Your /tmp directory is currently mounted with the 'noexec' flag enabled.\n"
"Some versions of Minecraft may not launch.\n");
"Some versions of Minecraft may not launch.\n"
"\n"
"You may solve this issue by remounting /tmp as 'exec' or setting "
"the java.io.tmpdir JVM argument to a writeable directory in a "
"filesystem where the 'exec' flag is set (e.g., /home/user/.local/tmp)\n");
auto msgBox = new QMessageBox(QMessageBox::Information, tr("Incompatible system configuration"), infoMsg, QMessageBox::Ok);
msgBox->setDefaultButton(QMessageBox::Ok);
msgBox->setAttribute(Qt::WA_DeleteOnClose);
@ -1870,6 +1878,7 @@ QUrl Application::normalizeImportUrl(QString const& url)
return QUrl::fromUserInput(url);
}
}
const QString Application::javaPath()
{
return m_settings->get("JavaDir").toString();

View File

@ -439,6 +439,8 @@ set(JAVA_SOURCES
java/download/ArchiveDownloadTask.h
java/download/ManifestDownloadTask.cpp
java/download/ManifestDownloadTask.h
java/download/SymlinkTask.cpp
java/download/SymlinkTask.h
ui/java/InstallJavaDialog.h
ui/java/InstallJavaDialog.cpp

View File

@ -173,7 +173,11 @@ void InstanceCopyTask::copyFinished()
allowed_symlinks_file
.filePath()); // we dont want to modify the original. also make sure the resulting file is not itself a link.
FS::write(allowed_symlinks_file.filePath(), allowed_symlinks);
try {
FS::write(allowed_symlinks_file.filePath(), allowed_symlinks);
} catch (const FS::FileSystemException& e) {
qCritical() << "Failed to write symlink :" << e.cause();
}
}
emitSucceeded();

View File

@ -53,6 +53,7 @@
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QRegularExpression>
#include <QStringList>
#include "BuildConfig.h"

View File

@ -140,9 +140,9 @@ QVariant VersionProxyModel::headerData(int section, Qt::Orientation orientation,
case Path:
return tr("Filesystem path to this version");
case JavaName:
return tr("The alternative name of the java version");
return tr("The alternative name of the Java version");
case JavaMajor:
return tr("The java major version");
return tr("The Java major version");
case Time:
return tr("Release date of this version");
}

View File

@ -65,7 +65,7 @@ void ArchiveDownloadTask::executeTask()
void ArchiveDownloadTask::extractJava(QString input)
{
setStatus(tr("Extracting java"));
setStatus(tr("Extracting Java"));
if (input.endsWith("tar")) {
setStatus(tr("Extracting Java (Progress is not reported for tar archives)"));
QFile in(input);
@ -95,7 +95,7 @@ void ArchiveDownloadTask::extractJava(QString input)
}
auto files = zip->getFileNameList();
if (files.isEmpty()) {
emitFailed(tr("No files were found in the supplied zip file,"));
emitFailed(tr("No files were found in the supplied zip file."));
return;
}
m_task = makeShared<MMCZip::ExtractZipTask>(zip, m_final_path, files[0]);

View File

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (c) 2023-2024 Trial97 <alexandru.tripon97@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#include "java/download/SymlinkTask.h"
#include <QFileInfo>
#include "FileSystem.h"
namespace Java {
SymlinkTask::SymlinkTask(QString final_path) : m_path(final_path) {}
QString findBinPath(QString root, QString pattern)
{
auto path = FS::PathCombine(root, pattern);
if (QFileInfo::exists(path)) {
return path;
}
auto entries = QDir(root).entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot);
for (auto& entry : entries) {
path = FS::PathCombine(entry.absoluteFilePath(), pattern);
if (QFileInfo::exists(path)) {
return path;
}
}
return {};
}
void SymlinkTask::executeTask()
{
setStatus(tr("Checking for Java binary path"));
const auto binPath = FS::PathCombine("bin", "java");
const auto wantedPath = FS::PathCombine(m_path, binPath);
if (QFileInfo::exists(wantedPath)) {
emitSucceeded();
return;
}
setStatus(tr("Searching for Java binary path"));
const auto contentsPartialPath = FS::PathCombine("Contents", "Home", binPath);
const auto relativePathToBin = findBinPath(m_path, contentsPartialPath);
if (relativePathToBin.isEmpty()) {
emitFailed(tr("Failed to find Java binary path"));
return;
}
const auto folderToLink = relativePathToBin.chopped(binPath.length());
setStatus(tr("Collecting folders to symlink"));
auto entries = QDir(folderToLink).entryInfoList(QDir::NoDotAndDotDot | QDir::AllEntries);
QList<FS::LinkPair> files;
setProgress(0, entries.length());
for (auto& entry : entries) {
files.append({ entry.absoluteFilePath(), FS::PathCombine(m_path, entry.fileName()) });
}
setStatus(tr("Symlinking Java binary path"));
FS::create_link folderLink(files);
connect(&folderLink, &FS::create_link::fileLinked, [this](QString src, QString dst) { setProgress(m_progress + 1, m_progressTotal); });
if (!folderLink()) {
emitFailed(folderLink.getOSError().message().c_str());
} else {
emitSucceeded();
}
}
} // namespace Java

View File

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (c) 2023-2024 Trial97 <alexandru.tripon97@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include "tasks/Task.h"
namespace Java {
class SymlinkTask : public Task {
Q_OBJECT
public:
SymlinkTask(QString final_path);
virtual ~SymlinkTask() = default;
void executeTask() override;
protected:
QString m_path;
Task::Ptr m_task;
};
} // namespace Java

View File

@ -90,15 +90,16 @@ MSAStep::MSAStep(AccountData* data, bool silent) : AuthStep(data), m_silent(sile
{
auto replyHandler = new QOAuthHttpServerReplyHandler(this);
replyHandler->setCallbackText(R"XXX(
replyHandler->setCallbackText(QString(R"XXX(
<noscript>
<meta http-equiv="Refresh" content="0; URL=https://prismlauncher.org/successful-login" />
<meta http-equiv="Refresh" content="0; URL=%1" />
</noscript>
Login Successful, redirecting...
<script>
window.location.replace("https://prismlauncher.org/successful-login");
window.location.replace("%1");
</script>
)XXX");
)XXX")
.arg(BuildConfig.LOGIN_CALLBACK_URL));
oauth2.setReplyHandler(replyHandler);
} else {
oauth2.setReplyHandler(new CustomOAuthOobReplyHandler(this));

View File

@ -41,6 +41,7 @@
#include "Application.h"
#include "FileSystem.h"
#include "MessageLevel.h"
#include "QObjectPtr.h"
#include "SysInfo.h"
#include "java/JavaInstall.h"
#include "java/JavaInstallList.h"
@ -48,10 +49,12 @@
#include "java/JavaVersion.h"
#include "java/download/ArchiveDownloadTask.h"
#include "java/download/ManifestDownloadTask.h"
#include "java/download/SymlinkTask.h"
#include "meta/Index.h"
#include "minecraft/MinecraftInstance.h"
#include "minecraft/PackProfile.h"
#include "net/Mode.h"
#include "tasks/SequentialTask.h"
AutoInstallJava::AutoInstallJava(LaunchTask* parent)
: LaunchStep(parent)
@ -175,6 +178,12 @@ void AutoInstallJava::downloadJava(Meta::Version::Ptr version, QString javaName)
emitFailed(tr("Could not determine Java download type!"));
return;
}
#if defined(Q_OS_MACOS)
auto seq = makeShared<SequentialTask>(this, tr("Install Java"));
seq->addTask(m_current_task);
seq->addTask(makeShared<Java::SymlinkTask>(final_path));
m_current_task = seq;
#endif
auto deletePath = [final_path] { FS::deletePath(final_path); };
connect(m_current_task.get(), &Task::failed, this, [this, deletePath](QString reason) {
deletePath();

View File

@ -42,6 +42,6 @@ class LocalModUpdateTask : public Task {
private:
QDir m_index_dir;
ModPlatform::IndexedPack& m_mod;
ModPlatform::IndexedVersion& m_mod_version;
ModPlatform::IndexedPack m_mod;
ModPlatform::IndexedVersion m_mod_version;
};

View File

@ -336,7 +336,11 @@ void SkinList::save()
arr << s.toJSON();
}
doc["skins"] = arr;
Json::write(doc, m_dir.absoluteFilePath("index.json"));
try {
Json::write(doc, m_dir.absoluteFilePath("index.json"));
} catch (const FS::FileSystemException& e) {
qCritical() << "Failed to write skin index file :" << e.cause();
}
}
int SkinList::getSelectedAccountSkin()

View File

@ -41,7 +41,7 @@ SkinModel::SkinModel(QDir skinDir, QJsonObject obj)
QString SkinModel::name() const
{
return QFileInfo(m_path).baseName();
return QFileInfo(m_path).completeBaseName();
}
bool SkinModel::rename(QString newName)

View File

@ -42,6 +42,9 @@ EnsureMetadataTask::EnsureMetadataTask(QList<Mod*>& mods, QDir dir, ModPlatform:
m_hashing_task->addTask(hash_task);
}
}
EnsureMetadataTask::EnsureMetadataTask(QHash<QString, Mod*>& mods, QDir dir, ModPlatform::ResourceProvider prov)
: Task(nullptr), m_mods(mods), m_index_dir(dir), m_provider(prov), m_current_task(nullptr)
{}
Hashing::Hasher::Ptr EnsureMetadataTask::createNewHash(Mod* mod)
{

View File

@ -1,14 +1,14 @@
#pragma once
#include "ModIndex.h"
#include "net/NetJob.h"
#include "modplatform/helpers/HashUtils.h"
#include "tasks/ConcurrentTask.h"
#include <QDir>
class Mod;
class QDir;
class EnsureMetadataTask : public Task {
Q_OBJECT
@ -16,6 +16,7 @@ class EnsureMetadataTask : public Task {
public:
EnsureMetadataTask(Mod*, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH);
EnsureMetadataTask(QList<Mod*>&, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH);
EnsureMetadataTask(QHash<QString, Mod*>&, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH);
~EnsureMetadataTask() = default;

View File

@ -1,87 +1,101 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (c) 2024 Trial97 <alexandru.tripon97@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#include "FileResolvingTask.h"
#include <algorithm>
#include "Json.h"
#include "QObjectPtr.h"
#include "modplatform/ModIndex.h"
#include "net/ApiDownload.h"
#include "net/ApiUpload.h"
#include "net/Upload.h"
#include "modplatform/flame/FlameAPI.h"
#include "modplatform/flame/FlameModIndex.h"
#include "modplatform/modrinth/ModrinthAPI.h"
#include "modplatform/modrinth/ModrinthPackIndex.h"
#include "net/NetJob.h"
#include "tasks/Task.h"
static const FlameAPI flameAPI;
static ModrinthAPI modrinthAPI;
Flame::FileResolvingTask::FileResolvingTask(const shared_qobject_ptr<QNetworkAccessManager>& network, Flame::Manifest& toProcess)
: m_network(network), m_toProcess(toProcess)
: m_network(network), m_manifest(toProcess)
{}
bool Flame::FileResolvingTask::abort()
{
bool aborted = true;
if (m_dljob)
aborted &= m_dljob->abort();
if (m_checkJob)
aborted &= m_checkJob->abort();
if (m_task) {
aborted = m_task->abort();
}
return aborted ? Task::abort() : false;
}
void Flame::FileResolvingTask::executeTask()
{
if (m_toProcess.files.isEmpty()) { // no file to resolve so leave it empty and emit success immediately
if (m_manifest.files.isEmpty()) { // no file to resolve so leave it empty and emit success immediately
emitSucceeded();
return;
}
setStatus(tr("Resolving mod IDs..."));
setProgress(0, 3);
m_dljob.reset(new NetJob("Mod id resolver", m_network));
result.reset(new QByteArray());
// build json data to send
QJsonObject object;
m_result.reset(new QByteArray());
object["fileIds"] = QJsonArray::fromVariantList(
std::accumulate(m_toProcess.files.begin(), m_toProcess.files.end(), QVariantList(), [](QVariantList& l, const File& s) {
l.push_back(s.fileId);
return l;
}));
QByteArray data = Json::toText(object);
auto dl = Net::ApiUpload::makeByteArray(QUrl("https://api.curseforge.com/v1/mods/files"), result, data);
m_dljob->addNetAction(dl);
QStringList fileIds;
for (auto file : m_manifest.files) {
fileIds.push_back(QString::number(file.fileId));
}
m_task = flameAPI.getFiles(fileIds, m_result);
auto step_progress = std::make_shared<TaskStepProgress>();
connect(m_dljob.get(), &NetJob::finished, this, [this, step_progress]() {
connect(m_task.get(), &Task::finished, this, [this, step_progress]() {
step_progress->state = TaskStepState::Succeeded;
stepProgress(*step_progress);
netJobFinished();
});
connect(m_dljob.get(), &NetJob::failed, this, [this, step_progress](QString reason) {
connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) {
step_progress->state = TaskStepState::Failed;
stepProgress(*step_progress);
emitFailed(reason);
});
connect(m_dljob.get(), &NetJob::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_dljob.get(), &NetJob::progress, this, [this, step_progress](qint64 current, qint64 total) {
connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) {
qDebug() << "Resolve slug progress" << current << total;
step_progress->update(current, total);
stepProgress(*step_progress);
});
connect(m_dljob.get(), &NetJob::status, this, [this, step_progress](QString status) {
connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) {
step_progress->status = status;
stepProgress(*step_progress);
});
m_dljob->start();
m_task->start();
}
void Flame::FileResolvingTask::netJobFinished()
{
setProgress(1, 3);
// job to check modrinth for blocked projects
m_checkJob.reset(new NetJob("Modrinth check", m_network));
m_checkJob->setAskRetry(false);
blockedProjects = QMap<File*, std::shared_ptr<QByteArray>>();
QJsonDocument doc;
QJsonArray array;
try {
doc = Json::requireDocument(*result);
doc = Json::requireDocument(*m_result);
array = Json::requireArray(doc.object()["data"]);
} catch (Json::JsonException& e) {
qCritical() << "Non-JSON data returned from the CF API";
@ -92,125 +106,157 @@ void Flame::FileResolvingTask::netJobFinished()
return;
}
QStringList hashes;
for (QJsonValueRef file : array) {
auto fileid = Json::requireInteger(Json::requireObject(file)["id"]);
auto& out = m_toProcess.files[fileid];
try {
out.parseFromObject(Json::requireObject(file));
} catch ([[maybe_unused]] const JSONValidationError& e) {
qDebug() << "Blocked mod on curseforge" << out.fileName;
auto hash = out.hash;
if (!hash.isEmpty()) {
auto url = QString("https://api.modrinth.com/v2/version_file/%1?algorithm=sha1").arg(hash);
auto output = std::make_shared<QByteArray>();
auto dl = Net::ApiDownload::makeByteArray(QUrl(url), output);
QObject::connect(dl.get(), &Task::succeeded, [&out]() { out.resolved = true; });
m_checkJob->addNetAction(dl);
blockedProjects.insert(&out, output);
auto obj = Json::requireObject(file);
auto version = FlameMod::loadIndexedPackVersion(obj);
auto fileid = version.fileId.toInt();
m_manifest.files[fileid].version = version;
auto url = QUrl(version.downloadUrl, QUrl::TolerantMode);
if (!url.isValid() && "sha1" == version.hash_type && !version.hash.isEmpty()) {
hashes.push_back(version.hash);
}
} catch (Json::JsonException& e) {
qCritical() << "Non-JSON data returned from the CF API";
qCritical() << e.cause();
emitFailed(tr("Invalid data returned from the API."));
return;
}
}
if (hashes.isEmpty()) {
getFlameProjects();
return;
}
m_result.reset(new QByteArray());
m_task = modrinthAPI.currentVersions(hashes, "sha1", m_result);
(dynamic_cast<NetJob*>(m_task.get()))->setAskRetry(false);
auto step_progress = std::make_shared<TaskStepProgress>();
connect(m_checkJob.get(), &NetJob::finished, this, [this, step_progress]() {
connect(m_task.get(), &Task::finished, this, [this, step_progress]() {
step_progress->state = TaskStepState::Succeeded;
stepProgress(*step_progress);
modrinthCheckFinished();
QJsonParseError parse_error{};
QJsonDocument doc = QJsonDocument::fromJson(*m_result, &parse_error);
if (parse_error.error != QJsonParseError::NoError) {
qWarning() << "Error while parsing JSON response from Modrinth::CurrentVersions at " << parse_error.offset
<< " reason: " << parse_error.errorString();
qWarning() << *m_result;
failed(parse_error.errorString());
return;
}
try {
auto entries = Json::requireObject(doc);
for (auto& out : m_manifest.files) {
auto url = QUrl(out.version.downloadUrl, QUrl::TolerantMode);
if (!url.isValid() && "sha1" == out.version.hash_type && !out.version.hash.isEmpty()) {
try {
auto entry = Json::requireObject(entries, out.version.hash);
auto file = Modrinth::loadIndexedPackVersion(entry);
// If there's more than one mod loader for this version, we can't know for sure
// which file is relative to each loader, so it's best to not use any one and
// let the user download it manually.
if (!file.loaders || hasSingleModLoaderSelected(file.loaders)) {
out.version.downloadUrl = file.downloadUrl;
qDebug() << "Found alternative on modrinth " << out.version.fileName;
}
} catch (Json::JsonException& e) {
qDebug() << e.cause();
qDebug() << entries;
}
}
}
} catch (Json::JsonException& e) {
qDebug() << e.cause();
qDebug() << doc;
}
getFlameProjects();
});
connect(m_checkJob.get(), &NetJob::failed, this, [this, step_progress](QString reason) {
connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) {
step_progress->state = TaskStepState::Failed;
stepProgress(*step_progress);
});
connect(m_checkJob.get(), &NetJob::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_checkJob.get(), &NetJob::progress, this, [this, step_progress](qint64 current, qint64 total) {
connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) {
qDebug() << "Resolve slug progress" << current << total;
step_progress->update(current, total);
stepProgress(*step_progress);
});
connect(m_checkJob.get(), &NetJob::status, this, [this, step_progress](QString status) {
connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) {
step_progress->status = status;
stepProgress(*step_progress);
});
m_checkJob->start();
m_task->start();
}
void Flame::FileResolvingTask::modrinthCheckFinished()
void Flame::FileResolvingTask::getFlameProjects()
{
setProgress(2, 3);
qDebug() << "Finished with blocked mods : " << blockedProjects.size();
for (auto it = blockedProjects.keyBegin(); it != blockedProjects.keyEnd(); it++) {
auto& out = *it;
auto bytes = blockedProjects[out];
if (!out->resolved) {
continue;
}
QJsonDocument doc = QJsonDocument::fromJson(*bytes);
auto obj = doc.object();
auto file = Modrinth::loadIndexedPackVersion(obj);
// If there's more than one mod loader for this version, we can't know for sure
// which file is relative to each loader, so it's best to not use any one and
// let the user download it manually.
if (!file.loaders || hasSingleModLoaderSelected(file.loaders)) {
out->url = file.downloadUrl;
qDebug() << "Found alternative on modrinth " << out->fileName;
} else {
out->resolved = false;
}
m_result.reset(new QByteArray());
QStringList addonIds;
for (auto file : m_manifest.files) {
addonIds.push_back(QString::number(file.projectId));
}
// copy to an output list and filter out projects found on modrinth
auto block = std::make_shared<QList<File*>>();
auto it = blockedProjects.keys();
std::copy_if(it.begin(), it.end(), std::back_inserter(*block), [](File* f) { return !f->resolved; });
// Display not found mods early
if (!block->empty()) {
// blocked mods found, we need the slug for displaying.... we need another job :D !
m_slugJob.reset(new NetJob("Slug Job", m_network));
int index = 0;
for (auto mod : *block) {
auto projectId = mod->projectId;
auto output = std::make_shared<QByteArray>();
auto url = QString("https://api.curseforge.com/v1/mods/%1").arg(projectId);
auto dl = Net::ApiDownload::makeByteArray(url, output);
qDebug() << "Fetching url slug for file:" << mod->fileName;
QObject::connect(dl.get(), &Task::succeeded, [block, index, output]() {
auto mod = block->at(index); // use the shared_ptr so it is captured and only freed when we are done
auto json = QJsonDocument::fromJson(*output);
auto base =
Json::requireString(Json::requireObject(Json::requireObject(Json::requireObject(json), "data"), "links"), "websiteUrl");
auto link = QString("%1/download/%2").arg(base, QString::number(mod->fileId));
mod->websiteUrl = link;
});
m_slugJob->addNetAction(dl);
index++;
}
auto step_progress = std::make_shared<TaskStepProgress>();
connect(m_slugJob.get(), &NetJob::succeeded, this, [this, step_progress]() {
step_progress->state = TaskStepState::Succeeded;
stepProgress(*step_progress);
emitSucceeded();
});
connect(m_slugJob.get(), &NetJob::failed, this, [this, step_progress](QString reason) {
step_progress->state = TaskStepState::Failed;
stepProgress(*step_progress);
emitFailed(reason);
});
connect(m_slugJob.get(), &NetJob::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_slugJob.get(), &NetJob::progress, this, [this, step_progress](qint64 current, qint64 total) {
qDebug() << "Resolve slug progress" << current << total;
step_progress->update(current, total);
stepProgress(*step_progress);
});
connect(m_slugJob.get(), &NetJob::status, this, [this, step_progress](QString status) {
step_progress->status = status;
stepProgress(*step_progress);
});
m_slugJob->start();
} else {
m_task = flameAPI.getProjects(addonIds, m_result);
auto step_progress = std::make_shared<TaskStepProgress>();
connect(m_task.get(), &Task::succeeded, this, [this, step_progress] {
QJsonParseError parse_error{};
auto doc = QJsonDocument::fromJson(*m_result, &parse_error);
if (parse_error.error != QJsonParseError::NoError) {
qWarning() << "Error while parsing JSON response from Modrinth projects task at " << parse_error.offset
<< " reason: " << parse_error.errorString();
qWarning() << *m_result;
return;
}
try {
QJsonArray entries;
entries = Json::requireArray(Json::requireObject(doc), "data");
for (auto entry : entries) {
auto entry_obj = Json::requireObject(entry);
auto id = Json::requireInteger(entry_obj, "id");
auto file = std::find_if(m_manifest.files.begin(), m_manifest.files.end(),
[id](const Flame::File& file) { return file.projectId == id; });
if (file == m_manifest.files.end()) {
continue;
}
setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(file->version.fileName));
FlameMod::loadIndexedPack(file->pack, entry_obj);
}
} catch (Json::JsonException& e) {
qDebug() << e.cause();
qDebug() << doc;
}
step_progress->state = TaskStepState::Succeeded;
stepProgress(*step_progress);
emitSucceeded();
}
});
connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) {
step_progress->state = TaskStepState::Failed;
stepProgress(*step_progress);
emitFailed(reason);
});
connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress);
connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) {
qDebug() << "Resolve slug progress" << current << total;
step_progress->update(current, total);
stepProgress(*step_progress);
});
connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) {
step_progress->status = status;
stepProgress(*step_progress);
});
m_task->start();
}

View File

@ -1,7 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (c) 2024 Trial97 <alexandru.tripon97@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QNetworkAccessManager>
#include "PackManifest.h"
#include "net/NetJob.h"
#include "tasks/Task.h"
namespace Flame {
@ -9,12 +27,12 @@ class FileResolvingTask : public Task {
Q_OBJECT
public:
explicit FileResolvingTask(const shared_qobject_ptr<QNetworkAccessManager>& network, Flame::Manifest& toProcess);
virtual ~FileResolvingTask() {};
virtual ~FileResolvingTask() = default;
bool canAbort() const override { return true; }
bool abort() override;
const Flame::Manifest& getResults() const { return m_toProcess; }
const Flame::Manifest& getResults() const { return m_manifest; }
protected:
virtual void executeTask() override;
@ -22,16 +40,13 @@ class FileResolvingTask : public Task {
protected slots:
void netJobFinished();
private:
void getFlameProjects();
private: /* data */
shared_qobject_ptr<QNetworkAccessManager> m_network;
Flame::Manifest m_toProcess;
std::shared_ptr<QByteArray> result;
NetJob::Ptr m_dljob;
NetJob::Ptr m_checkJob;
NetJob::Ptr m_slugJob;
void modrinthCheckFinished();
QMap<File*, std::shared_ptr<QByteArray>> blockedProjects;
Flame::Manifest m_manifest;
std::shared_ptr<QByteArray> m_result;
Task::Ptr m_task;
};
} // namespace Flame

View File

@ -35,8 +35,11 @@
#include "FlameInstanceCreationTask.h"
#include "QObjectPtr.h"
#include "minecraft/mod/tasks/LocalModUpdateTask.h"
#include "modplatform/flame/FileResolvingTask.h"
#include "modplatform/flame/FlameAPI.h"
#include "modplatform/flame/FlameModIndex.h"
#include "modplatform/flame/PackManifest.h"
#include "Application.h"
@ -51,6 +54,7 @@
#include "settings/INISettingsObject.h"
#include "tasks/ConcurrentTask.h"
#include "ui/dialogs/BlockedModsDialog.h"
#include "ui/dialogs/CustomMessageBox.h"
@ -58,7 +62,6 @@
#include <QFileInfo>
#include "meta/Index.h"
#include "meta/VersionList.h"
#include "minecraft/World.h"
#include "minecraft/mod/tasks/LocalResourceParse.h"
#include "net/ApiDownload.h"
@ -208,8 +211,7 @@ bool FlameCreationTask::updateInstance()
Flame::File file;
// We don't care about blocked mods, we just need local data to delete the file
file.parseFromObject(entry_obj, false);
file.version = FlameMod::loadIndexedPackVersion(entry_obj);
auto id = Json::requireInteger(entry_obj, "id");
old_files.insert(id, file);
}
@ -219,10 +221,10 @@ bool FlameCreationTask::updateInstance()
// Delete the files
for (auto& file : old_files) {
if (file.fileName.isEmpty() || file.targetFolder.isEmpty())
if (file.version.fileName.isEmpty() || file.targetFolder.isEmpty())
continue;
QString relative_path(FS::PathCombine(file.targetFolder, file.fileName));
QString relative_path(FS::PathCombine(file.targetFolder, file.version.fileName));
qDebug() << "Scheduling" << relative_path << "for removal";
m_files_to_remove.append(old_minecraft_dir.absoluteFilePath(relative_path));
}
@ -471,15 +473,15 @@ void FlameCreationTask::idResolverSucceeded(QEventLoop& loop)
QList<BlockedMod> blocked_mods;
auto anyBlocked = false;
for (const auto& result : results.files.values()) {
if (result.fileName.endsWith(".zip")) {
m_ZIP_resources.append(std::make_pair(result.fileName, result.targetFolder));
if (result.version.fileName.endsWith(".zip")) {
m_ZIP_resources.append(std::make_pair(result.version.fileName, result.targetFolder));
}
if (!result.resolved || result.url.isEmpty()) {
if (result.version.downloadUrl.isEmpty()) {
BlockedMod blocked_mod;
blocked_mod.name = result.fileName;
blocked_mod.websiteUrl = result.websiteUrl;
blocked_mod.hash = result.hash;
blocked_mod.name = result.version.fileName;
blocked_mod.websiteUrl = QString("%1/download/%2").arg(result.pack.websiteUrl, QString::number(result.fileId));
blocked_mod.hash = result.version.hash;
blocked_mod.matched = false;
blocked_mod.localPath = "";
blocked_mod.targetFolder = result.targetFolder;
@ -521,7 +523,7 @@ void FlameCreationTask::setupDownloadJob(QEventLoop& loop)
QStringList optionalFiles;
for (auto& result : results) {
if (!result.required) {
optionalFiles << FS::PathCombine(result.targetFolder, result.fileName);
optionalFiles << FS::PathCombine(result.targetFolder, result.version.fileName);
}
}
@ -537,7 +539,7 @@ void FlameCreationTask::setupDownloadJob(QEventLoop& loop)
selectedOptionalMods = optionalModDialog.getResult();
}
for (const auto& result : results) {
auto fileName = result.fileName;
auto fileName = result.version.fileName;
fileName = FS::RemoveInvalidPathChars(fileName);
auto relpath = FS::PathCombine(result.targetFolder, fileName);
@ -548,36 +550,16 @@ void FlameCreationTask::setupDownloadJob(QEventLoop& loop)
relpath = FS::PathCombine("minecraft", relpath);
auto path = FS::PathCombine(m_stagingPath, relpath);
switch (result.type) {
case Flame::File::Type::Folder: {
logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath));
// fallthrough intentional, we treat these as plain old mods and dump them wherever.
}
/* fallthrough */
case Flame::File::Type::SingleFile:
case Flame::File::Type::Mod: {
if (!result.url.isEmpty()) {
qDebug() << "Will download" << result.url << "to" << path;
auto dl = Net::ApiDownload::makeFile(result.url, path);
m_files_job->addNetAction(dl);
}
break;
}
case Flame::File::Type::Modpack:
logWarning(tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg(relpath));
break;
case Flame::File::Type::Cmod2:
case Flame::File::Type::Ctoc:
case Flame::File::Type::Unknown:
logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath));
break;
if (!result.version.downloadUrl.isEmpty()) {
qDebug() << "Will download" << result.version.downloadUrl << "to" << path;
auto dl = Net::ApiDownload::makeFile(result.version.downloadUrl, path);
m_files_job->addNetAction(dl);
}
}
m_mod_id_resolver.reset();
connect(m_files_job.get(), &NetJob::succeeded, this, [&]() {
connect(m_files_job.get(), &NetJob::finished, this, [this, &loop]() {
m_files_job.reset();
validateZIPResources();
validateZIPResources(loop);
});
connect(m_files_job.get(), &NetJob::failed, [&](QString reason) {
m_files_job.reset();
@ -588,7 +570,6 @@ void FlameCreationTask::setupDownloadJob(QEventLoop& loop)
setProgress(current, total);
});
connect(m_files_job.get(), &NetJob::stepProgress, this, &FlameCreationTask::propagateStepProgress);
connect(m_files_job.get(), &NetJob::finished, &loop, &QEventLoop::quit);
setStatus(tr("Downloading mods..."));
m_files_job->start();
@ -626,9 +607,10 @@ void FlameCreationTask::copyBlockedMods(QList<BlockedMod> const& blocked_mods)
setAbortable(true);
}
void FlameCreationTask::validateZIPResources()
void FlameCreationTask::validateZIPResources(QEventLoop& loop)
{
qDebug() << "Validating whether resources stored as .zip are in the right place";
QStringList zipMods;
for (auto [fileName, targetFolder] : m_ZIP_resources) {
qDebug() << "Checking" << fileName << "...";
auto localPath = FS::PathCombine(m_stagingPath, "minecraft", targetFolder, fileName);
@ -668,6 +650,7 @@ void FlameCreationTask::validateZIPResources()
switch (type) {
case PackedResourceType::Mod:
validatePath(fileName, targetFolder, "mods");
zipMods.push_back(fileName);
break;
case PackedResourceType::ResourcePack:
validatePath(fileName, targetFolder, "resourcepacks");
@ -693,4 +676,16 @@ void FlameCreationTask::validateZIPResources()
break;
}
}
auto task = makeShared<ConcurrentTask>(this, "CreateModMetadata", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt());
auto results = m_mod_id_resolver->getResults().files;
auto folder = FS::PathCombine(m_stagingPath, "minecraft", "mods", ".index");
for (auto file : results) {
if (file.targetFolder != "mods" || (file.version.fileName.endsWith(".zip") && !zipMods.contains(file.version.fileName))) {
continue;
}
task->addTask(makeShared<LocalModUpdateTask>(folder, file.pack, file.version));
}
connect(task.get(), &Task::finished, &loop, &QEventLoop::quit);
m_process_update_file_info_job = task;
task->start();
}

View File

@ -74,7 +74,7 @@ class FlameCreationTask final : public InstanceCreationTask {
void idResolverSucceeded(QEventLoop&);
void setupDownloadJob(QEventLoop&);
void copyBlockedMods(QList<BlockedMod> const& blocked_mods);
void validateZIPResources();
void validateZIPResources(QEventLoop& loop);
QString getVersionForLoader(QString uid, QString loaderType, QString version, QString mcVersion);
private:

View File

@ -68,35 +68,3 @@ void Flame::loadManifest(Flame::Manifest& m, const QString& filepath)
}
loadManifestV1(m, obj);
}
bool Flame::File::parseFromObject(const QJsonObject& obj, bool throw_on_blocked)
{
fileName = Json::requireString(obj, "fileName");
// This is a piece of a Flame project JSON pulled out into the file metadata (here) for convenience
// It is also optional
type = File::Type::SingleFile;
targetFolder = "mods";
// get the hash
hash = QString();
auto hashes = Json::ensureArray(obj, "hashes");
for (QJsonValueRef item : hashes) {
auto hobj = Json::requireObject(item);
auto algo = Json::requireInteger(hobj, "algo");
auto value = Json::requireString(hobj, "value");
if (algo == 1) {
hash = value;
}
}
// may throw, if the project is blocked
QString rawUrl = Json::ensureString(obj, "downloadUrl");
url = QUrl(rawUrl, QUrl::TolerantMode);
if (!url.isValid() && throw_on_blocked) {
throw JSONValidationError(QString("Invalid URL: %1").arg(rawUrl));
}
resolved = true;
return true;
}

View File

@ -40,26 +40,20 @@
#include <QString>
#include <QUrl>
#include <QVector>
#include "modplatform/ModIndex.h"
namespace Flame {
struct File {
// NOTE: throws JSONValidationError
bool parseFromObject(const QJsonObject& object, bool throw_on_blocked = true);
int projectId = 0;
int fileId = 0;
// NOTE: the opposite to 'optional'
bool required = true;
QString hash;
// NOTE: only set on blocked files ! Empty otherwise.
QString websiteUrl;
ModPlatform::IndexedPack pack;
ModPlatform::IndexedVersion version;
// our
bool resolved = false;
QString fileName;
QUrl url;
QString targetFolder = QStringLiteral("mods");
enum class Type { Unknown, Folder, Ctoc, SingleFile, Cmod2, Modpack, Mod } type = Type::Mod;
};
struct Modloader {

View File

@ -5,8 +5,12 @@
#include "InstanceList.h"
#include "Json.h"
#include "QObjectPtr.h"
#include "minecraft/MinecraftInstance.h"
#include "minecraft/PackProfile.h"
#include "minecraft/mod/Mod.h"
#include "modplatform/EnsureMetadataTask.h"
#include "modplatform/helpers/OverrideUtils.h"
#include "modplatform/modrinth/ModrinthPackManifest.h"
@ -21,6 +25,7 @@
#include <QAbstractButton>
#include <QFileInfo>
#include <QHash>
#include <vector>
bool ModrinthCreationTask::abort()
@ -29,8 +34,8 @@ bool ModrinthCreationTask::abort()
return false;
m_abort = true;
if (m_files_job)
m_files_job->abort();
if (m_task)
m_task->abort();
return Task::abort();
}
@ -234,11 +239,11 @@ bool ModrinthCreationTask::createInstance()
instance.setName(name());
instance.saveNow();
m_files_job.reset(new NetJob(tr("Mod Download Modrinth"), APPLICATION->network()));
auto downloadMods = makeShared<NetJob>(tr("Mod Download Modrinth"), APPLICATION->network());
auto root_modpack_path = FS::PathCombine(m_stagingPath, m_root_path);
auto root_modpack_url = QUrl::fromLocalFile(root_modpack_path);
QHash<QString, Mod*> mods;
for (auto file : m_files) {
auto fileName = file.path;
fileName = FS::RemoveInvalidPathChars(fileName);
@ -249,20 +254,27 @@ bool ModrinthCreationTask::createInstance()
.arg(fileName));
return false;
}
if (fileName.startsWith("mods/")) {
auto mod = new Mod(file_path);
ModDetails d;
d.mod_id = file_path;
mod->setDetails(d);
mods[file.hash.toHex()] = mod;
}
qDebug() << "Will try to download" << file.downloads.front() << "to" << file_path;
auto dl = Net::ApiDownload::makeFile(file.downloads.dequeue(), file_path);
dl->addValidator(new Net::ChecksumValidator(file.hashAlgorithm, file.hash));
m_files_job->addNetAction(dl);
downloadMods->addNetAction(dl);
if (!file.downloads.empty()) {
// FIXME: This really needs to be put into a ConcurrentTask of
// MultipleOptionsTask's , once those exist :)
auto param = dl.toWeakRef();
connect(dl.get(), &Task::failed, [this, &file, file_path, param] {
connect(dl.get(), &Task::failed, [&file, file_path, param, downloadMods] {
auto ndl = Net::ApiDownload::makeFile(file.downloads.dequeue(), file_path);
ndl->addValidator(new Net::ChecksumValidator(file.hashAlgorithm, file.hash));
m_files_job->addNetAction(ndl);
downloadMods->addNetAction(ndl);
if (auto shared = param.lock())
shared->succeeded();
});
@ -271,23 +283,44 @@ bool ModrinthCreationTask::createInstance()
bool ended_well = false;
connect(m_files_job.get(), &NetJob::succeeded, this, [&]() { ended_well = true; });
connect(m_files_job.get(), &NetJob::failed, [&](const QString& reason) {
connect(downloadMods.get(), &NetJob::succeeded, this, [&]() { ended_well = true; });
connect(downloadMods.get(), &NetJob::failed, [&](const QString& reason) {
ended_well = false;
setError(reason);
});
connect(m_files_job.get(), &NetJob::finished, &loop, &QEventLoop::quit);
connect(m_files_job.get(), &NetJob::progress, [&](qint64 current, qint64 total) {
connect(downloadMods.get(), &NetJob::finished, &loop, &QEventLoop::quit);
connect(downloadMods.get(), &NetJob::progress, [&](qint64 current, qint64 total) {
setDetails(tr("%1 out of %2 complete").arg(current).arg(total));
setProgress(current, total);
});
connect(m_files_job.get(), &NetJob::stepProgress, this, &ModrinthCreationTask::propagateStepProgress);
connect(downloadMods.get(), &NetJob::stepProgress, this, &ModrinthCreationTask::propagateStepProgress);
setStatus(tr("Downloading mods..."));
m_files_job->start();
downloadMods->start();
m_task = downloadMods;
loop.exec();
QEventLoop ensureMetaLoop;
QDir folder = FS::PathCombine(instance.modsRoot(), ".index");
auto ensureMetadataTask = makeShared<EnsureMetadataTask>(mods, folder, ModPlatform::ResourceProvider::MODRINTH);
connect(ensureMetadataTask.get(), &Task::succeeded, this, [&]() { ended_well = true; });
connect(ensureMetadataTask.get(), &Task::finished, &ensureMetaLoop, &QEventLoop::quit);
connect(ensureMetadataTask.get(), &Task::progress, [&](qint64 current, qint64 total) {
setDetails(tr("%1 out of %2 complete").arg(current).arg(total));
setProgress(current, total);
});
connect(ensureMetadataTask.get(), &Task::stepProgress, this, &ModrinthCreationTask::propagateStepProgress);
ensureMetadataTask->start();
m_task = ensureMetadataTask;
ensureMetaLoop.exec();
for (auto m : mods) {
delete m;
}
mods.clear();
// Update information of the already installed instance, if any.
if (m_instance && ended_well) {
setAbortable(false);

View File

@ -1,15 +1,11 @@
#pragma once
#include <optional>
#include "BaseInstance.h"
#include "InstanceCreationTask.h"
#include <optional>
#include "minecraft/MinecraftInstance.h"
#include "modplatform/modrinth/ModrinthPackManifest.h"
#include "net/NetJob.h"
class ModrinthCreationTask final : public InstanceCreationTask {
Q_OBJECT
@ -43,7 +39,7 @@ class ModrinthCreationTask final : public InstanceCreationTask {
QString m_managed_id, m_managed_version_id, m_managed_name;
std::vector<Modrinth::File> m_files;
NetJob::Ptr m_files_job;
Task::Ptr m_task;
std::optional<InstancePtr> m_instance;

View File

@ -208,9 +208,9 @@ void V1::updateModIndex(QDir& index_dir, Mod& mod)
auto tbl = toml::table{ { "name", mod.name.toStdString() },
{ "filename", mod.filename.toStdString() },
{ "side", sideToString(mod.side).toStdString() },
{ "loaders", loaders },
{ "mcVersions", mcVersions },
{ "releaseType", mod.releaseType.toString().toStdString() },
{ "x-prismlauncher-loaders", loaders },
{ "x-prismlauncher-mc-versions", mcVersions },
{ "x-prismlauncher-release-type", mod.releaseType.toString().toStdString() },
{ "download",
toml::table{
{ "mode", mod.mode.toStdString() },
@ -295,15 +295,15 @@ auto V1::getIndexForMod(QDir& index_dir, QString slug) -> Mod
mod.name = stringEntry(table, "name");
mod.filename = stringEntry(table, "filename");
mod.side = stringToSide(stringEntry(table, "side"));
mod.releaseType = ModPlatform::IndexedVersionType(stringEntry(table, "releaseType"));
if (auto loaders = table["loaders"]; loaders && loaders.is_array()) {
mod.releaseType = ModPlatform::IndexedVersionType(stringEntry(table, "x-prismlauncher-release-type"));
if (auto loaders = table["x-prismlauncher-loaders"]; loaders && loaders.is_array()) {
for (auto&& loader : *loaders.as_array()) {
if (loader.is_string()) {
mod.loaders |= ModPlatform::getModLoaderFromString(QString::fromStdString(loader.as_string()->value_or("")));
}
}
}
if (auto versions = table["mcVersions"]; versions && versions.is_array()) {
if (auto versions = table["x-prismlauncher-mc-versions"]; versions && versions.is_array()) {
for (auto&& version : *versions.as_array()) {
if (version.is_string()) {
auto ver = QString::fromStdString(version.as_string()->value_or(""));

View File

@ -550,7 +550,7 @@ void TranslationsModel::downloadIndex()
d->m_index_job.reset(new NetJob("Translations Index", APPLICATION->network()));
MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("translations", "index_v2.json");
entry->setStale(true);
auto task = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATIONS_BASE_URL + "index_v2.json"), entry);
auto task = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATION_FILES_URL + "index_v2.json"), entry);
d->m_index_task = task.get();
d->m_index_job->addNetAction(task);
d->m_index_job->setAskRetry(false);
@ -591,7 +591,7 @@ void TranslationsModel::downloadTranslation(QString key)
MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("translations", "mmc_" + key + ".qm");
entry->setStale(true);
auto dl = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATIONS_BASE_URL + lang->file_name), entry);
auto dl = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATION_FILES_URL + lang->file_name), entry);
dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, lang->file_sha1));
dl->setProgress(dl->getProgress(), lang->file_size);

View File

@ -233,6 +233,8 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), ui(new Ui::MainWi
if (qgetenv("XDG_CURRENT_DESKTOP") == "gamescope") {
ui->mainToolBar->addAction(ui->actionCloseWindow);
}
ui->actionViewJavaFolder->setEnabled(BuildConfig.JAVA_DOWNLOADER_ENABLED);
}
// add the toolbar toggles to the view menu
@ -1223,6 +1225,11 @@ void MainWindow::on_actionViewLogsFolder_triggered()
DesktopServices::openPath("logs", true);
}
void MainWindow::on_actionViewJavaFolder_triggered()
{
DesktopServices::openPath(APPLICATION->javaPath(), true);
}
void MainWindow::refreshInstances()
{
APPLICATION->instances()->loadList();

View File

@ -48,7 +48,6 @@
#include "BaseInstance.h"
#include "minecraft/auth/MinecraftAccount.h"
#include "net/NetJob.h"
class LaunchController;
class NewsChecker;
@ -119,6 +118,7 @@ class MainWindow : public QMainWindow {
void on_actionViewCatPackFolder_triggered();
void on_actionViewIconsFolder_triggered();
void on_actionViewLogsFolder_triggered();
void on_actionViewJavaFolder_triggered();
void on_actionViewSkinsFolder_triggered();

View File

@ -131,7 +131,7 @@
<x>0</x>
<y>0</y>
<width>800</width>
<height>22</height>
<height>27</height>
</rect>
</property>
<widget class="QMenu" name="fileMenu">
@ -192,6 +192,7 @@
<addaction name="actionViewInstanceFolder"/>
<addaction name="actionViewCentralModsFolder"/>
<addaction name="actionViewSkinsFolder"/>
<addaction name="actionViewJavaFolder"/>
<addaction name="separator"/>
<addaction name="actionViewIconThemeFolder"/>
<addaction name="actionViewWidgetThemeFolder"/>
@ -788,6 +789,18 @@
<string>Open the cat packs folder in a file browser.</string>
</property>
</action>
<action name="actionViewJavaFolder">
<property name="icon">
<iconset theme="viewfolder">
<normaloff>.</normaloff>.</iconset>
</property>
<property name="text">
<string>Java</string>
</property>
<property name="toolTip">
<string>Open the Java folder in a file browser. Only available if the built-in Java downloader is used.</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>

View File

@ -164,7 +164,12 @@ void ExportToModListDialog::done(int result)
if (output.isEmpty())
return;
FS::write(output, ui->finalText->toPlainText().toUtf8());
try {
FS::write(output, ui->finalText->toPlainText().toUtf8());
} catch (const FS::FileSystemException& e) {
qCritical() << "Failed to save mod list file :" << e.cause();
}
}
QDialog::done(result);

View File

@ -116,7 +116,7 @@ void SkinManageDialog::selectionChanged(QItemSelection selected, QItemSelection
return;
m_selected_skin = key;
auto skin = m_list.skin(key);
if (!skin)
if (!skin || !skin->isValid())
return;
ui->selectedModel->setPixmap(skin->getTexture().scaled(size() * (1. / 3), Qt::KeepAspectRatio, Qt::FastTransformation));
ui->capeCombo->setCurrentIndex(m_capes_idx.value(skin->getCapeId()));
@ -212,7 +212,10 @@ void SkinManageDialog::setupCapes()
void SkinManageDialog::on_capeCombo_currentIndexChanged(int index)
{
auto id = ui->capeCombo->currentData();
ui->capeImage->setPixmap(m_capes.value(id.toString(), {}).scaled(size() * (1. / 3), Qt::KeepAspectRatio, Qt::FastTransformation));
auto cape = m_capes.value(id.toString(), {});
if (!cape.isNull()) {
ui->capeImage->setPixmap(cape.scaled(size() * (1. / 3), Qt::KeepAspectRatio, Qt::FastTransformation));
}
if (auto skin = m_list.skin(m_selected_skin); skin) {
skin->setCapeId(id.toString());
}
@ -505,8 +508,13 @@ void SkinManageDialog::resizeEvent(QResizeEvent* event)
QSize s = size() * (1. / 3);
if (auto skin = m_list.skin(m_selected_skin); skin) {
ui->selectedModel->setPixmap(skin->getTexture().scaled(s, Qt::KeepAspectRatio, Qt::FastTransformation));
if (skin->isValid()) {
ui->selectedModel->setPixmap(skin->getTexture().scaled(s, Qt::KeepAspectRatio, Qt::FastTransformation));
}
}
auto id = ui->capeCombo->currentData();
ui->capeImage->setPixmap(m_capes.value(id.toString(), {}).scaled(s, Qt::KeepAspectRatio, Qt::FastTransformation));
auto cape = m_capes.value(id.toString(), {});
if (!cape.isNull()) {
ui->capeImage->setPixmap(cape.scaled(s, Qt::KeepAspectRatio, Qt::FastTransformation));
}
}

View File

@ -31,10 +31,12 @@
#include "Filter.h"
#include "java/download/ArchiveDownloadTask.h"
#include "java/download/ManifestDownloadTask.h"
#include "java/download/SymlinkTask.h"
#include "meta/Index.h"
#include "meta/VersionList.h"
#include "minecraft/MinecraftInstance.h"
#include "minecraft/PackProfile.h"
#include "tasks/SequentialTask.h"
#include "ui/dialogs/CustomMessageBox.h"
#include "ui/dialogs/ProgressDialog.h"
#include "ui/java/VersionList.h"
@ -55,13 +57,13 @@ class InstallJavaPage : public QWidget, public BasePage {
majorVersionSelect = new VersionSelectWidget(this);
majorVersionSelect->selectCurrent();
majorVersionSelect->setEmptyString(tr("No java versions are currently available in the meta."));
majorVersionSelect->setEmptyErrorString(tr("Couldn't load or download the java version lists!"));
majorVersionSelect->setEmptyString(tr("No Java versions are currently available in the meta."));
majorVersionSelect->setEmptyErrorString(tr("Couldn't load or download the Java version lists!"));
horizontalLayout->addWidget(majorVersionSelect, 1);
javaVersionSelect = new VersionSelectWidget(this);
javaVersionSelect->setEmptyString(tr("No java versions are currently available for your OS."));
javaVersionSelect->setEmptyErrorString(tr("Couldn't load or download the java version lists!"));
javaVersionSelect->setEmptyString(tr("No Java versions are currently available for your OS."));
javaVersionSelect->setEmptyErrorString(tr("Couldn't load or download the Java version lists!"));
horizontalLayout->addWidget(javaVersionSelect, 4);
connect(majorVersionSelect, &VersionSelectWidget::selectedVersionChanged, this, &InstallJavaPage::setSelectedVersion);
connect(majorVersionSelect, &VersionSelectWidget::selectedVersionChanged, this, &InstallJavaPage::selectionChanged);
@ -313,6 +315,12 @@ void InstallDialog::done(int result)
CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)->show();
deletePath();
}
#if defined(Q_OS_MACOS)
auto seq = makeShared<SequentialTask>(this, tr("Install Java"));
seq->addTask(task);
seq->addTask(makeShared<Java::SymlinkTask>(final_path));
task = seq;
#endif
connect(task.get(), &Task::failed, this, [this, &deletePath](QString reason) {
QString error = QString("Java download failed: %1").arg(reason);
CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)->show();

View File

@ -143,6 +143,7 @@ void APIPage::loadSettings()
ui->modrinthToken->setText(modrinthToken);
QString customUserAgent = s->get("UserAgentOverride").toString();
ui->userAgentLineEdit->setText(customUserAgent);
ui->technicClientID->setText(s->get("TechnicClientID").toString());
}
void APIPage::applySettings()
@ -172,6 +173,7 @@ void APIPage::applySettings()
QString modrinthToken = ui->modrinthToken->text();
s->set("ModrinthToken", modrinthToken);
s->set("UserAgentOverride", ui->userAgentLineEdit->text());
s->set("TechnicClientID", ui->technicClientID->text());
}
bool APIPage::apply()

View File

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
<width>841</width>
<height>620</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
@ -288,6 +288,36 @@
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Technic Client ID</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<widget class="QLabel" name="label_11">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Note: you only need to set this to access private data.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="technicClientID">
<property name="placeholderText">
<string>(None)</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_12">
<property name="text">
<string>Enter a custom GUID client ID for Technic here.</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">

View File

@ -67,8 +67,8 @@ JavaPage::JavaPage(QWidget* parent) : QWidget(parent), ui(new Ui::JavaPage)
ui->managedJavaList->initialize(new JavaInstallList(this, true));
ui->managedJavaList->setResizeOn(2);
ui->managedJavaList->selectCurrent();
ui->managedJavaList->setEmptyString(tr("No managed java versions are installed"));
ui->managedJavaList->setEmptyErrorString(tr("Couldn't load the managed java list!"));
ui->managedJavaList->setEmptyString(tr("No managed Java versions are installed"));
ui->managedJavaList->setEmptyErrorString(tr("Couldn't load the managed Java list!"));
connect(ui->autodetectJavaCheckBox, &QCheckBox::stateChanged, this, [this] {
ui->autodownloadCheckBox->setEnabled(ui->autodetectJavaCheckBox->isChecked());
if (!ui->autodetectJavaCheckBox->isChecked())

View File

@ -234,7 +234,7 @@ bool LogPage::apply()
bool LogPage::shouldDisplay() const
{
return m_instance->isRunning() || m_proxy->rowCount() > 0;
return true;
}
void LogPage::on_btnPaste_clicked()

View File

@ -154,6 +154,10 @@ void Technic::ListModel::performSearch()
QString("%1search?build=%2&q=%3").arg(BuildConfig.TECHNIC_API_BASE_URL, BuildConfig.TECHNIC_API_BUILD, currentSearchTerm);
searchMode = List;
}
auto clientId = APPLICATION->settings()->get("TechnicClientID").toString();
if (!clientId.isEmpty()) {
searchUrl += "?cid=" + clientId;
}
netJob->addNetAction(Net::ApiDownload::makeByteArray(QUrl(searchUrl), response));
jobPtr = netJob;
jobPtr->start();

View File

@ -30,7 +30,7 @@
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>We've added a feature to automatically download the correct Java version for each version of Minecraft(this can be changed in the Java Settings). Would you like to enable or disable this feature?</string>
<string>We've added a feature to automatically download the correct Java version for each version of Minecraft (this can be changed in the Java Settings). Would you like to enable or disable this feature?</string>
</property>
<property name="wordWrap">
<bool>true</bool>

View File

@ -83,6 +83,6 @@ void JavaWizardPage::retranslate()
{
setTitle(tr("Java"));
setSubTitle(
tr("Please select how much memory to allocate to instances and if Prism Launcher should manage java automatically or manually."));
tr("Please select how much memory to allocate to instances and if Prism Launcher should manage Java automatically or manually."));
m_java_widget->retranslate();
}

View File

@ -35,11 +35,10 @@ void LoginWizardPage::on_pushButton_clicked()
if (account) {
APPLICATION->accounts()->addAccount(account);
APPLICATION->accounts()->setDefaultAccount(account);
}
if (wizard()->currentId() == wizard()->pageIds().last()) {
wizard()->accept();
} else {
wizard()->next();
if (wizard()->currentId() == wizard()->pageIds().last()) {
wizard()->accept();
} else {
wizard()->next();
}
}
}

View File

@ -18,10 +18,12 @@
*/
#pragma once
#include <QDir>
#include <QLoggingCategory>
#include <QString>
#include <memory>
#include "IconTheme.h"
#include "ui/MainWindow.h"
#include "ui/themes/CatPack.h"
#include "ui/themes/ITheme.h"

View File

@ -1210,7 +1210,7 @@ std::optional<QDir> PrismUpdaterApp::unpackArchive(QFileInfo archive)
QProcess proc = QProcess();
proc.start(cmd, args);
if (!proc.waitForStarted(5000)) { // wait 5 seconds to start
auto msg = tr("Failed to launcher child process \"%1 %2\".").arg(cmd).arg(args.join(" "));
auto msg = tr("Failed to launch child process \"%1 %2\".").arg(cmd).arg(args.join(" "));
logUpdate(msg);
showFatalErrorMessage(tr("Failed extract archive"), msg);
return std::nullopt;
@ -1241,7 +1241,7 @@ bool PrismUpdaterApp::loadPrismVersionFromExe(const QString& exe_path)
proc.setReadChannel(QProcess::StandardOutput);
proc.start(exe_path, { "--version" });
if (!proc.waitForStarted(5000)) {
showFatalErrorMessage(tr("Failed to Check Version"), tr("Failed to launcher child launcher process to read version."));
showFatalErrorMessage(tr("Failed to Check Version"), tr("Failed to launch child process to read version."));
return false;
} // wait 5 seconds to start
if (!proc.waitForFinished(5000)) {

View File

@ -8,8 +8,8 @@ See [Package variants](#package-variants) for a list of available packages.
## Installing a development release (flake)
We use [garnix](https://garnix.io/) to build and cache our development builds.
If you want to avoid rebuilds you may add the garnix cache to your substitutors, or use `--accept-flake-config`
We use [cachix](https://cachix.org/) to cache our development and release builds.
If you want to avoid rebuilds you may add the Cachix bucket to your substitutors, or use `--accept-flake-config`
to temporarily enable it when using `nix` commands.
Example (NixOS):
@ -17,12 +17,10 @@ Example (NixOS):
```nix
{
nix.settings = {
trusted-substituters = [
"https://cache.garnix.io"
];
trusted-substituters = [ "https://prismlauncher.cachix.org" ];
trusted-public-keys = [
"cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="
"prismlauncher.cachix.org-1:9/n/FGyABA2jLUVfY+DEp4hKds/rwO+SCOtbOkDzd+c="
];
};
}
@ -137,20 +135,18 @@ nix profile install github:PrismLauncher/PrismLauncher
## Installing a development release (without flakes)
We use [garnix](https://garnix.io/) to build and cache our development builds.
If you want to avoid rebuilds you may add the garnix cache to your substitutors.
We use [Cachix](https://cachix.org/) to cache our development and release builds.
If you want to avoid rebuilds you may add the Cachix bucket to your substitutors.
Example (NixOS):
```nix
{
nix.settings = {
trusted-substituters = [
"https://cache.garnix.io"
];
trusted-substituters = [ "https://prismlauncher.cachix.org" ];
trusted-public-keys = [
"cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="
"prismlauncher.cachix.org-1:9/n/FGyABA2jLUVfY+DEp4hKds/rwO+SCOtbOkDzd+c="
];
};
}

View File

@ -23,7 +23,7 @@
cd ${self}
echo "Running clang-format...."
clang-format -i --style='file' --Werror */**.{c,cc,cpp,h,hh,hpp}
clang-format --dry-run --style='file' --Werror */**.{c,cc,cpp,h,hh,hpp}
echo "Running deadnix..."
deadnix --fail