diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-20 15:40:45 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-20 15:40:45 -0500 |
| commit | 6b9f0876cfbf6f8c723c86ec941ee70933aa883f (patch) | |
| tree | de8c9b3121ddcf505ba282f760e78bac2967cbf3 /src | |
| parent | 915298db2a22213441c5da909786ccc0db64bbaa (diff) | |
Bind-mount profile saves, fix multi-download delete
Replace the per-launch __MO_Saves symlink with a kernel bind mount
inside an unprivileged user+mount namespace. Writes from the game
land directly in the profile's saves dir via the mount, which Wine
can't replace with a real directory the way it could with a symlink.
The namespace (and therefore the bind) tears down automatically when
the game process tree exits, so afterRun no longer needs to sync
saves or undeploy anything. Fall back to the symlink path on
kernels where unprivileged_userns_clone is disabled (Debian buster,
linux-hardened); detection is via /proc/sys/kernel/unprivileged_userns_clone.
DownloadListView::issueDeleteSelected captured indices up-front and
looped emit removeDownload(row). DownloadManager::removeDownload
calls refreshList() at the end, which clears finished entries from
m_ActiveDownloads and rebuilds from disk enumeration — stale indices
after the first delete pointed at the wrong file or triggered the
invalid-index error. Capture filenames instead and re-resolve the
current row each iteration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/downloadlistview.cpp | 28 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 78 | ||||
| -rw-r--r-- | src/src/organizercore.h | 4 | ||||
| -rw-r--r-- | src/src/processrunner.cpp | 9 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 60 | ||||
| -rw-r--r-- | src/src/protonlauncher.h | 14 | ||||
| -rw-r--r-- | src/src/spawn.cpp | 4 | ||||
| -rw-r--r-- | src/src/spawn.h | 7 |
8 files changed, 178 insertions, 26 deletions
diff --git a/src/src/downloadlistview.cpp b/src/src/downloadlistview.cpp index 0920980..cfb9468 100644 --- a/src/src/downloadlistview.cpp +++ b/src/src/downloadlistview.cpp @@ -415,20 +415,24 @@ void DownloadListView::issueDeleteSelected() auto* proxy = qobject_cast<QSortFilterProxyModel*>(model());
if (!proxy) return;
- QList<int> sourceRows;
+ // Capture filenames (not indices): removeDownload() runs refreshList()
+ // internally, which rebuilds m_ActiveDownloads from disk enumeration and
+ // can reorder entries, so stale indices from before the first delete may
+ // point at the wrong file or be out of range.
+ QStringList fileNames;
for (const auto& idx : selectionModel()->selectedRows()) {
int row = proxy->mapToSource(idx).row();
auto state = m_Manager->getState(row);
if (state >= DownloadManager::STATE_READY) {
- sourceRows.append(row);
+ fileNames.append(m_Manager->getFileName(row));
}
}
- if (sourceRows.isEmpty()) return;
+ if (fileNames.isEmpty()) return;
const auto r = MOBase::TaskDialog(this, tr("Delete downloads"))
.main(tr("Are you sure you want to delete %1 download(s)?")
- .arg(sourceRows.size()))
+ .arg(fileNames.size()))
.icon(QMessageBox::Question)
.button({tr("Move to the Recycle Bin"), QMessageBox::Yes})
.button({tr("Cancel"), QMessageBox::Cancel})
@@ -436,10 +440,18 @@ void DownloadListView::issueDeleteSelected() if (r != QMessageBox::Yes) return;
- // Delete in reverse order so indices remain valid
- std::sort(sourceRows.begin(), sourceRows.end(), std::greater<int>());
- for (int row : sourceRows) {
- emit removeDownload(row, true);
+ for (const QString& name : fileNames) {
+ const int total = m_Manager->numTotalDownloads();
+ int row = -1;
+ for (int i = 0; i < total; ++i) {
+ if (m_Manager->getFileName(i) == name) {
+ row = i;
+ break;
+ }
+ }
+ if (row >= 0) {
+ emit removeDownload(row, true);
+ }
}
}
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index d236abe..c1cb9aa 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -40,6 +40,7 @@ #include <uibase/filesystemutilities.h>
#include <uibase/utility.h>
#include "fluorineconfig.h"
+#include "protonlauncher.h"
#include "wineprefix.h"
#include <chrono>
@@ -2416,9 +2417,12 @@ bool OrganizerCore::checkGameRegistryKey() bool OrganizerCore::beforeRun(
const QFileInfo& binary, const QDir& cwd, const QString& arguments,
const QString& profileName, const QString& customOverwrite,
- const QList<MOBase::ExecutableForcedLoadSetting>& forcedLibraries)
+ const QList<MOBase::ExecutableForcedLoadSetting>& forcedLibraries,
+ QString* saveBindMountSource, QString* saveBindMountTarget)
{
saveCurrentProfile();
+ if (saveBindMountSource) saveBindMountSource->clear();
+ if (saveBindMountTarget) saveBindMountTarget->clear();
// need to wait until directory structure is ready
if (m_DirectoryUpdate) {
@@ -2620,14 +2624,43 @@ bool OrganizerCore::beforeRun( if (m_CurrentProfile->localSavesEnabled()) {
const QString profileSavesDir =
QDir(m_CurrentProfile->absolutePath()).filePath("saves");
- log::info("Save deploy target: '{}' -> '{}'", profileSavesDir,
- absoluteSaveDir);
- if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, true)) {
- log::warn("Failed to deploy profile saves from '{}' to prefix '{}'",
- profileSavesDir, prefixPathStr);
+
+ const bool useBindMount =
+ saveBindMountSource && saveBindMountTarget &&
+ ProtonLauncher::unprivilegedBindMountSupported();
+
+ if (useBindMount) {
+ // Clean up any stale symlink from previous symlink-based runs:
+ // mount --bind won't traverse a symlink as the target.
+ if (QFileInfo(absoluteSaveDir).isSymLink()) {
+ QFile::remove(absoluteSaveDir);
+ }
+ const QFileInfo leafInfo(absoluteSaveDir);
+ const QString lowerSaveDir =
+ QDir(leafInfo.dir().absolutePath())
+ .filePath(leafInfo.fileName().toLower());
+ if (lowerSaveDir != absoluteSaveDir &&
+ QFileInfo(lowerSaveDir).isSymLink()) {
+ QFile::remove(lowerSaveDir);
+ }
+
+ // Both endpoints must exist before the kernel can bind them.
+ QDir().mkpath(profileSavesDir);
+ QDir().mkpath(absoluteSaveDir);
+
+ *saveBindMountSource = profileSavesDir;
+ *saveBindMountTarget = absoluteSaveDir;
+ log::info("Save bind mount: '{}' -> '{}'", profileSavesDir,
+ absoluteSaveDir);
} else {
- log::debug("Deployed profile saves '{}' -> '{}' in prefix '{}'",
- profileSavesDir, absoluteSaveDir, prefixPathStr);
+ log::info("Save deploy target: '{}' -> '{}'", profileSavesDir,
+ absoluteSaveDir);
+ if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir,
+ true)) {
+ log::warn("Failed to deploy profile saves from '{}' to "
+ "prefix '{}'",
+ profileSavesDir, prefixPathStr);
+ }
}
// Ensure the prefix INI points to __MO_Saves so the game reads
@@ -2717,16 +2750,27 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) if (m_CurrentProfile->localSavesEnabled()) {
const QString profileSavesDir =
QDir(m_CurrentProfile->absolutePath()).filePath("saves");
- log::info("Save sync target: '{}' <- '{}'", profileSavesDir,
- absoluteSaveDir);
- if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) {
- log::warn("Failed to sync saves back from prefix '{}' to '{}'",
- prefixPathStr, profileSavesDir);
- }
- // Remove the __MO_Saves symlinks and revert the prefix INI so a
- // vanilla launch outside MO2 uses the default Saves dir.
- prefix.undeployProfileSaves(absoluteSaveDir);
+ // Bind-mount path: writes already landed live in the profile via
+ // the per-launch mount namespace, which teardown automatically
+ // when the game exited. Nothing to sync and nothing to undeploy.
+ // The symlink path below only runs on kernels without
+ // unprivileged user namespaces.
+ const bool usedBindMount =
+ !QFileInfo(absoluteSaveDir).isSymLink() &&
+ ProtonLauncher::unprivilegedBindMountSupported();
+ if (!usedBindMount) {
+ log::info("Save sync target: '{}' <- '{}'", profileSavesDir,
+ absoluteSaveDir);
+ if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) {
+ log::warn("Failed to sync saves back from prefix '{}' to '{}'",
+ prefixPathStr, profileSavesDir);
+ }
+
+ // Remove the __MO_Saves symlinks and revert the prefix INI so a
+ // vanilla launch outside MO2 uses the default Saves dir.
+ prefix.undeployProfileSaves(absoluteSaveDir);
+ }
const QStringList iniFiles = managedGame()->iniFiles();
if (!iniFiles.isEmpty()) {
const QString prefixIni =
diff --git a/src/src/organizercore.h b/src/src/organizercore.h index 834b8a7..dcf07c7 100644 --- a/src/src/organizercore.h +++ b/src/src/organizercore.h @@ -310,7 +310,9 @@ public: bool beforeRun(const QFileInfo& binary, const QDir& cwd, const QString& arguments,
const QString& profileName, const QString& customOverwrite,
- const QList<MOBase::ExecutableForcedLoadSetting>& forcedLibraries);
+ const QList<MOBase::ExecutableForcedLoadSetting>& forcedLibraries,
+ QString* saveBindMountSource = nullptr,
+ QString* saveBindMountTarget = nullptr);
#ifndef _WIN32
bool checkGameRegistryKey();
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index 1a50849..4457cdb 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -1356,10 +1356,19 @@ std::optional<ProcessRunner::Results> ProcessRunner::runBinary() { // saves profile, sets up usvfs, notifies plugins, etc.; can return false if
// a plugin doesn't want the program to run (such as when checkFNIS fails to
// run FNIS and the user clicks cancel)
+#ifdef _WIN32
if (!m_core.beforeRun(m_sp.binary, m_sp.currentDirectory, m_sp.arguments,
m_profileName, m_customOverwrite, m_forcedLibraries)) {
return Error;
}
+#else
+ if (!m_core.beforeRun(m_sp.binary, m_sp.currentDirectory, m_sp.arguments,
+ m_profileName, m_customOverwrite, m_forcedLibraries,
+ &m_sp.saveBindMountSource,
+ &m_sp.saveBindMountTarget)) {
+ return Error;
+ }
+#endif
// parent widget used for any dialog popped up while checking for things
QWidget *parent = (m_ui ? m_ui->mainWindow() : nullptr);
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index c1b6b64..cd96afa 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -417,6 +417,37 @@ ProtonLauncher& ProtonLauncher::setUseTerminal(bool useTerminal) return *this; } +ProtonLauncher& ProtonLauncher::setSavesBindMount(const QString& source, + const QString& target) +{ + m_bindMountSource = source.trimmed(); + m_bindMountTarget = target.trimmed(); + return *this; +} + +bool ProtonLauncher::unprivilegedBindMountSupported() +{ + // Need both an `unshare` binary and a kernel that lets unprivileged users + // enter a user namespace with CAP_SYS_ADMIN (which is what makes + // `mount --bind` work without root). Debian stable + some hardened + // kernels disable this via a sysctl; treat missing/zero as "no". + if (QStandardPaths::findExecutable("unshare").isEmpty()) { + return false; + } + + QFile f(QStringLiteral("/proc/sys/kernel/unprivileged_userns_clone")); + if (f.open(QIODevice::ReadOnly)) { + const QByteArray v = f.readAll().trimmed(); + // File exists and explicitly says "0" -> disabled. Any other value + // (including "1") or an unreadable/missing file -> assume enabled, which + // is the modern-kernel default. + if (v == "0") { + return false; + } + } + return true; +} + std::pair<bool, qint64> ProtonLauncher::launch() const { qint64 pid = -1; @@ -606,6 +637,35 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const env.insert(it.key(), it.value()); } + // If a saves bind mount was requested (and the kernel supports + // unprivileged user namespaces), wrap the whole invocation in + // `unshare --user --mount -r` so the mount lives only inside the game's + // process tree and is torn down automatically on exit. Must happen + // AFTER wrapProgram but BEFORE startWithEnv so that SLR/pressure-vessel + // inherits the mount from the outer namespace. + if (!m_bindMountSource.isEmpty() && !m_bindMountTarget.isEmpty() && + unprivilegedBindMountSupported()) { + const QString unshareBin = QStandardPaths::findExecutable("unshare"); + QStringList newArgs; + newArgs << "--user" << "--mount" << "-r" << "--" + << "/bin/sh" << "-c" + << "mount --bind \"$1\" \"$2\" && shift 2 && exec \"$@\"" + << "_mo2bind" + << m_bindMountSource + << m_bindMountTarget + << program; + newArgs.append(arguments); + program = unshareBin; + arguments = newArgs; + MOBase::log::info("Saves bind mount: '{}' -> '{}'", m_bindMountSource, + m_bindMountTarget); + } else if (!m_bindMountSource.isEmpty()) { + MOBase::log::warn("Saves bind mount requested but unprivileged user " + "namespaces unavailable; game will write to prefix " + "'{}' directly", + m_bindMountTarget); + } + MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary); MOBase::log::info("Final command: '{}' {}", program, arguments.join(" ").toStdString()); diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h index 13be35a..c9b5de8 100644 --- a/src/src/protonlauncher.h +++ b/src/src/protonlauncher.h @@ -26,6 +26,18 @@ public: ProtonLauncher& addEnvVar(const QString& key, const QString& value); ProtonLauncher& setUseTerminal(bool useTerminal); + // Bind-mount `source` over `target` inside a per-launch user+mount + // namespace so the game's view of `target` redirects to `source`. Used to + // make `<prefix>/__MO_Saves` resolve to the profile's saves dir without + // symlinks (which Wine can accidentally replace with a real directory). + // Both paths must already exist before launch; the mount is torn down + // automatically when the game process tree exits. + ProtonLauncher& setSavesBindMount(const QString& source, const QString& target); + + // True iff the running kernel supports unprivileged user namespaces with + // CAP_SYS_ADMIN so that `setSavesBindMount` will actually take effect. + static bool unprivilegedBindMountSupported(); + // Launch dispatch: Proton -> Direct std::pair<bool, qint64> launch() const; @@ -47,6 +59,8 @@ private: QMap<QString, QString> m_envVars; QMap<QString, QString> m_wrapperEnvVars; bool m_useTerminal = false; + QString m_bindMountSource; + QString m_bindMountTarget; }; #endif // PROTONLAUNCHER_H diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index 76c1787..59bb7d6 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -686,6 +686,10 @@ int spawn(const SpawnParameters &sp, pid_t &processId) { if (!wrapper.isEmpty()) {
launcher.setWrapper(wrapper);
}
+
+ if (!sp.saveBindMountSource.isEmpty() && !sp.saveBindMountTarget.isEmpty()) {
+ launcher.setSavesBindMount(sp.saveBindMountSource, sp.saveBindMountTarget);
+ }
} else {
MOBase::log::info("Proton disabled for this executable, launching directly");
}
diff --git a/src/src/spawn.h b/src/src/spawn.h index 378c20f..da124b3 100644 --- a/src/src/spawn.h +++ b/src/src/spawn.h @@ -65,6 +65,13 @@ struct SpawnParameters #else
int stdOut = -1;
int stdErr = -1;
+ // When both are set and unprivileged user namespaces are available,
+ // spawn() wraps the launch so `saveBindMountTarget` becomes a live view
+ // of `saveBindMountSource` for the duration of the game process tree.
+ // Used to redirect `<prefix>/__MO_Saves` to the profile's saves dir
+ // without symlinks, which Wine can accidentally replace.
+ QString saveBindMountSource;
+ QString saveBindMountTarget;
#endif
};
|
