aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/plugins/rootbuilder.py368
-rw-r--r--src/src/createinstancedialog.cpp9
-rw-r--r--src/src/createinstancedialogpages.cpp40
-rw-r--r--src/src/filedialogmemory.cpp9
-rw-r--r--src/src/fuseconnector.cpp165
-rw-r--r--src/src/fuseconnector.h9
-rw-r--r--src/src/instancemanager.cpp32
-rw-r--r--src/src/instancemanager.h6
-rw-r--r--src/src/instancemanagerdialog.cpp59
-rw-r--r--src/src/loglist.cpp51
-rw-r--r--src/src/main.cpp24
-rw-r--r--src/src/modinfo.cpp13
-rw-r--r--src/src/nxmhandler_linux.cpp4
-rw-r--r--src/src/nxmhandler_linux.h1
-rw-r--r--src/src/organizercore.cpp40
-rw-r--r--src/src/processrunner.cpp647
-rw-r--r--src/src/profile.cpp26
-rw-r--r--src/src/protonlauncher.cpp18
-rw-r--r--src/src/settings.cpp320
-rw-r--r--src/src/settings.h5
-rw-r--r--src/src/settingsdialogpaths.cpp31
-rw-r--r--src/src/settingsdialogproton.cpp30
-rw-r--r--src/src/vfs/vfs_helper_main.cpp9
-rw-r--r--src/src/vfs/vfstree.cpp19
-rw-r--r--src/src/vfs/vfstree.h7
25 files changed, 1318 insertions, 624 deletions
diff --git a/src/plugins/rootbuilder.py b/src/plugins/rootbuilder.py
index 7237400..fbe082c 100644
--- a/src/plugins/rootbuilder.py
+++ b/src/plugins/rootbuilder.py
@@ -15,6 +15,7 @@ import shutil
import subprocess
import mobase
+from PyQt6.QtCore import qInfo, qWarning
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (
QCheckBox,
@@ -26,8 +27,16 @@ from PyQt6.QtWidgets import (
QVBoxLayout,
)
-MANIFEST_NAME = ".rootbuilder_manifest.json"
-BACKUP_DIR_NAME = ".rootbuilder_backup"
+# Storage lives under <instance_dir>/rootbuilder/ — NOT the game directory,
+# which Steam/Wine may make read-only during gameplay.
+_STORAGE_SUBDIR = "rootbuilder"
+_MANIFEST_NAME = "manifest.json"
+_SETTINGS_NAME = "settings.json"
+_BACKUP_SUBDIR = "backup"
+
+# Legacy names (stored in game dir by older versions)
+_LEGACY_MANIFEST = ".rootbuilder_manifest.json"
+_LEGACY_BACKUP = ".rootbuilder_backup"
def _find_root_dir(mod_path: str) -> str | None:
@@ -48,51 +57,116 @@ def _walk_files(root_dir: str):
yield os.path.join(dirpath, name)
+def _host_cp(src: str, dst: str) -> bool:
+ """Copy a file via the host OS (bypasses Flatpak sandbox restrictions)."""
+ try:
+ result = subprocess.run(
+ ["flatpak-spawn", "--host", "cp", "-f", "--reflink=auto", "--", src, dst],
+ capture_output=True, timeout=30,
+ )
+ return result.returncode == 0
+ except (subprocess.SubprocessError, OSError):
+ return False
+
+
def _reflink_copy(src: str, dst: str):
"""Copy with reflink (CoW) if supported, fallback to regular copy."""
try:
subprocess.run(
- ["cp", "--reflink=auto", "--", src, dst],
+ ["cp", "--reflink=auto", "-f", "--", src, dst],
check=True,
capture_output=True,
)
+ return
except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ try:
shutil.copy2(src, dst)
+ return
+ except OSError:
+ pass
+ if _IN_FLATPAK and _host_cp(src, dst):
+ return
+ raise OSError(f"Root Builder: failed to copy {src} -> {dst}")
-def _manifest_path(game_dir: str) -> str:
- return os.path.join(game_dir, MANIFEST_NAME)
+def _ensure_writable(path: str):
+ """Make a file or directory writable by the owner."""
+ try:
+ st = os.stat(path)
+ os.chmod(path, st.st_mode | 0o200)
+ except OSError:
+ pass
-def _load_manifest(game_dir: str) -> dict | None:
- path = _manifest_path(game_dir)
- if not os.path.isfile(path):
- return None
+_IN_FLATPAK = os.path.exists("/.flatpak-info")
+
+
+def _host_rm(path: str) -> bool:
+ """Remove a file via the host OS (bypasses Flatpak sandbox restrictions)."""
try:
- with open(path, "r") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return None
+ result = subprocess.run(
+ ["flatpak-spawn", "--host", "rm", "-f", "--", path],
+ capture_output=True, timeout=10,
+ )
+ return result.returncode == 0
+ except (subprocess.SubprocessError, OSError):
+ return False
-def _save_manifest(game_dir: str, manifest: dict):
- path = _manifest_path(game_dir)
- with open(path, "w") as f:
- json.dump(manifest, f, indent=2)
+def _host_mv(src: str, dst: str) -> bool:
+ """Move a file via the host OS (bypasses Flatpak sandbox restrictions)."""
+ try:
+ result = subprocess.run(
+ ["flatpak-spawn", "--host", "mv", "-f", "--", src, dst],
+ capture_output=True, timeout=10,
+ )
+ return result.returncode == 0
+ except (subprocess.SubprocessError, OSError):
+ return False
-def _remove_manifest(game_dir: str):
- path = _manifest_path(game_dir)
- if os.path.isfile(path):
+def _force_remove(path: str) -> bool:
+ """Remove a file, fixing permissions if needed. Returns True on success."""
+ try:
+ os.remove(path)
+ return True
+ except PermissionError:
+ pass
+ try:
+ _ensure_writable(os.path.dirname(path))
+ _ensure_writable(path)
os.remove(path)
+ return True
+ except OSError:
+ pass
+ if _IN_FLATPAK and _host_rm(path):
+ return True
+ qWarning(f"Root Builder: could not remove {path}")
+ return False
-def _cleanup_empty_dirs(game_dir: str, deployed: list[str]):
+def _rmtree_onerror(_func, path, _exc_info):
+ """onerror handler for shutil.rmtree that fixes permissions and retries."""
+ try:
+ _ensure_writable(os.path.dirname(path))
+ _ensure_writable(path)
+ os.remove(path)
+ except OSError:
+ pass
+
+
+def _cleanup_empty_dirs(base_dir: str, paths: list[str]):
"""Remove empty directories left behind after clearing deployed files."""
dirs_to_check = set()
- for path in deployed:
+ for path in paths:
parent = os.path.dirname(path)
- while parent and parent != game_dir and not os.path.samefile(parent, game_dir):
+ while parent and parent != base_dir:
+ try:
+ if os.path.samefile(parent, base_dir):
+ break
+ except OSError:
+ break
dirs_to_check.add(parent)
parent = os.path.dirname(parent)
@@ -104,15 +178,44 @@ def _cleanup_empty_dirs(game_dir: str, deployed: list[str]):
pass
+# --- Manifest helpers (operate on storage_dir, NOT game dir) ---
+
+def _load_manifest(storage_dir: str) -> dict | None:
+ path = os.path.join(storage_dir, _MANIFEST_NAME)
+ if not os.path.isfile(path):
+ return None
+ try:
+ with open(path, "r") as f:
+ return json.load(f)
+ except (OSError, json.JSONDecodeError):
+ return None
+
+
+def _save_manifest(storage_dir: str, manifest: dict):
+ os.makedirs(storage_dir, exist_ok=True)
+ path = os.path.join(storage_dir, _MANIFEST_NAME)
+ with open(path, "w") as f:
+ json.dump(manifest, f, indent=2)
+
+
+def _remove_manifest(storage_dir: str):
+ path = os.path.join(storage_dir, _MANIFEST_NAME)
+ if os.path.isfile(path):
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+
class RootBuilderDialog(QDialog):
"""Small settings/control dialog shown from the Tools menu."""
- def __init__(self, organizer: mobase.IOrganizer, build_fn, clear_fn, parent=None):
+ def __init__(self, settings: dict, save_fn, build_fn, clear_fn, parent=None):
super().__init__(parent)
- self._organizer = organizer
+ self._settings = settings
+ self._save_fn = save_fn
self._build_fn = build_fn
self._clear_fn = clear_fn
- self._plugin_name = "Root Builder"
self.setWindowTitle("Root Builder")
self.resize(350, 220)
@@ -125,9 +228,7 @@ class RootBuilderDialog(QDialog):
# Enable checkbox
self._enableCheck = QCheckBox("Auto-deploy on game launch")
- self._enableCheck.setChecked(
- bool(organizer.pluginSetting(self._plugin_name, "enabled"))
- )
+ self._enableCheck.setChecked(settings.get("enabled", False) is True)
layout.addWidget(self._enableCheck)
# Mode selector
@@ -135,8 +236,7 @@ class RootBuilderDialog(QDialog):
mode_layout.addWidget(QLabel("Deploy mode:"))
self._modeCombo = QComboBox()
self._modeCombo.addItems(["copy", "link"])
- current = organizer.pluginSetting(self._plugin_name, "mode")
- self._modeCombo.setCurrentText(current if current else "copy")
+ self._modeCombo.setCurrentText(settings.get("mode", "copy"))
mode_layout.addWidget(self._modeCombo)
layout.addLayout(mode_layout)
@@ -159,28 +259,23 @@ class RootBuilderDialog(QDialog):
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
- def _save_settings(self):
- self._organizer.setPluginSetting(
- self._plugin_name, "enabled", self._enableCheck.isChecked()
- )
- self._organizer.setPluginSetting(
- self._plugin_name, "mode", self._modeCombo.currentText()
- )
+ # Save settings whenever the user changes them
+ self._enableCheck.stateChanged.connect(lambda _: self._do_save())
+ self._modeCombo.currentTextChanged.connect(lambda _: self._do_save())
+
+ def _do_save(self):
+ self._settings["enabled"] = self._enableCheck.isChecked()
+ self._settings["mode"] = self._modeCombo.currentText()
+ self._save_fn(self._settings)
def _on_build(self):
- self._save_settings()
count = self._build_fn()
self._status.setText(f"Deployed {count} file(s).")
def _on_clear(self):
- self._save_settings()
count = self._clear_fn()
self._status.setText(f"Cleared {count} file(s).")
- def accept(self):
- self._save_settings()
- super().accept()
-
class RootBuilder(mobase.IPluginTool):
_organizer: mobase.IOrganizer
@@ -193,24 +288,77 @@ class RootBuilder(mobase.IPluginTool):
def init(self, organizer: mobase.IOrganizer) -> bool:
self._organizer = organizer
+ self._migrate_legacy()
self._check_third_party_rootbuilder()
organizer.onAboutToRun(self._on_about_to_run)
organizer.onFinishedRun(self._on_finished_run)
return True
+ # --- Storage paths (instance dir, always writable) ---
+
+ def _storage_dir(self) -> str:
+ d = os.path.join(self._organizer.basePath(), _STORAGE_SUBDIR)
+ os.makedirs(d, exist_ok=True)
+ return d
+
+ # --- Settings (our own JSON, not pluginSetting) ---
+
+ def _load_settings(self) -> dict:
+ path = os.path.join(self._storage_dir(), _SETTINGS_NAME)
+ try:
+ with open(path, "r") as f:
+ return json.load(f)
+ except (OSError, json.JSONDecodeError, ValueError):
+ return {"enabled": False, "mode": "copy"}
+
+ def _save_settings(self, settings: dict):
+ path = os.path.join(self._storage_dir(), _SETTINGS_NAME)
+ with open(path, "w") as f:
+ json.dump(settings, f, indent=2)
+
+ def _is_enabled(self) -> bool:
+ return self._load_settings().get("enabled", False) is True
+
+ # --- Legacy migration ---
+
+ def _migrate_legacy(self):
+ """Move legacy manifest/backup from game dir to our storage dir."""
+ game = self._organizer.managedGame()
+ if game is None:
+ return
+ game_dir = game.gameDirectory().absolutePath()
+ storage = self._storage_dir()
+
+ old_manifest = os.path.join(game_dir, _LEGACY_MANIFEST)
+ if os.path.isfile(old_manifest):
+ try:
+ new_path = os.path.join(storage, _MANIFEST_NAME)
+ if not os.path.isfile(new_path):
+ shutil.copy2(old_manifest, new_path)
+ _force_remove(old_manifest)
+ except OSError:
+ pass
+
+ old_backup = os.path.join(game_dir, _LEGACY_BACKUP)
+ if os.path.isdir(old_backup):
+ try:
+ new_backup = os.path.join(storage, _BACKUP_SUBDIR)
+ if not os.path.isdir(new_backup):
+ shutil.copytree(old_backup, new_backup)
+ shutil.rmtree(old_backup, onerror=_rmtree_onerror)
+ except OSError:
+ pass
+
def _check_third_party_rootbuilder(self):
"""Move any third-party Root Builder plugins into DisabledPlugins/."""
plugins_dir = os.path.dirname(os.path.abspath(__file__))
disabled_dir = os.path.join(os.path.dirname(plugins_dir), "DisabledPlugins")
my_file = os.path.basename(__file__)
- # Collect conflicts first, then move (don't modify dir during iteration)
conflicts = []
for entry in os.scandir(plugins_dir):
- # Kezyma's standard install: plugins/rootbuilder/
if entry.is_dir() and entry.name.lower() == "rootbuilder":
conflicts.append((entry.name, entry.path))
- # Other rootbuilder*.py files that aren't us
elif (
entry.is_file()
and entry.name.lower().startswith("rootbuilder")
@@ -224,17 +372,15 @@ class RootBuilder(mobase.IPluginTool):
try:
os.makedirs(disabled_dir, exist_ok=True)
shutil.move(path, dst)
- mobase.log(
- mobase.LogLevel.INFO,
+ qInfo(
f"Root Builder: moved incompatible third-party plugin "
f"'{name}' to DisabledPlugins/. "
- f"It uses Windows-only USVFS and cannot work on Linux.",
+ f"It uses Windows-only USVFS and cannot work on Linux."
)
except OSError as e:
- mobase.log(
- mobase.LogLevel.WARNING,
+ qWarning(
f"Root Builder: failed to move third-party plugin "
- f"'{name}' to DisabledPlugins/: {e}",
+ f"'{name}' to DisabledPlugins/: {e}"
)
def name(self) -> str:
@@ -256,15 +402,10 @@ class RootBuilder(mobase.IPluginTool):
return mobase.VersionInfo(1, 0, 0)
def enabledByDefault(self) -> bool:
- return False
+ return True
def settings(self) -> list[mobase.PluginSetting]:
- return [
- mobase.PluginSetting("mode", "Deploy mode: copy or link", "copy"),
- mobase.PluginSetting(
- "enabled", "Auto-deploy root files on launch", True
- ),
- ]
+ return []
# --- IPluginTool ---
@@ -281,20 +422,22 @@ class RootBuilder(mobase.IPluginTool):
self.__parentWidget = widget
def display(self):
+ settings = self._load_settings()
dialog = RootBuilderDialog(
- self._organizer, self._build, self._clear, self.__parentWidget
+ settings, self._save_settings, self._build, self._clear,
+ self.__parentWidget,
)
dialog.exec()
# --- Hooks ---
def _on_about_to_run(self, executable: str) -> bool:
- if self._organizer.pluginSetting(self.name(), "enabled"):
+ if self._is_enabled():
self._build()
return True
def _on_finished_run(self, executable: str, exit_code: int):
- if self._organizer.pluginSetting(self.name(), "enabled"):
+ if self._is_enabled():
self._clear()
# --- Build / Clear ---
@@ -302,16 +445,17 @@ class RootBuilder(mobase.IPluginTool):
def _build(self) -> int:
"""Deploy root files from all active mods. Returns number of files deployed."""
game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
+ storage = self._storage_dir()
mod_list = self._organizer.modList()
mods = mod_list.allModsByProfilePriority()
- mode = self._organizer.pluginSetting(self.name(), "mode") or "copy"
+ mode = self._load_settings().get("mode", "copy")
# Clear any previous deployment first
- if _load_manifest(game_dir) is not None:
+ if _load_manifest(storage) is not None:
self._clear()
manifest = {"deployed": [], "backups": {}}
- backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
+ backup_dir = os.path.join(storage, _BACKUP_SUBDIR)
deployed_set = set()
for mod_name in mods:
@@ -333,62 +477,96 @@ class RootBuilder(mobase.IPluginTool):
rel = os.path.relpath(src_file, root_dir)
dst = os.path.join(game_dir, rel)
- # Backup existing file if not already deployed by us
- if os.path.exists(dst) and dst not in deployed_set:
- bak = os.path.join(backup_dir, rel)
- os.makedirs(os.path.dirname(bak), exist_ok=True)
- shutil.move(dst, bak)
- manifest["backups"][dst] = bak
+ try:
+ # Backup existing file if not already deployed by us
+ if os.path.exists(dst) and dst not in deployed_set:
+ bak = os.path.join(backup_dir, rel)
+ os.makedirs(os.path.dirname(bak), exist_ok=True)
+ try:
+ shutil.copy2(dst, bak)
+ except PermissionError:
+ _ensure_writable(dst)
+ shutil.copy2(dst, bak)
+ manifest["backups"][dst] = bak
- os.makedirs(os.path.dirname(dst), exist_ok=True)
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
- if os.path.lexists(dst):
- os.remove(dst)
+ if os.path.lexists(dst):
+ _force_remove(dst)
- # In link mode, .exe and .dll must be copied — Wine/Proton
- # resolves a symlinked exe's path to the target, so the
- # process can't find sibling files in the game directory.
- ext = os.path.splitext(src_file)[1].lower()
- if mode == "link" and ext not in (".exe", ".dll"):
- os.symlink(src_file, dst)
- else:
- _reflink_copy(src_file, dst)
+ # In link mode, .exe and .dll must be copied — Wine/Proton
+ # resolves a symlinked exe's path to the target, so the
+ # process can't find sibling files in the game directory.
+ ext = os.path.splitext(src_file)[1].lower()
+ if mode == "link" and ext not in (".exe", ".dll"):
+ os.symlink(src_file, dst)
+ else:
+ _reflink_copy(src_file, dst)
- if dst not in deployed_set:
- manifest["deployed"].append(dst)
- deployed_set.add(dst)
+ if dst not in deployed_set:
+ manifest["deployed"].append(dst)
+ deployed_set.add(dst)
+ except OSError as e:
+ qWarning(f"Root Builder: could not deploy {rel}: {e}")
- _save_manifest(game_dir, manifest)
+ _save_manifest(storage, manifest)
return len(manifest["deployed"])
def _clear(self) -> int:
"""Remove deployed files and restore backups. Returns count of removed files."""
game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
- manifest = _load_manifest(game_dir)
+ storage = self._storage_dir()
+ manifest = _load_manifest(storage)
if manifest is None:
return 0
count = 0
+ failed = []
# Remove deployed files
for path in manifest["deployed"]:
if os.path.lexists(path):
- os.remove(path)
- count += 1
+ if _force_remove(path):
+ count += 1
+ else:
+ failed.append(path)
# Restore backups
for dst, bak in manifest["backups"].items():
if os.path.exists(bak):
- os.makedirs(os.path.dirname(dst), exist_ok=True)
- shutil.move(bak, dst)
+ try:
+ parent = os.path.dirname(dst)
+ os.makedirs(parent, exist_ok=True)
+ _ensure_writable(parent)
+ if os.path.lexists(dst):
+ _force_remove(dst)
+ shutil.move(bak, dst)
+ except OSError:
+ if not (_IN_FLATPAK and _host_mv(bak, dst)):
+ qWarning(
+ f"Root Builder: could not restore backup "
+ f"{bak} -> {dst}"
+ )
# Clean up backup dir
- backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
+ backup_dir = os.path.join(storage, _BACKUP_SUBDIR)
if os.path.isdir(backup_dir):
shutil.rmtree(backup_dir, ignore_errors=True)
- _remove_manifest(game_dir)
- _cleanup_empty_dirs(game_dir, manifest["deployed"])
+ if failed:
+ # Update manifest to only contain files we couldn't remove,
+ # so the next clear attempt can retry them.
+ manifest["deployed"] = failed
+ manifest["backups"] = {}
+ _save_manifest(storage, manifest)
+ qWarning(
+ f"Root Builder: {len(failed)} file(s) could not be removed. "
+ f"They will be retried on next clear."
+ )
+ else:
+ _remove_manifest(storage)
+
+ _cleanup_empty_dirs(game_dir, [p for p in manifest["deployed"] if p not in failed])
return count
diff --git a/src/src/createinstancedialog.cpp b/src/src/createinstancedialog.cpp
index d3dcdb2..10bcce5 100644
--- a/src/src/createinstancedialog.cpp
+++ b/src/src/createinstancedialog.cpp
@@ -390,6 +390,15 @@ void CreateInstanceDialog::finish()
logCreation(tr("Done."));
+ // register non-default portable instances so they appear in the sidebar
+ if (ci.type == Portable) {
+ const auto defaultPortable =
+ QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ if (QDir(ci.dataPath).absolutePath() != defaultPortable) {
+ InstanceManager::singleton().registerPortableInstance(ci.dataPath);
+ }
+ }
+
// launch the new instance
if (ui->launch->isChecked()) {
if (ci.type == Portable) {
diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp
index 7c4f167..3138a17 100644
--- a/src/src/createinstancedialogpages.cpp
+++ b/src/src/createinstancedialogpages.cpp
@@ -6,6 +6,7 @@
#include "settingsdialognexus.h"
#include "shared/appconfig.h"
#include "ui_createinstancedialog.h"
+#include <QFileDialog>
#include <iplugingame.h>
#include <report.h>
#include <utility.h>
@@ -13,6 +14,19 @@
namespace cid
{
+// On Flatpak the native file dialog returns XDG portal FUSE paths that may
+// not properly expose directory contents. Returns options that force the
+// Qt built-in dialog when running inside Flatpak.
+static QFileDialog::Options flatpakSafeOptions()
+{
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
+ return opts;
+}
+
using namespace MOBase;
using MOBase::TaskDialog;
@@ -314,7 +328,8 @@ void GamePage::select(IPluginGame* game, const QString& dir)
const auto path = QFileDialog::getExistingDirectory(
&m_dlg,
- QObject::tr("Find game installation for %1").arg(game->displayGameName()));
+ QObject::tr("Find game installation for %1").arg(game->displayGameName()),
+ {}, flatpakSafeOptions());
if (path.isEmpty()) {
// cancelled
@@ -361,8 +376,8 @@ void GamePage::select(IPluginGame* game, const QString& dir)
void GamePage::selectCustom()
{
- const auto path =
- QFileDialog::getExistingDirectory(&m_dlg, QObject::tr("Find game installation"));
+ const auto path = QFileDialog::getExistingDirectory(
+ &m_dlg, QObject::tr("Find game installation"), {}, flatpakSafeOptions());
if (path.isEmpty()) {
// reselect the previous button
@@ -1045,6 +1060,20 @@ void PathsPage::doActivated(bool firstTime)
// regenerated
const bool changed = (m_lastInstanceName != name) || (m_lastType != type);
+ const bool isGlobal = (type == CreateInstanceDialog::Global);
+
+ // Global instances have a fixed location derived from the instance name;
+ // the user should not be able to browse to an arbitrary directory.
+ ui->location->setReadOnly(isGlobal);
+ ui->browseLocation->setVisible(!isGlobal);
+ ui->advancedPathOptions->setVisible(!isGlobal);
+
+ // If switching from portable back to global, reset to simple view
+ if (isGlobal && ui->advancedPathOptions->isChecked()) {
+ ui->advancedPathOptions->setChecked(false);
+ ui->pathPages->setCurrentIndex(0);
+ }
+
// generating and paths
setPaths(name, changed);
checkPaths();
@@ -1085,7 +1114,8 @@ void PathsPage::onChanged()
void PathsPage::browse(QLineEdit* e)
{
- const auto s = QFileDialog::getExistingDirectory(&m_dlg, {}, e->text());
+ const auto s =
+ QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), flatpakSafeOptions());
if (s.isNull() || s.isEmpty()) {
return;
}
@@ -1148,6 +1178,8 @@ void PathsPage::setPaths(const QString& name, bool force)
} else {
const auto root = InstanceManager::singleton().globalInstancesRootPath();
basePath = root + "/" + name;
+ // Global instances always use the auto-derived path
+ force = true;
}
basePath = QDir::toNativeSeparators(QDir::cleanPath(basePath));
diff --git a/src/src/filedialogmemory.cpp b/src/src/filedialogmemory.cpp
index 390843f..3c4ea11 100644
--- a/src/src/filedialogmemory.cpp
+++ b/src/src/filedialogmemory.cpp
@@ -72,6 +72,15 @@ QString FileDialogMemory::getExistingDirectory(const QString& dirID, QWidget* pa
}
}
+#ifndef _WIN32
+ // On Flatpak, the native file dialog goes through the XDG Desktop Portal,
+ // which returns FUSE paths (/run/user/.../doc/...) that may not properly
+ // expose directory contents. Use the Qt built-in dialog to get real paths.
+ if (qEnvironmentVariableIsSet("FLATPAK_ID")) {
+ options |= QFileDialog::DontUseNativeDialog;
+ }
+#endif
+
QString result =
QFileDialog::getExistingDirectory(parent, caption, currentDir, options);
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 2e3a7ba..a276074 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -252,8 +252,11 @@ bool FuseConnector::mount(
m_dataDirName = data_dir_name.toStdString();
m_lastMods = mods;
- // Compute the actual data directory path and mount directly on it
- m_dataDirPath = (fs::path(m_gameDir) / m_dataDirName).string();
+ // Use the caller-supplied data directory path directly. Re-computing it
+ // as gameDir/dataDirName breaks games where the data directory IS the game
+ // directory (e.g. BG3 with GameDataPath=""), because dirName() returns the
+ // last path component and appending it produces a non-existent double path.
+ m_dataDirPath = mount_point.toStdString();
m_mountPoint = m_dataDirPath;
if (!fs::exists(m_dataDirPath)) {
@@ -275,10 +278,19 @@ bool FuseConnector::mount(
fs::create_directories(m_stagingDir, ec);
fs::create_directories(m_overwriteDir, ec);
- // Scan + cache base game files BEFORE mounting (after mount they're hidden)
- m_baseFileCache = scanDataDir(m_dataDirPath);
- log::debug("Cached {} base game entries from {}", m_baseFileCache.size(),
- QString::fromStdString(m_dataDirPath));
+ // Scan + cache base game files BEFORE mounting (after mount they're hidden).
+ // Reuse the cache across mount/unmount cycles since base game files don't
+ // change between runs — this avoids a full recursive directory walk on
+ // every launch.
+ if (m_baseFileCache.empty() || m_dataDirPath != m_cachedDataDirPath) {
+ m_baseFileCache = scanDataDir(m_dataDirPath);
+ m_cachedDataDirPath = m_dataDirPath;
+ log::debug("Scanned {} base game entries from {}", m_baseFileCache.size(),
+ QString::fromStdString(m_dataDirPath));
+ } else {
+ log::debug("Reusing cached {} base game entries for {}",
+ m_baseFileCache.size(), QString::fromStdString(m_dataDirPath));
+ }
// Open fd to data dir BEFORE mounting so we can access original files
m_backingFd = open(m_dataDirPath.c_str(), O_RDONLY | O_DIRECTORY);
@@ -292,6 +304,9 @@ bool FuseConnector::mount(
auto tree = std::make_shared<VfsTree>(
buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir));
+ // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt)
+ injectExtraFiles(*tree, m_extraVfsFiles);
+
m_context = std::make_shared<Mo2FsContext>();
m_context->tree = tree;
m_context->inodes = std::make_unique<InodeTable>();
@@ -362,6 +377,7 @@ void FuseConnector::unmount()
m_helperProcess = nullptr;
m_mounted = false;
setFuseMountPointForCrashCleanup(nullptr);
+ cleanupExternalMappings();
log::debug("VFS helper stopped, FUSE unmounted from {}",
QString::fromStdString(m_mountPoint));
return;
@@ -392,6 +408,9 @@ void FuseConnector::unmount()
m_mounted = false;
setFuseMountPointForCrashCleanup(nullptr);
+ // Clean up symlinks created for non-data-dir mappings.
+ cleanupExternalMappings();
+
log::debug("FUSE unmounted from {}", QString::fromStdString(m_mountPoint));
}
@@ -431,6 +450,9 @@ void FuseConnector::rebuild(
auto newTree = std::make_shared<VfsTree>(
buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir));
+ // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt)
+ injectExtraFiles(*newTree, m_extraVfsFiles);
+
std::unique_lock lock(m_context->tree_mutex);
m_context->tree.swap(newTree);
}
@@ -449,14 +471,138 @@ void FuseConnector::updateMapping(const MappingType& mapping)
auto mods = buildModsFromMapping(mapping, dataDirPath, overwriteDir);
+ // Deploy non-data-dir mappings as real symlinks and collect file-level
+ // data-dir mappings for VFS tree injection.
+ deployExternalMappings(mapping, dataDirPath);
+
if (!m_mounted) {
- // mount_point param is ignored — mount() computes it from gameDir + dataDirName
mount(dataDirPath, overwriteDir, gameDir, dataDirName, mods);
} else {
rebuild(mods, overwriteDir, dataDirName);
}
}
+void FuseConnector::deployExternalMappings(const MappingType& mapping,
+ const QString& dataDir)
+{
+ cleanupExternalMappings();
+ m_extraVfsFiles.clear();
+
+ const QString cleanDataDir = QDir::cleanPath(dataDir);
+ const QString dataPrefix = cleanDataDir + QStringLiteral("/");
+
+ for (const auto& map : mapping) {
+ const QString src =
+ QDir::cleanPath(QDir::fromNativeSeparators(map.source));
+ const QString dst =
+ QDir::cleanPath(QDir::fromNativeSeparators(map.destination));
+
+ const bool targetsDataDir =
+ (dst == cleanDataDir || dst.startsWith(dataPrefix));
+
+ if (targetsDataDir) {
+ if (!map.isDirectory) {
+ // File-level mapping INTO the data directory (e.g. plugins.txt).
+ // FUSE sits on top, so we cannot create a physical symlink there.
+ // Record it for injection into the VFS tree instead.
+ const QString relPath = dst.startsWith(dataPrefix)
+ ? dst.mid(dataPrefix.length())
+ : QFileInfo(src).fileName();
+ m_extraVfsFiles.emplace_back(relPath.toStdString(), src.toStdString());
+ }
+ // Directory-level data-dir mappings are handled by the FUSE VFS.
+ continue;
+ }
+
+ // Non-data-dir mapping — deploy via real symlinks so the game
+ // (running through Proton) can see the files.
+ std::error_code ec;
+
+ if (map.isDirectory) {
+ const fs::path srcPath(src.toStdString());
+ if (!fs::exists(srcPath, ec)) {
+ continue;
+ }
+
+ for (auto it = fs::recursive_directory_iterator(
+ srcPath, fs::directory_options::skip_permission_denied);
+ it != fs::recursive_directory_iterator(); ++it) {
+ const auto& entry = *it;
+ const fs::path rel = fs::relative(entry.path(), srcPath, ec);
+ if (ec || rel.empty()) {
+ continue;
+ }
+
+ const fs::path destPath = fs::path(dst.toStdString()) / rel;
+ if (entry.is_directory(ec)) {
+ fs::create_directories(destPath, ec);
+ } else if (entry.is_regular_file(ec) || entry.is_symlink(ec)) {
+ fs::create_directories(destPath.parent_path(), ec);
+ if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) {
+ // Never overwrite real game files — only replace our own symlinks.
+ continue;
+ }
+ if (fs::is_symlink(destPath, ec)) {
+ fs::remove(destPath, ec);
+ }
+ fs::create_symlink(entry.path(), destPath, ec);
+ if (!ec) {
+ m_externalSymlinks.push_back(destPath.string());
+ } else {
+ log::warn("Failed to symlink {} -> {}: {}",
+ QString::fromStdString(destPath.string()),
+ QString::fromStdString(entry.path().string()),
+ QString::fromStdString(ec.message()));
+ }
+ }
+ }
+ } else {
+ // Single file symlink.
+ const fs::path destPath(dst.toStdString());
+ fs::create_directories(destPath.parent_path(), ec);
+ if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) {
+ continue;
+ }
+ if (fs::is_symlink(destPath, ec)) {
+ fs::remove(destPath, ec);
+ }
+ fs::create_symlink(fs::path(src.toStdString()), destPath, ec);
+ if (!ec) {
+ m_externalSymlinks.push_back(destPath.string());
+ } else {
+ log::warn("Failed to symlink {} -> {}: {}", dst, src,
+ QString::fromStdString(ec.message()));
+ }
+ }
+ }
+
+ if (!m_externalSymlinks.empty()) {
+ log::debug("Deployed {} external symlinks for non-data-dir mappings",
+ m_externalSymlinks.size());
+ }
+ if (!m_extraVfsFiles.empty()) {
+ log::debug("Collected {} extra file mappings for VFS injection",
+ m_extraVfsFiles.size());
+ }
+}
+
+void FuseConnector::cleanupExternalMappings()
+{
+ if (m_externalSymlinks.empty()) {
+ return;
+ }
+
+ std::error_code ec;
+ for (const auto& path : m_externalSymlinks) {
+ if (fs::is_symlink(path, ec)) {
+ fs::remove(path, ec);
+ }
+ }
+
+ log::debug("Cleaned up {} external symlinks", m_externalSymlinks.size());
+ m_externalSymlinks.clear();
+}
+
void FuseConnector::updateParams(MOBase::log::Levels /*logLevel*/,
env::CoreDumpTypes /*coreDumpType*/,
const QString& /*crashDumpsPath*/,
@@ -682,4 +828,9 @@ void FuseConnector::writeVfsConfig(
out << "mod=" << QString::fromStdString(name) << "|"
<< QString::fromStdString(path) << "\n";
}
+
+ for (const auto& [relPath, realPath] : m_extraVfsFiles) {
+ out << "extra_file=" << QString::fromStdString(relPath) << "|"
+ << QString::fromStdString(realPath) << "\n";
+ }
}
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index b33c52c..fd5cb01 100644
--- a/src/src/fuseconnector.h
+++ b/src/src/fuseconnector.h
@@ -61,6 +61,8 @@ public:
private:
void flushStaging();
+ void deployExternalMappings(const MappingType& mapping, const QString& dataDir);
+ void cleanupExternalMappings();
std::string m_mountPoint;
std::string m_stagingDir;
@@ -70,9 +72,16 @@ private:
std::string m_dataDirPath;
int m_backingFd = -1;
std::vector<CachedBaseFile> m_baseFileCache;
+ std::string m_cachedDataDirPath;
std::vector<std::pair<std::string, std::string>> m_lastMods;
+ // Symlinks created for non-data-dir mappings (e.g. Paks, OBSE, UE4SS).
+ std::vector<std::string> m_externalSymlinks;
+ // File-level mappings targeting the data directory (e.g. plugins.txt).
+ // Injected into the VFS tree after building. (relPath, absRealPath)
+ std::vector<std::pair<std::string, std::string>> m_extraVfsFiles;
+
std::shared_ptr<Mo2FsContext> m_context;
struct fuse_session* m_session = nullptr;
diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp
index 0c6ca63..1b9eff1 100644
--- a/src/src/instancemanager.cpp
+++ b/src/src/instancemanager.cpp
@@ -84,10 +84,15 @@ Instance::Instance(QString dir, bool portable, QString profileName)
QString Instance::displayName() const
{
- if (isPortable())
- return QObject::tr("Portable");
- else
+ if (isPortable()) {
+ const auto defaultPortable =
+ QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ if (QDir(m_dir).absolutePath() == defaultPortable) {
+ return QObject::tr("Portable");
+ }
return QDir(m_dir).dirName();
+ }
+ return QDir(m_dir).dirName();
}
QString Instance::gameName() const
@@ -135,9 +140,9 @@ bool Instance::isActive() const
auto& m = InstanceManager::singleton();
if (auto i = m.currentInstance()) {
- if (m_portable) {
- return i->isPortable();
- } else {
+ if (m_portable && i->isPortable()) {
+ return QDir(m_dir).absolutePath() == QDir(i->directory()).absolutePath();
+ } else if (!m_portable && !i->isPortable()) {
return (i->displayName() == displayName());
}
}
@@ -762,6 +767,21 @@ bool InstanceManager::instanceExists(const QString& instanceName) const
return root.exists(instanceName);
}
+QStringList InstanceManager::registeredPortablePaths() const
+{
+ return GlobalSettings::portableInstances();
+}
+
+void InstanceManager::registerPortableInstance(const QString& path)
+{
+ GlobalSettings::addPortableInstance(path);
+}
+
+void InstanceManager::unregisterPortableInstance(const QString& path)
+{
+ GlobalSettings::removePortableInstance(path);
+}
+
std::unique_ptr<Instance> selectInstance()
{
auto& m = InstanceManager::singleton();
diff --git a/src/src/instancemanager.h b/src/src/instancemanager.h
index 794728a..e4bf7a3 100644
--- a/src/src/instancemanager.h
+++ b/src/src/instancemanager.h
@@ -317,6 +317,12 @@ public:
//
QString iniPath(const QString& instanceDir) const;
+ // persistent registry of portable instance paths (stored in GlobalSettings)
+ //
+ QStringList registeredPortablePaths() const;
+ void registerPortableInstance(const QString& path);
+ void unregisterPortableInstance(const QString& path);
+
private:
InstanceManager();
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index 605f80e..24ce3bd 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -258,6 +258,25 @@ void InstanceManagerDialog::updateInstances()
return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0);
});
+ // add registered portable instances (non-default paths)
+ const QString defaultPortable = QDir(m.portablePath()).absolutePath();
+ for (const auto& path : m.registeredPortablePaths()) {
+ // skip the default portable path (handled separately below)
+ if (QDir(path).absolutePath() == defaultPortable) {
+ continue;
+ }
+ // skip paths where ModOrganizer.ini no longer exists
+ if (!QFileInfo::exists(QDir(path).filePath("ModOrganizer.ini"))) {
+ continue;
+ }
+ m_instances.push_back(std::make_unique<Instance>(path, true));
+ }
+
+ // re-sort to interleave registered portables alphabetically
+ std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) {
+ return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0);
+ });
+
if (m.portableInstanceExists()) {
m_instances.insert(m_instances.begin(),
std::make_unique<Instance>(m.portablePath(), true));
@@ -342,8 +361,9 @@ void InstanceManagerDialog::selectActiveInstance()
const auto active = InstanceManager::singleton().currentInstance();
if (active) {
+ const QString activeDir = QDir(active->directory()).absolutePath();
for (std::size_t i = 0; i < m_instances.size(); ++i) {
- if (m_instances[i]->displayName() == active->displayName()) {
+ if (QDir(m_instances[i]->directory()).absolutePath() == activeDir) {
select(i);
ui->list->scrollTo(m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0)));
@@ -588,6 +608,11 @@ void InstanceManagerDialog::deleteInstance()
return;
}
+ // unregister portable instance from the persistent list
+ if (i->isPortable()) {
+ InstanceManager::singleton().unregisterPortableInstance(i->directory());
+ }
+
// updating ui
updateInstances();
updateList();
@@ -750,9 +775,19 @@ void InstanceManagerDialog::setButtonsEnabled(bool b)
void InstanceManagerDialog::openExistingPortable()
{
+ // On Flatpak, the native file dialog goes through the XDG Desktop Portal,
+ // which returns FUSE paths (/run/user/.../doc/...) that may not properly
+ // expose directory contents. Use the Qt built-in dialog to get real paths.
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID")) {
+ opts |= QFileDialog::DontUseNativeDialog;
+ }
+#endif
+
const QString dir = QFileDialog::getExistingDirectory(
this, tr("Select portable instance folder"),
- QStandardPaths::writableLocation(QStandardPaths::HomeLocation));
+ QStandardPaths::writableLocation(QStandardPaths::HomeLocation), opts);
if (dir.isEmpty()) {
return;
@@ -766,12 +801,20 @@ void InstanceManagerDialog::openExistingPortable()
return;
}
- // Switch directly to this portable instance
- InstanceManager::singleton().setCurrentInstance(dir);
+ // Register the portable instance so it persists in the sidebar
+ auto& m = InstanceManager::singleton();
+ m.registerPortableInstance(dir);
- if (m_restartOnSelect) {
- ExitModOrganizer(Exit::Restart);
- }
+ // Refresh the instance list and select the newly added entry
+ updateInstances();
+ updateList();
- accept();
+ // Find and select the new instance by directory
+ const QString canonical = QDir(dir).absolutePath();
+ for (std::size_t i = 0; i < m_instances.size(); ++i) {
+ if (QDir(m_instances[i]->directory()).absolutePath() == canonical) {
+ select(i);
+ break;
+ }
+ }
}
diff --git a/src/src/loglist.cpp b/src/src/loglist.cpp
index 34df015..55ace68 100644
--- a/src/src/loglist.cpp
+++ b/src/src/loglist.cpp
@@ -18,16 +18,25 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
*/
#include "loglist.h"
-#include "copyeventfilter.h"
-#include "env.h"
-#include "organizercore.h"
-#include <cstdlib>
+#include "copyeventfilter.h"
+#include "env.h"
+#include "organizercore.h"
+#include <cstdlib>
using namespace MOBase;
static LogModel* g_instance = nullptr;
const std::size_t MaxLines = 1000;
+static QString fluorineLogDir()
+{
+#ifndef _WIN32
+ return QDir::homePath() + "/.var/app/com.fluorine.manager/logs";
+#else
+ return {};
+#endif
+}
+
static std::unique_ptr<env::Console> m_console;
static bool m_stdout = false;
static std::mutex m_stdoutMutex;
@@ -239,8 +248,12 @@ void LogList::clear()
void LogList::openLogsFolder()
{
- QString logsPath = qApp->property("dataPath").toString() + "/" +
- QString::fromStdWString(AppConfig::logPath());
+#ifndef _WIN32
+ const QString logsPath = fluorineLogDir();
+#else
+ const QString logsPath = qApp->property("dataPath").toString() + "/" +
+ QString::fromStdWString(AppConfig::logPath());
+#endif
shell::Explore(logsPath);
}
@@ -369,15 +382,15 @@ void initLogging()
LogModel::instance().add(e);
});
- const char* username = std::getenv("USERNAME");
- if (username == nullptr || username[0] == '\0') {
- username = std::getenv("USER");
- }
-
- if (username != nullptr && username[0] != '\0') {
- log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME");
- log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME");
- }
+ const char* username = std::getenv("USERNAME");
+ if (username == nullptr || username[0] == '\0') {
+ username = std::getenv("USER");
+ }
+
+ if (username != nullptr && username[0] != '\0') {
+ log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME");
+ log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME");
+ }
qInstallMessageHandler(qtLogCallback);
}
@@ -400,12 +413,20 @@ bool createAndMakeWritable(const std::wstring& subPath)
bool setLogDirectory(const QString& dir)
{
+#ifndef _WIN32
+ // On Linux, all logs go to ~/.var/app/com.fluorine.manager/logs/
+ const QString logDir = fluorineLogDir();
+ QDir().mkpath(logDir);
+ const auto logFile = logDir + "/" +
+ QString::fromStdWString(AppConfig::logFileName());
+#else
const auto logFile = dir + "/" + QString::fromStdWString(AppConfig::logPath()) + "/" +
QString::fromStdWString(AppConfig::logFileName());
if (!createAndMakeWritable(AppConfig::logPath())) {
return false;
}
+#endif
log::getDefault().setFile(MOBase::log::File::single(logFile.toStdWString()));
diff --git a/src/src/main.cpp b/src/src/main.cpp
index b4c4b6f..b82b1b8 100644
--- a/src/src/main.cpp
+++ b/src/src/main.cpp
@@ -9,6 +9,7 @@
#include "shared/util.h"
#include "thread_utils.h"
#include <log.h>
+#include <nak_ffi.h>
#include <report.h>
#include <QString>
@@ -17,6 +18,7 @@
#else
#include <csignal>
#include <cstdlib>
+#include <cstring>
#include <execinfo.h>
#include <sys/wait.h>
#include <unistd.h>
@@ -82,6 +84,23 @@ int run(int argc, char* argv[])
initLogging();
+ // Route NaK (Rust) log messages through MOBase::log
+ nak_init_logging([](const char* level, const char* message) {
+ if (!message || !*message) return;
+ if (!level || !*level) {
+ log::info("[nak] {}", message);
+ return;
+ }
+ // Map NaK levels to MOBase log levels
+ if (std::strcmp(level, "error") == 0) {
+ log::error("[nak] {}", message);
+ } else if (std::strcmp(level, "warning") == 0) {
+ log::warn("[nak] {}", message);
+ } else {
+ log::info("[nak] {}", message);
+ }
+ });
+
// must be after logging
TimeThis tt("main() multiprocess");
@@ -175,12 +194,13 @@ int run(int argc, char* argv[])
QObject::connect(&nxmHandler, &NxmHandlerLinux::nxmReceived, &app.core(),
[&](const NxmLink& link) {
app.core().downloadRequestedNXM(
- QString("nxm://%1/mods/%2/files/%3?key=%4&expires=%5")
+ QString("nxm://%1/mods/%2/files/%3?key=%4&expires=%5&user_id=%6")
.arg(link.game_domain)
.arg(link.mod_id)
.arg(link.file_id)
.arg(link.key)
- .arg(link.expires));
+ .arg(link.expires)
+ .arg(link.user_id));
});
}
#endif
diff --git a/src/src/modinfo.cpp b/src/src/modinfo.cpp
index e89b3a0..f12ceda 100644
--- a/src/src/modinfo.cpp
+++ b/src/src/modinfo.cpp
@@ -241,11 +241,22 @@ void ModInfo::updateFromDisc(const QString& modsDirectory, OrganizerCore& core,
s_Overwrite = nullptr;
{ // list all directories in the mod directory and make a mod out of each
- QDir mods(QDir::fromNativeSeparators(modsDirectory));
+ const QString cleanModsDir = QDir::fromNativeSeparators(modsDirectory);
+ QDir mods(cleanModsDir);
+ if (!mods.exists()) {
+ log::error("mods directory does not exist: '{}'", cleanModsDir);
+ }
mods.setFilter(QDir::Dirs | QDir::NoDotAndDotDot);
QDirIterator modIter(mods);
+ std::size_t managedCount = 0;
while (modIter.hasNext()) {
createFrom(QDir(modIter.next()), core);
+ ++managedCount;
+ }
+ log::info("found {} managed mod directories in '{}'", managedCount, cleanModsDir);
+ if (managedCount == 0 && mods.exists()) {
+ log::warn("mods directory exists but contains no subdirectories; "
+ "check path and permissions");
}
}
diff --git a/src/src/nxmhandler_linux.cpp b/src/src/nxmhandler_linux.cpp
index eb9f1df..a93826d 100644
--- a/src/src/nxmhandler_linux.cpp
+++ b/src/src/nxmhandler_linux.cpp
@@ -138,7 +138,9 @@ std::optional<NxmLink> NxmLink::parse(const QString& url)
return {};
}
- return NxmLink{gameDomain, modId, fileId, key, expires};
+ const int userId = query.queryItemValue("user_id").toInt();
+
+ return NxmLink{gameDomain, modId, fileId, key, expires, userId};
}
QString NxmLink::lookupKey() const
diff --git a/src/src/nxmhandler_linux.h b/src/src/nxmhandler_linux.h
index 0d2fd4d..b6bd675 100644
--- a/src/src/nxmhandler_linux.h
+++ b/src/src/nxmhandler_linux.h
@@ -14,6 +14,7 @@ struct NxmLink
uint64_t file_id = 0;
QString key;
uint64_t expires = 0;
+ int user_id = 0;
static std::optional<NxmLink> parse(const QString& url);
QString lookupKey() const;
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 9fba748..5a3d21d 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -309,7 +309,10 @@ void OrganizerCore::updateExecutablesList()
void OrganizerCore::updateModInfoFromDisc()
{
- ModInfo::updateFromDisc(m_Settings.paths().mods(), *this,
+ const QString modsPath = m_Settings.paths().mods();
+ log::debug("updateModInfoFromDisc: base='{}', mods='{}'",
+ m_Settings.paths().base(), modsPath);
+ ModInfo::updateFromDisc(modsPath, *this,
m_Settings.interface().displayForeign(),
m_Settings.refreshThreadCount());
}
@@ -465,8 +468,11 @@ void OrganizerCore::downloadRequested(QNetworkReply* reply, QString gameName, in
void OrganizerCore::removeOrigin(const QString& name)
{
- FilesOrigin& origin = m_DirectoryStructure->getOriginByName(ToWString(name));
- origin.enable(false);
+ const auto wname = ToWString(name);
+ if (m_DirectoryStructure->originExists(wname)) {
+ FilesOrigin& origin = m_DirectoryStructure->getOriginByName(wname);
+ origin.enable(false);
+ }
refreshLists();
}
@@ -846,8 +852,15 @@ void OrganizerCore::setPersistent(const QString& pluginName, const QString& key,
QString OrganizerCore::pluginDataPath()
{
+#ifndef _WIN32
+ // On Linux, the plugins/ directory may contain symlinks into a read-only
+ // bundled directory (e.g. /app/ in Flatpak). Place plugin data in a
+ // separate writable directory so mkdir() never hits a read-only FS.
+ return AppConfig::basePath() + "/plugin_data";
+#else
return AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()) +
"/data";
+#endif
}
MOBase::IModInterface* OrganizerCore::installMod(const QString& archivePath,
@@ -1782,10 +1795,11 @@ void OrganizerCore::modPrioritiesChanged(const QModelIndexList& indices)
int priority = currentProfile()->getModPriority(i);
if (currentProfile()->modEnabled(i)) {
ModInfo::Ptr modInfo = ModInfo::getByIndex(i);
+ const auto name = MOBase::ToWString(modInfo->internalName());
// priorities in the directory structure are one higher because data is 0
- directoryStructure()
- ->getOriginByName(MOBase::ToWString(modInfo->internalName()))
- .setPriority(priority + 1);
+ if (directoryStructure()->originExists(name)) {
+ directoryStructure()->getOriginByName(name).setPriority(priority + 1);
+ }
}
}
refreshBSAList();
@@ -2221,11 +2235,12 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode)
QFile::remove(m_CurrentProfile->getLoadOrderFileName());
}
- refreshDirectoryStructure();
-
#ifndef _WIN32
- // Flush staged VFS writes to overwrite and rebuild tree
- m_USVFS.flushStagingLive();
+ // Unmount the FUSE VFS now that the application has exited. unmount()
+ // flushes the staging directory (moves new/changed files to overwrite)
+ // and tears down the FUSE session. This mirrors Windows behaviour where
+ // USVFS is only active while a hooked process is running.
+ m_USVFS.unmount();
if (m_CurrentProfile != nullptr) {
const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame());
@@ -2272,6 +2287,11 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode)
}
#endif
+ // Refresh directory structure after VFS is unmounted so the refresher
+ // reads the real (vanilla) data directory plus individual mod directories,
+ // matching Windows USVFS behaviour.
+ refreshDirectoryStructure();
+
refreshESPList(true);
savePluginList();
cycleDiagnostics();
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp
index c07aa2a..a6a37ca 100644
--- a/src/src/processrunner.cpp
+++ b/src/src/processrunner.cpp
@@ -8,24 +8,24 @@
#include <log.h>
#include <report.h>
#include <uibase/utility.h>
-#ifndef _WIN32
-#include <QCoreApplication>
-#include <QEventLoop>
-#include <QFile>
-#include <QFileInfo>
-#include <QMetaObject>
-#include <QPointer>
-#include <QProcess>
-#include <QThread>
-#include <cerrno>
-#include <deque>
-#include <dirent.h>
-#include <fstream>
-#include <signal.h>
-#include <unordered_map>
-#include <unordered_set>
-#include <sys/wait.h>
-#endif
+#ifndef _WIN32
+#include <QCoreApplication>
+#include <QEventLoop>
+#include <QFile>
+#include <QFileInfo>
+#include <QMetaObject>
+#include <QPointer>
+#include <QProcess>
+#include <QThread>
+#include <cerrno>
+#include <deque>
+#include <dirent.h>
+#include <fstream>
+#include <signal.h>
+#include <unordered_map>
+#include <unordered_set>
+#include <sys/wait.h>
+#endif
using namespace MOBase;
@@ -63,6 +63,8 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp,
binPath += adjustedBin;
}
+#ifdef _WIN32
+ // On Windows, launch through MO2 helper to set up USVFS.
QString cmdline = QString("launch \"%1\" \"%2\" %3")
.arg(QDir::toNativeSeparators(cwdPath),
QDir::toNativeSeparators(binPath), sp.arguments);
@@ -70,6 +72,27 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp,
sp.binary = QFileInfo(QCoreApplication::applicationFilePath());
sp.arguments = cmdline;
sp.currentDirectory.setPath(QCoreApplication::applicationDirPath());
+#else
+ // On Linux, FUSE is already mounted — resolve paths directly without
+ // launching through MO2-core (which would fail in the Proton prefix).
+ //
+ // Root Builder deploys Root/ contents to the game directory root,
+ // stripping the "Root/" prefix. Fix paths that were remapped to
+ // <dataDir>/Root/... so they point to <gameDir>/... instead.
+ const QString gameDir = game->gameDirectory().absolutePath();
+ const QString dataDir = game->dataDirectory().absolutePath();
+ const QString rootTag = dataDir + QStringLiteral("/Root/");
+
+ if (binPath.startsWith(rootTag, Qt::CaseInsensitive)) {
+ binPath = gameDir + QStringLiteral("/") + binPath.mid(rootTag.length());
+ }
+ if (cwdPath.startsWith(rootTag, Qt::CaseInsensitive)) {
+ cwdPath = gameDir + QStringLiteral("/") + cwdPath.mid(rootTag.length());
+ }
+
+ sp.binary = QFileInfo(binPath);
+ sp.currentDirectory.setPath(cwdPath);
+#endif
}
}
@@ -475,153 +498,153 @@ ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode,
#else // !_WIN32
-pid_t handleToPid(HANDLE h)
-{
- return static_cast<pid_t>(reinterpret_cast<intptr_t>(h));
-}
-
-QString readProcComm(pid_t pid)
-{
- QFile f(QString("/proc/%1/comm").arg(pid));
- if (!f.open(QIODevice::ReadOnly)) {
- return {};
- }
-
- return QString::fromUtf8(f.readAll()).trimmed();
-}
-
-std::unordered_map<pid_t, std::vector<pid_t>> buildProcChildrenMap()
-{
- std::unordered_map<pid_t, std::vector<pid_t>> children;
- DIR* proc = opendir("/proc");
- if (!proc) {
- return children;
- }
-
- 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));
- std::ifstream status(QString("/proc/%1/status").arg(pid).toStdString());
- if (!status.is_open()) {
- continue;
- }
-
- std::string line;
- pid_t ppid = 0;
- while (std::getline(status, line)) {
- if (line.rfind("PPid:", 0) == 0) {
- ppid = static_cast<pid_t>(std::strtol(line.c_str() + 5, nullptr, 10));
- break;
- }
- }
-
- if (ppid > 0) {
- children[ppid].push_back(pid);
- }
- }
-
- closedir(proc);
- return children;
-}
-
-std::unordered_set<pid_t>
-collectDescendants(pid_t root, const std::unordered_map<pid_t, std::vector<pid_t>>& children)
-{
- std::unordered_set<pid_t> out;
- std::deque<pid_t> q;
- q.push_back(root);
-
- while (!q.empty()) {
- const pid_t cur = q.front();
- q.pop_front();
-
- const auto it = children.find(cur);
- if (it == children.end()) {
- continue;
- }
-
- for (pid_t child : it->second) {
- if (out.insert(child).second) {
- q.push_back(child);
- }
- }
- }
-
- return out;
-}
-
-QStringList buildExpectedExecutables(const QFileInfo& binary, const QString& arguments)
-{
- QStringList expected;
- auto addName = [&](QString name) {
- name = name.trimmed().toLower();
- if (!name.isEmpty() && !expected.contains(name)) {
- expected.push_back(name);
- }
- };
-
- addName(binary.fileName());
-
- const auto args = QProcess::splitCommand(arguments);
- for (const QString& arg : args) {
- const QFileInfo fi(arg);
- const QString base = fi.fileName();
- if (base.endsWith(".exe", Qt::CaseInsensitive)) {
- addName(base);
- }
- }
-
- return expected;
-}
-
-pid_t findTrackedProcess(pid_t rootPid, const QStringList& expected,
- QString* trackedNameOut)
-{
- if (expected.isEmpty()) {
- return 0;
- }
-
- const auto children = buildProcChildrenMap();
- const auto descendants = collectDescendants(rootPid, children);
- if (descendants.empty()) {
- return 0;
- }
-
- pid_t best = 0;
- QString bestName;
- for (pid_t pid : descendants) {
- const QString comm = readProcComm(pid);
- if (comm.isEmpty()) {
- continue;
- }
- const QString lower = comm.toLower();
- if (expected.contains(lower)) {
- best = pid;
- bestName = comm;
- break;
- }
- }
-
- if (best > 0 && trackedNameOut) {
- *trackedNameOut = bestName;
- }
- return best;
-}
-
-DWORD exitCodeFromWaitStatus(int status)
-{
- if (WIFEXITED(status)) {
- return static_cast<DWORD>(WEXITSTATUS(status));
- }
+pid_t handleToPid(HANDLE h)
+{
+ return static_cast<pid_t>(reinterpret_cast<intptr_t>(h));
+}
+
+QString readProcComm(pid_t pid)
+{
+ QFile f(QString("/proc/%1/comm").arg(pid));
+ if (!f.open(QIODevice::ReadOnly)) {
+ return {};
+ }
+
+ return QString::fromUtf8(f.readAll()).trimmed();
+}
+
+std::unordered_map<pid_t, std::vector<pid_t>> buildProcChildrenMap()
+{
+ std::unordered_map<pid_t, std::vector<pid_t>> children;
+ DIR* proc = opendir("/proc");
+ if (!proc) {
+ return children;
+ }
+
+ 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));
+ std::ifstream status(QString("/proc/%1/status").arg(pid).toStdString());
+ if (!status.is_open()) {
+ continue;
+ }
+
+ std::string line;
+ pid_t ppid = 0;
+ while (std::getline(status, line)) {
+ if (line.rfind("PPid:", 0) == 0) {
+ ppid = static_cast<pid_t>(std::strtol(line.c_str() + 5, nullptr, 10));
+ break;
+ }
+ }
+
+ if (ppid > 0) {
+ children[ppid].push_back(pid);
+ }
+ }
+
+ closedir(proc);
+ return children;
+}
+
+std::unordered_set<pid_t>
+collectDescendants(pid_t root, const std::unordered_map<pid_t, std::vector<pid_t>>& children)
+{
+ std::unordered_set<pid_t> out;
+ std::deque<pid_t> q;
+ q.push_back(root);
+
+ while (!q.empty()) {
+ const pid_t cur = q.front();
+ q.pop_front();
+
+ const auto it = children.find(cur);
+ if (it == children.end()) {
+ continue;
+ }
+
+ for (pid_t child : it->second) {
+ if (out.insert(child).second) {
+ q.push_back(child);
+ }
+ }
+ }
+
+ return out;
+}
+
+QStringList buildExpectedExecutables(const QFileInfo& binary, const QString& arguments)
+{
+ QStringList expected;
+ auto addName = [&](QString name) {
+ name = name.trimmed().toLower();
+ if (!name.isEmpty() && !expected.contains(name)) {
+ expected.push_back(name);
+ }
+ };
+
+ addName(binary.fileName());
+
+ const auto args = QProcess::splitCommand(arguments);
+ for (const QString& arg : args) {
+ const QFileInfo fi(arg);
+ const QString base = fi.fileName();
+ if (base.endsWith(".exe", Qt::CaseInsensitive)) {
+ addName(base);
+ }
+ }
+
+ return expected;
+}
+
+pid_t findTrackedProcess(pid_t rootPid, const QStringList& expected,
+ QString* trackedNameOut)
+{
+ if (expected.isEmpty()) {
+ return 0;
+ }
+
+ const auto children = buildProcChildrenMap();
+ const auto descendants = collectDescendants(rootPid, children);
+ if (descendants.empty()) {
+ return 0;
+ }
+
+ pid_t best = 0;
+ QString bestName;
+ for (pid_t pid : descendants) {
+ const QString comm = readProcComm(pid);
+ if (comm.isEmpty()) {
+ continue;
+ }
+ const QString lower = comm.toLower();
+ if (expected.contains(lower)) {
+ best = pid;
+ bestName = comm;
+ break;
+ }
+ }
+
+ if (best > 0 && trackedNameOut) {
+ *trackedNameOut = bestName;
+ }
+ return best;
+}
+
+DWORD exitCodeFromWaitStatus(int status)
+{
+ if (WIFEXITED(status)) {
+ return static_cast<DWORD>(WEXITSTATUS(status));
+ }
if (WIFSIGNALED(status)) {
return static_cast<DWORD>(128 + WTERMSIG(status));
@@ -630,12 +653,12 @@ DWORD exitCodeFromWaitStatus(int status)
return 0;
}
-ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls,
- const QStringList& expected)
-{
- if (pid <= 0) {
- return ProcessRunner::Error;
- }
+ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls,
+ const QStringList& expected)
+{
+ if (pid <= 0) {
+ return ProcessRunner::Error;
+ }
// startDetached() creates a non-child process, so waitpid() will fail with
// ECHILD. Detect this on the first call and switch to kill(pid, 0) polling
@@ -657,34 +680,34 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session
useKillPoll = true;
log::debug("process {} is detached, using kill(0) polling", pid);
}
- }
-
- bool seenTrackedProcess = false;
-
- while (true) {
- QString trackedName;
- pid_t displayPid = pid;
- QString displayName = readProcComm(pid);
- const pid_t tracked = findTrackedProcess(pid, expected, &trackedName);
- if (tracked > 0) {
- seenTrackedProcess = true;
- displayPid = tracked;
- displayName = trackedName;
- } else if (seenTrackedProcess) {
- if (exitCode != nullptr) {
- *exitCode = 0;
- }
- log::debug("tracked child process for root {} exited", pid);
- return ProcessRunner::Completed;
- }
-
- if (ls != nullptr) {
- ls->setInfo(static_cast<DWORD>(std::max<pid_t>(0, displayPid)), displayName);
- }
-
- if (useKillPoll) {
- // Poll for process existence via kill(pid, 0)
- if (::kill(pid, 0) != 0) {
+ }
+
+ bool seenTrackedProcess = false;
+
+ while (true) {
+ QString trackedName;
+ pid_t displayPid = pid;
+ QString displayName = readProcComm(pid);
+ const pid_t tracked = findTrackedProcess(pid, expected, &trackedName);
+ if (tracked > 0) {
+ seenTrackedProcess = true;
+ displayPid = tracked;
+ displayName = trackedName;
+ } else if (seenTrackedProcess) {
+ if (exitCode != nullptr) {
+ *exitCode = 0;
+ }
+ log::debug("tracked child process for root {} exited", pid);
+ return ProcessRunner::Completed;
+ }
+
+ if (ls != nullptr) {
+ ls->setInfo(static_cast<DWORD>(std::max<pid_t>(0, displayPid)), displayName);
+ }
+
+ if (useKillPoll) {
+ // Poll for process existence via kill(pid, 0)
+ if (::kill(pid, 0) != 0) {
if (errno == ESRCH) {
if (exitCode != nullptr) {
*exitCode = 0;
@@ -724,21 +747,21 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session
}
}
- if (ls != nullptr) {
- switch (ls->result()) {
- case UILocker::StillLocked:
- break;
+ if (ls != nullptr) {
+ switch (ls->result()) {
+ case UILocker::StillLocked:
+ break;
case UILocker::ForceUnlocked:
log::debug("waiting for {} force unlocked by user", pid);
return ProcessRunner::ForceUnlocked;
- case UILocker::Cancelled:
- log::debug("waiting for {} cancelled by user, terminating", displayPid);
- if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) {
- log::warn("failed to terminate {}, errno={}", displayPid, errno);
- }
- return ProcessRunner::Cancelled;
+ case UILocker::Cancelled:
+ log::debug("waiting for {} cancelled by user, terminating", displayPid);
+ if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) {
+ log::warn("failed to terminate {}, errno={}", displayPid, errno);
+ }
+ return ProcessRunner::Cancelled;
case UILocker::NoResult:
default:
@@ -752,27 +775,27 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session
}
}
-ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode,
- UILocker::Session* ls,
- const QStringList& expected)
-{
- return waitForPid(handleToPid(initialProcess), exitCode, ls, expected);
-}
-
-ProcessRunner::Results waitForProcesses(const std::vector<HANDLE>& initialProcesses,
- UILocker::Session* ls,
- const QStringList& expected)
-{
- if (initialProcesses.empty()) {
- return ProcessRunner::Completed;
- }
-
- for (HANDLE h : initialProcesses) {
- DWORD ignored = 0;
- const auto r = waitForPid(handleToPid(h), &ignored, ls, expected);
- if (r != ProcessRunner::Completed) {
- return r;
- }
+ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode,
+ UILocker::Session* ls,
+ const QStringList& expected)
+{
+ return waitForPid(handleToPid(initialProcess), exitCode, ls, expected);
+}
+
+ProcessRunner::Results waitForProcesses(const std::vector<HANDLE>& initialProcesses,
+ UILocker::Session* ls,
+ const QStringList& expected)
+{
+ if (initialProcesses.empty()) {
+ return ProcessRunner::Completed;
+ }
+
+ for (HANDLE h : initialProcesses) {
+ DWORD ignored = 0;
+ const auto r = waitForPid(handleToPid(h), &ignored, ls, expected);
+ if (r != ProcessRunner::Completed) {
+ return r;
+ }
}
return ProcessRunner::Completed;
@@ -1122,22 +1145,22 @@ std::optional<ProcessRunner::Results> ProcessRunner::runBinary()
// parent widget used for any dialog popped up while checking for things
QWidget* parent = (m_ui ? m_ui->mainWindow() : nullptr);
- const auto* game = m_core.managedGame();
- auto& settings = m_core.settings();
-
- if (m_sp.steamAppID.trimmed().isEmpty()) {
- const QString gameSteamId = game->steamAPPId().trimmed();
- if (!gameSteamId.isEmpty()) {
- m_sp.steamAppID = gameSteamId;
- log::debug("process runner: using game steam app id '{}' for launch",
- m_sp.steamAppID);
- }
- }
-
- // start steam if needed
- if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) {
- return Error;
- }
+ const auto* game = m_core.managedGame();
+ auto& settings = m_core.settings();
+
+ if (m_sp.steamAppID.trimmed().isEmpty()) {
+ const QString gameSteamId = game->steamAPPId().trimmed();
+ if (!gameSteamId.isEmpty()) {
+ m_sp.steamAppID = gameSteamId;
+ log::debug("process runner: using game steam app id '{}' for launch",
+ m_sp.steamAppID);
+ }
+ }
+
+ // start steam if needed
+ if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) {
+ return Error;
+ }
// warn if the executable is on the blacklist
if (!checkBlacklist(parent, m_sp, settings)) {
@@ -1185,12 +1208,12 @@ bool ProcessRunner::shouldRefresh(Results r) const
return true;
}
- case ForceUnlocked: {
- // The process may still be running when the user force-unlocks.
- // Refreshing in that state can race with file updates.
- log::debug("process runner: not refreshing because the ui was force unlocked");
- return false;
- }
+ case ForceUnlocked: {
+ // The process may still be running when the user force-unlocks.
+ // Refreshing in that state can race with file updates.
+ log::debug("process runner: not refreshing because the ui was force unlocked");
+ return false;
+ }
case Error: // fall-through
case Cancelled:
@@ -1218,9 +1241,9 @@ ProcessRunner::Results ProcessRunner::postRun()
m_lockReason = UILocker::LockUI;
}
- const bool lockEnabled = m_core.settings().interface().lockGUI();
- const QStringList expectedExecutables =
- buildExpectedExecutables(m_sp.binary, m_sp.arguments);
+ const bool lockEnabled = m_core.settings().interface().lockGUI();
+ const QStringList expectedExecutables =
+ buildExpectedExecutables(m_sp.binary, m_sp.arguments);
if (mustWait) {
if (!lockEnabled) {
@@ -1228,56 +1251,56 @@ ProcessRunner::Results ProcessRunner::postRun()
log::debug("locking is disabled, but the output of the application is required; "
"overriding this setting and locking the ui");
}
- } else {
- // no force wait
-
- if (m_lockReason == UILocker::NoReason) {
- // no locking requested
-#ifndef _WIN32
- // Main window launches typically use TriggerRefresh without waiting/locking.
- // In that mode we still need post-run refresh/sync once the process exits.
- if (m_waitFlags.testFlag(TriggerRefresh)) {
- const pid_t pid = static_cast<pid_t>(reinterpret_cast<intptr_t>(m_handle.get()));
- const QFileInfo binary = m_sp.binary;
- QPointer<OrganizerCore> core = &m_core;
-
- std::thread([core, binary, pid]() {
- int status = 0;
- pid_t waited = -1;
- do {
- waited = ::waitpid(pid, &status, 0);
- } while (waited == -1 && errno == EINTR);
-
- DWORD exitCode = 0;
- if (waited == pid) {
- if (WIFEXITED(status)) {
- exitCode = static_cast<DWORD>(WEXITSTATUS(status));
- } else if (WIFSIGNALED(status)) {
- exitCode = static_cast<DWORD>(128 + WTERMSIG(status));
- }
- } else {
- MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid,
- errno);
- }
-
- if (!core) {
- return;
- }
-
- QMetaObject::invokeMethod(
- core, [core, binary, exitCode]() {
- if (core) {
- core->afterRun(binary, exitCode);
- }
- },
- Qt::QueuedConnection);
- }).detach();
-
- log::debug("process runner: scheduled async post-run refresh for pid {}", pid);
- }
-#endif
- return Running;
- }
+ } else {
+ // no force wait
+
+ if (m_lockReason == UILocker::NoReason) {
+ // no locking requested
+#ifndef _WIN32
+ // Main window launches typically use TriggerRefresh without waiting/locking.
+ // In that mode we still need post-run refresh/sync once the process exits.
+ if (m_waitFlags.testFlag(TriggerRefresh)) {
+ const pid_t pid = static_cast<pid_t>(reinterpret_cast<intptr_t>(m_handle.get()));
+ const QFileInfo binary = m_sp.binary;
+ QPointer<OrganizerCore> core = &m_core;
+
+ std::thread([core, binary, pid]() {
+ int status = 0;
+ pid_t waited = -1;
+ do {
+ waited = ::waitpid(pid, &status, 0);
+ } while (waited == -1 && errno == EINTR);
+
+ DWORD exitCode = 0;
+ if (waited == pid) {
+ if (WIFEXITED(status)) {
+ exitCode = static_cast<DWORD>(WEXITSTATUS(status));
+ } else if (WIFSIGNALED(status)) {
+ exitCode = static_cast<DWORD>(128 + WTERMSIG(status));
+ }
+ } else {
+ MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid,
+ errno);
+ }
+
+ if (!core) {
+ return;
+ }
+
+ QMetaObject::invokeMethod(
+ core, [core, binary, exitCode]() {
+ if (core) {
+ core->afterRun(binary, exitCode);
+ }
+ },
+ Qt::QueuedConnection);
+ }).detach();
+
+ log::debug("process runner: scheduled async post-run refresh for pid {}", pid);
+ }
+#endif
+ return Running;
+ }
if (!lockEnabled) {
// disabling locking is like clicking on unlock immediately
@@ -1290,7 +1313,7 @@ ProcessRunner::Results ProcessRunner::postRun()
auto r = Error;
- if (mustWait && m_lockReason == UILocker::PreventExit && !lockEnabled) {
+ if (mustWait && m_lockReason == UILocker::PreventExit && !lockEnabled) {
// this happens when running shortcuts and locking is disabled
//
// MO must stay alive until all processes are dead or child processes
@@ -1301,12 +1324,12 @@ ProcessRunner::Results ProcessRunner::postRun()
//
// MO will be running in the background with no visual feedback, but that's
// how it is
- r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables);
- } else {
- withLock([&](auto& ls) {
- r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables);
- });
- }
+ r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables);
+ } else {
+ withLock([&](auto& ls) {
+ r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables);
+ });
+ }
if (shouldRefresh(r)) {
QEventLoop loop;
diff --git a/src/src/profile.cpp b/src/src/profile.cpp
index fdc3852..405ba4e 100644
--- a/src/src/profile.cpp
+++ b/src/src/profile.cpp
@@ -444,10 +444,12 @@ void Profile::refreshModStatus()
bool modStatusModified = false;
m_ModStatus.clear();
m_ModStatus.resize(ModInfo::getNumMods());
+ log::debug("refreshModStatus: ModInfo has {} entries", ModInfo::getNumMods());
std::set<QString> namesRead;
bool warnAboutOverwrite = false;
+ unsigned int modsNotFound = 0;
// load mods from file and update enabled state and priority for them
int index = 0;
@@ -488,7 +490,10 @@ void Profile::refreshModStatus()
unsigned int modIndex = ModInfo::getIndex(modName);
if (modIndex == UINT_MAX) {
- log::debug("mod not found: \"{}\" (profile \"{}\")", modName, m_Directory.path());
+ if (modsNotFound < 5) {
+ log::warn("mod not found: \"{}\" (profile \"{}\")", modName, m_Directory.path());
+ }
+ ++modsNotFound;
// need to rewrite the modlist to fix this
modStatusModified = true;
continue;
@@ -515,6 +520,12 @@ void Profile::refreshModStatus()
file.close();
+ if (modsNotFound > 0) {
+ log::error("refreshModStatus: {} mods from modlist.txt were not found in "
+ "ModInfo (total ModInfo entries: {})",
+ modsNotFound, ModInfo::getNumMods());
+ }
+
const int numKnownMods = index;
int topInsert = 0;
@@ -919,7 +930,11 @@ bool Profile::localSettingsEnabled() const
QStringList missingFiles;
for (QString file : m_GamePlugin->iniFiles()) {
QString fileName = QFileInfo(file).fileName();
- if (!QFile::exists(m_Directory.filePath(fileName))) {
+ // Use case-insensitive lookup on Linux — the file may exist with
+ // different casing (e.g. "skyrimprefs.ini" vs "SkyrimPrefs.ini").
+ QString resolved = MOBase::resolveFileCaseInsensitive(
+ m_Directory.filePath(fileName));
+ if (!QFile::exists(resolved)) {
log::warn("missing {} in {}", fileName, m_Directory.path());
missingFiles << fileName;
}
@@ -959,7 +974,9 @@ bool Profile::enableLocalSettings(bool enable)
if (res == QMessageBox::Yes) {
QStringList filesToDelete;
for (QString file : m_GamePlugin->iniFiles()) {
- filesToDelete << m_Directory.absoluteFilePath(QFileInfo(file).fileName());
+ QString resolved = MOBase::resolveFileCaseInsensitive(
+ m_Directory.absoluteFilePath(QFileInfo(file).fileName()));
+ filesToDelete << resolved;
}
shellDelete(filesToDelete, true);
} else if (res == QMessageBox::No) {
@@ -1003,7 +1020,8 @@ QString Profile::getIniFileName() const
if (iniFiles.isEmpty())
return "";
else
- return m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName());
+ return MOBase::resolveFileCaseInsensitive(
+ m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName()));
}
QString Profile::absoluteIniFilePath(QString iniFile) const
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 61e11ff..5c3ef54 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -336,6 +336,15 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
env.insert(it.key(), it.value());
}
+ // Set DXVK config if available
+ if (char* dxvkPath = nak_get_dxvk_conf_path(); dxvkPath != nullptr) {
+ const QString dxvkConf = QString::fromUtf8(dxvkPath);
+ nak_string_free(dxvkPath);
+ if (QFileInfo::exists(dxvkConf)) {
+ env.insert("DXVK_CONFIG_FILE", dxvkConf);
+ }
+ }
+
maybeWrapForFlatpak(program, arguments, env);
MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary);
@@ -446,6 +455,15 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const
env.insert(it.key(), it.value());
}
+ // Set DXVK config if available
+ if (char* dxvkPath = nak_get_dxvk_conf_path(); dxvkPath != nullptr) {
+ const QString dxvkConf = QString::fromUtf8(dxvkPath);
+ nak_string_free(dxvkPath);
+ if (QFileInfo::exists(dxvkConf)) {
+ env.insert("DXVK_CONFIG_FILE", dxvkConf);
+ }
+ }
+
maybeWrapForFlatpak(program, arguments, env);
MOBase::log::info("UMU launch: '{}' '{}' (game id: {}, steam: '{}')", umuRun,
diff --git a/src/src/settings.cpp b/src/src/settings.cpp
index a2d10e4..5578daf 100644
--- a/src/src/settings.cpp
+++ b/src/src/settings.cpp
@@ -21,51 +21,51 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "env.h"
#include "envmetrics.h"
#include "executableslist.h"
-#include "instancemanager.h"
-#include "modelutils.h"
-#include "nxmhandler_linux.h"
-#include "serverinfo.h"
-#include "settingsutilities.h"
-#include "shared/appconfig.h"
+#include "instancemanager.h"
+#include "modelutils.h"
+#include "nxmhandler_linux.h"
+#include "serverinfo.h"
+#include "settingsutilities.h"
+#include "shared/appconfig.h"
#include <expanderwidget.h>
#include <iplugingame.h>
#include <utility.h>
-using namespace MOBase;
-using namespace MOShared;
-
-namespace
-{
-bool usesBaseDirVariable(const QString& path)
-{
- return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive);
-}
-
-QString loadStoredPath(const QString& path)
-{
- QString value = QDir::fromNativeSeparators(path);
- if (!usesBaseDirVariable(value)) {
- value = MOBase::normalizePathForHost(value);
- }
- return value;
-}
-
-QString storePathForIni(const QString& path)
-{
- if (path.isEmpty()) {
- return path;
- }
-
- if (usesBaseDirVariable(path)) {
- return QDir::fromNativeSeparators(path);
- }
-
- return MOBase::normalizePathForWine(path);
-}
-} // namespace
-
-EndorsementState endorsementStateFromString(const QString& s)
-{
+using namespace MOBase;
+using namespace MOShared;
+
+namespace
+{
+bool usesBaseDirVariable(const QString& path)
+{
+ return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive);
+}
+
+QString loadStoredPath(const QString& path)
+{
+ QString value = QDir::fromNativeSeparators(path);
+ if (!usesBaseDirVariable(value)) {
+ value = MOBase::normalizePathForHost(value);
+ }
+ return value;
+}
+
+QString storePathForIni(const QString& path)
+{
+ if (path.isEmpty()) {
+ return path;
+ }
+
+ if (usesBaseDirVariable(path)) {
+ return QDir::fromNativeSeparators(path);
+ }
+
+ return MOBase::normalizePathForWine(path);
+}
+} // namespace
+
+EndorsementState endorsementStateFromString(const QString& s)
+{
if (s == "Endorsed") {
return EndorsementState::Accepted;
} else if (s == "Abstained") {
@@ -395,27 +395,27 @@ void Settings::setArchiveParsing(bool b)
set(m_Settings, "Settings", "archive_parsing_experimental", b);
}
-std::vector<std::map<QString, QVariant>> Settings::executables() const
-{
- ScopedReadArray sra(m_Settings, "customExecutables");
- std::vector<std::map<QString, QVariant>> v;
+std::vector<std::map<QString, QVariant>> Settings::executables() const
+{
+ ScopedReadArray sra(m_Settings, "customExecutables");
+ std::vector<std::map<QString, QVariant>> v;
sra.for_each([&] {
std::map<QString, QVariant> map;
- for (auto&& key : sra.keys()) {
- map[key] = sra.get<QVariant>(key);
- }
- if (map.contains("binary")) {
- map["binary"] = loadStoredPath(map["binary"].toString());
- }
- if (map.contains("workingDirectory")) {
- map["workingDirectory"] =
- loadStoredPath(map["workingDirectory"].toString());
- }
-
- v.push_back(map);
- });
+ for (auto&& key : sra.keys()) {
+ map[key] = sra.get<QVariant>(key);
+ }
+ if (map.contains("binary")) {
+ map["binary"] = loadStoredPath(map["binary"].toString());
+ }
+ if (map.contains("workingDirectory")) {
+ map["workingDirectory"] =
+ loadStoredPath(map["workingDirectory"].toString());
+ }
+
+ v.push_back(map);
+ });
return v;
}
@@ -436,18 +436,18 @@ void Settings::setExecutables(const std::vector<std::map<QString, QVariant>>& v)
ScopedWriteArray swa(m_Settings, "customExecutables", v.size());
- for (const auto& map : v) {
- swa.next();
-
- for (auto&& p : map) {
- if ((p.first == "binary") || (p.first == "workingDirectory")) {
- swa.set(p.first, storePathForIni(p.second.toString()));
- } else {
- swa.set(p.first, p.second);
- }
- }
- }
-}
+ for (const auto& map : v) {
+ swa.next();
+
+ for (auto&& p : map) {
+ if ((p.first == "binary") || (p.first == "workingDirectory")) {
+ swa.set(p.first, storePathForIni(p.second.toString()));
+ } else {
+ swa.set(p.first, p.second);
+ }
+ }
+ }
+}
bool Settings::keepBackupOnInstall() const
{
@@ -660,19 +660,19 @@ void GameSettings::setForceEnableCoreFiles(bool b)
set(m_Settings, "Settings", "force_enable_core_files", b);
}
-std::optional<QString> GameSettings::directory() const
-{
- if (auto v = getOptional<QByteArray>(m_Settings, "General", "gamePath")) {
- return loadStoredPath(QString::fromUtf8(*v));
- }
-
- return {};
-}
-
-void GameSettings::setDirectory(const QString& path)
-{
- set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8());
-}
+std::optional<QString> GameSettings::directory() const
+{
+ if (auto v = getOptional<QByteArray>(m_Settings, "General", "gamePath")) {
+ return loadStoredPath(QString::fromUtf8(*v));
+ }
+
+ return {};
+}
+
+void GameSettings::setDirectory(const QString& path)
+{
+ set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8());
+}
std::optional<QString> GameSettings::name() const
{
@@ -1629,20 +1629,20 @@ const QString PathSettings::BaseDirVariable = "%BASE_DIR%";
PathSettings::PathSettings(QSettings& settings) : m_Settings(settings) {}
-std::map<QString, QString> PathSettings::recent() const
-{
- std::map<QString, QString> map;
+std::map<QString, QString> PathSettings::recent() const
+{
+ std::map<QString, QString> map;
ScopedReadArray sra(m_Settings, "recentDirectories");
sra.for_each([&] {
- const QVariant name = sra.get<QVariant>("name");
- const QVariant dir = sra.get<QVariant>("directory");
-
- if (name.isValid() && dir.isValid()) {
- map.emplace(name.toString(), loadStoredPath(dir.toString()));
- }
- });
+ const QVariant name = sra.get<QVariant>("name");
+ const QVariant dir = sra.get<QVariant>("directory");
+
+ if (name.isValid() && dir.isValid()) {
+ map.emplace(name.toString(), loadStoredPath(dir.toString()));
+ }
+ });
return map;
}
@@ -1658,35 +1658,35 @@ void PathSettings::setRecent(const std::map<QString, QString>& map)
ScopedWriteArray swa(m_Settings, "recentDirectories", map.size());
- for (auto&& p : map) {
- swa.next();
-
- swa.set("name", p.first);
- swa.set("directory", storePathForIni(p.second));
- }
-}
+ for (auto&& p : map) {
+ swa.next();
+
+ swa.set("name", p.first);
+ swa.set("directory", storePathForIni(p.second));
+ }
+}
+
+QString PathSettings::getConfigurablePath(const QString& key, const QString& def,
+ bool resolve) const
+{
+ QString result =
+ loadStoredPath(get<QString>(m_Settings, "Settings", key, makeDefaultPath(def)));
-QString PathSettings::getConfigurablePath(const QString& key, const QString& def,
- bool resolve) const
-{
- QString result =
- loadStoredPath(get<QString>(m_Settings, "Settings", key, makeDefaultPath(def)));
-
- if (resolve) {
- result = PathSettings::resolve(result, base());
- }
+ if (resolve) {
+ result = PathSettings::resolve(result, base());
+ }
return result;
}
-void PathSettings::setConfigurablePath(const QString& key, const QString& path)
-{
- if (path.isEmpty()) {
- remove(m_Settings, "Settings", key);
- } else {
- set(m_Settings, "Settings", key, storePathForIni(path));
- }
-}
+void PathSettings::setConfigurablePath(const QString& key, const QString& path)
+{
+ if (path.isEmpty()) {
+ remove(m_Settings, "Settings", key);
+ } else {
+ set(m_Settings, "Settings", key, storePathForIni(path));
+ }
+}
QString PathSettings::resolve(const QString& path, const QString& baseDir)
{
@@ -1700,13 +1700,13 @@ QString PathSettings::makeDefaultPath(const QString dirName)
return BaseDirVariable + "/" + dirName;
}
-QString PathSettings::base() const
-{
- const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path();
-
- return loadStoredPath(get<QString>(m_Settings, "Settings", "base_directory",
- dataPath));
-}
+QString PathSettings::base() const
+{
+ const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path();
+
+ return loadStoredPath(get<QString>(m_Settings, "Settings", "base_directory",
+ dataPath));
+}
QString PathSettings::downloads(bool resolve) const
{
@@ -1738,14 +1738,14 @@ QString PathSettings::overwrite(bool resolve) const
ToQString(AppConfig::overwritePath()), resolve);
}
-void PathSettings::setBase(const QString& path)
-{
- if (path.isEmpty()) {
- remove(m_Settings, "Settings", "base_directory");
- } else {
- set(m_Settings, "Settings", "base_directory", storePathForIni(path));
- }
-}
+void PathSettings::setBase(const QString& path)
+{
+ if (path.isEmpty()) {
+ remove(m_Settings, "Settings", "base_directory");
+ } else {
+ set(m_Settings, "Settings", "base_directory", storePathForIni(path));
+ }
+}
void PathSettings::setDownloads(const QString& path)
{
@@ -2026,15 +2026,15 @@ void NexusSettings::setCategoryMappings(bool b) const
set(m_Settings, "Settings", "category_mappings", b);
}
-void NexusSettings::registerAsNXMHandler(bool force)
-{
-#ifndef _WIN32
- Q_UNUSED(force);
- NxmHandlerLinux handler;
- handler.registerHandler();
-#else
- const auto nxmPath = QCoreApplication::applicationDirPath() + "/" +
- QString::fromStdWString(AppConfig::nxmHandlerExe());
+void NexusSettings::registerAsNXMHandler(bool force)
+{
+#ifndef _WIN32
+ Q_UNUSED(force);
+ NxmHandlerLinux handler;
+ handler.registerHandler();
+#else
+ const auto nxmPath = QCoreApplication::applicationDirPath() + "/" +
+ QString::fromStdWString(AppConfig::nxmHandlerExe());
const auto executable = QCoreApplication::applicationFilePath();
@@ -2047,13 +2047,13 @@ void NexusSettings::registerAsNXMHandler(bool force)
const auto r = shell::Execute(nxmPath, parameters);
- if (!r.success()) {
- QMessageBox::critical(
- nullptr, QObject::tr("Failed"),
- QObject::tr("Failed to start the helper application: %1").arg(r.toString()));
- }
-#endif
-}
+ if (!r.success()) {
+ QMessageBox::critical(
+ nullptr, QObject::tr("Failed"),
+ QObject::tr("Failed to start the helper application: %1").arg(r.toString()));
+ }
+#endif
+}
std::vector<std::chrono::seconds> NexusSettings::validationTimeouts() const
{
@@ -2582,3 +2582,27 @@ void GlobalSettings::resetDialogs()
setHideCreateInstanceIntro(false);
setHideTutorialQuestion(false);
}
+
+QStringList GlobalSettings::portableInstances()
+{
+ return settings().value("PortableInstances").toStringList();
+}
+
+void GlobalSettings::addPortableInstance(const QString& path)
+{
+ const QString canonical = QDir(path).absolutePath();
+ QStringList list = portableInstances();
+ if (!list.contains(canonical)) {
+ list.append(canonical);
+ settings().setValue("PortableInstances", list);
+ }
+}
+
+void GlobalSettings::removePortableInstance(const QString& path)
+{
+ const QString canonical = QDir(path).absolutePath();
+ QStringList list = portableInstances();
+ if (list.removeAll(canonical) > 0) {
+ settings().setValue("PortableInstances", list);
+ }
+}
diff --git a/src/src/settings.h b/src/src/settings.h
index 7b2516f..f1ea342 100644
--- a/src/src/settings.h
+++ b/src/src/settings.h
@@ -966,6 +966,11 @@ public:
// resets anything that the user can disable
static void resetDialogs();
+ // persistent registry of portable instance paths
+ static QStringList portableInstances();
+ static void addPortableInstance(const QString& path);
+ static void removePortableInstance(const QString& path);
+
private:
static QSettings settings();
};
diff --git a/src/src/settingsdialogpaths.cpp b/src/src/settingsdialogpaths.cpp
index 1e48523..dee5362 100644
--- a/src/src/settingsdialogpaths.cpp
+++ b/src/src/settingsdialogpaths.cpp
@@ -1,8 +1,21 @@
#include "settingsdialogpaths.h"
#include "shared/appconfig.h"
#include "ui_settingsdialog.h"
+#include <QFileDialog>
#include <iplugingame.h>
+namespace {
+QFileDialog::Options flatpakSafeOptions()
+{
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
+ return opts;
+}
+} // namespace
+
PathsSettingsTab::PathsSettingsTab(Settings& s, SettingsDialog& d)
: SettingsTab(s, d), m_gameDir(settings().game().plugin()->gameDirectory())
{
@@ -129,7 +142,8 @@ void PathsSettingsTab::update()
void PathsSettingsTab::on_browseBaseDirBtn_clicked()
{
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text());
+ &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text(),
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->baseDirEdit->setText(temp);
}
@@ -141,7 +155,8 @@ void PathsSettingsTab::on_browseDownloadDirBtn_clicked()
searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select download directory"), searchPath);
+ &dialog(), QObject::tr("Select download directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->downloadDirEdit->setText(temp);
}
@@ -153,7 +168,8 @@ void PathsSettingsTab::on_browseModDirBtn_clicked()
searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select mod directory"), searchPath);
+ &dialog(), QObject::tr("Select mod directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->modDirEdit->setText(temp);
}
@@ -165,7 +181,8 @@ void PathsSettingsTab::on_browseCacheDirBtn_clicked()
searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select cache directory"), searchPath);
+ &dialog(), QObject::tr("Select cache directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->cacheDirEdit->setText(temp);
}
@@ -177,7 +194,8 @@ void PathsSettingsTab::on_browseProfilesDirBtn_clicked()
searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select profiles directory"), searchPath);
+ &dialog(), QObject::tr("Select profiles directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->profilesDirEdit->setText(temp);
}
@@ -189,7 +207,8 @@ void PathsSettingsTab::on_browseOverwriteDirBtn_clicked()
searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select overwrite directory"), searchPath);
+ &dialog(), QObject::tr("Select overwrite directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->overwriteDirEdit->setText(temp);
}
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 8c9c225..af1b344 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -4,6 +4,7 @@
#include "ui_settingsdialog.h"
#include <QtConcurrent/QtConcurrentRun>
+#include <log.h>
#include <nak_ffi.h>
#include <atomic>
#include <QComboBox>
@@ -272,8 +273,14 @@ void ProtonSettingsTab::onOpenPrefixFolder()
void ProtonSettingsTab::onBrowsePrefixLocation()
{
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
const QString dir = QFileDialog::getExistingDirectory(
- parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text());
+ parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text(),
+ opts);
if (!dir.isEmpty()) {
ui->prefixLocationEdit->setText(dir);
}
@@ -281,7 +288,7 @@ void ProtonSettingsTab::onBrowsePrefixLocation()
QString ProtonSettingsTab::ensureWinetricks()
{
- const QString nakWinetricks = QDir::homePath() + "/.config/nak/bin/winetricks";
+ const QString nakWinetricks = QDir::homePath() + "/.var/app/com.fluorine.manager/bin/winetricks";
if (QFileInfo::exists(nakWinetricks)) {
return nakWinetricks;
}
@@ -291,7 +298,7 @@ QString ProtonSettingsTab::ensureWinetricks()
return systemWinetricks;
}
- const QString nakBinDir = QDir::homePath() + "/.config/nak/bin";
+ const QString nakBinDir = QDir::homePath() + "/.var/app/com.fluorine.manager/bin";
QDir().mkpath(nakBinDir);
QString downloadTool;
@@ -469,9 +476,14 @@ void ProtonSettingsTab::showGameRegistryDialog()
layout->addLayout(pathLayout);
QObject::connect(browseBtn, &QPushButton::clicked, &dialog, [&dialog, pathEdit]() {
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
const QString dir = QFileDialog::getExistingDirectory(
&dialog, QObject::tr("Select Game Installation Folder"),
- pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text());
+ pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text(), opts);
if (!dir.isEmpty()) {
pathEdit->setText(dir);
}
@@ -688,7 +700,9 @@ void ProtonSettingsTab::statusCallback(const char* message)
void ProtonSettingsTab::logCallback(const char* message)
{
- Q_UNUSED(message);
+ if (message && *message) {
+ MOBase::log::info("{}", message);
+ }
}
void ProtonSettingsTab::progressCallback(float progress)
@@ -718,6 +732,12 @@ void ProtonSettingsTab::onInstallFinished()
nak_create_game_symlinks_auto(prefixPathUtf8.constData());
}
+ // Ensure DXVK config exists for game launches
+ if (char* dxvkErr = nak_ensure_dxvk_conf(); dxvkErr != nullptr) {
+ MOBase::log::warn("Failed to create dxvk.conf: {}", dxvkErr);
+ nak_string_free(dxvkErr);
+ }
+
FluorineConfig cfg;
cfg.app_id = m_pendingAppId;
cfg.prefix_path = m_pendingPrefixPath;
diff --git a/src/src/vfs/vfs_helper_main.cpp b/src/src/vfs/vfs_helper_main.cpp
index f0b4889..9139d4c 100644
--- a/src/src/vfs/vfs_helper_main.cpp
+++ b/src/src/vfs/vfs_helper_main.cpp
@@ -32,6 +32,7 @@ struct HelperConfig
std::string data_dir_name;
std::string overwrite_dir;
std::vector<std::pair<std::string, std::string>> mods;
+ std::vector<std::pair<std::string, std::string>> extra_files;
};
static HelperConfig readConfig(const std::string& path)
@@ -66,6 +67,11 @@ static HelperConfig readConfig(const std::string& path)
if (pipe != std::string::npos) {
cfg.mods.emplace_back(val.substr(0, pipe), val.substr(pipe + 1));
}
+ } else if (key == "extra_file") {
+ const auto pipe = val.find('|');
+ if (pipe != std::string::npos) {
+ cfg.extra_files.emplace_back(val.substr(0, pipe), val.substr(pipe + 1));
+ }
}
}
@@ -206,6 +212,7 @@ int main(int argc, char* argv[])
auto tree = std::make_shared<VfsTree>(
buildDataDirVfs(baseFileCache, dataDirPath, config.mods,
config.overwrite_dir));
+ injectExtraFiles(*tree, config.extra_files);
auto context = std::make_shared<Mo2FsContext>();
context->tree = tree;
@@ -272,6 +279,7 @@ int main(int argc, char* argv[])
auto newConfig = readConfig(configPath);
auto newTree = std::make_shared<VfsTree>(buildDataDirVfs(
baseFileCache, dataDirPath, newConfig.mods, newConfig.overwrite_dir));
+ injectExtraFiles(*newTree, newConfig.extra_files);
{
std::unique_lock lock(context->tree_mutex);
@@ -286,6 +294,7 @@ int main(int argc, char* argv[])
auto newTree = std::make_shared<VfsTree>(buildDataDirVfs(
baseFileCache, dataDirPath, config.mods, config.overwrite_dir));
+ injectExtraFiles(*newTree, config.extra_files);
{
std::unique_lock lock(context->tree_mutex);
diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp
index 5070635..7ab8f70 100644
--- a/src/src/vfs/vfstree.cpp
+++ b/src/src/vfs/vfstree.cpp
@@ -378,3 +378,22 @@ VfsTree buildDataDirVfs(const std::vector<CachedBaseFile>& cached_files,
return tree;
}
+
+void injectExtraFiles(
+ VfsTree& tree,
+ const std::vector<std::pair<std::string, std::string>>& extra_files)
+{
+ for (const auto& [relPath, realPath] : extra_files) {
+ auto components = splitPath(relPath);
+ if (components.empty()) {
+ continue;
+ }
+
+ std::error_code ec;
+ const auto size = fs::file_size(realPath, ec);
+ tree.root.insertFile(components, realPath, ec ? 0ULL : size,
+ std::chrono::system_clock::now(), "_profile",
+ /*is_backing=*/false);
+ ++tree.file_count;
+ }
+}
diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h
index 5d6de43..3fb257e 100644
--- a/src/src/vfs/vfstree.h
+++ b/src/src/vfs/vfstree.h
@@ -77,4 +77,11 @@ VfsTree buildDataDirVfs(const std::vector<CachedBaseFile>& cached_files,
const std::vector<std::pair<std::string, std::string>>& mods,
const std::string& overwrite_dir);
+// Inject individual file mappings into an already-built VFS tree.
+// Each entry is (relative_vfs_path, absolute_real_path). Inserted with
+// highest priority (overwrites any existing entry at the same path).
+void injectExtraFiles(
+ VfsTree& tree,
+ const std::vector<std::pair<std::string, std::string>>& extra_files);
+
#endif