diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-23 16:09:59 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-23 16:09:59 -0500 |
| commit | 96a6ba196c72e955c3db822d93da38fce8cd7045 (patch) | |
| tree | 3fcc40e939bd609c4cb7b84f05e85875dde64fc6 /src | |
| parent | 6b9f0876cfbf6f8c723c86ec941ee70933aa883f (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.txt | 2 | ||||
| -rw-r--r-- | src/src/aboutdialog.cpp | 25 | ||||
| -rw-r--r-- | src/src/filerenamer.cpp | 37 | ||||
| -rw-r--r-- | src/src/fluorine_build_info.h.in | 24 | ||||
| -rw-r--r-- | src/src/fluorineupdater.cpp | 303 | ||||
| -rw-r--r-- | src/src/fluorineupdater.h | 75 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 1 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 49 | ||||
| -rw-r--r-- | src/src/organizercore.h | 2 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.cpp | 42 | ||||
| -rw-r--r-- | src/src/processrunner.cpp | 167 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 19 | ||||
| -rw-r--r-- | src/src/settings.cpp | 17 | ||||
| -rw-r--r-- | src/src/settings.h | 6 | ||||
| -rw-r--r-- | src/src/settingsdialoggeneral.cpp | 14 | ||||
| -rw-r--r-- | src/src/shared/util.cpp | 14 | ||||
| -rw-r--r-- | src/src/slrmanager.cpp | 141 | ||||
| -rw-r--r-- | src/src/slrmanager.h | 13 | ||||
| -rw-r--r-- | src/src/spawn.cpp | 33 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 66 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 1 | ||||
| -rw-r--r-- | src/src/vfs/overwritemanager.cpp | 29 | ||||
| -rw-r--r-- | src/src/vfs/overwritemanager.h | 1 |
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, ¬Empty); + 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; |
