aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:12 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:25 -0600
commit817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch)
tree52aed11d6751bb7d20b06acf197d56fac113203d /src
parent51a9f8f197727f00896e5de44569b098923527dd (diff)
Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration
FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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