From 63faea21cb4d4b35fc2fdbb6e48ce2f11288c362 Mon Sep 17 00:00:00 2001 From: redstrate Date: Mon, 6 Dec 2021 21:15:31 -0500 Subject: [PATCH] Add Watchdog Helps you get through the queue with instant access to your place, as well as giving you notifications on lobby errors and what else. At the moment, only supported under X11 and Linux but will grow in the future assuming the queues are still terrible. --- CMakeLists.txt | 26 +++++- README.md | 6 +- src/gameparser.cpp | 83 +++++++++++++++++++ src/gameparser.h | 39 +++++++++ src/launchercore.cpp | 11 +++ src/launchercore.h | 4 + src/settingswindow.cpp | 12 +++ src/settingswindow.h | 1 + src/squarelauncher.cpp | 7 +- src/watchdog.cpp | 180 +++++++++++++++++++++++++++++++++++++++++ src/watchdog.h | 31 +++++++ 11 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/gameparser.cpp create mode 100644 src/gameparser.h create mode 100644 src/watchdog.cpp create mode 100644 src/watchdog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b497bf5..081f263 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ find_package(Qt5 COMPONENTS Core Widgets Network CONFIG REQUIRED) add_subdirectory(external) -add_executable(xivlauncher +set(SRC src/main.cpp src/launchercore.cpp src/sapphirelauncher.cpp @@ -21,9 +21,29 @@ add_executable(xivlauncher src/assetupdater.cpp src/assetupdater.h src/launcherwindow.cpp - src/launcherwindow.h) + src/launcherwindow.h + src/watchdog.h + src/watchdog.cpp) -target_link_libraries(xivlauncher Qt5::Core Qt5::Widgets Qt5::Network qt5keychain QuaZip) +set(LIBRARIES + Qt5::Core Qt5::Widgets Qt5::Network qt5keychain QuaZip) + +if(UNIX) + set(SRC ${SRC} + src/gameparser.h + src/gameparser.cpp) + + set(LIBRARIES ${LIBRARIES} + tesseract + lept + X11 + Xcomposite + Xrender) +endif() + +add_executable(xivlauncher ${SRC}) + +target_link_libraries(xivlauncher PUBLIC ${LIBRARIES}) # disgusting, thanks qtkeychain and quazip target_include_directories(xivlauncher PRIVATE diff --git a/README.md b/README.md index 39d862b..76f39ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Finally, a cross-platform FFXIV launcher. It should run on Windows, macOS and Li Compared to XIVQuickLauncher and the official launcher, this supports **multiple profiles**, **Dalamud mods**, _and_ **macOS and Linux**! This means you no longer -have to suffer running your FFXIV launcher through Wine. +have to suffer running your FFXIV launcher through Wine. Under Linux, you have **Watchdog** available to help you through the login queue. As of this moment, the three missing major features are **game patching**, **external tool launching** and **the news list**. If you don't use these features then the launcher is still usable. @@ -23,6 +23,10 @@ More information can be found in the [FAQ](https://github.com/redstrate/xivlaunc * Saving username and/or password. These are saved per-profile, and are encrypted using your system wallet. * Encrypted game argument support similiar to what XIVQuickLauncher and the official ffxivboot already does. * Enable several (Linux) Wine-specific performance enhancements such as enabling Esync. +* You have a **Watchdog** available to help you through queues. + * Only works on X11 and Linux (at the moment) + * Will send you a notification on any change in the login queue. (moving up, logged in, lobby error, etc) + * Can view your spot in the queue easily by using the system tray icon. ## Usage Pre-compiled binaries are not (yet) available, so you must compile from source. Please see the relevant diff --git a/src/gameparser.cpp b/src/gameparser.cpp new file mode 100644 index 0000000..605a119 --- /dev/null +++ b/src/gameparser.cpp @@ -0,0 +1,83 @@ +#include "gameparser.h" + +#include +#include +#include + +GameParser::GameParser() { + api = new tesseract::TessBaseAPI(); + + if (api->Init(nullptr, "eng")) { + qDebug() << "Could not initialize tesseract!"; + return; + } + + api->SetPageSegMode(tesseract::PageSegMode::PSM_SINGLE_BLOCK); +} + +GameParser::~GameParser() { + api->End(); + delete api; +} + +GameParseResult GameParser::parseImage(QImage img) { + QBuffer buf; + img = img.convertToFormat(QImage::Format_Grayscale8); + img.save(&buf, "PNG", 100); + + Pix* image = pixReadMem((const l_uint8 *) buf.data().data(), buf.size()); + api->SetImage(image); + api->SetSourceResolution(300); + + const QString text = api->GetUTF8Text(); + + // TODO: clean up these names + const bool hasWorldFullText = text.contains("This World is currently full.") || text.contains("Players in queue"); + const bool hasLobbyErrorText = text.contains("The lobby server connection has encountered an error."); + const bool hasCONFIGURATIONText = text.contains("CONFIGURATION") || text.contains("ONLINE"); + const bool hasConnectingToData = text.contains("Connecting to data center"); + const bool worldTotallyFull = text.contains("3001"); + + if(hasLobbyErrorText) { + qDebug() << "LOBBY ERROR"; + + return {ScreenState::LobbyError, -1}; + } else { + if(worldTotallyFull) { + qDebug() << "TOTALLY FULL WORLD (CLOSED BY SQENIX)"; + + return {ScreenState::WorldFull, -1}; + } else { + if(hasConnectingToData) { + qDebug() << "CONNECTING TO DATA CENTER"; + + return {ScreenState::ConnectingToDataCenter, -1}; + } else { + if(hasWorldFullText) { + qDebug() << "FULL WORLD"; + + // attempt to extract number of players in queue + QRegularExpression exp("(?:Players in queue: )([\\d|,]*)"); + + auto match = exp.match(text); + if(match.isValid()) { + return {ScreenState::InLoginQueue, match.captured(1).remove(',').toInt()}; + } + + return {ScreenState::InLoginQueue, -1}; + } else { + if(hasCONFIGURATIONText) { + qDebug() << "TITLE SCREEN"; + return {ScreenState::EnteredTitleScreen, -1}; + } + } + } + } + } + + // TODO: figure out how to properly clear tesseract data + api->Clear(); + api->ClearAdaptiveClassifier(); + + return {ScreenState::Splash, -1}; +} \ No newline at end of file diff --git a/src/gameparser.h b/src/gameparser.h new file mode 100644 index 0000000..03d619e --- /dev/null +++ b/src/gameparser.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +enum class ScreenState { + Splash, + LobbyError, + WorldFull, + ConnectingToDataCenter, + EnteredTitleScreen, + InLoginQueue +}; + +struct GameParseResult { + ScreenState state; + + int playersInQueue = -1; +}; + +inline bool operator==(const GameParseResult a, const GameParseResult b) { + return a.state == b.state && a.playersInQueue == b.playersInQueue; +} + +inline bool operator!=(const GameParseResult a, const GameParseResult b) { + return !(a == b); +} + +class GameParser { +public: + GameParser(); + ~GameParser(); + + GameParseResult parseImage(QImage image); + +private: + tesseract::TessBaseAPI* api; +}; \ No newline at end of file diff --git a/src/launchercore.cpp b/src/launchercore.cpp index 33c8f89..ca16a6e 100755 --- a/src/launchercore.cpp +++ b/src/launchercore.cpp @@ -34,6 +34,7 @@ #include "settingswindow.h" #include "blowfish.h" #include "assetupdater.h" +#include "watchdog.h" void LauncherCore::setSSL(QNetworkRequest& request) { QSslConfiguration config; @@ -150,7 +151,10 @@ void LauncherCore::launchGame(const ProfileSettings& profile, const LoginAuth au connect(gameProcess, &QProcess::readyReadStandardOutput, [this, gameProcess, profile] { QString output = gameProcess->readAllStandardOutput(); + qDebug() << "Now launching dalamud..."; + auto dalamudProcess = new QProcess(); + dalamudProcess->setProcessChannelMode(QProcess::ForwardedChannels); QStringList dalamudEnv = gameProcess->environment(); @@ -300,6 +304,7 @@ void LauncherCore::readInitialInformation() { profile.useGamemode = settings.value("useGamemode", false).toBool(); profile.useGamescope = settings.value("useGamescope", false).toBool(); profile.enableDXVKhud = settings.value("enableDXVKhud", false).toBool(); + profile.enableWatchdog = settings.value("enableWatchdog", false).toBool(); profile.enableDalamud = settings.value("enableDalamud", false).toBool(); @@ -365,6 +370,7 @@ LauncherCore::LauncherCore() : settings(QSettings::IniFormat, QSettings::UserSco squareLauncher = new SquareLauncher(*this); squareBoot = new SquareBoot(*this, *squareLauncher); assetUpdater = new AssetUpdater(*this); + watchdog = new Watchdog(*this); readInitialInformation(); @@ -375,6 +381,10 @@ LauncherCore::LauncherCore() : settings(QSettings::IniFormat, QSettings::UserSco connect(squareLauncher, &SquareLauncher::gateStatusRecieved, this, &LauncherCore::settingsChanged); } +LauncherCore::~LauncherCore() noexcept { + delete watchdog; +} + ProfileSettings LauncherCore::getProfile(int index) const { return profileSettings[index]; } @@ -462,6 +472,7 @@ void LauncherCore::saveSettings() { settings.setValue("rememberPassword", profile.rememberPassword); settings.setValue("enableDalamud", profile.enableDalamud); + settings.setValue("enableWatchdog", profile.enableWatchdog); settings.endGroup(); } diff --git a/src/launchercore.h b/src/launchercore.h index 22aca79..ca6f28d 100755 --- a/src/launchercore.h +++ b/src/launchercore.h @@ -12,6 +12,7 @@ class SapphireLauncher; class SquareLauncher; class SquareBoot; class AssetUpdater; +class Watchdog; struct ProfileSettings { QUuid uuid; @@ -23,6 +24,7 @@ struct ProfileSettings { QString bootVersion, gameVersion; int installedMaxExpansion = -1; QList expansionVersions; + bool enableWatchdog = false; // wine // 0 = system, 1 = custom, 2 = built-in (mac only) @@ -63,6 +65,7 @@ class LauncherCore : public QObject { Q_OBJECT public: LauncherCore(); + ~LauncherCore(); QNetworkAccessManager* mgr; @@ -93,6 +96,7 @@ public: SquareBoot* squareBoot; SquareLauncher* squareLauncher; AssetUpdater* assetUpdater; + Watchdog* watchdog; int defaultProfileIndex = 0; signals: diff --git a/src/settingswindow.cpp b/src/settingswindow.cpp index 34ba69e..46f6de6 100644 --- a/src/settingswindow.cpp +++ b/src/settingswindow.cpp @@ -91,6 +91,17 @@ SettingsWindow::SettingsWindow(LauncherWindow& window, LauncherCore& core, QWidg }); gameBoxLayout->addWidget(gameDirectoryButton); +#if defined(Q_OS_LINUX) + enableWatchdog = new QCheckBox("Enable Watchdog (X11 only)"); + gameBoxLayout->addWidget(enableWatchdog); + + connect(enableWatchdog, &QCheckBox::stateChanged, [this](int state) { + getCurrentProfile().enableWatchdog = state; + + this->core.saveSettings(); + }); +#endif + expansionVersionLabel = new QLabel(); gameBoxLayout->addRow("Version Info", expansionVersionLabel); @@ -343,6 +354,7 @@ void SettingsWindow::reloadControls() { useEsync->setChecked(profile.useEsync); useGamescope->setChecked(profile.useGamescope); useGamemode->setChecked(profile.useGamemode); + enableWatchdog->setChecked(profile.enableWatchdog); #endif // login diff --git a/src/settingswindow.h b/src/settingswindow.h index 2a0d29d..cee9e01 100644 --- a/src/settingswindow.h +++ b/src/settingswindow.h @@ -39,6 +39,7 @@ private: QLabel* winePrefixDirectory; QCheckBox* useGamescope, *useEsync, *useGamemode; + QCheckBox* enableWatchdog; // login QCheckBox* encryptArgumentsBox = nullptr; diff --git a/src/squarelauncher.cpp b/src/squarelauncher.cpp index cf0efa7..ff253ae 100644 --- a/src/squarelauncher.cpp +++ b/src/squarelauncher.cpp @@ -9,6 +9,7 @@ #include #include "launchercore.h" +#include "watchdog.h" SquareLauncher::SquareLauncher(LauncherCore& window) : window(window) { @@ -123,7 +124,11 @@ void SquareLauncher::registerSession(const LoginInformation& info) { if(reply->rawHeaderList().contains("X-Patch-Unique-Id")) { auth.SID = reply->rawHeader("X-Patch-Unique-Id"); - window.launchGame(*info.settings, auth); + if(info.settings->enableWatchdog) { + window.watchdog->launchGame(*info.settings, auth); + } else { + window.launchGame(*info.settings, auth); + } } else { auto messageBox = new QMessageBox(QMessageBox::Icon::Critical, "Failed to Login", "Failed the anti-tamper check. Please restore your game to the original state or update the game."); window.addUpdateButtons(*info.settings, *messageBox); diff --git a/src/watchdog.cpp b/src/watchdog.cpp new file mode 100644 index 0000000..531acc4 --- /dev/null +++ b/src/watchdog.cpp @@ -0,0 +1,180 @@ +#include "watchdog.h" + +#include +#include +#include +#include + +#if defined(Q_OS_LINUX) +#include +#include +#include +#include + +// from https://github.com/adobe/webkit/blob/master/Source/WebCore/plugins/qt/QtX11ImageConversion.cpp +// code is licensed under GPLv2 +// Copyright (C) 2011 Nokia Corporation and/or its subsidiary(-ies) +QImage qimageFromXImage(XImage *xi) { + QImage::Format format = QImage::Format_ARGB32_Premultiplied; + if (xi->depth == 24) + format = QImage::Format_RGB32; + else if (xi->depth == 16) + format = QImage::Format_RGB16; + + QImage image = QImage(reinterpret_cast(xi->data), xi->width, xi->height, xi->bytes_per_line, + format).copy(); + + // we may have to swap the byte order + if ((QSysInfo::ByteOrder == QSysInfo::LittleEndian && xi->byte_order == MSBFirst) + || (QSysInfo::ByteOrder == QSysInfo::BigEndian && xi->byte_order == LSBFirst)) { + + for (int i = 0; i < image.height(); i++) { + if (xi->depth == 16) { + ushort *p = reinterpret_cast(image.scanLine(i)); + ushort *end = p + image.width(); + while (p < end) { + *p = ((*p << 8) & 0xff00) | ((*p >> 8) & 0x00ff); + p++; + } + } else { + uint *p = reinterpret_cast(image.scanLine(i)); + uint *end = p + image.width(); + while (p < end) { + *p = ((*p << 24) & 0xff000000) | ((*p << 8) & 0x00ff0000) + | ((*p >> 8) & 0x0000ff00) | ((*p >> 24) & 0x000000ff); + p++; + } + } + } + } + + // fix-up alpha channel + if (format == QImage::Format_RGB32) { + QRgb *p = reinterpret_cast(image.bits()); + for (int y = 0; y < xi->height; ++y) { + for (int x = 0; x < xi->width; ++x) + p[x] |= 0xff000000; + p += xi->bytes_per_line / 4; + } + } + + return image; +} +#endif + +void Watchdog::launchGame(const ProfileSettings &settings, LoginAuth auth) { + // TODO: stubbed out on other platforms + // (you can't actually enable it on other platforms, so this is fine for now.) +#if defined(Q_OS_LINUX) + if(icon == nullptr) { + icon = new QSystemTrayIcon(); + } + + icon->setToolTip("Queue Status"); + icon->show(); + icon->showMessage("Watchdog", "Watchdog service has started. Waiting for you to connect to data center..."); + + auto timer = new QTimer(this); + + auto menu = new QMenu(); + + auto stopAction = menu->addAction("Stop"); + connect(stopAction, &QAction::triggered, [=] { + timer->stop(); + processWindowId = -1; + icon->hide(); + }); + + icon->setContextMenu(menu); + + core.launchGame(settings, auth); + + if(parser == nullptr) { + parser = std::make_unique(); + } + + connect(timer, &QTimer::timeout, [=] { + if (processWindowId == -1) { + auto xdoProcess = new QProcess(); + + connect(xdoProcess, static_cast(&QProcess::finished), + [=](int, QProcess::ExitStatus) { + QString output = xdoProcess->readAllStandardOutput(); + qDebug() << "Found XIV Window: " << output.toInt(); + + processWindowId = output.toInt(); + }); + + // TODO: don't use xdotool for this, find a better way to + xdoProcess->start("bash", {"-c", "xdotool search --name \"FINAL FANTASY XIV\""}); + } else { + Display* display = XOpenDisplay(nullptr); + + XSynchronize(display, True); + + XWindowAttributes attr; + Status status = XGetWindowAttributes(display, processWindowId, &attr); + if (status == 0) { + qDebug() << "Failed to get window attributes! The window is possibly closed now."; + processWindowId = -1; + timer->stop(); + icon->hide(); + } else { + XCompositeRedirectWindow(display, processWindowId, CompositeRedirectAutomatic); + XCompositeNameWindowPixmap(display, processWindowId); + + XRenderPictFormat* format = XRenderFindVisualFormat(display, attr.visual); + + XRenderPictureAttributes pa; + pa.subwindow_mode = IncludeInferiors; + + Picture picture = XRenderCreatePicture(display, processWindowId, format, + CPSubwindowMode, &pa); + XFlush(display); // TODO: does this actually make a difference? + + XImage* image = XGetImage(display, processWindowId, 0, 0, attr.width, attr.height, AllPlanes, ZPixmap); + if (!image) { + qDebug() << "Unable to get image..."; + } else { + auto result = parser->parseImage(qimageFromXImage(image)); + if (result != lastResult) { + lastResult = result; + + switch (result.state) { + case ScreenState::InLoginQueue: { + icon->showMessage("Watchdog", + QString("You are now at position %1 (moved %2 spots)").arg( + result.playersInQueue).arg( + lastResult.playersInQueue - result.playersInQueue)); + + icon->setToolTip(QString("Queue Status (%1)").arg(result.playersInQueue)); + } + break; + case ScreenState::LobbyError: { + // TODO: kill game? + icon->showMessage("Watchdog", "You have been disconnected due to a lobby error."); + } + break; + case ScreenState::ConnectingToDataCenter: { + icon->showMessage("Watchdog", + "You are in the process of being connected to the data center."); + } + break; + case ScreenState::WorldFull: { + icon->showMessage("Watchdog", "You have been disconnected due to a lobby error."); + } + break; + } + } + + XFreePixmap(display, picture); + } + } + + XCompositeUnredirectWindow(display, processWindowId, CompositeRedirectAutomatic); + } + }); + + timer->start(5000); +#endif +} \ No newline at end of file diff --git a/src/watchdog.h b/src/watchdog.h new file mode 100644 index 0000000..d1e2630 --- /dev/null +++ b/src/watchdog.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "launchercore.h" + +#if defined(Q_OS_LINUX) +#include "gameparser.h" +#endif + +#include + +class Watchdog : public QObject { + Q_OBJECT +public: + Watchdog(LauncherCore& core) : core(core) {} + + void launchGame(const ProfileSettings& settings, LoginAuth auth); + +private: + LauncherCore& core; + QSystemTrayIcon* icon = nullptr; + + int processWindowId = -1; + +#if defined(Q_OS_LINUX) + GameParseResult lastResult; +#endif + + std::unique_ptr parser; +}; \ No newline at end of file