1
Fork 0
mirror of https://github.com/redstrate/Astra.git synced 2025-04-22 20:47:45 +00:00
astra/launcher/src/launchercore.cpp
Joshua Goins f136f6475c Inhibit sleep on Linux when playing the game
This fixes a "deficiency" in KWin, where controller input does not
wake up the screen. You have to manually block the screen locking or
else you need to move the mouse every so often. The system could also
sleep while patching, which is really bad.

This is a really simple implementation that can be expanded upon later.
2024-08-22 21:22:56 -04:00

681 lines
No EOL
22 KiB
C++
Executable file

// SPDX-FileCopyrightText: 2023 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "gameinstaller.h"
#include <KLocalizedString>
#include <QDir>
#include <QImage>
#include <QNetworkAccessManager>
#include <QStandardPaths>
#include <algorithm>
#include <qcoronetworkreply.h>
#include "account.h"
#include "assetupdater.h"
#include "astra_log.h"
#include "benchmarkinstaller.h"
#include "compatibilitytoolinstaller.h"
#include "gamerunner.h"
#include "launchercore.h"
#include "sapphirelogin.h"
#include "squareenixlogin.h"
#include "utility.h"
#ifdef BUILD_SYNC
#include "charactersync.h"
#include "syncmanager.h"
#endif
#ifdef HAS_DBUS
#include <QDBusConnection>
#include <QDBusReply>
#include <QGuiApplication>
#endif
using namespace Qt::StringLiterals;
LauncherCore::LauncherCore()
: QObject()
{
m_settings = new LauncherSettings(this);
m_mgr = new QNetworkAccessManager(this);
m_sapphireLogin = new SapphireLogin(*this, this);
m_squareEnixLogin = new SquareEnixLogin(*this, this);
m_profileManager = new ProfileManager(this);
m_accountManager = new AccountManager(this);
m_runner = new GameRunner(*this, this);
connect(m_accountManager, &AccountManager::accountAdded, this, &LauncherCore::fetchAvatar);
connect(m_accountManager, &AccountManager::accountLodestoneIdChanged, this, &LauncherCore::fetchAvatar);
connect(this, &LauncherCore::gameClosed, this, &LauncherCore::handleGameExit);
#ifdef BUILD_SYNC
m_syncManager = new SyncManager(this);
#endif
m_profileManager->load();
m_accountManager->load();
// restore profile -> account connections
for (const auto profile : m_profileManager->profiles()) {
if (const auto account = m_accountManager->getByUuid(profile->accountUuid())) {
profile->setAccount(account);
}
}
// set default profile, if found
if (const auto profile = m_profileManager->getProfileByUUID(m_settings->currentProfile())) {
setCurrentProfile(profile);
}
m_loadingFinished = true;
Q_EMIT loadingFinished();
}
void LauncherCore::initializeSteam()
{
m_steamApi = new SteamAPI(this);
m_steamApi->setLauncherMode(true);
}
void LauncherCore::login(Profile *profile, const QString &username, const QString &password, const QString &oneTimePassword)
{
Q_ASSERT(profile != nullptr);
inhibitSleep();
const auto loginInformation = new LoginInformation(this);
loginInformation->profile = profile;
// Benchmark never has to login, of course
if (!profile->isBenchmark()) {
loginInformation->username = username;
loginInformation->password = password;
loginInformation->oneTimePassword = oneTimePassword;
if (profile->account()->rememberPassword()) {
profile->account()->setPassword(password);
}
}
beginLogin(*loginInformation);
}
bool LauncherCore::autoLogin(Profile *profile)
{
Q_ASSERT(profile != nullptr);
QString otp;
if (profile->account()->useOTP()) {
if (!profile->account()->rememberOTP()) {
Q_EMIT loginError(i18n("This account does not have an OTP secret set, but requires it for login."));
return false;
}
otp = profile->account()->getOTP();
if (otp.isEmpty()) {
Q_EMIT loginError(i18n("Failed to generate OTP, review the stored secret."));
return false;
}
}
login(profile, profile->account()->name(), profile->account()->getPassword(), otp);
return true;
}
void LauncherCore::immediatelyLaunch(Profile *profile)
{
Q_ASSERT(profile != nullptr);
m_runner->beginGameExecutable(*profile, std::nullopt);
}
GameInstaller *LauncherCore::createInstaller(Profile *profile)
{
Q_ASSERT(profile != nullptr);
return new GameInstaller(*this, *profile, this);
}
GameInstaller *LauncherCore::createInstallerFromExisting(Profile *profile, const QString &filePath)
{
Q_ASSERT(profile != nullptr);
return new GameInstaller(*this, *profile, filePath, this);
}
CompatibilityToolInstaller *LauncherCore::createCompatInstaller()
{
return new CompatibilityToolInstaller(*this, this);
}
BenchmarkInstaller *LauncherCore::createBenchmarkInstaller(Profile *profile)
{
Q_ASSERT(profile != nullptr);
return new BenchmarkInstaller(*this, *profile, this);
}
BenchmarkInstaller *LauncherCore::createBenchmarkInstallerFromExisting(Profile *profile, const QString &filePath)
{
Q_ASSERT(profile != nullptr);
return new BenchmarkInstaller(*this, *profile, filePath, this);
}
void LauncherCore::fetchAvatar(Account *account)
{
if (account->lodestoneId().isEmpty()) {
return;
}
const QString cacheLocation = QStandardPaths::standardLocations(QStandardPaths::CacheLocation)[0] + QStringLiteral("/avatars");
Utility::createPathIfNeeded(cacheLocation);
const QString filename = QStringLiteral("%1/%2.jpg").arg(cacheLocation, account->lodestoneId());
if (!QFile(filename).exists()) {
qDebug(ASTRA_LOG) << "Did not find lodestone character " << account->lodestoneId() << " in cache, fetching from Lodestone.";
QUrl url;
url.setScheme(settings()->preferredProtocol());
url.setHost(QStringLiteral("na.%1").arg(settings()->mainServer())); // TODO: NA isnt the only thing in the world...
url.setPath(QStringLiteral("/lodestone/character/%1").arg(account->lodestoneId()));
const QNetworkRequest request(url);
Utility::printRequest(QStringLiteral("GET"), request);
const auto reply = mgr()->get(request);
connect(reply, &QNetworkReply::finished, [this, filename, reply, account] {
const QString document = QString::fromUtf8(reply->readAll());
if (!document.isEmpty()) {
const static QRegularExpression re(
QStringLiteral(R"lit(<div\s[^>]*class=["|']frame__chara__face["|'][^>]*>\s*<img\s[&>]*src=["|']([^"']*))lit"));
const QRegularExpressionMatch match = re.match(document);
if (match.hasCaptured(1)) {
const QString newAvatarUrl = match.captured(1);
const auto avatarRequest = QNetworkRequest(QUrl(newAvatarUrl));
Utility::printRequest(QStringLiteral("GET"), avatarRequest);
auto avatarReply = mgr()->get(avatarRequest);
connect(avatarReply, &QNetworkReply::finished, [this, filename, avatarReply, account] {
QFile file(filename);
file.open(QIODevice::ReadWrite);
file.write(avatarReply->readAll());
file.close();
account->setAvatarUrl(QStringLiteral("file:///%1").arg(filename));
});
}
}
});
} else {
account->setAvatarUrl(QStringLiteral("file:///%1").arg(filename));
}
}
void LauncherCore::clearAvatarCache()
{
const QString cacheLocation = QStandardPaths::standardLocations(QStandardPaths::CacheLocation)[0] + QStringLiteral("/avatars");
if (QDir(cacheLocation).exists()) {
QDir(cacheLocation).removeRecursively();
}
}
void LauncherCore::refreshNews()
{
fetchNews();
}
void LauncherCore::refreshLogoImage()
{
const QDir cacheDir = QStandardPaths::standardLocations(QStandardPaths::StandardLocation::CacheLocation).last();
const QDir logoDir = cacheDir.absoluteFilePath(QStringLiteral("logos"));
if (!logoDir.exists()) {
Q_UNUSED(QDir().mkpath(logoDir.absolutePath()))
}
const auto saveTexture = [](GameData *data, const QString &path, const QString &name) {
if (QFile::exists(name)) {
return;
}
const auto file = physis_gamedata_extract_file(data, path.toStdString().c_str());
if (file.data != nullptr) {
const auto tex = physis_texture_parse(file);
const QImage image(tex.rgba, tex.width, tex.height, QImage::Format_RGBA8888);
Q_UNUSED(image.save(name))
}
};
// TODO: this finds the first profile that has a valid image, but this could probably be cached per-profile
for (int i = 0; i < m_profileManager->numProfiles(); i++) {
const auto profile = m_profileManager->getProfile(i);
if (profile->isGameInstalled() && profile->gameData()) {
// A Realm Reborn
saveTexture(profile->gameData(), QStringLiteral("ui/uld/Title_Logo.tex"), logoDir.absoluteFilePath(QStringLiteral("ffxiv.png")));
for (int j = 0; j < profile->numInstalledExpansions(); j++) {
const int expansionNumber = 100 * (j + 3); // logo number starts at 300 for ex1
saveTexture(profile->gameData(),
QStringLiteral("ui/uld/Title_Logo%1_hr1.tex").arg(expansionNumber),
logoDir.absoluteFilePath(QStringLiteral("ex%1.png").arg(j + 1)));
}
}
}
QList<QString> imageFiles;
// TODO: sort
QDirIterator it(logoDir.absolutePath());
while (it.hasNext()) {
const QFileInfo logoFile(it.next());
if (logoFile.completeSuffix() != QStringLiteral("png")) {
continue;
}
imageFiles.push_back(logoFile.absoluteFilePath());
}
if (!imageFiles.isEmpty()) {
m_cachedLogoImage = imageFiles.last();
Q_EMIT cachedLogoImageChanged();
}
}
Profile *LauncherCore::currentProfile() const
{
return m_profileManager->getProfile(m_currentProfileIndex);
}
void LauncherCore::setCurrentProfile(const Profile *profile)
{
Q_ASSERT(profile != nullptr);
const int newIndex = m_profileManager->getProfileIndex(profile->uuid());
if (newIndex != m_currentProfileIndex) {
m_currentProfileIndex = newIndex;
m_settings->setCurrentProfile(profile->uuid());
m_settings->config()->save();
Q_EMIT currentProfileChanged();
}
}
[[nodiscard]] QString LauncherCore::autoLoginProfileName() const
{
return m_settings->config()->autoLoginProfile();
}
[[nodiscard]] Profile *LauncherCore::autoLoginProfile() const
{
if (m_settings->config()->autoLoginProfile().isEmpty()) {
return nullptr;
}
return m_profileManager->getProfileByUUID(m_settings->config()->autoLoginProfile());
}
void LauncherCore::setAutoLoginProfile(const Profile *profile)
{
if (profile != nullptr) {
auto uuid = profile->uuid();
if (uuid != m_settings->config()->autoLoginProfile()) {
m_settings->config()->setAutoLoginProfile(uuid);
}
} else {
m_settings->config()->setAutoLoginProfile({});
}
m_settings->config()->save();
Q_EMIT autoLoginProfileChanged();
}
void LauncherCore::buildRequest(const Profile &settings, QNetworkRequest &request)
{
Utility::setSSL(request);
if (settings.account()->license() == Account::GameLicense::macOS) {
request.setHeader(QNetworkRequest::UserAgentHeader, QByteArrayLiteral("macSQEXAuthor/2.0.0(MacOSX; ja-jp)"));
} else {
request.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("SQEXAuthor/2.0.0(Windows 6.2; ja-jp; %1)").arg(QString::fromUtf8(QSysInfo::bootUniqueId())));
}
request.setRawHeader(QByteArrayLiteral("Accept"),
QByteArrayLiteral("image/gif, image/jpeg, image/pjpeg, application/x-ms-application, application/xaml+xml, "
"application/x-ms-xbap, */*"));
request.setRawHeader(QByteArrayLiteral("Accept-Encoding"), QByteArrayLiteral("gzip, deflate"));
request.setRawHeader(QByteArrayLiteral("Accept-Language"), QByteArrayLiteral("en-us"));
}
void LauncherCore::setupIgnoreSSL(QNetworkReply *reply)
{
Q_ASSERT(reply != nullptr);
if (m_settings->preferredProtocol() == QStringLiteral("http")) {
connect(reply, &QNetworkReply::sslErrors, this, [reply](const QList<QSslError> &errors) {
reply->ignoreSslErrors(errors);
});
}
}
bool LauncherCore::isLoadingFinished() const
{
return m_loadingFinished;
}
bool LauncherCore::isSteam() const
{
return m_steamApi != nullptr;
}
bool LauncherCore::isSteamDeck() const
{
return Utility::isSteamDeck();
}
bool LauncherCore::isWindows()
{
#if defined(Q_OS_WIN)
return true;
#else
return false;
#endif
}
bool LauncherCore::needsCompatibilityTool()
{
return !isWindows();
}
bool LauncherCore::isPatching() const
{
return m_isPatching;
}
bool LauncherCore::supportsSync() const
{
#ifdef BUILD_SYNC
return true;
#else
return false;
#endif
}
QNetworkAccessManager *LauncherCore::mgr()
{
return m_mgr;
}
LauncherSettings *LauncherCore::settings()
{
return m_settings;
}
ProfileManager *LauncherCore::profileManager()
{
return m_profileManager;
}
AccountManager *LauncherCore::accountManager()
{
return m_accountManager;
}
Headline *LauncherCore::headline() const
{
return m_headline;
}
QString LauncherCore::cachedLogoImage() const
{
return m_cachedLogoImage;
}
#ifdef BUILD_SYNC
SyncManager *LauncherCore::syncManager() const
{
return m_syncManager;
}
#endif
QCoro::Task<> LauncherCore::beginLogin(LoginInformation &info)
{
// Hmm, I don't think we're set up for this yet?
if (!info.profile->isBenchmark()) {
updateConfig(info.profile->account());
}
#ifdef BUILD_SYNC
const auto characterSync = new CharacterSync(*info.profile->account(), *this, this);
if (!co_await characterSync->sync()) {
co_return;
}
#endif
std::optional<LoginAuth> auth;
if (!info.profile->isBenchmark()) {
if (info.profile->account()->isSapphire()) {
auth = co_await m_sapphireLogin->login(info.profile->account()->lobbyUrl(), info);
} else {
auth = co_await m_squareEnixLogin->login(&info);
}
}
const auto assetUpdater = new AssetUpdater(*info.profile, *this, this);
if (co_await assetUpdater->update()) {
// If we expect an auth ticket, don't continue if missing
if (!info.profile->isBenchmark() && auth == std::nullopt) {
co_return;
}
Q_EMIT stageChanged(i18n("Launching game..."));
if (isSteam()) {
m_steamApi->setLauncherMode(false);
}
m_runner->beginGameExecutable(*info.profile, auth);
}
assetUpdater->deleteLater();
}
QCoro::Task<> LauncherCore::fetchNews()
{
qInfo(ASTRA_LOG) << "Fetching news...";
QUrlQuery query;
query.addQueryItem(QStringLiteral("lang"), QStringLiteral("en-us"));
query.addQueryItem(QStringLiteral("media"), QStringLiteral("pcapp"));
QUrl headlineUrl;
headlineUrl.setScheme(m_settings->preferredProtocol());
headlineUrl.setHost(QStringLiteral("frontier.%1").arg(m_settings->squareEnixServer()));
headlineUrl.setPath(QStringLiteral("/news/headline.json"));
headlineUrl.setQuery(query);
QNetworkRequest headlineRequest(QUrl(QStringLiteral("%1&%2").arg(headlineUrl.toString(), QString::number(QDateTime::currentMSecsSinceEpoch()))));
headlineRequest.setRawHeader(QByteArrayLiteral("Accept"), QByteArrayLiteral("application/json, text/plain, */*"));
headlineRequest.setRawHeader(QByteArrayLiteral("Origin"), QByteArrayLiteral("https://launcher.finalfantasyxiv.com"));
headlineRequest.setRawHeader(
QByteArrayLiteral("Referer"),
QStringLiteral("%1/index.html?rc_lang=%2&time=%3")
.arg(currentProfile()->frontierUrl(), QStringLiteral("en-us"), QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyy-MM-dd-HH")))
.toUtf8());
Utility::printRequest(QStringLiteral("GET"), headlineRequest);
auto headlineReply = mgr()->get(headlineRequest);
co_await headlineReply;
QUrl bannerUrl;
bannerUrl.setScheme(m_settings->preferredProtocol());
bannerUrl.setHost(QStringLiteral("frontier.%1").arg(m_settings->squareEnixServer()));
bannerUrl.setPath(QStringLiteral("/v2/topics/%1/banner.json").arg(QStringLiteral("en-us")));
bannerUrl.setQuery(query);
QNetworkRequest bannerRequest(QUrl(QStringLiteral("%1&_=%3").arg(bannerUrl.toString(), QString::number(QDateTime::currentMSecsSinceEpoch()))));
bannerRequest.setRawHeader(QByteArrayLiteral("Accept"), QByteArrayLiteral("application/json, text/plain, */*"));
bannerRequest.setRawHeader(QByteArrayLiteral("Origin"), QByteArrayLiteral("https://launcher.finalfantasyxiv.com"));
bannerRequest.setRawHeader(
QByteArrayLiteral("Referer"),
QStringLiteral("%1/v700/index.html?rc_lang=%2&time=%3")
.arg(currentProfile()->frontierUrl(), QStringLiteral("en-us"), QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyy-MM-dd-HH")))
.toUtf8());
Utility::printRequest(QStringLiteral("GET"), bannerRequest);
auto bannerReply = mgr()->get(bannerRequest);
co_await bannerReply;
const auto document = QJsonDocument::fromJson(headlineReply->readAll());
const auto bannerDocument = QJsonDocument::fromJson(bannerReply->readAll());
const auto headline = new Headline(this);
if (document.isEmpty() || bannerDocument.isEmpty()) {
headline->failedToLoad = true;
} else {
const auto parseNews = [](QJsonObject object) -> News {
News news;
news.date = QDateTime::fromString(object["date"_L1].toString(), Qt::DateFormat::ISODate);
news.id = object["id"_L1].toString();
news.tag = object["tag"_L1].toString();
news.title = object["title"_L1].toString();
if (object["url"_L1].toString().isEmpty()) {
news.url = QUrl(QStringLiteral("https://na.finalfantasyxiv.com/lodestone/news/detail/%1").arg(news.id));
} else {
news.url = QUrl(object["url"_L1].toString());
}
return news;
};
for (const auto bannerObject : bannerDocument.object()["banner"_L1].toArray()) {
// TODO: use new order_priority and fix_order params
headline->banners.push_back(
{.link = QUrl(bannerObject.toObject()["link"_L1].toString()), .bannerImage = QUrl(bannerObject.toObject()["lsb_banner"_L1].toString())});
}
for (const auto newsObject : document.object()["news"_L1].toArray()) {
headline->news.push_back(parseNews(newsObject.toObject()));
}
for (const auto pinnedObject : document.object()["pinned"_L1].toArray()) {
headline->pinned.push_back(parseNews(pinnedObject.toObject()));
}
for (const auto pinnedObject : document.object()["topics"_L1].toArray()) {
headline->topics.push_back(parseNews(pinnedObject.toObject()));
}
}
m_headline = headline;
Q_EMIT newsChanged();
}
QCoro::Task<> LauncherCore::handleGameExit(const Profile *profile)
{
qCDebug(ASTRA_LOG) << "Game has closed.";
uninhibitSleep();
#ifdef BUILD_SYNC
// TODO: once we have Steam API support we can tell Steam to delay putting the Deck to sleep until our upload is complete
if (m_settings->enableSync()) {
Q_EMIT showWindow();
qCDebug(ASTRA_LOG) << "Game closed! Uploading character data...";
const auto characterSync = new CharacterSync(*profile->account(), *this, this);
co_await characterSync->sync(false);
// Tell the user they can now quit.
Q_EMIT stageChanged(i18n("You may now safely close the game."));
co_return;
}
#endif
// Otherwise, quit when everything is finished.
if (m_settings->closeWhenLaunched()) {
QCoreApplication::exit();
}
co_return;
}
void LauncherCore::updateConfig(const Account *account)
{
const auto configDir = account->getConfigDir().absoluteFilePath(QStringLiteral("FFXIV.cfg"));
if (!QFile::exists(configDir)) {
return;
}
qInfo(ASTRA_LOG) << "Updating FFXIV.cfg...";
const auto configDirStd = configDir.toStdString();
const auto cfgFileBuffer = physis_read_file(configDirStd.c_str());
const auto cfgFile = physis_cfg_parse(cfgFileBuffer);
// Ensure that the opening cutscene movie never plays, since it's broken in most versions of Wine
physis_cfg_set_value(cfgFile, "CutsceneMovieOpening", "1");
const auto screenshotDir = settings()->screenshotDir();
Utility::createPathIfNeeded(screenshotDir);
const auto screenshotDirWin = Utility::toWindowsPath(screenshotDir);
const auto screenshotDirWinStd = screenshotDirWin.toStdString();
// Set the screenshot path
physis_cfg_set_value(cfgFile, "ScreenShotDir", screenshotDirWinStd.c_str());
const auto buffer = physis_cfg_write(cfgFile);
QFile file(configDir);
file.open(QIODevice::WriteOnly);
file.write(reinterpret_cast<const char *>(buffer.data), buffer.size);
file.close();
}
void LauncherCore::inhibitSleep()
{
#ifdef HAS_DBUS
if (screenSaverDbusCookie != 0)
return;
QDBusMessage message = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("Inhibit"));
message << QGuiApplication::desktopFileName();
message << i18n("Playing FFXIV");
const QDBusReply<uint> reply = QDBusConnection::sessionBus().call(message);
if (reply.isValid()) {
screenSaverDbusCookie = reply.value();
}
#endif
}
void LauncherCore::uninhibitSleep()
{
#ifdef HAS_DBUS
if (screenSaverDbusCookie == 0)
return;
QDBusMessage message = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("UnInhibit"));
message << static_cast<uint>(screenSaverDbusCookie);
screenSaverDbusCookie = 0;
QDBusConnection::sessionBus().send(message);
#endif
}
#include "moc_launchercore.cpp"