From e4598069045a3dfa8bdb5a8da0e3b262bfdba499 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 21 Jun 2026 08:56:02 -0500 Subject: Prompt for SLR updates safely Fixes #98 Addresses #102 --- src/src/mainwindow.cpp | 9 +-- src/src/organizercore.cpp | 168 +++++++++++++++++++++++++++++++++++++++++++--- src/src/slrmanager.cpp | 139 +++++++++++++++++++++++++++++++------- src/src/slrmanager.h | 13 ++++ 4 files changed, 288 insertions(+), 41 deletions(-) diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 431a9db..11f2cde 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -2264,7 +2264,6 @@ void MainWindow::readSettings() s.geometry().restoreState(ui->splitter); s.geometry().restoreState(ui->categoriesSplitter); ui->menuBar->show(); - ui->toolBar->show(); s.geometry().restoreVisibility(ui->statusBar); FilterWidget::setOptions(s.interface().filterOptions()); @@ -2421,15 +2420,11 @@ void MainWindow::on_startButton_clicked() }); // Pre-check: Proton launches always use SLR. Native Linux executables skip - // this branch entirely. downloadSlr() short-circuits on BUILD_ID match, so - // once SLR is current this is a single HTTP GET; no UI is shown unless the - // runtime actually needs (re)downloading. + // this branch entirely. Startup update checks offer SLR updates separately; + // launch only blocks when no managed runtime exists yet. if (selectedExecutable->useProton()) { const bool freshInstall = !isSlrInstalled(); // Only show the heavyweight progress dialog on a fresh install. - // Up-to-date checks happen quietly in the background here so we don't - // block launch on a remote BUILD_ID fetch — the up-to-date case is - // handled at startup by OrganizerCore::checkForSlrUpdates(). if (freshInstall) { auto* progress = new QProgressDialog( tr("Downloading Steam Linux Runtime (~200 MB)...\n" diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 64772fa..fec0dfd 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -58,15 +58,20 @@ #include #include #include +#include #include #include +#include #include +#include +#include #include #include #include #include #include #include +#include #include #include // for qUtf8Printable, etc @@ -76,6 +81,7 @@ #include // for memset, wcsrchr #include +#include #include #include #include @@ -96,6 +102,7 @@ static env::CoreDumpTypes g_coreDumpType = env::CoreDumpTypes::Mini; namespace { +std::atomic s_slrUpdateCheckInProgress{false}; QString uniqueFilePath(const QDir& dir, const QString& fileName) { @@ -127,6 +134,104 @@ QString uniqueFilePath(const QDir& dir, const QString& fileName) : QStringLiteral("%1_%2.%3").arg(base, timestamp, suffix)); } +void installSlrUpdate(QWidget* parent, const SlrUpdateInfo& info); + +void promptSlrUpdate(QWidget* parent, const SlrUpdateInfo& info) +{ + if (parent == nullptr) { + parent = qApp->activeWindow(); + } + + QMessageBox box(parent); + box.setIcon(QMessageBox::Information); + box.setWindowTitle(QObject::tr("Steam Linux Runtime Update")); + box.setText(QObject::tr("A Steam Linux Runtime update is available.")); + box.setInformativeText( + QObject::tr("Current build: %1\nLatest build: %2\n\n" + "You can update now, be reminded later, or skip this build.") + .arg(info.localBuildId.isEmpty() ? QObject::tr("unknown") : info.localBuildId, + info.remoteBuildId)); + + QPushButton* updateButton = + box.addButton(QObject::tr("Update Now"), QMessageBox::AcceptRole); + box.addButton(QObject::tr("Remind Me Later"), QMessageBox::RejectRole); + QPushButton* skipButton = + box.addButton(QObject::tr("Skip This Version"), QMessageBox::DestructiveRole); + box.setDefaultButton(updateButton); + + box.exec(); + + if (box.clickedButton() == updateButton) { + installSlrUpdate(parent, info); + } else if (box.clickedButton() == skipButton) { + QSettings globalSettings = GlobalSettings::settings(); + globalSettings.setValue(QStringLiteral("SteamLinuxRuntime/skipped_build_id"), + info.remoteBuildId); + } +} + +void installSlrUpdate(QWidget* parent, const SlrUpdateInfo& info) +{ + if (parent == nullptr) { + parent = qApp->activeWindow(); + } + + auto* progress = new QProgressDialog( + QObject::tr("Updating Steam Linux Runtime...\n" + "Current build: %1\nLatest build: %2") + .arg(info.localBuildId.isEmpty() ? QObject::tr("unknown") : info.localBuildId, + info.remoteBuildId), + QObject::tr("Cancel"), 0, 0, parent); + progress->setWindowTitle(QObject::tr("Steam Linux Runtime")); + progress->setWindowModality(Qt::WindowModal); + progress->setMinimumDuration(0); + + auto* cancelFlag = new int(0); + QObject::connect(progress, &QProgressDialog::canceled, progress, [cancelFlag] { + *cancelFlag = 1; + }); + + auto* watcher = new QFutureWatcher(progress); + QObject::connect(watcher, &QFutureWatcher::finished, progress, + [watcher, progress, cancelFlag] { + progress->close(); + const QString err = watcher->result(); + watcher->deleteLater(); + progress->deleteLater(); + + if (*cancelFlag != 0) { + delete cancelFlag; + return; + } + + if (!err.isEmpty()) { + MOBase::log::warn("SLR update failed: {}", err); + QMessageBox::warning( + qApp->activeWindow(), + QObject::tr("Steam Linux Runtime"), + QObject::tr("Steam Linux Runtime update failed:\n%1\n\n" + "The existing runtime was kept.") + .arg(err)); + } else { + QSettings globalSettings = GlobalSettings::settings(); + globalSettings.remove( + QStringLiteral("SteamLinuxRuntime/skipped_build_id")); + MOBase::log::info("Steam Linux Runtime updated successfully"); + QMessageBox::information( + qApp->activeWindow(), + QObject::tr("Steam Linux Runtime"), + QObject::tr("Steam Linux Runtime updated successfully.")); + } + delete cancelFlag; + }); + + int* cancelPtr = cancelFlag; + watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString { + return downloadSlr(nullptr, nullptr, cancelPtr); + })); + progress->show(); +} + } // namespace template @@ -470,11 +575,9 @@ void OrganizerCore::checkForUpdates() void OrganizerCore::checkForSlrUpdates() { - // SLR auto-update: only relevant if SLR is already installed. The first- - // install case is handled at game launch in MainWindow, where the user gets - // a progress dialog. Here we silently background-fetch a newer steamrt4 - // image when one exists; downloadSlr() short-circuits on BUILD_ID match so - // the cost is one HTTP GET when already up-to-date. + // SLR updates are offered at startup but remain user-controlled. The first + // install case is still handled at game launch, where Proton cannot proceed + // without a runtime. if (!m_Settings.checkForUpdates()) { return; } @@ -485,11 +588,56 @@ void OrganizerCore::checkForSlrUpdates() return; } - // Fire and forget on a detached thread. No UI; result is logged. - std::thread([]() { - const QString err = downloadSlr(nullptr, nullptr, nullptr); - if (!err.isEmpty()) { - MOBase::log::warn("SLR update check failed: {}", err); + bool expected = false; + if (!s_slrUpdateCheckInProgress.compare_exchange_strong(expected, true)) { + return; + } + + QPointer self(this); + std::thread([self]() { + const SlrUpdateInfo info = checkSlrUpdate(); + if (!self) { + s_slrUpdateCheckInProgress = false; + return; + } + + const bool invoked = QMetaObject::invokeMethod(self, [self, info]() { + if (!self) { + s_slrUpdateCheckInProgress = false; + return; + } + + s_slrUpdateCheckInProgress = false; + + if (!info.error.isEmpty()) { + MOBase::log::warn("SLR update check failed: {}", info.error); + return; + } + + if (!info.updateAvailable) { + MOBase::log::debug("Steam Linux Runtime is up to date ({})", + info.localBuildId); + return; + } + + QSettings globalSettings = GlobalSettings::settings(); + const QString skippedBuild = + globalSettings.value(QStringLiteral("SteamLinuxRuntime/skipped_build_id")) + .toString(); + if (skippedBuild == info.remoteBuildId) { + MOBase::log::debug("Skipping SLR update prompt for ignored build {}", + info.remoteBuildId); + return; + } + + QWidget* parent = nullptr; + if (self->m_UserInterface) { + parent = self->m_UserInterface->mainWindow(); + } + promptSlrUpdate(parent, info); + }, Qt::QueuedConnection); + if (!invoked) { + s_slrUpdateCheckInProgress = false; } }).detach(); } diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp index 9fa529e..6c863f4 100644 --- a/src/src/slrmanager.cpp +++ b/src/src/slrmanager.cpp @@ -3,9 +3,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -44,7 +46,13 @@ QByteArray httpGet(const QString& url, const int* cancelFlag, const QString& destFile = {}) { QNetworkAccessManager mgr; - QNetworkReply* reply = mgr.get(QNetworkRequest(QUrl(url))); + QNetworkRequest request{QUrl(url)}; + request.setRawHeader("User-Agent", "Fluorine-Manager/slr"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setAttribute(QNetworkRequest::Http2AllowedAttribute, false); + + QNetworkReply* reply = mgr.get(request); QEventLoop loop; QFile outFile; @@ -91,6 +99,9 @@ QByteArray httpGet(const QString& url, const int* cancelFlag, outFile.close(); if (reply->error() != QNetworkReply::NoError) { + MOBase::log::warn("SLR download request failed: {} ({})", + url, + reply->errorString()); reply->deleteLater(); if (!destFile.isEmpty()) QFile::remove(destFile); @@ -101,6 +112,70 @@ QByteArray httpGet(const QString& url, const int* cancelFlag, return inMemoryBuf; } +QString readLocalBuildId() +{ + QFile f(localBuildIdPath()); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(f.readAll()).trimmed(); +} + +bool writeLocalBuildId(const QString& buildId) +{ + QFile f(localBuildIdPath()); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + f.write(buildId.toUtf8()); + f.write("\n"); + return true; +} + +bool makeExecutable(const QString& path) +{ + return QFile::setPermissions(path, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); +} + +bool replaceRuntimeAtomically(const QString& stagedRuntimeDir, QString& err) +{ + const QString installDir = slrInstallDir(); + const QString extractedDir = installDir + "/" + EXTRACTED_DIR; + const QString backupDir = installDir + "/" + EXTRACTED_DIR + ".previous"; + + if (!QFileInfo::exists(stagedRuntimeDir + "/run")) { + err = QStringLiteral("staged runtime is missing run script"); + return false; + } + makeExecutable(stagedRuntimeDir + "/run"); + + QDir().mkpath(installDir); + QDir(backupDir).removeRecursively(); + + bool hadPrevious = QFileInfo::exists(extractedDir); + if (hadPrevious && !QDir().rename(extractedDir, backupDir)) { + err = QStringLiteral("failed to move existing runtime aside"); + return false; + } + + if (!QDir().rename(stagedRuntimeDir, extractedDir)) { + if (hadPrevious) { + QDir().rename(backupDir, extractedDir); + } + err = QStringLiteral("failed to install staged runtime"); + return false; + } + + if (hadPrevious) { + QDir(backupDir).removeRecursively(); + } + return true; +} + } // namespace bool isSlrInstalled() @@ -198,6 +273,24 @@ QString getSlrRunScript() return isSlrInstalled() ? slrRunScriptPath() : QString(); } +SlrUpdateInfo checkSlrUpdate(const int* cancelFlag) +{ + SlrUpdateInfo info; + info.installed = isSlrInstalled(); + info.localBuildId = readLocalBuildId(); + + const QByteArray remoteBuildIdRaw = httpGet( + QStringLiteral("%1/BUILD_ID.txt").arg(QLatin1String(BASE_URL)), cancelFlag); + if (remoteBuildIdRaw.isEmpty()) { + info.error = QStringLiteral("Failed to fetch SLR BUILD_ID"); + return info; + } + + info.remoteBuildId = QString::fromUtf8(remoteBuildIdRaw).trimmed(); + info.updateAvailable = info.installed && info.localBuildId != info.remoteBuildId; + return info; +} + QString downloadSlr(const std::function& progressCb, const std::function& statusCb, const int* cancelFlag) @@ -208,20 +301,12 @@ QString downloadSlr(const std::function& progressCb, // 1. Check for updates. status(QStringLiteral("Checking Steam Linux Runtime version...")); - const QByteArray remoteBuildIdRaw = httpGet( - QStringLiteral("%1/BUILD_ID.txt").arg(QLatin1String(BASE_URL)), cancelFlag); - if (remoteBuildIdRaw.isEmpty()) - return QStringLiteral("Failed to fetch SLR BUILD_ID"); - - const QString remoteBuildId = QString::fromUtf8(remoteBuildIdRaw).trimmed(); + const SlrUpdateInfo updateInfo = checkSlrUpdate(cancelFlag); + if (!updateInfo.error.isEmpty()) + return updateInfo.error; - // Read local BUILD_ID. - QString localBuildId; - { - QFile f(localBuildIdPath()); - if (f.open(QIODevice::ReadOnly)) - localBuildId = QString::fromUtf8(f.readAll()).trimmed(); - } + const QString remoteBuildId = updateInfo.remoteBuildId; + const QString localBuildId = updateInfo.localBuildId; if (localBuildId == remoteBuildId && isSlrInstalled()) { MOBase::log::info("Steam Linux Runtime is already up to date"); @@ -241,7 +326,12 @@ QString downloadSlr(const std::function& progressCb, const QString installDir = slrInstallDir(); QDir().mkpath(installDir); - const QString archivePath = installDir + "/" + ARCHIVE_NAME; + + QTemporaryDir stagingRoot(installDir + "/slr-update-XXXXXX"); + if (!stagingRoot.isValid()) { + return QStringLiteral("Failed to create SLR staging directory"); + } + const QString archivePath = stagingRoot.filePath(ARCHIVE_NAME); // 2. Download. status(QStringLiteral("Downloading Steam Linux Runtime (steamrt4, ~200 MB)...")); @@ -254,12 +344,9 @@ QString downloadSlr(const std::function& progressCb, // 3. Extract. status(QStringLiteral("Extracting Steam Linux Runtime...")); - const QString extractedDir = installDir + "/" + EXTRACTED_DIR; - if (QFileInfo::exists(extractedDir)) - QDir(extractedDir).removeRecursively(); QProcess tar; - tar.setWorkingDirectory(installDir); + tar.setWorkingDirectory(stagingRoot.path()); tar.start(QStringLiteral("tar"), {QStringLiteral("xJf"), archivePath}); tar.waitForFinished(600000); QFile::remove(archivePath); @@ -267,19 +354,23 @@ QString downloadSlr(const std::function& progressCb, if (tar.exitStatus() != QProcess::NormalExit || tar.exitCode() != 0) return QStringLiteral("tar extraction failed (exit code %1)").arg(tar.exitCode()); - if (!QFileInfo::exists(slrRunScriptPath())) + const QString stagedRuntimeDir = stagingRoot.filePath(EXTRACTED_DIR); + if (!QFileInfo::exists(stagedRuntimeDir + "/run")) return QStringLiteral("Extraction succeeded but run script not found"); + QString replaceError; + if (!replaceRuntimeAtomically(stagedRuntimeDir, replaceError)) { + return replaceError; + } + // 4. Inject xrandr into the container (steamrt4 ships without it, but // Proton-GE and several protonfixes invoke xrandr during launch). status(QStringLiteral("Injecting xrandr into runtime...")); installXrandrAssets(cancelFlag, statusCb); // 5. Save BUILD_ID. - { - QFile f(localBuildIdPath()); - if (f.open(QIODevice::WriteOnly)) - f.write(remoteBuildId.toUtf8()); + if (!writeLocalBuildId(remoteBuildId)) { + MOBase::log::warn("Failed to write SLR BUILD_ID marker"); } MOBase::log::info("Steam Linux Runtime installed successfully"); diff --git a/src/src/slrmanager.h b/src/src/slrmanager.h index 6a842b9..149708a 100644 --- a/src/src/slrmanager.h +++ b/src/src/slrmanager.h @@ -4,12 +4,25 @@ #include #include +struct SlrUpdateInfo +{ + bool installed = false; + bool updateAvailable = false; + QString localBuildId; + QString remoteBuildId; + QString error; +}; + /// Returns true if the SLR `run` script is present and executable. __attribute__((visibility("default"))) bool isSlrInstalled(); /// Returns the path to the SLR `run` script, or empty if not installed. __attribute__((visibility("default"))) QString getSlrRunScript(); +/// Check the remote SLR BUILD_ID without downloading or replacing anything. +__attribute__((visibility("default"))) +SlrUpdateInfo checkSlrUpdate(const int* cancelFlag = nullptr); + /// Download and install SteamLinuxRuntime_4 (steamrt4, ~200 MB). /// Skips if already at the latest version (BUILD_ID check). /// Returns empty string on success, or an error message. -- cgit v1.3.1