Files
minecraft-launcher/gamelauncher.cpp
T

345 lines
14 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 "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;
}
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")] = QStringLiteral("0");
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;
for (const QString &argument : version.jvmArgs) {
const QString resolved = substitute(argument, variables);
if (!resolved.contains(QStringLiteral("${")))
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 = tr("Не удалось создать папку %1").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 = tr("Не удалось прочитать натив-библиотеку %1")
.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 = tr("Не удалось распаковать %1 в %2")
.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 = tr("Игра уже запущена");
return false;
}
emit progress(tr("Проверка файлов игры…"));
const QStringList missing = missingFiles(options, version);
if (!missing.isEmpty()) {
if (error)
*error = tr("В .minecraft не хватает файлов (%1):\n%2")
.arg(missing.size())
.arg(missing.join(u'\n'));
return false;
}
emit progress(tr("Распаковка нативных библиотек…"));
const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id
+ QStringLiteral("/natives");
if (!extractNatives(options, version, nativesDir, error))
return false;
emit progress(tr("Поиск Java %1…").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 = tr("Не удалось запустить %1: %2")
.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();
}