diff options
| -rw-r--r-- | CMakeLists.txt | 3 | ||||
| -rw-r--r-- | libs/installer_fomod/src/fomodinstallerdialog.cpp | 21 | ||||
| -rw-r--r-- | libs/installer_fomod/src/installerfomod.cpp | 15 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 1 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 21 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 174 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.cpp | 20 | ||||
| -rw-r--r-- | src/src/slrmanager.cpp | 139 | ||||
| -rw-r--r-- | src/src/slrmanager.h | 13 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 66 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 4 | ||||
| -rw-r--r-- | src/tests/test_vfs_file_ops.cpp | 26 |
12 files changed, 428 insertions, 75 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ec1a74..ebb415d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,7 @@ if(HAS_NO_DIRECT_EXTERN_ACCESS) endif() option(BUILD_TESTING "Build tests" OFF) +option(BUILD_FLUORINE_TESTING "Build Fluorine standalone tests" OFF) option(BUILD_BSA_FFI "Build Rust BSA/BA2 FFI library" ON) option(BUILD_DOTNET_PLUGINS "Build C++/CLI .NET plugins (Windows+MSVC only)" OFF) option(BUILD_PLUGIN_PYTHON "Build plugin_python bridge on Linux" ON) @@ -285,7 +286,7 @@ endif() # Main organizer executable add_subdirectory(src/src) -if (BUILD_TESTING) +if (BUILD_FLUORINE_TESTING) enable_testing() add_subdirectory(src/tests) endif() diff --git a/libs/installer_fomod/src/fomodinstallerdialog.cpp b/libs/installer_fomod/src/fomodinstallerdialog.cpp index 7c27726..9e3eaaa 100644 --- a/libs/installer_fomod/src/fomodinstallerdialog.cpp +++ b/libs/installer_fomod/src/fomodinstallerdialog.cpp @@ -166,6 +166,21 @@ static QString tempArchivePath(const QString& archivePath) return QDir(QDir::tempPath()).filePath(normalizeArchivePath(archivePath)); } +static QString fomodFilePath(const QString& fomodPath, const QString& fomodDirName, + const QString& fileName) +{ + const QString directory = tempArchivePath(joinArchivePath(fomodPath, fomodDirName)); + const QDir dir(directory); + + for (const QString& candidate : dir.entryList(QDir::Files | QDir::NoDotAndDotDot)) { + if (candidate.compare(fileName, Qt::CaseInsensitive) == 0) { + return dir.filePath(candidate); + } + } + + return dir.filePath(fileName); +} + QByteArray skipXmlHeader(QIODevice& file) { static const unsigned char UTF16LE_BOM[] = {0xFF, 0xFE}; @@ -265,8 +280,7 @@ void FomodInstallerDialog::readXml(QFile& file, void FomodInstallerDialog::readInfoXml() { - QFile file(tempArchivePath(joinArchivePath( - joinArchivePath(m_FomodPath, m_FomodDirName), "info.xml"))); + QFile file(fomodFilePath(m_FomodPath, m_FomodDirName, "info.xml")); // We don't need a info.xml file, so we just return if we cannot open it: if (!file.open(QIODevice::ReadOnly)) { @@ -277,8 +291,7 @@ void FomodInstallerDialog::readInfoXml() void FomodInstallerDialog::readModuleConfigXml() { - QFile file(tempArchivePath(joinArchivePath( - joinArchivePath(m_FomodPath, m_FomodDirName), "ModuleConfig.xml"))); + QFile file(fomodFilePath(m_FomodPath, m_FomodDirName, "ModuleConfig.xml")); if (!file.open(QIODevice::ReadOnly)) { throw Exception(tr("%1 missing.").arg(file.fileName())); } diff --git a/libs/installer_fomod/src/installerfomod.cpp b/libs/installer_fomod/src/installerfomod.cpp index e526610..9d9690a 100644 --- a/libs/installer_fomod/src/installerfomod.cpp +++ b/libs/installer_fomod/src/installerfomod.cpp @@ -1,5 +1,7 @@ #include "installerfomod.h" +#include <algorithm> + #include <QImageReader> #include <QStringList> #include <QtPlugin> @@ -115,10 +117,14 @@ InstallerFomod::findFomodDirectory(std::shared_ptr<const IFileTree> tree) const bool InstallerFomod::isArchiveSupported(std::shared_ptr<const IFileTree> tree) const { tree = findFomodDirectory(tree); - if (tree != nullptr) { - return tree->exists("ModuleConfig.xml", FileTreeEntry::FILE); + if (tree == nullptr) { + return false; } - return false; + + return std::any_of(tree->begin(), tree->end(), [](const auto& entry) { + return entry->isFile() && + entry->name().compare("ModuleConfig.xml", Qt::CaseInsensitive) == 0; + }); } void InstallerFomod::appendImageFiles( @@ -145,7 +151,8 @@ InstallerFomod::buildFomodTree(std::shared_ptr<const IFileTree> tree) const for (auto entry : *fomodTree) { if (entry->isFile() && - (entry->compare("info.xml") == 0 || entry->compare("ModuleConfig.xml") == 0)) { + (entry->name().compare("info.xml", Qt::CaseInsensitive) == 0 || + entry->name().compare("ModuleConfig.xml", Qt::CaseInsensitive) == 0)) { entries.push_back(entry); } } diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index c5b5718..3b3c1ba 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -578,6 +578,7 @@ bool FuseConnector::mount( m_backingFd = -1; throw FuseConnectorException(QObject::tr("Failed to create FUSE session")); } + m_context->session = m_session; if (fuse_session_mount(m_session, m_mountPoint.c_str()) != 0) { fuse_session_destroy(m_session); diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 431a9db..2f7193f 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -95,6 +95,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <QAbstractItemDelegate> #include <QAction> #include <QApplication> +#include <QGuiApplication> #include <QBuffer> #include <QButtonGroup> #include <QCheckBox> @@ -2254,8 +2255,15 @@ void MainWindow::readSettings() { const auto& s = m_OrganizerCore.settings(); - if (!s.geometry().restoreGeometry(this)) { - resize(1300, 800); + // Qt Wayland's session-management client restores the geometry before the + // first surface commit. Calling restoreGeometry() ourselves races that + // protocol and prevents the compositor from restoring the prior window + // position. X11/XWayland still relies on the existing Qt geometry state. + if (!QGuiApplication::platformName().startsWith(QStringLiteral("wayland"), + Qt::CaseInsensitive)) { + if (!s.geometry().restoreGeometry(this)) { + resize(1300, 800); + } } s.geometry().restoreState(this); @@ -2264,7 +2272,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 +2428,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 2f62251..107a607 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -58,15 +58,20 @@ #include <QDirIterator> #include <QFile> #include <QFileInfo> +#include <QFutureWatcher> #include <QMessageBox> #include <QNetworkInterface> +#include <QPointer> #include <QProcess> +#include <QProgressDialog> +#include <QPushButton> #include <QRegularExpression> #include <QTemporaryFile> #include <QTextStream> #include <QTimer> #include <QUrl> #include <QWidget> +#include <QtConcurrent/QtConcurrentRun> #include <QtDebug> #include <QtGlobal> // for qUtf8Printable, etc @@ -76,6 +81,7 @@ #include <cstring> // for memset, wcsrchr #include <boost/algorithm/string/predicate.hpp> +#include <atomic> #include <exception> #include <functional> #include <memory> @@ -96,6 +102,13 @@ static env::CoreDumpTypes g_coreDumpType = env::CoreDumpTypes::Mini; namespace { +std::atomic<bool> s_slrUpdateCheckInProgress{false}; + +QSettings globalSettings() +{ + return QSettings(QStringLiteral("Mod Organizer Team"), + QStringLiteral("Mod Organizer")); +} QString uniqueFilePath(const QDir& dir, const QString& fileName) { @@ -127,6 +140,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 settings = globalSettings(); + settings.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<QString>(progress); + QObject::connect(watcher, &QFutureWatcher<QString>::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 settings = globalSettings(); + settings.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 <typename InputIterator> @@ -470,11 +581,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 +594,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<OrganizerCore> 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 settings = globalSettings(); + const QString skippedBuild = + settings.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/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp index f599a30..d6ce33e 100644 --- a/src/src/prefixsetuprunner.cpp +++ b/src/src/prefixsetuprunner.cpp @@ -220,19 +220,6 @@ static const char* DOTNET8_X64_URL = "https://download.visualstudio.microsoft.com/download/pr/136f4593-e3cd-4d52-bc25-579cdf46e80c/" "8b98c1347293b48c56c3a68d72f586a1/dotnet-runtime-8.0.12-win-x64.exe"; -static const char* DOTNET_DESKTOP6_X86_URL = - "https://download.visualstudio.microsoft.com/download/pr/cdc314df-4a4c-4709-868d-b974f336f77f/" - "acd5ab7637e456c8a3aa667661324f6d/windowsdesktop-runtime-6.0.36-win-x86.exe"; -static const char* DOTNET_DESKTOP6_X64_URL = - "https://download.visualstudio.microsoft.com/download/pr/f6b6c5dc-e02d-4738-9559-296e938dabcb/" - "b66d365729359df8e8ea131197715076/windowsdesktop-runtime-6.0.36-win-x64.exe"; - -static const QStringList DOTNET_DESKTOP6_X86_SHA256 = { - QStringLiteral("4e77bd970df0a06528ee88d33e4a8c9fb85beedbdd7219b017083acf0c3aa39e"), -}; -static const QStringList DOTNET_DESKTOP6_X64_SHA256 = { - QStringLiteral("0d20debb26fc8b2bc84f25fbd9d4596a6364af8517ebf012e8b871127b798941"), -}; static const QStringList DOTNET6_X86_SHA256 = { QStringLiteral("3b3cb4636251a582158f4b6b340f20b3861e6793eb9a3e64bda29cbf32da3604"), }; @@ -682,13 +669,6 @@ void PrefixSetupRunner::buildStepList() // Runtime installers (run via Wine). addStep("vcrun2022", "Visual C++ 2022", [this] { return stepVcrun2022(); }); - addStep("dotnetdesktop6", ".NET Desktop Runtime 6", - [this] { - return stepDotNetInstallPair( - DOTNET_DESKTOP6_X86_URL, DOTNET_DESKTOP6_X64_URL, - ".NET Desktop 6", DOTNET_DESKTOP6_X86_SHA256, - DOTNET_DESKTOP6_X64_SHA256); - }); addStep("dotnet_runtimes", ".NET Runtimes (6-9)", [this] { return stepDotNetRuntimes(); }); addStep("dotnet10_sdk", ".NET 10 SDK", 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 <QDir> #include <QFile> #include <QFileInfo> +#include <QNetworkRequest> #include <QNetworkAccessManager> #include <QNetworkReply> #include <QProcess> +#include <QTemporaryDir> #include <QTimer> #include <QEventLoop> #include <uibase/log.h> @@ -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<void(float)>& progressCb, const std::function<void(const QString&)>& statusCb, const int* cancelFlag) @@ -208,20 +301,12 @@ QString downloadSlr(const std::function<void(float)>& 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<void(float)>& 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<void(float)>& 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<void(float)>& 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 <QString> #include <functional> +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. diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 5176614..5fa170e 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -12,6 +12,7 @@ #include <cstdint> #include <cstring> #include <filesystem> +#include <string_view> #include <system_error> #include <utility> @@ -1380,6 +1381,54 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) const auto lr = lookupChild(ctx, parent, name); if (!lr.found) { + // USVFS-parity: games (e.g. Starfield) sometimes skip mkdir for intermediate + // directories and go straight to CreateFile("ShaderCache/Lighting/X.pso"). + // USVFS intercepts at Win32 level and auto-creates parent dirs in overwrite. + // FUSE sits below the kernel path-resolver, so ENOENT here prevents mo2_create + // from ever being called. Work around it: if the requested name looks like a + // directory (no '.' — file-like names are excluded to avoid spurious dirs), + // auto-create it in staging so the kernel can continue resolving the path. + // createDirectory uses create_directories internally, so staging parent dirs + // are also created if the parent came from overwrite rather than this session. + const std::string_view nameView(name); + if (nameView.find('.') == std::string_view::npos) { + bool parentOk = false; + const std::string parentPath = inodeToPath(ctx, parent, &parentOk); + if (parentOk) { + const std::string childName = canonicalChildName(ctx, parentPath, name); + const std::string childPath = joinPath(parentPath, childName); + std::error_code createErr; + if (ctx->overwrite->createDirectory(childPath, &createErr)) { + { + std::unique_lock lock(ctx->tree_mutex); + ctx->tree->root.insertDirectory(splitPath(childPath)); + ++ctx->tree->dir_count; + invalidateNodeCache(ctx, childPath); + } + invalidateDirCache(ctx, parentPath); + fuse_ino_t dirIno; + { + std::unique_lock lock(ctx->inode_mutex); + dirIno = ctx->inodes->getOrCreate(childPath); + } + struct fuse_entry_param e; + std::memset(&e, 0, sizeof(e)); + e.ino = dirIno; + e.attr_timeout = TTL_SECONDS; + e.entry_timeout = TTL_SECONDS; + fillStatForDir(&e.attr, dirIno, ctx->uid, ctx->gid); + { + std::scoped_lock cacheLock(ctx->lookup_cache_mutex); + ctx->lookup_cache[cacheKey] = + Mo2FsContext::LookupCacheEntry{.child_ino=dirIno, .entry=e}; + } + std::fprintf(stderr, "[VFS] auto-mkdir staging: %s\n", childPath.c_str()); + fuse_reply_entry(req, &e); + return; + } + } + } + struct fuse_entry_param e; std::memset(&e, 0, sizeof(e)); e.ino = 0; @@ -1849,6 +1898,9 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) openMtime = std::chrono::system_clock::now(); updateFileNodeKnown(ctx, path, realPath, originForPath(ctx, realPath), openSize, openMtime); + // Flush stale kernel page cache so the next read sees the truncated + // content, not the pre-truncation data cached from a prior open. + fuse_lowlevel_notify_inval_inode(ctx->session, ino, 0, 0); } } else { // Mod file disappeared — fall through to normal handling @@ -1868,6 +1920,7 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) openMtime = std::chrono::system_clock::now(); updateFileNodeKnown(ctx, path, realPath, originForPath(ctx, realPath), openSize, openMtime); + fuse_lowlevel_notify_inval_inode(ctx->session, ino, 0, 0); } } else if (fd < 0 && truncateOnOpen) { try { @@ -1887,6 +1940,10 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) openMtime = std::chrono::system_clock::now(); updateFileNodeKnown(ctx, path, newPath, originForPath(ctx, newPath), openSize, openMtime); + // The file's backing path changed from mod/base-game to staging. + // Flush the kernel page cache so subsequent reads go through FUSE + // and see the staging content, not the pre-COW cached data. + fuse_lowlevel_notify_inval_inode(ctx->session, ino, 0, 0); } } catch (...) { fuse_reply_err(req, EIO); @@ -2016,7 +2073,7 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off, fuse_reply_data(req, &buf, FUSE_BUF_SPLICE_MOVE); } -void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, +void mo2_write(fuse_req_t req, fuse_ino_t ino, const char* buf, size_t size, off_t off, struct fuse_file_info* fi) { Mo2FsContext* ctx = getContext(req); @@ -2089,6 +2146,13 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, } realPath = newPath; updateFileNode(ctx, relativePath, newPath, originForPath(ctx, newPath)); + // Lazy COW: backing path just changed to staging. Flush the kernel page + // cache so the caller's next read sees staging content, not the stale + // pre-COW pages. This is the counterpart to the O_TRUNC invalidation in + // mo2_open — without it, repeated WritePrivateProfileString calls on the + // same INI file each read the old mod-file content and only the last + // write survives. + fuse_lowlevel_notify_inval_inode(ctx->session, ino, 0, 0); } catch (...) { fuse_reply_err(req, EIO); return; diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index f8d5330..e717825 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -173,6 +173,10 @@ struct Mo2FsContext uid_t uid = 0; gid_t gid = 0; + // Set once after fuse_session_new(); used by notify_inval_inode calls to + // flush stale kernel page cache without needing fuse_req_session(req). + struct fuse_session* session = nullptr; + }; void mo2_init(void* userdata, struct fuse_conn_info* conn); diff --git a/src/tests/test_vfs_file_ops.cpp b/src/tests/test_vfs_file_ops.cpp index 9a37f52..56f516a 100644 --- a/src/tests/test_vfs_file_ops.cpp +++ b/src/tests/test_vfs_file_ops.cpp @@ -92,7 +92,7 @@ TEST(VfsFileOps, CreateFileReturnsWritableStagingHandle) OverwriteManager overwriteManager(staging.string(), overwrite.string()); std::string realPath; - const int fd = overwriteManager.createFile("ShaderCache/Lighting/test.pso", + const int fd = overwriteManager.createFile("Data/ShaderCache/Lighting/test.pso", 0600, &realPath); ASSERT_GE(fd, 0); @@ -100,10 +100,32 @@ TEST(VfsFileOps, CreateFileReturnsWritableStagingHandle) ASSERT_EQ(write(fd, payload, sizeof(payload) - 1), ssize_t(sizeof(payload) - 1)); close(fd); - EXPECT_EQ(fs::path(realPath), staging / "ShaderCache/Lighting/test.pso"); + EXPECT_EQ(fs::path(realPath), staging / "Data/ShaderCache/Lighting/test.pso"); EXPECT_EQ(readText(realPath), "shader"); } +TEST(VfsFileOps, CreateShaderCacheFileReusesExistingCaseVariant) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + + const fs::path staging = tmp.path() / "staging"; + const fs::path overwrite = tmp.path() / "overwrite"; + fs::create_directories(staging / "Data" / "ShaderCache" / "Lighting"); + + OverwriteManager overwriteManager(staging.string(), overwrite.string()); + std::error_code ec; + std::string realPath; + const int fd = overwriteManager.createFile("data/shadercache/lighting/test.pso", + 0600, &realPath, &ec); + ASSERT_GE(fd, 0); + ASSERT_FALSE(ec); + close(fd); + + EXPECT_EQ(fs::path(realPath), + staging / "Data" / "ShaderCache" / "Lighting" / "test.pso"); +} + TEST(VfsFileOps, CreateFileReusesExistingCaseVariant) { TempRoot tmp; |
