diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/plugins/rootbuilder.py | 368 | ||||
| -rw-r--r-- | src/src/createinstancedialog.cpp | 9 | ||||
| -rw-r--r-- | src/src/createinstancedialogpages.cpp | 40 | ||||
| -rw-r--r-- | src/src/filedialogmemory.cpp | 9 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 165 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 9 | ||||
| -rw-r--r-- | src/src/instancemanager.cpp | 32 | ||||
| -rw-r--r-- | src/src/instancemanager.h | 6 | ||||
| -rw-r--r-- | src/src/instancemanagerdialog.cpp | 59 | ||||
| -rw-r--r-- | src/src/loglist.cpp | 51 | ||||
| -rw-r--r-- | src/src/main.cpp | 24 | ||||
| -rw-r--r-- | src/src/modinfo.cpp | 13 | ||||
| -rw-r--r-- | src/src/nxmhandler_linux.cpp | 4 | ||||
| -rw-r--r-- | src/src/nxmhandler_linux.h | 1 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 40 | ||||
| -rw-r--r-- | src/src/processrunner.cpp | 647 | ||||
| -rw-r--r-- | src/src/profile.cpp | 26 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 18 | ||||
| -rw-r--r-- | src/src/settings.cpp | 320 | ||||
| -rw-r--r-- | src/src/settings.h | 5 | ||||
| -rw-r--r-- | src/src/settingsdialogpaths.cpp | 31 | ||||
| -rw-r--r-- | src/src/settingsdialogproton.cpp | 30 | ||||
| -rw-r--r-- | src/src/vfs/vfs_helper_main.cpp | 9 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.cpp | 19 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.h | 7 |
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 |
