aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/src/downloadlistview.cpp28
-rw-r--r--src/src/organizercore.cpp78
-rw-r--r--src/src/organizercore.h4
-rw-r--r--src/src/processrunner.cpp9
-rw-r--r--src/src/protonlauncher.cpp60
-rw-r--r--src/src/protonlauncher.h14
-rw-r--r--src/src/spawn.cpp4
-rw-r--r--src/src/spawn.h7
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
};