Files
minecraft-launcher/gamelauncher.cpp
T

373 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "gamelauncher.h"
#include "localization.h"
#include "javalocator.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QtCore/private/qzipreader_p.h>
namespace {
#if defined(Q_OS_WIN)
constexpr QChar kClasspathSeparator = u';';
#else
constexpr QChar kClasspathSeparator = u':';
#endif
QString libraryPath(const QString &gameDir, const MinecraftLibrary &library)
{
return gameDir + QStringLiteral("/libraries/") + library.path;
}
QString nativeToken(const QString &path)
{
return QDir::toNativeSeparators(path);
}
// Подставляет ${...}. Аргументы, в которых после подстановки остались
// неизвестные плейсхолдеры, вызывающий выбрасывает.
QString substitute(QString argument, const QHash<QString, QString> &variables)
{
for (auto it = variables.cbegin(); it != variables.cend(); ++it)
argument.replace(QStringLiteral("${") + it.key() + u'}', it.value());
return argument;
}
bool isNativeLibraryFile(const QString &fileName)
{
static const QStringList suffixes = {QStringLiteral(".dll"),
QStringLiteral(".so"),
QStringLiteral(".dylib"),
QStringLiteral(".jnilib")};
for (const QString &suffix : suffixes)
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
return true;
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
}
} // namespace
GameLauncher::GameLauncher(QObject *parent)
: QObject(parent)
{
}
bool GameLauncher::isRunning() const
{
return m_process && m_process->state() != QProcess::NotRunning;
}
QStringList GameLauncher::missingFiles(const LaunchOptions &options,
const MinecraftVersion &version,
int limit)
{
QStringList missing;
const auto add = [&missing, limit](const QString &path) {
if (missing.size() < limit)
missing << QDir::toNativeSeparators(path);
};
if (!QDir(options.gameDir).exists())
return {QDir::toNativeSeparators(options.gameDir)};
if (!QFile::exists(version.clientJarPath))
add(version.clientJarPath);
for (const MinecraftLibrary &library : version.libraries) {
const QString path = libraryPath(options.gameDir, library);
if (!QFile::exists(path))
add(path);
}
if (!version.assetIndexId.isEmpty()) {
const QString index = options.gameDir + QStringLiteral("/assets/indexes/")
+ version.assetIndexId + QStringLiteral(".json");
if (!QFile::exists(index))
add(index);
else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists())
add(options.gameDir + QStringLiteral("/assets/objects"));
}
return missing;
}
namespace {
// -DignoreList у Forge 1.17+ — список префиксов имён файлов, которые
// BootstrapLauncher не берёт в слой модулей. Сравнение идёт по началу имени.
bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName)
{
const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size());
const QStringList prefixes = value.split(u',', Qt::SkipEmptyParts);
for (const QString &prefix : prefixes) {
if (!prefix.isEmpty() && fileName.startsWith(prefix))
return true;
}
return false;
}
} // namespace
QStringList GameLauncher::buildArguments(const LaunchOptions &options,
const MinecraftVersion &version,
const QString &nativesDir)
{
QStringList classpath;
for (const MinecraftLibrary &library : version.libraries) {
if (library.native)
continue; // натив-библиотеки только распаковываются
classpath << nativeToken(libraryPath(options.gameDir, library));
}
classpath << nativeToken(version.clientJarPath);
const QString assetsRoot = options.gameDir + QStringLiteral("/assets");
const bool legacyAssets = version.assetsKind == QLatin1String("legacy")
|| version.assetsKind == QLatin1String("pre-1.6");
QHash<QString, QString> variables;
variables[QStringLiteral("auth_player_name")] = options.playerName;
variables[QStringLiteral("auth_uuid")] = options.uuid;
variables[QStringLiteral("auth_access_token")] = options.accessToken;
variables[QStringLiteral("auth_session")] =
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
variables[QStringLiteral("auth_xuid")] =
options.xuid.isEmpty() ? QStringLiteral("0") : options.xuid;
variables[QStringLiteral("clientid")] =
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
variables[QStringLiteral("user_type")] = options.userType;
variables[QStringLiteral("user_properties")] = QStringLiteral("{}");
variables[QStringLiteral("version_name")] = version.id;
variables[QStringLiteral("version_type")] = version.type;
variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir);
variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot);
variables[QStringLiteral("assets_index_name")] = version.assetIndexId;
variables[QStringLiteral("game_assets")] =
nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot);
variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir);
variables[QStringLiteral("library_directory")] =
nativeToken(options.gameDir + QStringLiteral("/libraries"));
variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator);
variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator);
variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath);
variables[QStringLiteral("launcher_name")] = options.launcherName;
variables[QStringLiteral("launcher_version")] = options.launcherVersion;
variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth);
variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight);
QStringList arguments;
// Свои jvm-аргументы идут первыми — как в строке запуска из примера.
if (!options.authlibInjectorPath.isEmpty()) {
arguments << QStringLiteral("-javaagent:%1=%2")
.arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi)
<< QStringLiteral("-Dauthlibinjector.side=client");
}
if (options.minMemoryMb > 0)
arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb);
if (options.maxMemoryMb > 0)
arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb);
arguments += options.extraJvmArgs;
// Forge 1.17+ собирает слой модулей из classpath, пропуская всё, чьё имя
// начинается с записи в -DignoreList. Ванильный клиентский jar записан там
// как ${version_name}.jar — с расчётом на лаунчер Mojang, который кладёт в
// classpath jar самого профиля. Своего jar у профиля Forge нет, мы кладём
// родительский, и без его имени в списке он становится вторым модулем с
// классами Minecraft: «Modules minecraft and _1._20._1 export package ...».
const QString clientJarName = QFileInfo(version.clientJarPath).fileName();
for (const QString &argument : version.jvmArgs) {
QString resolved = substitute(argument, variables);
if (resolved.contains(QStringLiteral("${")))
continue;
if (!clientJarName.isEmpty() && resolved.startsWith(QLatin1String("-DignoreList="))
&& !ignoreListCovers(resolved, clientJarName))
resolved += u',' + clientJarName;
arguments << resolved;
}
if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) {
QHash<QString, QString> loggingVariable;
loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath);
arguments << substitute(version.loggingArgument, loggingVariable);
}
arguments << version.mainClass;
for (const QString &argument : version.gameArgs) {
const QString resolved = substitute(argument, variables);
if (!resolved.contains(QStringLiteral("${")))
arguments << resolved;
}
// Разрешение окна: у старых версий фич-флага нет, аргументы всё равно понимаются.
if (options.windowWidth > 0 && options.windowHeight > 0
&& !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) {
arguments << QStringLiteral("--width") << QString::number(options.windowWidth)
<< QStringLiteral("--height") << QString::number(options.windowHeight);
}
if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen")))
arguments << QStringLiteral("--fullscreen");
if (!options.serverAddress.isEmpty()) {
if (version.supportsQuickPlay) {
arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress;
} else {
const int colon = options.serverAddress.lastIndexOf(u':');
const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress;
const QString port = colon > 0 ? options.serverAddress.mid(colon + 1)
: QStringLiteral("25565");
arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port;
}
}
return arguments;
}
bool GameLauncher::extractNatives(const LaunchOptions &options,
const MinecraftVersion &version,
const QString &nativesDir,
QString *error)
{
if (!QDir().mkpath(nativesDir)) {
if (error)
*error = Loc::text("launch.error.createFolderFailed").arg(QDir::toNativeSeparators(nativesDir));
return false;
}
for (const MinecraftLibrary &library : version.libraries) {
if (!library.native)
continue;
const QString jar = libraryPath(options.gameDir, library);
QZipReader reader(jar);
if (!reader.isReadable()) {
if (error)
*error = Loc::text("launch.error.nativeReadFailed")
.arg(QDir::toNativeSeparators(jar));
return false;
}
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
for (const QZipReader::FileInfo &entry : entries) {
if (!entry.isFile)
continue;
if (entry.filePath.startsWith(QStringLiteral("META-INF/")))
continue;
bool excluded = false;
for (const QString &prefix : library.extractExclude) {
if (entry.filePath.startsWith(prefix)) {
excluded = true;
break;
}
}
if (excluded)
continue;
// java.library.path ищет библиотеки только в корне папки, поэтому
// складываем их плоско, без структуры каталогов внутри jar.
const QString fileName = QFileInfo(entry.filePath).fileName();
if (!isNativeLibraryFile(fileName))
continue;
const QString target = nativesDir + u'/' + fileName;
const QByteArray data = reader.fileData(entry.filePath);
if (QFileInfo(target).size() == data.size() && QFile::exists(target))
continue; // уже распаковано этим же лаунчером
QFile file(target);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
if (error)
*error = Loc::text("launch.error.extractFailed")
.arg(fileName, QDir::toNativeSeparators(nativesDir));
return false;
}
}
}
return true;
}
bool GameLauncher::launch(const LaunchOptions &options,
const MinecraftVersion &version,
QString *error)
{
if (isRunning()) {
if (error)
*error = Loc::text("common.error.gameRunning");
return false;
}
emit progress(Loc::text("launch.progress.checkingFiles"));
const QStringList missing = missingFiles(options, version);
if (!missing.isEmpty()) {
if (error)
*error = Loc::text("launch.error.filesMissing")
.arg(missing.size())
.arg(missing.join(u'\n'));
return false;
}
emit progress(Loc::text("launch.progress.extractingNatives"));
const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id
+ QStringLiteral("/natives");
if (!extractNatives(options, version, nativesDir, error))
return false;
emit progress(Loc::text("launch.progress.lookingForJava").arg(version.javaMajor));
const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error);
if (java.isEmpty())
return false;
const QStringList arguments = buildArguments(options, version, nativesDir);
const QString executable = JavaLocator::windowlessVariant(java);
m_process = new QProcess(this);
m_process->setProgram(executable);
m_process->setArguments(arguments);
m_process->setWorkingDirectory(options.gameDir);
m_process->setProcessChannelMode(QProcess::MergedChannels);
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput());
const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts);
for (const QString &line : lines)
emit output(line.trimmed());
});
connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
emit gameFinished(exitCode, status == QProcess::CrashExit);
m_process->deleteLater();
m_process = nullptr;
});
// Токен в лог не попадает — маскируем его в отображаемой строке запуска.
QStringList shown = arguments;
const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken"));
if (tokenIndex >= 0 && tokenIndex + 1 < shown.size())
shown[tokenIndex + 1] = QStringLiteral("<скрыт>");
m_process->start();
if (!m_process->waitForStarted(15000)) {
if (error)
*error = Loc::text("launch.error.startFailed")
.arg(QDir::toNativeSeparators(executable), m_process->errorString());
m_process->deleteLater();
m_process = nullptr;
return false;
}
emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ")
+ shown.join(QStringLiteral(" ")));
return true;
}
void GameLauncher::terminate()
{
if (isRunning())
m_process->kill();
}