aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt3
-rw-r--r--libs/installer_fomod/src/fomodinstallerdialog.cpp21
-rw-r--r--libs/installer_fomod/src/installerfomod.cpp15
-rw-r--r--src/src/fuseconnector.cpp1
-rw-r--r--src/src/mainwindow.cpp21
-rw-r--r--src/src/organizercore.cpp174
-rw-r--r--src/src/prefixsetuprunner.cpp20
-rw-r--r--src/src/slrmanager.cpp139
-rw-r--r--src/src/slrmanager.h13
-rw-r--r--src/src/vfs/mo2filesystem.cpp66
-rw-r--r--src/src/vfs/mo2filesystem.h4
-rw-r--r--src/tests/test_vfs_file_ops.cpp26
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;