aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 16:09:59 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 16:09:59 -0500
commit96a6ba196c72e955c3db822d93da38fce8cd7045 (patch)
tree3fcc40e939bd609c4cb7b84f05e85875dde64fc6 /src
parent6b9f0876cfbf6f8c723c86ec941ee70933aa883f (diff)
Fix VFS rmdir, prefix/launch issues, add version + beta channel
- VFS: add mo2_rmdir + OverwriteManager::removeDirectory so Wrye Bash shutil.rmtree stops failing with WinError 267 (issue #47). - FileRenamer: case-insensitive fallback via new resolvePathCaseInsensitive walks each path component; bulk hide in Conflict tab no longer dies with a misleading "Input/output error" when DirectoryEntry normalised parent dir case (issue #54). formatSystemMessage stops aliasing Windows error code 5 to errno 5 (EIO). - Versioning: FLUORINE_VERSION_* + fluorine_build_info.h generated from CMake; Linux createVersionInfo returns Fluorine's version. About dialog shows Fluorine version + MO2 engine + commit. Beta builds stamp "<semver>B<yyyymmddhhmm>" (issue #51). - CI: ci.yml determines channel from ref (v* tags = stable, main = beta), publishes a rolling `beta` GitHub release whose body carries a machine-parseable fluorine-meta block (timestamp, commit) for the in-app updater. - Updater: new FluorineUpdater polls GitHub API on startup, compares against embedded metadata, notifies on new release without auto-install. Channel toggle piggybacks on the existing "beta versions" checkbox. - Bethesda plugins: determineMyGamesPath no longer creates Documents/My Games/<GameName> for every possible title at plugin load; only games with a detected install path get their dirs created (issue #55). - Starfield: dataDirectory() on Linux now points at <game>/Data instead of My Games/Starfield/Data so the single FUSE mount lands where SFSE loads plugins; My Games Data becomes a secondary symlinked mapping (issue #56). - Prefix resolution: prefer Fluorine config, then explicit fluorine/prefix_path, then legacy Settings/* keys (with a warning) so auto-detected Heroic/Bottles prefixes can't silently override a user- configured Fluorine prefix (issue #52). compatDataPathFromPrefix no longer hands Proton the wrong parent dir for plain wine prefixes. - xrandr: ensureXrandrInstalled back-fills the helper for existing SLR installs, prefix init runs it before wineboot, SLR wrap exposes xrandr-bin via --filesystem= and prepends PATH inside the container (issue #49). - Process tracking: drop the wineserver-as-last-resort fallback that made MO2 hang on Proton's session manager after game exit. Rescan the prefix for matching game executables instead (covers reparented launcher children like f4se_loader → Fallout4.exe). Unlock button now kills wineserver for the prefix (SIGTERM then SIGKILL on timeout). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/aboutdialog.cpp25
-rw-r--r--src/src/filerenamer.cpp37
-rw-r--r--src/src/fluorine_build_info.h.in24
-rw-r--r--src/src/fluorineupdater.cpp303
-rw-r--r--src/src/fluorineupdater.h75
-rw-r--r--src/src/fuseconnector.cpp1
-rw-r--r--src/src/organizercore.cpp49
-rw-r--r--src/src/organizercore.h2
-rw-r--r--src/src/prefixsetuprunner.cpp42
-rw-r--r--src/src/processrunner.cpp167
-rw-r--r--src/src/protonlauncher.cpp19
-rw-r--r--src/src/settings.cpp17
-rw-r--r--src/src/settings.h6
-rw-r--r--src/src/settingsdialoggeneral.cpp14
-rw-r--r--src/src/shared/util.cpp14
-rw-r--r--src/src/slrmanager.cpp141
-rw-r--r--src/src/slrmanager.h13
-rw-r--r--src/src/spawn.cpp33
-rw-r--r--src/src/vfs/mo2filesystem.cpp66
-rw-r--r--src/src/vfs/mo2filesystem.h1
-rw-r--r--src/src/vfs/overwritemanager.cpp29
-rw-r--r--src/src/vfs/overwritemanager.h1
23 files changed, 995 insertions, 86 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index e5a8230..e59c624 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -90,6 +90,8 @@ set_target_properties(organizer PROPERTIES
target_include_directories(organizer PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
+ # Generated Fluorine build-info header lives alongside other generated files.
+ ${CMAKE_BINARY_DIR}/generated
# Bare includes: source uses <iplugin*.h>, <log.h>, etc. without uibase/ prefix
${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase
${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase/game_features)
diff --git a/src/src/aboutdialog.cpp b/src/src/aboutdialog.cpp
index 94ccfcb..421b976 100644
--- a/src/src/aboutdialog.cpp
+++ b/src/src/aboutdialog.cpp
@@ -20,6 +20,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "aboutdialog.h"
#include "shared/util.h"
#include "ui_aboutdialog.h"
+#include <fluorine_build_info.h>
#include <utility.h>
#include <QApplication>
@@ -78,17 +79,31 @@ AboutDialog::AboutDialog(const QString& version, QWidget* parent)
addLicense("DXTex Headers", LICENSE_DXTEX);
addLicense("Valve File VDF Reader", LICENSE_VDF);
+ // Show Fluorine Manager's own version (e.g. "0.1.4" stable or "0.1.4B202604231800"
+ // for beta builds) instead of the upstream MO2 engine version. The incoming
+ // `version` parameter is the engine version string and is retained on the
+ // revision line for reference.
+ QString displayVersion = QStringLiteral(FLUORINE_DISPLAY_VERSION);
+ if (FLUORINE_IS_BETA_BUILD) {
+ displayVersion += QStringLiteral(" (beta)");
+ }
ui->nameLabel->setText(
QString("<span style=\"font-size:12pt; font-weight:600;\">%1 %2</span>")
.arg(ui->nameLabel->text())
- .arg(version));
+ .arg(displayVersion));
+
+ QString revision = ui->revisionLabel->text() +
+ QStringLiteral(" MO2 engine ") + version;
+ const QString commit = QStringLiteral(FLUORINE_BUILD_COMMIT);
+ if (!commit.isEmpty()) {
+ revision += QStringLiteral(" · ") + commit;
+ }
#if defined(HGID)
- ui->revisionLabel->setText(ui->revisionLabel->text() + " " + HGID);
+ revision += QStringLiteral(" · ") + HGID;
#elif defined(GITID)
- ui->revisionLabel->setText(ui->revisionLabel->text() + " " + GITID);
-#else
- ui->revisionLabel->setText(ui->revisionLabel->text() + " unknown");
+ revision += QStringLiteral(" · ") + GITID;
#endif
+ ui->revisionLabel->setText(revision);
ui->usvfsLabel->setText(ui->usvfsLabel->text() + " FUSE 3");
ui->licenseText->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
diff --git a/src/src/filerenamer.cpp b/src/src/filerenamer.cpp
index 7319a63..1b61f7b 100644
--- a/src/src/filerenamer.cpp
+++ b/src/src/filerenamer.cpp
@@ -1,6 +1,7 @@
#include "filerenamer.h"
#include <QFileInfo>
#include <QMessageBox>
+#include <filesystemutilities.h>
#include <log.h>
#include <utility.h>
@@ -22,7 +23,29 @@ FileRenamer::RenameResults FileRenamer::rename(const QString& oldName,
{
log::debug("renaming {} to {}", oldName, newName);
- if (QFileInfo(newName).exists()) {
+ // Case-sensitivity safety net: if oldName doesn't resolve on disk but a
+ // case-folded variant does, retarget the rename to the real path. This
+ // hits when the source path comes from MO2's case-normalized
+ // DirectoryEntry tree (e.g. Conflict tab bulk-hide) while the real
+ // filesystem has different casing for parent dirs.
+ QString effectiveOld = oldName;
+ QString effectiveNew = newName;
+ if (!QFileInfo::exists(effectiveOld)) {
+ const QString resolved = MOBase::resolvePathCaseInsensitive(oldName);
+ if (resolved != oldName && QFileInfo::exists(resolved)) {
+ log::debug("resolved case-mismatched source '{}' -> '{}'", oldName,
+ resolved);
+ effectiveOld = resolved;
+
+ // Keep the destination's directory component aligned with the resolved
+ // source so we don't create a parallel lowercase directory tree.
+ const QFileInfo oldInfo(resolved);
+ const QFileInfo newInfo(newName);
+ effectiveNew = oldInfo.absolutePath() + '/' + newInfo.fileName();
+ }
+ }
+
+ if (QFileInfo(effectiveNew).exists()) {
log::debug("{} already exists", newName);
// target file already exists, confirm replacement
@@ -69,25 +92,27 @@ FileRenamer::RenameResults FileRenamer::rename(const QString& oldName,
}
// target either didn't exist or was removed correctly
- const auto r = shell::Rename(QFileInfo(oldName), QFileInfo(newName));
+ const auto r =
+ shell::Rename(QFileInfo(effectiveOld), QFileInfo(effectiveNew));
if (!r.success()) {
- log::error("failed to rename '{}' to '{}': {}", oldName, newName, r.toString());
+ log::error("failed to rename '{}' to '{}': {}", effectiveOld, effectiveNew,
+ r.toString());
// renaming failed, warn the user and allow canceling
- if (!renameFailed(oldName, newName, r)) {
+ if (!renameFailed(effectiveOld, effectiveNew, r)) {
// user wants to cancel
log::debug("canceling");
return RESULT_CANCEL;
}
// ignore this file and continue on
- log::debug("skipping {}", oldName);
+ log::debug("skipping {}", effectiveOld);
return RESULT_SKIP;
}
// everything worked
- log::debug("successfully renamed {} to {}", oldName, newName);
+ log::debug("successfully renamed {} to {}", effectiveOld, effectiveNew);
return RESULT_OK;
}
diff --git a/src/src/fluorine_build_info.h.in b/src/src/fluorine_build_info.h.in
new file mode 100644
index 0000000..32e440e
--- /dev/null
+++ b/src/src/fluorine_build_info.h.in
@@ -0,0 +1,24 @@
+#ifndef FLUORINE_BUILD_INFO_H
+#define FLUORINE_BUILD_INFO_H
+
+// Generated by CMake from fluorine_build_info.h.in — do not edit.
+
+#define FLUORINE_VERSION_MAJOR @FLUORINE_VERSION_MAJOR@
+#define FLUORINE_VERSION_MINOR @FLUORINE_VERSION_MINOR@
+#define FLUORINE_VERSION_PATCH @FLUORINE_VERSION_PATCH@
+#define FLUORINE_VERSION_STRING "@FLUORINE_VERSION_MAJOR@.@FLUORINE_VERSION_MINOR@.@FLUORINE_VERSION_PATCH@"
+
+#define FLUORINE_BUILD_CHANNEL "@FLUORINE_BUILD_CHANNEL@"
+#define FLUORINE_BUILD_TIMESTAMP "@FLUORINE_BUILD_TIMESTAMP@"
+#define FLUORINE_BUILD_COMMIT "@FLUORINE_BUILD_COMMIT@"
+#define FLUORINE_IS_BETA_BUILD @FLUORINE_IS_BETA_BUILD@
+
+// Full display string. For beta builds: "<semver>B<timestamp>".
+// For stable builds: plain "<semver>".
+#if FLUORINE_IS_BETA_BUILD
+# define FLUORINE_DISPLAY_VERSION FLUORINE_VERSION_STRING "B" FLUORINE_BUILD_TIMESTAMP
+#else
+# define FLUORINE_DISPLAY_VERSION FLUORINE_VERSION_STRING
+#endif
+
+#endif // FLUORINE_BUILD_INFO_H
diff --git a/src/src/fluorineupdater.cpp b/src/src/fluorineupdater.cpp
new file mode 100644
index 0000000..dd96eb9
--- /dev/null
+++ b/src/src/fluorineupdater.cpp
@@ -0,0 +1,303 @@
+#include "fluorineupdater.h"
+
+#include <fluorine_build_info.h>
+#include <log.h>
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QRegularExpression>
+#include <QScopeGuard>
+#include <QStringList>
+#include <QUrl>
+
+#include <array>
+
+namespace
+{
+// Hardcoded for now — if the project ever moves, these become CMake-configured
+// constants like the version info. Keeping them here avoids wiring a second
+// generated header just for two strings.
+constexpr const char* kRepoOwner = "SulfurNitride";
+constexpr const char* kRepoProject = "Fluorine-Manager";
+
+QString buildApiUrl(FluorineUpdater::Channel c)
+{
+ const QString base =
+ QStringLiteral("https://api.github.com/repos/%1/%2/releases")
+ .arg(kRepoOwner, kRepoProject);
+ return c == FluorineUpdater::Channel::Stable
+ ? base + QStringLiteral("/latest")
+ : base + QStringLiteral("/tags/beta");
+}
+
+// Returns {major, minor, patch}; components that fail to parse stay at -1.
+std::array<int, 3> parseSemver(const QString& tagOrVersion)
+{
+ QString s = tagOrVersion.trimmed();
+ if (s.startsWith('v') || s.startsWith('V')) {
+ s.remove(0, 1);
+ }
+
+ std::array<int, 3> out{-1, -1, -1};
+ const QStringList parts = s.split('.', Qt::SkipEmptyParts);
+ for (int i = 0; i < 3 && i < parts.size(); ++i) {
+ bool ok = false;
+ // Drop any prerelease suffix like "0.1.5-beta.2" — we only care about
+ // the numeric triple for the coarse newer/older decision.
+ QString num = parts[i];
+ const int dashIdx = num.indexOf(QRegularExpression("[^0-9]"));
+ if (dashIdx >= 0) {
+ num = num.left(dashIdx);
+ }
+ const int v = num.toInt(&ok);
+ if (ok) {
+ out[i] = v;
+ }
+ }
+ return out;
+}
+
+bool isStableNewerThan(const QString& releaseTag, const QString& currentSemver)
+{
+ const auto a = parseSemver(releaseTag);
+ const auto b = parseSemver(currentSemver);
+ for (int i = 0; i < 3; ++i) {
+ if (a[i] != b[i]) {
+ return a[i] > b[i];
+ }
+ }
+ return false;
+}
+} // namespace
+
+FluorineUpdater::FluorineUpdater(QObject* parent)
+ : QObject(parent), m_net(new QNetworkAccessManager(this))
+{}
+
+FluorineUpdater::~FluorineUpdater() = default;
+
+FluorineUpdater::Channel FluorineUpdater::buildChannel()
+{
+#if FLUORINE_IS_BETA_BUILD
+ return Channel::Beta;
+#else
+ return Channel::Stable;
+#endif
+}
+
+QString FluorineUpdater::channelToString(Channel c)
+{
+ return c == Channel::Beta ? QStringLiteral("beta") : QStringLiteral("stable");
+}
+
+FluorineUpdater::Channel FluorineUpdater::channelFromString(const QString& s,
+ Channel fallback)
+{
+ if (s.compare(QStringLiteral("beta"), Qt::CaseInsensitive) == 0) {
+ return Channel::Beta;
+ }
+ if (s.compare(QStringLiteral("stable"), Qt::CaseInsensitive) == 0) {
+ return Channel::Stable;
+ }
+ return fallback;
+}
+
+void FluorineUpdater::checkForUpdates(Channel channel)
+{
+ if (m_reply != nullptr) {
+ // A check is already in flight — cancel and restart with the new channel
+ // so the caller's Settings toggle wins over any stale startup probe.
+ m_reply->abort();
+ m_reply->deleteLater();
+ m_reply = nullptr;
+ }
+
+ m_pendingChannel = channel;
+
+ QNetworkRequest req{QUrl(buildApiUrl(channel))};
+ req.setRawHeader("User-Agent", "Fluorine-Manager/updater");
+ req.setRawHeader("Accept", "application/vnd.github+json");
+ req.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+
+ m_reply = m_net->get(req);
+ connect(m_reply, &QNetworkReply::finished, this,
+ &FluorineUpdater::onReplyFinished);
+}
+
+void FluorineUpdater::onReplyFinished()
+{
+ if (m_reply == nullptr) {
+ return;
+ }
+
+ QNetworkReply* reply = m_reply;
+ m_reply = nullptr;
+
+ const Channel channel = m_pendingChannel;
+ auto cleanup = qScopeGuard([reply] { reply->deleteLater(); });
+
+ if (reply->error() != QNetworkReply::NoError) {
+ const QString err = reply->errorString();
+ MOBase::log::warn("update check failed ({}): {}",
+ channelToString(channel), err);
+ emit checkFailed(err);
+ return;
+ }
+
+ const QByteArray raw = reply->readAll();
+ const QJsonDocument doc = QJsonDocument::fromJson(raw);
+ if (!doc.isObject()) {
+ emit checkFailed(tr("GitHub returned an unexpected response"));
+ return;
+ }
+
+ ReleaseInfo info;
+ info.channel = channel;
+ const QJsonObject obj = doc.object();
+
+ bool ok = false;
+ if (channel == Channel::Beta) {
+ ok = parseBetaRelease(obj, info);
+ } else {
+ ok = parseStableRelease(obj, info);
+ }
+
+ if (!ok) {
+ emit checkFailed(tr("Unable to parse release metadata"));
+ return;
+ }
+
+ if (channel == Channel::Beta) {
+ const QString currentTs = QStringLiteral(FLUORINE_BUILD_TIMESTAMP);
+ const QString currentCommit = QStringLiteral(FLUORINE_BUILD_COMMIT);
+
+ // If the fluorine-meta block is missing, fall back to timestamp-only
+ // comparison. If even the timestamp is empty, we can't make a safe
+ // decision — surface as checkFailed so the user knows.
+ if (info.timestamp.isEmpty()) {
+ emit checkFailed(
+ tr("Beta release is missing build metadata; cannot compare"));
+ return;
+ }
+
+ const bool sameCommit = !currentCommit.isEmpty() && !info.commit.isEmpty() &&
+ currentCommit == info.commit;
+ const bool sameTimestamp = !currentTs.isEmpty() && currentTs == info.timestamp;
+
+ if (sameCommit || sameTimestamp) {
+ emit upToDate(info);
+ } else {
+ emit updateAvailable(info);
+ }
+ } else {
+ const QString currentVersion = QStringLiteral(FLUORINE_VERSION_STRING);
+ if (isStableNewerThan(info.versionString, currentVersion)) {
+ emit updateAvailable(info);
+ } else {
+ emit upToDate(info);
+ }
+ }
+}
+
+bool FluorineUpdater::parseStableRelease(const QJsonObject& obj,
+ ReleaseInfo& out) const
+{
+ out.tagName = obj.value(QStringLiteral("tag_name")).toString();
+ out.name = obj.value(QStringLiteral("name")).toString();
+ out.htmlUrl = obj.value(QStringLiteral("html_url")).toString();
+
+ if (out.tagName.isEmpty()) {
+ return false;
+ }
+
+ out.versionString = out.tagName;
+ if (out.versionString.startsWith('v') || out.versionString.startsWith('V')) {
+ out.versionString.remove(0, 1);
+ }
+
+ // Pick the first tar.gz asset as the download target.
+ const QJsonArray assets = obj.value(QStringLiteral("assets")).toArray();
+ for (const QJsonValue& v : assets) {
+ const QJsonObject a = v.toObject();
+ const QString name = a.value(QStringLiteral("name")).toString();
+ if (name.endsWith(QStringLiteral(".tar.gz"), Qt::CaseInsensitive)) {
+ out.downloadUrl =
+ a.value(QStringLiteral("browser_download_url")).toString();
+ break;
+ }
+ }
+ return true;
+}
+
+bool FluorineUpdater::parseBetaRelease(const QJsonObject& obj,
+ ReleaseInfo& out) const
+{
+ out.tagName = obj.value(QStringLiteral("tag_name")).toString();
+ out.name = obj.value(QStringLiteral("name")).toString();
+ out.htmlUrl = obj.value(QStringLiteral("html_url")).toString();
+
+ // Extract fluorine-meta block from release body. Format emitted by the CI
+ // workflow:
+ // <!-- fluorine-meta
+ // channel=beta
+ // timestamp=YYYYMMDDHHMM
+ // commit=<full sha>
+ // short=<7-char sha>
+ // -->
+ const QString body = obj.value(QStringLiteral("body")).toString();
+ const int metaStart = body.indexOf(QStringLiteral("<!-- fluorine-meta"));
+ if (metaStart < 0) {
+ MOBase::log::debug("beta release body missing fluorine-meta block");
+ return true; // partially parsed — let caller surface checkFailed
+ }
+ const int metaEnd = body.indexOf(QStringLiteral("-->"), metaStart);
+ const QString block =
+ metaEnd > metaStart ? body.mid(metaStart, metaEnd - metaStart) : QString();
+
+ const QStringList lines =
+ block.split(QRegularExpression(QStringLiteral("[\\r\\n]+")),
+ Qt::SkipEmptyParts);
+ for (const QString& line : lines) {
+ const int eq = line.indexOf('=');
+ if (eq <= 0) {
+ continue;
+ }
+ const QString key = line.left(eq).trimmed();
+ const QString value = line.mid(eq + 1).trimmed();
+ if (key == QStringLiteral("timestamp")) {
+ out.timestamp = value;
+ } else if (key == QStringLiteral("commit")) {
+ out.commit = value;
+ }
+ }
+
+ // Prefer a tar.gz asset with "beta" in the name, else first tar.gz.
+ const QJsonArray assets = obj.value(QStringLiteral("assets")).toArray();
+ QString fallback;
+ for (const QJsonValue& v : assets) {
+ const QJsonObject a = v.toObject();
+ const QString name = a.value(QStringLiteral("name")).toString();
+ if (!name.endsWith(QStringLiteral(".tar.gz"), Qt::CaseInsensitive)) {
+ continue;
+ }
+ const QString url =
+ a.value(QStringLiteral("browser_download_url")).toString();
+ if (name.contains(QStringLiteral("beta"), Qt::CaseInsensitive)) {
+ out.downloadUrl = url;
+ break;
+ }
+ if (fallback.isEmpty()) {
+ fallback = url;
+ }
+ }
+ if (out.downloadUrl.isEmpty()) {
+ out.downloadUrl = fallback;
+ }
+ return true;
+}
diff --git a/src/src/fluorineupdater.h b/src/src/fluorineupdater.h
new file mode 100644
index 0000000..a573584
--- /dev/null
+++ b/src/src/fluorineupdater.h
@@ -0,0 +1,75 @@
+#ifndef FLUORINE_UPDATER_H
+#define FLUORINE_UPDATER_H
+
+#include <QJsonObject>
+#include <QObject>
+#include <QString>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// Lightweight self-update checker. Queries the GitHub Releases API for
+// Fluorine Manager and notifies when a newer build is available. Does not
+// perform auto-install — the user is given a download URL to apply manually,
+// matching the existing MO2 updater UX.
+//
+// Two channels:
+// stable: fetches the latest tagged `v*` release and compares against the
+// build's FLUORINE_VERSION_STRING as a semver-ish triple.
+// beta: fetches the rolling `beta` tag's release and compares the
+// fluorine-meta timestamp/commit block against the installed build.
+class FluorineUpdater : public QObject
+{
+ Q_OBJECT
+
+public:
+ enum class Channel
+ {
+ Stable,
+ Beta,
+ };
+
+ struct ReleaseInfo
+ {
+ Channel channel = Channel::Stable;
+ QString tagName; // "v0.1.5" or "beta"
+ QString name; // release title
+ QString htmlUrl; // release HTML page
+ QString downloadUrl; // first .tar.gz asset URL (may be empty)
+ QString timestamp; // beta only: "YYYYMMDDHHMM"
+ QString commit; // beta only: full commit SHA (from fluorine-meta)
+ QString versionString; // stable only: tag minus leading 'v'
+ };
+
+ explicit FluorineUpdater(QObject* parent = nullptr);
+ ~FluorineUpdater() override;
+
+ // Kick off an async check. Emits updateAvailable()/upToDate()/checkFailed()
+ // exactly once per call.
+ void checkForUpdates(Channel channel);
+
+ // Build channel that was baked into this binary at compile time. The
+ // Settings toggle defaults to this value.
+ static Channel buildChannel();
+
+ static QString channelToString(Channel c);
+ static Channel channelFromString(const QString& s, Channel fallback);
+
+signals:
+ void updateAvailable(const FluorineUpdater::ReleaseInfo& info);
+ void upToDate(const FluorineUpdater::ReleaseInfo& info);
+ void checkFailed(const QString& reason);
+
+private slots:
+ void onReplyFinished();
+
+private:
+ bool parseBetaRelease(const QJsonObject& obj, ReleaseInfo& out) const;
+ bool parseStableRelease(const QJsonObject& obj, ReleaseInfo& out) const;
+
+ QNetworkAccessManager* m_net;
+ QNetworkReply* m_reply = nullptr;
+ Channel m_pendingChannel = Channel::Stable;
+};
+
+#endif // FLUORINE_UPDATER_H
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 3e327bc..705bed8 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -174,6 +174,7 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->setattr = mo2_setattr;
ops->unlink = mo2_unlink;
ops->mkdir = mo2_mkdir;
+ ops->rmdir = mo2_rmdir;
ops->release = mo2_release;
ops->releasedir = mo2_releasedir;
// access handler removed: default_permissions mount option lets the kernel
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index c1cb9aa..96b3264 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -1,6 +1,7 @@
#include "organizercore.h"
#include "categoriesdialog.h"
#include "credentialsdialog.h"
+#include "fluorineupdater.h"
#include "delayedfilewriter.h"
#include "directoryrefresher.h"
#include "env.h"
@@ -114,9 +115,19 @@ QString resolveWinePrefixPath(const Settings& settings,
return cfg->prefix_path.trimmed();
}
+ // Same precedence rule as spawn.cpp's resolvePrefixPath: explicit
+ // fluorine/prefix_path wins over the legacy Settings/* keys, which may
+ // have been auto-populated with an external manager's prefix (Heroic,
+ // Bottles). Without this, switching instances or rebuilding the Fluorine
+ // config can silently drop us onto the wrong prefix (issue #52).
const QSettings instanceSettings(settings.filename(), QSettings::IniFormat);
+ const QString explicitPath =
+ instanceSettings.value("fluorine/prefix_path").toString().trimmed();
+ if (!explicitPath.isEmpty()) {
+ return explicitPath;
+ }
for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path",
- "Proton/prefix_path", "fluorine/prefix_path"}) {
+ "Proton/prefix_path"}) {
const QString value = instanceSettings.value(key).toString().trimmed();
if (!value.isEmpty()) {
return value;
@@ -389,9 +400,45 @@ void OrganizerCore::checkForUpdates()
// display the result
if (m_UserInterface != nullptr) {
m_Updater.testForUpdate(m_Settings);
+ checkForFluorineUpdates();
}
}
+void OrganizerCore::checkForFluorineUpdates()
+{
+ // Set up the Fluorine self-update checker lazily so repeated calls don't
+ // leak QNetworkAccessManager instances. The member is forward-declared in
+ // the header (pointer-only); the include lives here to keep the header
+ // lightweight for its many consumers.
+ if (m_FluorineUpdater == nullptr) {
+ m_FluorineUpdater = new FluorineUpdater(this);
+
+ connect(m_FluorineUpdater, &FluorineUpdater::updateAvailable, this,
+ [](const FluorineUpdater::ReleaseInfo& info) {
+ const QString channel =
+ FluorineUpdater::channelToString(info.channel);
+ MOBase::log::info(
+ "Fluorine update available ({}): {} at {}",
+ channel,
+ info.tagName.isEmpty() ? info.name : info.tagName,
+ info.htmlUrl);
+ });
+ connect(m_FluorineUpdater, &FluorineUpdater::upToDate, this,
+ [](const FluorineUpdater::ReleaseInfo& info) {
+ MOBase::log::debug("Fluorine is up to date ({})",
+ FluorineUpdater::channelToString(info.channel));
+ });
+ connect(m_FluorineUpdater, &FluorineUpdater::checkFailed, this,
+ [](const QString& reason) {
+ MOBase::log::debug("Fluorine update check failed: {}", reason);
+ });
+ }
+
+ const FluorineUpdater::Channel channel = FluorineUpdater::channelFromString(
+ m_Settings.fluorineUpdateChannel(), FluorineUpdater::buildChannel());
+ m_FluorineUpdater->checkForUpdates(channel);
+}
+
void OrganizerCore::connectPlugins(PluginContainer* container)
{
m_PluginContainer = container;
diff --git a/src/src/organizercore.h b/src/src/organizercore.h
index dcf07c7..2ff7484 100644
--- a/src/src/organizercore.h
+++ b/src/src/organizercore.h
@@ -257,6 +257,7 @@ public:
void updateModInfoFromDisc();
void checkForUpdates();
+ void checkForFluorineUpdates();
void startMOUpdate();
Settings& settings();
@@ -557,6 +558,7 @@ private:
Settings& m_Settings;
SelfUpdater m_Updater;
+ class FluorineUpdater* m_FluorineUpdater = nullptr;
SignalAboutToRunApplication m_AboutToRun;
SignalFinishedRunApplication m_FinishedRun;
diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp
index 8543437..8bca32e 100644
--- a/src/src/prefixsetuprunner.cpp
+++ b/src/src/prefixsetuprunner.cpp
@@ -3,6 +3,7 @@
#include "fluorinepaths.h"
#include "gamedetection.h"
+#include "slrmanager.h"
#include "steamdetection.h"
#include "prefixsymlinks.h"
@@ -592,6 +593,19 @@ QProcess* PrefixSetupRunner::buildWrappedProcess(
restoreOrStrip("XDG_DATA_DIRS", "FLUORINE_ORIG_XDG_DATA_DIRS");
restoreOrStrip("QT_PLUGIN_PATH", "FLUORINE_ORIG_QT_PLUGIN_PATH");
+ // Expose the injected xrandr (steamrt4 ships without it) so protonfixes
+ // and Proton-GE init scripts can find it. Pressure-vessel forces PATH
+ // inside the container, so we prepend the xrandr dir on the HOST PATH
+ // and also pass it through --filesystem below.
+ const QString xrandrDir =
+ QDir::homePath() + "/.local/share/fluorine/steamrt/xrandr-bin";
+ if (QDir(xrandrDir).exists()) {
+ const QString existing = env.value("PATH");
+ env.insert("PATH", existing.isEmpty()
+ ? xrandrDir
+ : xrandrDir + QLatin1Char(':') + existing);
+ }
+
// Apply caller-provided env vars.
for (auto it = extraEnv.begin(); it != extraEnv.end(); ++it) {
env.insert(it.key(), it.value());
@@ -626,7 +640,22 @@ QProcess* PrefixSetupRunner::buildWrappedProcess(
if (QDir(cacheDir).exists())
slrArgs << QStringLiteral("--filesystem=%1").arg(cacheDir);
- slrArgs << "--" << exe;
+ // Expose the injected xrandr bin dir so Proton-GE's protonfixes can
+ // invoke it during wineboot -u. Without this the container's PATH
+ // (forced to /usr/bin:/bin) has no xrandr and init fails on some
+ // modern Proton builds. See issue #49.
+ if (QDir(xrandrDir).exists())
+ slrArgs << QStringLiteral("--filesystem=%1").arg(xrandrDir);
+
+ // Pressure-vessel resets PATH inside the container. Wrap the exec
+ // through /usr/bin/env to inject the xrandr dir back into the
+ // container's PATH.
+ if (QDir(xrandrDir).exists()) {
+ slrArgs << "--" << QStringLiteral("/usr/bin/env")
+ << QStringLiteral("PATH=%1:/usr/bin:/bin").arg(xrandrDir) << exe;
+ } else {
+ slrArgs << "--" << exe;
+ }
proc->setProgram(m_slrRunScript);
proc->setArguments(slrArgs);
@@ -793,6 +822,17 @@ bool PrefixSetupRunner::stepProtonInit()
// for them. Nothing cleans them up automatically.
killStalePrefixProcesses();
+ // Proton-GE invokes `xrandr` during protonfixes at wineboot time. The
+ // steamrt4 pressure-vessel container ships without it, so back-fill our
+ // injected copy before init if missing — otherwise the protonfix
+ // silently no-ops and prefix setup can hang or fall back to a broken
+ // state on multi-monitor machines. See issue #49.
+ if (!isXrandrInjected()) {
+ emit logMessage("xrandr helper missing; downloading…");
+ ensureXrandrInstalled(
+ nullptr, [this](const QString& msg) { emit logMessage(msg); });
+ }
+
const QString steamPath = detectSteamPath();
// The compatdata path is the PARENT of the pfx directory.
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp
index 4457cdb..5a47d95 100644
--- a/src/src/processrunner.cpp
+++ b/src/src/processrunner.cpp
@@ -804,6 +804,107 @@ pid_t findTrackedProcess(pid_t rootPid, const QStringList &expected,
return best;
}
+// Scan every process owned by the current user for one whose comm or
+// cmdline matches an expected game executable (e.g. FalloutNV.exe,
+// SkyrimSE.exe). Used as a fallback after the immediate descendant tree
+// loses the game — Proton's session manager can reparent game processes
+// outside of our root PID's subtree, so a plain-descendant walk misses
+// them. Only processes inside the given WINEPREFIX are considered so we
+// don't latch onto an unrelated Wine/Proton session.
+pid_t findGameProcessInPrefix(const QStringList &expected,
+ const QString &winePrefix,
+ QString *matchedNameOut) {
+ if (expected.isEmpty()) {
+ return 0;
+ }
+
+ const uid_t myUid = ::getuid();
+ DIR *proc = opendir("/proc");
+ if (!proc) {
+ return 0;
+ }
+
+ QString expectedPrefixCanon;
+ if (!winePrefix.isEmpty()) {
+ expectedPrefixCanon = QDir(winePrefix).canonicalPath();
+ }
+
+ pid_t best = 0;
+ struct dirent *entry = nullptr;
+ while ((entry = readdir(proc)) != nullptr) {
+ if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) continue;
+ const char *name = entry->d_name;
+ if (*name == '\0' || !std::isdigit(static_cast<unsigned char>(*name)))
+ continue;
+
+ const pid_t pid = static_cast<pid_t>(std::strtol(name, nullptr, 10));
+
+ struct stat st;
+ if (::stat(QString("/proc/%1").arg(pid).toStdString().c_str(), &st) != 0 ||
+ st.st_uid != myUid) {
+ continue;
+ }
+
+ QString matched;
+ if (!processMatchesExpected(pid, expected, &matched)) {
+ continue;
+ }
+
+ // Constrain to the same WINEPREFIX so we don't latch onto an unrelated
+ // Wine process (another instance, winetricks, etc.).
+ if (!expectedPrefixCanon.isEmpty()) {
+ const QString pidPrefix = readProcEnvVar(pid, "WINEPREFIX");
+ if (pidPrefix.isEmpty()) continue;
+ if (QDir(pidPrefix).canonicalPath() != expectedPrefixCanon) continue;
+ }
+
+ best = pid;
+ if (matchedNameOut) *matchedNameOut = matched;
+ break;
+ }
+ closedir(proc);
+ return best;
+}
+
+// Best-effort "hard kill" of the wineserver (and every Wine process it
+// owns) for the given prefix. Used when the user clicks Unlock in the
+// lock dialog — Proton's session manager can keep wineserver alive for
+// tens of seconds after the game exits, and that's exactly what the
+// user is asking us to short-circuit. We call `wineserver -k` if the
+// Proton distribution exposes one, then fall back to SIGKILL on the
+// wineserver pid so the prefix is freed regardless.
+void killWineserverForPrefix(const QString &winePrefix) {
+ if (winePrefix.isEmpty()) {
+ return;
+ }
+
+ const pid_t ws = findWineserver(winePrefix);
+ if (ws > 0) {
+ log::info("sending SIGTERM to wineserver {} for prefix '{}'", ws,
+ winePrefix.toStdString());
+ if (::kill(ws, SIGTERM) != 0 && errno != ESRCH) {
+ log::warn("SIGTERM on wineserver {} failed, errno={}", ws, errno);
+ }
+ // Give wineserver a short window to tear down cleanly, then SIGKILL
+ // if it's still hanging around — the whole point of the Unlock
+ // button is to not keep the user waiting.
+ for (int i = 0; i < 10; ++i) {
+ if (::kill(ws, 0) != 0 && errno == ESRCH) {
+ break;
+ }
+ QThread::msleep(100);
+ }
+ if (::kill(ws, 0) == 0) {
+ log::warn("wineserver {} did not exit on SIGTERM, sending SIGKILL",
+ ws);
+ ::kill(ws, SIGKILL);
+ }
+ } else {
+ log::debug("killWineserverForPrefix: no wineserver found for '{}'",
+ winePrefix.toStdString());
+ }
+}
+
DWORD exitCodeFromWaitStatus(int status) {
if (WIFEXITED(status)) {
return static_cast<DWORD>(WEXITSTATUS(status));
@@ -873,30 +974,44 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
// The tracked process is no longer a descendant of the root PID.
// This can happen when:
// a) The root (proton) exits and wine/game processes get reparented
- // b) A launcher .exe (nvse_loader, skse_loader) exits after spawning
- // the actual game (FalloutNV.exe, SkyrimSE.exe)
+ // b) A launcher .exe (nvse_loader, skse_loader, f4se_loader) exits
+ // after spawning the actual game (FalloutNV.exe, SkyrimSE.exe,
+ // Fallout4.exe)
//
- // Before declaring the game exited, check if the last tracked PID is
- // still alive. If not, fall back to waiting for wineserver — it stays
- // alive as long as ANY wine process in the prefix is running.
+ // If the last tracked PID is still alive, keep polling it directly.
if (lastTrackedPid > 0 && ::kill(lastTrackedPid, 0) == 0) {
displayPid = lastTrackedPid;
displayName = readProcComm(lastTrackedPid);
} else {
- const pid_t ws = findWineserver(winePrefix);
- if (ws > 0) {
- log::info("tracked process exited, waiting for wineserver {}", ws);
- lastTrackedPid = ws;
+ // The previously tracked process is gone. Rescan the user's
+ // processes for any of the expected game executables in the same
+ // WINEPREFIX — Proton's session manager can reparent the game
+ // out of our descendant tree. If we find one, track that.
+ QString rescanName;
+ const pid_t rescanned =
+ findGameProcessInPrefix(expected, winePrefix, &rescanName);
+ if (rescanned > 0 && rescanned != lastTrackedPid) {
+ log::info("tracked process exited, resumed tracking game {}: {}",
+ rescanned, rescanName.toStdString());
+ lastTrackedPid = rescanned;
useKillPoll = true;
- displayPid = ws;
- displayName = QStringLiteral("wineserver");
+ displayPid = rescanned;
+ displayName = rescanName;
continue;
}
+
+ // No matching game process remains. Do NOT fall back to waiting
+ // for wineserver — Proton's session manager keeps wineserver
+ // alive for the prefix idle timeout (several seconds on modern
+ // Proton, much longer under load), and blocking MO2's lock on
+ // that is indistinguishable from a hang from the user's POV
+ // (issue: "Fluorine stuck tracking wineserver"). The game has
+ // truly exited once no expected executable is running.
if (exitCode != nullptr) {
*exitCode = 0;
}
- log::debug("tracked child process {} for root {} exited (no wineserver)",
- lastTrackedPid, pid);
+ log::debug("game processes for root {} exited; releasing lock "
+ "(wineserver may linger in background)", pid);
return ProcessRunner::Completed;
}
}
@@ -915,17 +1030,23 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
: pid;
if (::kill(pollPid, 0) != 0) {
if (errno == ESRCH) {
- // The polled process exited. If it was the game (not wineserver),
- // try falling back to wineserver — the real game may still be running
- // (e.g. nvse_loader exits after spawning FalloutNV.exe).
+ // The polled process exited. Rescan the prefix for any other
+ // matching game executable (launcher .exe's like f4se_loader
+ // exit after spawning the real game binary, and Proton can
+ // reparent that binary out of our root's subtree).
if (seenTrackedProcess) {
- const pid_t ws = findWineserver(winePrefix);
- if (ws > 0 && ws != pollPid) {
- log::info("polled process {} exited, falling back to wineserver {}", pollPid, ws);
- lastTrackedPid = ws;
+ QString rescanName;
+ const pid_t rescanned =
+ findGameProcessInPrefix(expected, winePrefix, &rescanName);
+ if (rescanned > 0 && rescanned != pollPid) {
+ log::info("polled process {} exited, resumed tracking game {}: {}",
+ pollPid, rescanned, rescanName.toStdString());
+ lastTrackedPid = rescanned;
continue;
}
}
+ // No game process remains — do NOT block on wineserver. See
+ // the equivalent note above for why.
if (exitCode != nullptr) {
*exitCode = 0;
}
@@ -988,6 +1109,12 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) {
log::warn("failed to terminate {}, errno={}", displayPid, errno);
}
+ // User clicked Unlock — they're asking us to stop waiting on the
+ // prefix, so also hard-kill the wineserver. Otherwise Proton's
+ // session manager keeps it alive for its idle timeout and the
+ // next launch inherits a dirty prefix. See the "stuck tracking
+ // wineserver" user report.
+ killWineserverForPrefix(winePrefix);
return ProcessRunner::Cancelled;
case UILocker::NoResult:
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index cd96afa..e0d0d44 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -104,12 +104,31 @@ QString compatDataPathFromPrefix(const QString& prefixPath)
}
QDir prefixDir(prefixPath);
+
+ // Case 1: prefix_path is `<compatdata>/pfx` — the usual Fluorine layout.
if (prefixDir.dirName() == "pfx") {
if (prefixDir.cdUp()) {
return QDir::cleanPath(prefixDir.absolutePath());
}
}
+ // Case 2: prefix_path is the compatdata directory itself (contains `pfx/`).
+ // Steam expects STEAM_COMPAT_DATA_PATH to be the compatdata dir, not pfx.
+ if (prefixDir.exists(QStringLiteral("pfx/drive_c"))) {
+ return QDir::cleanPath(prefixDir.absolutePath());
+ }
+
+ // Case 3: prefix_path is a plain Wine prefix (contains drive_c directly,
+ // no pfx wrapper). Pointing STEAM_COMPAT_DATA_PATH at the parent would be
+ // wrong (Proton would look for `<parent>/pfx` which doesn't exist and
+ // create a fresh prefix there). Returning empty lets the caller skip
+ // setting STEAM_COMPAT_DATA_PATH — Proton then respects WINEPREFIX only.
+ if (prefixDir.exists(QStringLiteral("drive_c"))) {
+ return {};
+ }
+
+ // Last-resort legacy behaviour — keep the old fallback for callers that
+ // pass non-existent paths (prefix creation UI flow).
return QDir::cleanPath(QFileInfo(prefixPath).dir().absolutePath());
}
diff --git a/src/src/settings.cpp b/src/src/settings.cpp
index 74204c7..dfe7591 100644
--- a/src/src/settings.cpp
+++ b/src/src/settings.cpp
@@ -18,6 +18,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
*/
#include "settings.h"
+#include <fluorine_build_info.h>
#include "env.h"
#include "envmetrics.h"
#include "executableslist.h"
@@ -245,6 +246,22 @@ void Settings::setUsePrereleases(bool b)
set(m_Settings, "Settings", "use_prereleases", b);
}
+QString Settings::fluorineUpdateChannel() const
+{
+#if FLUORINE_IS_BETA_BUILD
+ const QString defaultChannel = QStringLiteral("beta");
+#else
+ const QString defaultChannel = QStringLiteral("stable");
+#endif
+ return get<QString>(m_Settings, "Settings", "fluorine_update_channel",
+ defaultChannel);
+}
+
+void Settings::setFluorineUpdateChannel(const QString& channel)
+{
+ set(m_Settings, "Settings", "fluorine_update_channel", channel);
+}
+
bool Settings::profileLocalInis() const
{
return get<bool>(m_Settings, "Settings", "profile_local_inis", true);
diff --git a/src/src/settings.h b/src/src/settings.h
index 8abb650..c1a1eb8 100644
--- a/src/src/settings.h
+++ b/src/src/settings.h
@@ -818,6 +818,12 @@ public:
bool usePrereleases() const;
void setUsePrereleases(bool b);
+ // Fluorine self-update channel: "stable" (tagged releases) or "beta"
+ // (rolling build). Defaults to whichever channel the installed binary
+ // was built under — set at first launch, mutable via Settings dialog.
+ QString fluorineUpdateChannel() const;
+ void setFluorineUpdateChannel(const QString& channel);
+
// whether profiles should default to local INIs
//
bool profileLocalInis() const;
diff --git a/src/src/settingsdialoggeneral.cpp b/src/src/settingsdialoggeneral.cpp
index 9585d3a..a53f6ba 100644
--- a/src/src/settingsdialoggeneral.cpp
+++ b/src/src/settingsdialoggeneral.cpp
@@ -23,7 +23,16 @@ GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d)
// updates
ui->checkForUpdates->setChecked(settings().checkForUpdates());
- ui->usePrereleaseBox->setChecked(settings().usePrereleases());
+ // The "beta versions" checkbox drives both the legacy Nexus-side pre-release
+ // opt-in and the Fluorine self-update channel, so a single toggle covers the
+ // user-visible concept of "I want beta builds".
+ const bool onBeta =
+ settings().fluorineUpdateChannel() == QStringLiteral("beta") ||
+ settings().usePrereleases();
+ ui->usePrereleaseBox->setChecked(onBeta);
+ ui->usePrereleaseBox->setToolTip(
+ QObject::tr("Receive rolling beta builds from the main branch. When "
+ "off, only tagged stable releases are offered."));
// profile defaults
ui->localINIs->setChecked(settings().profileLocalInis());
@@ -68,6 +77,9 @@ void GeneralSettingsTab::update()
// updates
settings().setCheckForUpdates(ui->checkForUpdates->isChecked());
settings().setUsePrereleases(ui->usePrereleaseBox->isChecked());
+ settings().setFluorineUpdateChannel(ui->usePrereleaseBox->isChecked()
+ ? QStringLiteral("beta")
+ : QStringLiteral("stable"));
// profile defaults
settings().setProfileLocalInis(ui->localINIs->isChecked());
diff --git a/src/src/shared/util.cpp b/src/src/shared/util.cpp
index b1df0a6..ec720d2 100644
--- a/src/src/shared/util.cpp
+++ b/src/src/shared/util.cpp
@@ -21,6 +21,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "../env.h"
#include "../mainwindow.h"
#include "windows_error.h"
+#include <fluorine_build_info.h>
#include <uibase/log.h>
#ifndef _WIN32
#include <pthread.h>
@@ -319,7 +320,18 @@ Version createVersionInfo()
#else
Version createVersionInfo()
{
- return Version(2, 5, 2, 0);
+ // Fluorine Manager version is the user-facing one. The numeric components
+ // are injected by CMake from top-level FLUORINE_VERSION_* variables.
+#if FLUORINE_IS_BETA_BUILD
+ // Beta builds tag themselves as Development pre-releases so the update
+ // checker can distinguish them from stable tags when comparing versions.
+ return Version(FLUORINE_VERSION_MAJOR, FLUORINE_VERSION_MINOR,
+ FLUORINE_VERSION_PATCH, 0,
+ {Version::Development});
+#else
+ return Version(FLUORINE_VERSION_MAJOR, FLUORINE_VERSION_MINOR,
+ FLUORINE_VERSION_PATCH, 0);
+#endif
}
#endif
diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp
index d2415a0..9fa529e 100644
--- a/src/src/slrmanager.cpp
+++ b/src/src/slrmanager.cpp
@@ -110,6 +110,89 @@ bool isSlrInstalled()
return fi.exists() && fi.isExecutable();
}
+QString xrandrInjectedPath()
+{
+ return slrInstallDir() + "/xrandr-bin/xrandr";
+}
+
+bool isXrandrInjected()
+{
+ QFileInfo fi(xrandrInjectedPath());
+ return fi.exists() && fi.isExecutable();
+}
+
+// Download + extract xrandr from the Debian x11-xserver-utils package into
+// the SLR install dir. Called standalone for users who installed the
+// runtime before the xrandr step existed, and inline from downloadSlr() for
+// fresh installs.
+static bool installXrandrAssets(const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb)
+{
+ auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); };
+
+ const QString installDir = slrInstallDir();
+ QDir().mkpath(installDir);
+
+ const QString debPath = installDir + "/x11-xserver-utils.deb";
+ status(QStringLiteral("Downloading xrandr..."));
+ httpGet(QString::fromLatin1(XRANDR_DEB_URL), cancelFlag, nullptr, debPath);
+ if (!QFileInfo::exists(debPath)) {
+ MOBase::log::warn("Failed to download xrandr .deb — runtime will lack xrandr");
+ return false;
+ }
+
+ const QString tmpExtract = installDir + "/xrandr_tmp";
+ QDir(tmpExtract).removeRecursively();
+ QDir().mkpath(tmpExtract);
+
+ QProcess ar;
+ ar.setWorkingDirectory(tmpExtract);
+ ar.start(QStringLiteral("ar"),
+ {QStringLiteral("x"), debPath, QStringLiteral("data.tar.xz")});
+ ar.waitForFinished(30000);
+
+ QProcess untar;
+ untar.setWorkingDirectory(tmpExtract);
+ untar.start(QStringLiteral("tar"),
+ {QStringLiteral("xf"), QStringLiteral("data.tar.xz"),
+ QStringLiteral("./usr/bin/xrandr")});
+ untar.waitForFinished(30000);
+
+ const QString xrandrSrc = tmpExtract + "/usr/bin/xrandr";
+ bool ok = false;
+ if (QFileInfo::exists(xrandrSrc)) {
+ const QString xrandrDir = installDir + "/xrandr-bin";
+ QDir().mkpath(xrandrDir);
+ const QString dst = xrandrDir + "/xrandr";
+ QFile::remove(dst);
+ if (QFile::copy(xrandrSrc, dst)) {
+ QFile::setPermissions(dst, QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner | QFileDevice::ReadGroup |
+ QFileDevice::ExeGroup | QFileDevice::ReadOther |
+ QFileDevice::ExeOther);
+ MOBase::log::info("Installed xrandr to {}", dst.toStdString());
+ ok = true;
+ } else {
+ MOBase::log::warn("Failed to copy xrandr into fluorine bin dir");
+ }
+ } else {
+ MOBase::log::warn("xrandr .deb extracted but binary not found");
+ }
+
+ QDir(tmpExtract).removeRecursively();
+ QFile::remove(debPath);
+ return ok;
+}
+
+bool ensureXrandrInstalled(const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb)
+{
+ if (isXrandrInjected()) {
+ return true;
+ }
+ return installXrandrAssets(cancelFlag, statusCb);
+}
+
QString getSlrRunScript()
{
return isSlrInstalled() ? slrRunScriptPath() : QString();
@@ -142,6 +225,13 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
if (localBuildId == remoteBuildId && isSlrInstalled()) {
MOBase::log::info("Steam Linux Runtime is already up to date");
+ // Existing installs from earlier Fluorine versions may not have the
+ // xrandr helper (issue #49). Back-fill it so Proton-GE prefix init
+ // doesn't silently fail on distros without host xrandr exposed.
+ if (!isXrandrInjected()) {
+ status(QStringLiteral("Injecting xrandr into existing runtime..."));
+ installXrandrAssets(cancelFlag, statusCb);
+ }
status(QStringLiteral("Steam Linux Runtime is already up to date"));
progress(1.0f);
return {};
@@ -182,55 +272,8 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
// 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..."));
- const QString debPath = installDir + "/x11-xserver-utils.deb";
- httpGet(QString::fromLatin1(XRANDR_DEB_URL), cancelFlag, nullptr, debPath);
- if (!QFileInfo::exists(debPath)) {
- MOBase::log::warn("Failed to download xrandr .deb — runtime will lack xrandr");
- } else {
- const QString tmpExtract = installDir + "/xrandr_tmp";
- QDir(tmpExtract).removeRecursively();
- QDir().mkpath(tmpExtract);
-
- QProcess ar;
- ar.setWorkingDirectory(tmpExtract);
- ar.start(QStringLiteral("ar"), {QStringLiteral("x"), debPath, QStringLiteral("data.tar.xz")});
- ar.waitForFinished(30000);
-
- QProcess untar;
- untar.setWorkingDirectory(tmpExtract);
- untar.start(QStringLiteral("tar"),
- {QStringLiteral("xf"), QStringLiteral("data.tar.xz"),
- QStringLiteral("./usr/bin/xrandr")});
- untar.waitForFinished(30000);
-
- const QString xrandrSrc = tmpExtract + "/usr/bin/xrandr";
- if (QFileInfo::exists(xrandrSrc)) {
- // Place xrandr in a dedicated dir that Fluorine's installer never
- // touches (the fluorine/bin dir gets overwritten on every install).
- // Launchers expose this dir via --filesystem and prepend it to PATH.
- const QString xrandrDir = installDir + "/xrandr-bin";
- QDir().mkpath(xrandrDir);
- const QString dst = xrandrDir + "/xrandr";
- QFile::remove(dst);
- if (QFile::copy(xrandrSrc, dst)) {
- QFile::setPermissions(dst, QFileDevice::ReadOwner | QFileDevice::WriteOwner |
- QFileDevice::ExeOwner | QFileDevice::ReadGroup |
- QFileDevice::ExeGroup | QFileDevice::ReadOther |
- QFileDevice::ExeOther);
- MOBase::log::info("Installed xrandr to {}", dst.toStdString());
- } else {
- MOBase::log::warn("Failed to copy xrandr into fluorine bin dir");
- }
- } else {
- MOBase::log::warn("xrandr .deb extracted but binary not found");
- }
-
- QDir(tmpExtract).removeRecursively();
- QFile::remove(debPath);
- }
- }
+ status(QStringLiteral("Injecting xrandr into runtime..."));
+ installXrandrAssets(cancelFlag, statusCb);
// 5. Save BUILD_ID.
{
diff --git a/src/src/slrmanager.h b/src/src/slrmanager.h
index e0a1b3f..6a842b9 100644
--- a/src/src/slrmanager.h
+++ b/src/src/slrmanager.h
@@ -18,4 +18,17 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
const std::function<void(const QString&)>& statusCb,
const int* cancelFlag);
+/// Returns true if our injected xrandr helper is present.
+__attribute__((visibility("default"))) bool isXrandrInjected();
+
+/// Ensure xrandr is extracted into the steamrt install dir. Blocking, but
+/// small (~150 KB download). Used to back-fill xrandr for users whose SLR
+/// was installed before Fluorine started injecting it (issue #49) — pre-
+/// existing installs short-circuit downloadSlr() and would otherwise never
+/// get xrandr. Safe to call unconditionally.
+__attribute__((visibility("default")))
+bool ensureXrandrInstalled(
+ const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb);
+
#endif // SLRMANAGER_H
diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp
index 59bb7d6..8e97ed0 100644
--- a/src/src/spawn.cpp
+++ b/src/src/spawn.cpp
@@ -518,8 +518,13 @@ QString firstExistingSetting(const QSettings &settings,
}
QString resolvePrefixPath() {
+ // The Fluorine config is authoritative: it's the prefix the user
+ // explicitly created through Settings > Proton and that Fluorine itself
+ // initialises with wineboot/DLL installs. Always prefer it.
if (auto cfg = FluorineConfig::load();
cfg.has_value() && cfg->prefixExists()) {
+ MOBase::log::debug("resolvePrefixPath: using Fluorine config prefix '{}'",
+ cfg->prefix_path);
return cfg->prefix_path.trimmed();
}
@@ -528,10 +533,34 @@ QString resolvePrefixPath() {
return {};
}
+ // Fallbacks, in priority order. `fluorine/prefix_path` is set only by
+ // explicit user action (CLI `--prefix` or the instance creation wizard),
+ // so we trust it above the `Settings/*` keys that game-detection can
+ // populate automatically with an external manager's prefix (Heroic,
+ // Bottles, Lutris). Those external prefixes are fine as discovery hints
+ // but must not silently override the user's chosen Fluorine prefix —
+ // see issue #52.
const QSettings instanceSettings(settings->filename(), QSettings::IniFormat);
- return firstExistingSetting(
+ const QString explicitPath =
+ instanceSettings.value("fluorine/prefix_path").toString().trimmed();
+ if (!explicitPath.isEmpty()) {
+ MOBase::log::debug(
+ "resolvePrefixPath: using explicit fluorine/prefix_path '{}'",
+ explicitPath);
+ return explicitPath;
+ }
+
+ const QString fallback = firstExistingSetting(
instanceSettings, {"Settings/proton_prefix_path", "Settings/prefix_path",
- "Proton/prefix_path", "fluorine/prefix_path"});
+ "Proton/prefix_path"});
+ if (!fallback.isEmpty()) {
+ MOBase::log::warn(
+ "resolvePrefixPath: falling back to auto-detected prefix '{}' — this "
+ "may point at an external manager's prefix (Heroic/Bottles). Create a "
+ "Fluorine prefix in Settings > Proton to override.",
+ fallback);
+ }
+ return fallback;
}
QString resolveProtonPath() {
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 7dab850..3d39de2 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -2110,6 +2110,72 @@ void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mod
replyEntryFromSnapshot(req, ctx, dirIno, snap);
}
+void mo2_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name)
+{
+ Mo2FsContext* ctx = getContext(req);
+ if (ctx == nullptr || name == nullptr) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+ if (std::strcmp(name, ".") == 0 || std::strcmp(name, "..") == 0) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+
+ bool ok = false;
+ const std::string parentPath = inodeToPath(ctx, parent, &ok);
+ if (!ok) {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+
+ const std::string relative =
+ joinPath(parentPath, canonicalChildName(ctx, parentPath, name));
+
+ // Check VFS tree: the directory must exist and be empty in the merged view.
+ // Backing/mod entries are read-only, so reject rmdir on anything that still
+ // has children visible through the VFS.
+ {
+ std::shared_lock lock(ctx->tree_mutex);
+ const VfsNode* node = ctx->tree->root.resolve(splitPath(relative));
+ if (node == nullptr) {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+ if (!node->is_directory) {
+ fuse_reply_err(req, ENOTDIR);
+ return;
+ }
+ if (!node->dir_info.children.empty()) {
+ fuse_reply_err(req, ENOTEMPTY);
+ return;
+ }
+ }
+
+ // Try to remove the real directory from staging/overwrite. If the directory
+ // is purely virtual (no real backing in staging/overwrite) that's fine —
+ // still accept the rmdir so the VFS view reflects the caller's intent.
+ bool notEmpty = false;
+ const bool removed = ctx->overwrite->removeDirectory(relative, &notEmpty);
+ if (notEmpty) {
+ fuse_reply_err(req, ENOTEMPTY);
+ return;
+ }
+ (void)removed;
+
+ {
+ std::unique_lock lock(ctx->tree_mutex);
+ invalidateNodeCache(ctx, relative);
+ if (ctx->tree->root.removeFromTree(splitPath(relative))) {
+ ctx->tree->dir_count =
+ ctx->tree->dir_count > 0 ? ctx->tree->dir_count - 1 : 0;
+ }
+ }
+ invalidateDirCache(ctx, parentPath);
+
+ fuse_reply_err(req, 0);
+}
+
void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
{
Mo2FsContext* ctx = getContext(req);
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index aa248ed..b343e3b 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -161,6 +161,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
struct fuse_file_info* fi);
void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name);
void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode);
+void mo2_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name);
void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
void mo2_releasedir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
#if FUSE_USE_VERSION < 35
diff --git a/src/src/vfs/overwritemanager.cpp b/src/src/vfs/overwritemanager.cpp
index 092a756..d5f84f3 100644
--- a/src/src/vfs/overwritemanager.cpp
+++ b/src/src/vfs/overwritemanager.cpp
@@ -171,6 +171,35 @@ bool OverwriteManager::removeFile(const std::string& relative_path)
return false;
}
+bool OverwriteManager::removeDirectory(const std::string& relative_path,
+ bool* out_not_empty)
+{
+ if (out_not_empty)
+ *out_not_empty = false;
+
+ std::error_code ec;
+ bool removedAny = false;
+
+ for (const fs::path candidate : {fs::path(stagingPath(relative_path)),
+ fs::path(overwritePath(relative_path))}) {
+ if (!fs::exists(candidate, ec))
+ continue;
+ if (!fs::is_directory(candidate, ec))
+ return false;
+
+ if (!fs::is_empty(candidate, ec)) {
+ if (out_not_empty)
+ *out_not_empty = true;
+ return false;
+ }
+
+ if (fs::remove(candidate, ec))
+ removedAny = true;
+ }
+
+ return removedAny;
+}
+
bool OverwriteManager::createDirectory(const std::string& relative_path)
{
std::error_code ec;
diff --git a/src/src/vfs/overwritemanager.h b/src/src/vfs/overwritemanager.h
index abc09d9..4ce2fb8 100644
--- a/src/src/vfs/overwritemanager.h
+++ b/src/src/vfs/overwritemanager.h
@@ -20,6 +20,7 @@ public:
bool rename(const std::string& old_relative, const std::string& new_relative);
bool removeFile(const std::string& relative_path);
+ bool removeDirectory(const std::string& relative_path, bool* out_not_empty = nullptr);
bool createDirectory(const std::string& relative_path);
bool exists(const std::string& relative_path) const;