aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/plugins/installer_omod.py272
-rw-r--r--src/plugins/rootbuilder.py396
-rw-r--r--src/src/createinstancedialog.cpp21
-rw-r--r--src/src/createinstancedialog.ui2
-rw-r--r--src/src/createinstancedialogpages.cpp159
-rw-r--r--src/src/fuseconnector.cpp29
-rw-r--r--src/src/instancemanager.cpp11
-rw-r--r--src/src/instancemanagerdialog.cpp43
-rw-r--r--src/src/instancemanagerdialog.h4
-rw-r--r--src/src/instancemanagerdialog.ui11
-rw-r--r--src/src/moapplication.cpp26
-rw-r--r--src/src/organizercore.cpp13
-rw-r--r--src/src/plugincontainer.cpp74
-rw-r--r--src/src/plugincontainer.h3
-rw-r--r--src/src/profile.cpp71
-rw-r--r--src/src/settingsdialog.ui24
-rw-r--r--src/src/settingsdialogproton.cpp381
-rw-r--r--src/src/settingsdialogproton.h6
-rw-r--r--src/src/shared/util.cpp5
-rw-r--r--src/src/wineprefix.cpp138
-rw-r--r--src/src/wineprefix.h4
21 files changed, 1460 insertions, 233 deletions
diff --git a/src/plugins/installer_omod.py b/src/plugins/installer_omod.py
new file mode 100644
index 0000000..2a586c1
--- /dev/null
+++ b/src/plugins/installer_omod.py
@@ -0,0 +1,272 @@
+# -*- encoding: utf-8 -*-
+
+"""
+OMOD Installer plugin for Mod Organizer 2 (Linux port).
+
+Handles .omod archives (Oblivion Mod Manager format) by parsing the binary
+config, extracting compressed data/plugin streams, and re-packaging as a
+standard zip for MO2's installation manager.
+"""
+
+import io
+import lzma
+import os
+import shutil
+import struct
+import tempfile
+import zipfile
+import zlib
+from pathlib import Path
+
+import mobase
+
+
+class OmodInstaller(mobase.IPluginInstallerCustom):
+ _organizer: mobase.IOrganizer
+
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ self._organizer = organizer
+ return True
+
+ def name(self) -> str:
+ return "OMOD Installer"
+
+ def localizedName(self) -> str:
+ return "OMOD Installer"
+
+ def author(self) -> str:
+ return "Fluorine Manager"
+
+ def description(self) -> str:
+ return "Installer for .omod archives (Oblivion Mod Manager format)"
+
+ def version(self) -> mobase.VersionInfo:
+ return mobase.VersionInfo(1, 0, 0)
+
+ def settings(self) -> list[mobase.PluginSetting]:
+ return []
+
+ def priority(self) -> int:
+ return 500
+
+ def isManualInstaller(self) -> bool:
+ return False
+
+ def isArchiveSupported(self, archive) -> bool:
+ # Called with IFileTree (mod list refresh) or str (install check).
+ if isinstance(archive, str):
+ return archive.lower().endswith(".omod")
+ # IFileTree: look for the "config" file that every OMOD contains.
+ try:
+ for entry in archive:
+ if entry.isFile() and entry.name() == "config":
+ return True
+ except TypeError:
+ pass
+ return False
+
+ def supportedExtensions(self) -> set[str]:
+ return {"omod"}
+
+ def install(
+ self,
+ mod_name: mobase.GuessedString,
+ game_name: str,
+ archive_name: str,
+ version: str,
+ nexus_id: int,
+ ) -> mobase.InstallResult:
+ try:
+ return self._do_install(mod_name, archive_name)
+ except Exception as e:
+ mobase.log(mobase.LogLevel.ERROR, f"OMOD install failed: {e}")
+ return mobase.InstallResult.FAILED
+
+ def _do_install(
+ self,
+ mod_name: mobase.GuessedString,
+ archive_name: str,
+ ) -> mobase.InstallResult:
+ with zipfile.ZipFile(archive_name, "r") as zf:
+ names = zf.namelist()
+
+ if "config" not in names:
+ mobase.log(mobase.LogLevel.WARNING, "OMOD: no config entry found")
+ return mobase.InstallResult.NOT_ATTEMPTED
+
+ config = self._parse_config(zf.read("config"))
+ if config.get("mod_name"):
+ mod_name.update(config["mod_name"])
+
+ compression = config.get("compression_type", 0)
+
+ tmpdir = tempfile.mkdtemp(prefix="omod_")
+ try:
+ self._extract_stream(
+ zf, names, "data", "data.crc", compression, tmpdir
+ )
+ self._extract_stream(
+ zf, names, "plugins", "plugins.crc", compression, tmpdir
+ )
+
+ # If the OMOD contains a readme, extract it too.
+ for entry in names:
+ lower = entry.lower()
+ if lower == "readme" or lower.startswith("readme."):
+ data = zf.read(entry)
+ dest = Path(tmpdir) / entry
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(data)
+
+ # Collect extracted files.
+ file_list = []
+ for root, _dirs, files in os.walk(tmpdir):
+ for f in files:
+ full = os.path.join(root, f)
+ rel = os.path.relpath(full, tmpdir)
+ file_list.append(rel)
+
+ if not file_list:
+ mobase.log(
+ mobase.LogLevel.WARNING, "OMOD: no files extracted"
+ )
+ return mobase.InstallResult.FAILED
+
+ # Repackage as a standard zip for MO2's installer.
+ repack_path = os.path.join(tmpdir, "_repack.zip")
+ with zipfile.ZipFile(repack_path, "w", zipfile.ZIP_DEFLATED) as out_zip:
+ for rel in file_list:
+ out_zip.write(os.path.join(tmpdir, rel), rel)
+
+ result, _, _ = self.manager().installArchive(mod_name, repack_path)
+ return result
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+ def _extract_stream(
+ self,
+ zf: zipfile.ZipFile,
+ names: list[str],
+ stream_name: str,
+ crc_name: str,
+ compression: int,
+ out_dir: str,
+ ) -> None:
+ if stream_name not in names:
+ return
+ if crc_name not in names:
+ mobase.log(
+ mobase.LogLevel.WARNING,
+ f"OMOD: {stream_name} present but {crc_name} missing",
+ )
+ return
+
+ file_list = self._parse_crc_file(zf.read(crc_name))
+ if not file_list:
+ return
+
+ raw = zf.read(stream_name)
+ decompressed = self._decompress_stream(raw, compression)
+
+ offset = 0
+ for path, size in file_list:
+ if offset + size > len(decompressed):
+ mobase.log(
+ mobase.LogLevel.WARNING,
+ f"OMOD: truncated stream for {path} "
+ f"(need {size} bytes at offset {offset}, "
+ f"have {len(decompressed)})",
+ )
+ break
+
+ file_data = decompressed[offset : offset + size]
+ offset += size
+
+ # Normalise path separators from Windows.
+ path = path.replace("\\", "/")
+ dest = Path(out_dir) / path
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(file_data)
+
+ def _parse_config(self, data: bytes) -> dict:
+ """Parse the OMOD binary config entry."""
+ reader = io.BytesIO(data)
+ config = {}
+
+ config["file_version"] = struct.unpack("<B", reader.read(1))[0]
+ config["mod_name"] = self._read_net_string(reader)
+
+ # Major and minor version.
+ config["major"] = struct.unpack("<i", reader.read(4))[0]
+ config["minor"] = struct.unpack("<i", reader.read(4))[0]
+
+ config["author"] = self._read_net_string(reader)
+ config["email"] = self._read_net_string(reader)
+ config["website"] = self._read_net_string(reader)
+ config["description"] = self._read_net_string(reader)
+
+ # Creation time (Windows FILETIME, 8 bytes) - skip.
+ reader.read(8)
+
+ # Compression type: 0 = deflate, 1 = lzma.
+ config["compression_type"] = struct.unpack("<B", reader.read(1))[0]
+
+ return config
+
+ def _parse_crc_file(self, data: bytes) -> list[tuple[str, int]]:
+ """Parse data.crc or plugins.crc.
+
+ Returns [(relative_path, uncompressed_size), ...].
+ """
+ reader = io.BytesIO(data)
+ count = self._read_7bit_encoded_int(reader)
+ files = []
+ for _ in range(count):
+ path = self._read_net_string(reader)
+ _crc = struct.unpack("<I", reader.read(4))[0]
+ size = struct.unpack("<q", reader.read(8))[0]
+ files.append((path, size))
+ return files
+
+ def _decompress_stream(self, data: bytes, compression_type: int) -> bytes:
+ """Decompress an OMOD data or plugins stream."""
+ if compression_type == 0:
+ # Raw deflate (no zlib/gzip header).
+ return zlib.decompress(data, -15)
+ elif compression_type == 1:
+ return lzma.decompress(data)
+ else:
+ raise ValueError(f"Unknown OMOD compression type: {compression_type}")
+
+ @staticmethod
+ def _read_net_string(reader: io.BytesIO) -> str:
+ """Read a .NET BinaryWriter-style length-prefixed string.
+
+ The length is encoded as a 7-bit encoded int, followed by that many
+ bytes of UTF-8.
+ """
+ length = OmodInstaller._read_7bit_encoded_int(reader)
+ if length == 0:
+ return ""
+ raw = reader.read(length)
+ return raw.decode("utf-8", errors="replace")
+
+ @staticmethod
+ def _read_7bit_encoded_int(reader: io.BytesIO) -> int:
+ """Read a .NET 7-bit encoded integer."""
+ result = 0
+ shift = 0
+ while True:
+ byte_data = reader.read(1)
+ if not byte_data:
+ break
+ b = byte_data[0]
+ result |= (b & 0x7F) << shift
+ shift += 7
+ if (b & 0x80) == 0:
+ break
+ return result
+
+
+def createPlugin() -> mobase.IPlugin:
+ return OmodInstaller()
diff --git a/src/plugins/rootbuilder.py b/src/plugins/rootbuilder.py
new file mode 100644
index 0000000..7237400
--- /dev/null
+++ b/src/plugins/rootbuilder.py
@@ -0,0 +1,396 @@
+# -*- encoding: utf-8 -*-
+from __future__ import annotations
+
+"""
+Root Builder plugin for Mod Organizer 2 (Linux port).
+
+Deploys files from mod Root/ subdirectories to the game's root directory.
+Supports copy (with reflink/CoW) and symlink modes, with automatic
+deploy/clear on game launch/close.
+"""
+
+import json
+import os
+import shutil
+import subprocess
+
+import mobase
+from PyQt6.QtGui import QIcon
+from PyQt6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
+)
+
+MANIFEST_NAME = ".rootbuilder_manifest.json"
+BACKUP_DIR_NAME = ".rootbuilder_backup"
+
+
+def _find_root_dir(mod_path: str) -> str | None:
+ """Find a 'Root' subdirectory (case-insensitive) inside a mod."""
+ try:
+ for entry in os.scandir(mod_path):
+ if entry.is_dir() and entry.name.lower() == "root":
+ return entry.path
+ except OSError:
+ pass
+ return None
+
+
+def _walk_files(root_dir: str):
+ """Yield all file paths under root_dir recursively."""
+ for dirpath, _dirnames, filenames in os.walk(root_dir):
+ for name in filenames:
+ yield os.path.join(dirpath, name)
+
+
+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],
+ check=True,
+ capture_output=True,
+ )
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ shutil.copy2(src, dst)
+
+
+def _manifest_path(game_dir: str) -> str:
+ return os.path.join(game_dir, MANIFEST_NAME)
+
+
+def _load_manifest(game_dir: str) -> dict | None:
+ path = _manifest_path(game_dir)
+ 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(game_dir: str, manifest: dict):
+ path = _manifest_path(game_dir)
+ with open(path, "w") as f:
+ json.dump(manifest, f, indent=2)
+
+
+def _remove_manifest(game_dir: str):
+ path = _manifest_path(game_dir)
+ if os.path.isfile(path):
+ os.remove(path)
+
+
+def _cleanup_empty_dirs(game_dir: str, deployed: list[str]):
+ """Remove empty directories left behind after clearing deployed files."""
+ dirs_to_check = set()
+ for path in deployed:
+ parent = os.path.dirname(path)
+ while parent and parent != game_dir and not os.path.samefile(parent, game_dir):
+ dirs_to_check.add(parent)
+ parent = os.path.dirname(parent)
+
+ for d in sorted(dirs_to_check, key=len, reverse=True):
+ try:
+ if os.path.isdir(d) and not os.listdir(d):
+ os.rmdir(d)
+ 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):
+ super().__init__(parent)
+ self._organizer = organizer
+ self._build_fn = build_fn
+ self._clear_fn = clear_fn
+ self._plugin_name = "Root Builder"
+
+ self.setWindowTitle("Root Builder")
+ self.resize(350, 220)
+
+ layout = QVBoxLayout(self)
+
+ desc = QLabel("Deploys files from mod Root/ folders to the game directory.")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ # Enable checkbox
+ self._enableCheck = QCheckBox("Auto-deploy on game launch")
+ self._enableCheck.setChecked(
+ bool(organizer.pluginSetting(self._plugin_name, "enabled"))
+ )
+ layout.addWidget(self._enableCheck)
+
+ # Mode selector
+ mode_layout = QHBoxLayout()
+ 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")
+ mode_layout.addWidget(self._modeCombo)
+ layout.addLayout(mode_layout)
+
+ # Manual build/clear buttons
+ btn_layout = QHBoxLayout()
+ build_btn = QPushButton("Build Now")
+ build_btn.clicked.connect(self._on_build)
+ clear_btn = QPushButton("Clear Now")
+ clear_btn.clicked.connect(self._on_clear)
+ btn_layout.addWidget(build_btn)
+ btn_layout.addWidget(clear_btn)
+ layout.addLayout(btn_layout)
+
+ # Status label
+ self._status = QLabel("")
+ layout.addWidget(self._status)
+
+ # Close button
+ close_btn = QPushButton("Close")
+ 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()
+ )
+
+ 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
+
+ def __init__(self):
+ super().__init__()
+ self.__parentWidget = None
+
+ # --- IPlugin ---
+
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ self._organizer = organizer
+ self._check_third_party_rootbuilder()
+ organizer.onAboutToRun(self._on_about_to_run)
+ organizer.onFinishedRun(self._on_finished_run)
+ return True
+
+ 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")
+ and entry.name.lower().endswith(".py")
+ and entry.name != my_file
+ ):
+ conflicts.append((entry.name, entry.path))
+
+ for name, path in conflicts:
+ dst = os.path.join(disabled_dir, name)
+ try:
+ os.makedirs(disabled_dir, exist_ok=True)
+ shutil.move(path, dst)
+ mobase.log(
+ mobase.LogLevel.INFO,
+ f"Root Builder: moved incompatible third-party plugin "
+ f"'{name}' to DisabledPlugins/. "
+ f"It uses Windows-only USVFS and cannot work on Linux.",
+ )
+ except OSError as e:
+ mobase.log(
+ mobase.LogLevel.WARNING,
+ f"Root Builder: failed to move third-party plugin "
+ f"'{name}' to DisabledPlugins/: {e}",
+ )
+
+ def name(self) -> str:
+ return "Root Builder"
+
+ def localizedName(self) -> str:
+ return "Root Builder"
+
+ def author(self) -> str:
+ return "Fluorine Manager"
+
+ def description(self) -> str:
+ return (
+ "Deploys mod files from Root/ subdirectories to the game's root directory. "
+ "Supports copy and symlink modes with auto-deploy on launch."
+ )
+
+ def version(self) -> mobase.VersionInfo:
+ return mobase.VersionInfo(1, 0, 0)
+
+ def enabledByDefault(self) -> bool:
+ return False
+
+ 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
+ ),
+ ]
+
+ # --- IPluginTool ---
+
+ def displayName(self) -> str:
+ return "Root Builder"
+
+ def tooltip(self) -> str:
+ return "Deploy mod Root/ files to the game directory"
+
+ def icon(self) -> QIcon:
+ return QIcon()
+
+ def setParentWidget(self, widget):
+ self.__parentWidget = widget
+
+ def display(self):
+ dialog = RootBuilderDialog(
+ self._organizer, 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"):
+ self._build()
+ return True
+
+ def _on_finished_run(self, executable: str, exit_code: int):
+ if self._organizer.pluginSetting(self.name(), "enabled"):
+ self._clear()
+
+ # --- Build / Clear ---
+
+ def _build(self) -> int:
+ """Deploy root files from all active mods. Returns number of files deployed."""
+ game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
+ mod_list = self._organizer.modList()
+ mods = mod_list.allModsByProfilePriority()
+ mode = self._organizer.pluginSetting(self.name(), "mode") or "copy"
+
+ # Clear any previous deployment first
+ if _load_manifest(game_dir) is not None:
+ self._clear()
+
+ manifest = {"deployed": [], "backups": {}}
+ backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
+ deployed_set = set()
+
+ for mod_name in mods:
+ if not (mod_list.state(mod_name) & mobase.ModState.ACTIVE):
+ continue
+
+ mod = mod_list.getMod(mod_name)
+ if mod is None:
+ continue
+ if mod.isSeparator() or mod.isBackup() or mod.isForeign():
+ continue
+
+ mod_path = mod.absolutePath()
+ root_dir = _find_root_dir(mod_path)
+ if root_dir is None:
+ continue
+
+ for src_file in _walk_files(root_dir):
+ 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
+
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
+
+ if os.path.lexists(dst):
+ os.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)
+
+ if dst not in deployed_set:
+ manifest["deployed"].append(dst)
+ deployed_set.add(dst)
+
+ _save_manifest(game_dir, 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)
+ if manifest is None:
+ return 0
+
+ count = 0
+
+ # Remove deployed files
+ for path in manifest["deployed"]:
+ if os.path.lexists(path):
+ os.remove(path)
+ count += 1
+
+ # 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)
+
+ # Clean up backup dir
+ backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
+ if os.path.isdir(backup_dir):
+ shutil.rmtree(backup_dir, ignore_errors=True)
+
+ _remove_manifest(game_dir)
+ _cleanup_empty_dirs(game_dir, manifest["deployed"])
+ return count
+
+
+def createPlugin() -> mobase.IPlugin:
+ return RootBuilder()
diff --git a/src/src/createinstancedialog.cpp b/src/src/createinstancedialog.cpp
index b01d436..d3dcdb2 100644
--- a/src/src/createinstancedialog.cpp
+++ b/src/src/createinstancedialog.cpp
@@ -392,7 +392,18 @@ void CreateInstanceDialog::finish()
// launch the new instance
if (ui->launch->isChecked()) {
- InstanceManager::singleton().setCurrentInstance(ci.instanceName);
+ if (ci.type == Portable) {
+ const auto defaultPortable =
+ QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ if (QDir(ci.dataPath).absolutePath() == defaultPortable) {
+ InstanceManager::singleton().setCurrentInstance("");
+ } else {
+ // Non-default portable location: store the absolute path
+ InstanceManager::singleton().setCurrentInstance(ci.dataPath);
+ }
+ } else {
+ InstanceManager::singleton().setCurrentInstance(ci.instanceName);
+ }
if (mustRestart) {
ExitModOrganizer(Exit::Restart);
@@ -489,7 +500,13 @@ CreateInstanceDialog::CreationInfo CreateInstanceDialog::rawCreationInfo() const
ci.paths = getSelected(&cid::Page::selectedPaths);
if (ci.type == Portable) {
- ci.dataPath = QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ // Use the base path from PathsPage which defaults to portablePath()
+ // but can be changed by the user to create a portable instance anywhere.
+ if (!ci.paths.base.isEmpty()) {
+ ci.dataPath = QDir(ci.paths.base).absolutePath();
+ } else {
+ ci.dataPath = QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ }
} else {
ci.dataPath = InstanceManager::singleton().instancePath(ci.instanceName);
}
diff --git a/src/src/createinstancedialog.ui b/src/src/createinstancedialog.ui
index b475075..019e405 100644
--- a/src/src/createinstancedialog.ui
+++ b/src/src/createinstancedialog.ui
@@ -190,7 +190,7 @@
<bool>true</bool>
</property>
<property name="description">
- <string>A portable instance stores everything in Mod Organizer's installation folder, currently %1. There can only be one portable instance per installation of Mod Organizer.</string>
+ <string>A portable instance stores everything in a single folder. The default location is %1, but you can choose any location on the next page.</string>
</property>
</widget>
</item>
diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp
index 72f0a27..7c4f167 100644
--- a/src/src/createinstancedialogpages.cpp
+++ b/src/src/createinstancedialogpages.cpp
@@ -18,33 +18,33 @@ using MOBase::TaskDialog;
// returns %base_dir%/dir
//
-QString makeDefaultPath(const std::wstring& dir)
-{
- return QDir::toNativeSeparators(
- PathSettings::makeDefaultPath(QString::fromStdWString(dir)));
-}
-
-QString compactPathForDescription(const QString& path)
-{
- const QString nativePath = QDir::toNativeSeparators(path);
- const QString homePath = QDir::toNativeSeparators(QDir::homePath());
-
- if (nativePath.startsWith(homePath, Qt::CaseInsensitive)) {
- return "~" + nativePath.mid(homePath.size());
- }
-
- return nativePath;
-}
-
-void tuneTypeButtonHeight(QCommandLinkButton* button)
-{
- // Qt can underestimate command-link height on Linux for long descriptions.
- button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
- button->setMinimumHeight(std::max(button->minimumSizeHint().height(), 110));
-}
-
-QString toLocalizedString(CreateInstanceDialog::Types t)
-{
+QString makeDefaultPath(const std::wstring& dir)
+{
+ return QDir::toNativeSeparators(
+ PathSettings::makeDefaultPath(QString::fromStdWString(dir)));
+}
+
+QString compactPathForDescription(const QString& path)
+{
+ const QString nativePath = QDir::toNativeSeparators(path);
+ const QString homePath = QDir::toNativeSeparators(QDir::homePath());
+
+ if (nativePath.startsWith(homePath, Qt::CaseInsensitive)) {
+ return "~" + nativePath.mid(homePath.size());
+ }
+
+ return nativePath;
+}
+
+void tuneTypeButtonHeight(QCommandLinkButton* button)
+{
+ // Qt can underestimate command-link height on Linux for long descriptions.
+ button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
+ button->setMinimumHeight(std::max(button->minimumSizeHint().height(), 110));
+}
+
+QString toLocalizedString(CreateInstanceDialog::Types t)
+{
switch (t) {
case CreateInstanceDialog::Global:
return QObject::tr("Global");
@@ -181,27 +181,22 @@ bool IntroPage::doSkip() const
return m_skip;
}
-TypePage::TypePage(CreateInstanceDialog& dlg)
- : Page(dlg), m_type(CreateInstanceDialog::NoType)
-{
- // replace placeholders with actual paths
- ui->createGlobal->setDescription(ui->createGlobal->description().arg(
- compactPathForDescription(InstanceManager::singleton().globalInstancesRootPath())));
-
- ui->createPortable->setDescription(ui->createPortable->description().arg(
- compactPathForDescription(InstanceManager::singleton().portablePath())));
-
- tuneTypeButtonHeight(ui->createGlobal);
- tuneTypeButtonHeight(ui->createPortable);
-
- // disable portable button if it already exists
- if (InstanceManager::singleton().portableInstanceExists()) {
- ui->createPortable->setEnabled(false);
- ui->portableExistsLabel->setVisible(true);
- } else {
- ui->createPortable->setEnabled(true);
- ui->portableExistsLabel->setVisible(false);
- }
+TypePage::TypePage(CreateInstanceDialog& dlg)
+ : Page(dlg), m_type(CreateInstanceDialog::NoType)
+{
+ // replace placeholders with actual paths
+ ui->createGlobal->setDescription(ui->createGlobal->description().arg(
+ compactPathForDescription(InstanceManager::singleton().globalInstancesRootPath())));
+
+ ui->createPortable->setDescription(ui->createPortable->description().arg(
+ compactPathForDescription(InstanceManager::singleton().portablePath())));
+
+ tuneTypeButtonHeight(ui->createGlobal);
+ tuneTypeButtonHeight(ui->createPortable);
+
+ // Portable instances can now be created at any location, so always enable
+ ui->createPortable->setEnabled(true);
+ ui->portableExistsLabel->setVisible(false);
QObject::connect(ui->createGlobal, &QAbstractButton::clicked, [&] {
global();
@@ -466,13 +461,13 @@ GamePage::Game* GamePage::findGame(IPluginGame* game)
return nullptr;
}
-void GamePage::createGameButton(Game* g)
-{
- g->button = new QCommandLinkButton;
- g->button->setCheckable(true);
- g->button->setIconSize(QSize(24, 24));
-
- updateButton(g);
+void GamePage::createGameButton(Game* g)
+{
+ g->button = new QCommandLinkButton;
+ g->button->setCheckable(true);
+ g->button->setIconSize(QSize(24, 24));
+
+ updateButton(g);
QObject::connect(g->button, &QAbstractButton::clicked, [g, this] {
select(g->game);
@@ -487,33 +482,33 @@ void GamePage::addButton(QAbstractButton* b)
ly->insertWidget(ly->count() - 1, b);
}
-void GamePage::updateButton(Game* g)
-{
- if (!g || !g->button) {
- return;
- }
-
- g->button->setText(g->game->displayGameName().replace("&", "&&"));
- QIcon icon = g->game->gameIcon();
- if (icon.isNull()) {
- icon = QIcon(":/MO/gui/executable");
- }
- if (icon.isNull()) {
- icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
- }
- g->button->setIcon(icon);
-
- if (g->installed) {
- const QString compact = compactPathForDescription(g->dir);
- const QFontMetrics fm(g->button->font());
- const QString elided = fm.elidedText(compact, Qt::ElideMiddle, 460);
- g->button->setDescription(elided);
- g->button->setToolTip(QDir::toNativeSeparators(g->dir));
- } else {
- g->button->setDescription(QObject::tr("No installation found"));
- g->button->setToolTip({});
- }
-}
+void GamePage::updateButton(Game* g)
+{
+ if (!g || !g->button) {
+ return;
+ }
+
+ g->button->setText(g->game->displayGameName().replace("&", "&&"));
+ QIcon icon = g->game->gameIcon();
+ if (icon.isNull()) {
+ icon = QIcon(":/MO/gui/executable");
+ }
+ if (icon.isNull()) {
+ icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
+ }
+ g->button->setIcon(icon);
+
+ if (g->installed) {
+ const QString compact = compactPathForDescription(g->dir);
+ const QFontMetrics fm(g->button->font());
+ const QString elided = fm.elidedText(compact, Qt::ElideMiddle, 460);
+ g->button->setDescription(elided);
+ g->button->setToolTip(QDir::toNativeSeparators(g->dir));
+ } else {
+ g->button->setDescription(QObject::tr("No installation found"));
+ g->button->setToolTip({});
+ }
+}
void GamePage::selectButton(Game* g)
{
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 9862664..2e3a7ba 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -144,22 +144,31 @@ bool isMountPoint(const QString& path)
bool runUnmountCommand(const QString& program, const QStringList& args)
{
- QProcess p;
+ // Suppress stderr from fusermount/umount to avoid confusing terminal output
+ // when unmount fails (e.g. permission denied in Flatpak sandbox).
+ auto tryRun = [&](const QString& cmd, const QStringList& cmdArgs) -> bool {
+ QProcess p;
+ p.setStandardErrorFile(QProcess::nullDevice());
+ p.start(cmd, cmdArgs);
+ if (!p.waitForFinished(3000)) {
+ p.kill();
+ return false;
+ }
+ return p.exitStatus() == QProcess::NormalExit && p.exitCode() == 0;
+ };
+ // In Flatpak: try sandbox-local unmount first (mount was likely created
+ // inside the sandbox), then fall back to host-side unmount.
if (isFlatpak()) {
+ if (tryRun(program, args)) {
+ return true;
+ }
QStringList spawnArgs = {QStringLiteral("--host"), program};
spawnArgs.append(args);
- p.start(QStringLiteral("flatpak-spawn"), spawnArgs);
- } else {
- p.start(program, args);
- }
-
- if (!p.waitForFinished(3000)) {
- p.kill();
- return false;
+ return tryRun(QStringLiteral("flatpak-spawn"), spawnArgs);
}
- return p.exitStatus() == QProcess::NormalExit && p.exitCode() == 0;
+ return tryRun(program, args);
}
std::vector<std::pair<std::string, std::string>>
diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp
index ba1d320..0c6ca63 100644
--- a/src/src/instancemanager.cpp
+++ b/src/src/instancemanager.cpp
@@ -572,12 +572,11 @@ std::unique_ptr<Instance> InstanceManager::currentInstance() const
return std::make_unique<Instance>(portablePath(), true, profile);
}
- if (hasPortable && !m_overrideInstanceName.has_value()) {
- // Prefer a colocated ModOrganizer.ini for portable bundles unless a
- // specific instance was explicitly requested on the command line.
- log::debug("portable instance detected at '{}', preferring it over stored global "
- "instance '{}'",
- portablePath(), name);
+ if (hasPortable && !m_overrideInstanceName.has_value() && name.isEmpty()) {
+ // Prefer a colocated ModOrganizer.ini for portable bundles only when
+ // no instance has been explicitly selected by the user.
+ log::debug("portable instance detected at '{}', no stored instance, using portable",
+ portablePath());
return std::make_unique<Instance>(portablePath(), true, profile);
}
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index f0f3f61..605f80e 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -8,6 +8,8 @@
#include "shared/appconfig.h"
#include "shared/util.h"
#include "ui_instancemanagerdialog.h"
+#include <QFileDialog>
+#include <QStandardPaths>
#include <iplugingame.h>
#include <report.h>
#include <utility.h>
@@ -170,6 +172,9 @@ InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* paren
connect(ui->createNew, &QPushButton::clicked, [&] {
createNew();
});
+ connect(ui->openExisting, &QPushButton::clicked, [&] {
+ openExistingPortable();
+ });
connect(ui->list->selectionModel(), &QItemSelectionModel::selectionChanged, [&] {
onSelection();
@@ -365,7 +370,15 @@ void InstanceManagerDialog::openSelectedInstance()
}
if (to.isPortable()) {
- InstanceManager::singleton().setCurrentInstance("");
+ // Store the actual directory for portable instances so we can distinguish
+ // between the default portable path and user-selected portable locations.
+ // An empty string means "use default portable path".
+ auto& m = InstanceManager::singleton();
+ if (to.directory() == m.portablePath()) {
+ m.setCurrentInstance("");
+ } else {
+ m.setCurrentInstance(to.directory());
+ }
} else {
InstanceManager::singleton().setCurrentInstance(to.displayName());
}
@@ -734,3 +747,31 @@ void InstanceManagerDialog::setButtonsEnabled(bool b)
ui->deleteInstance->setEnabled(b);
ui->switchToInstance->setEnabled(b);
}
+
+void InstanceManagerDialog::openExistingPortable()
+{
+ const QString dir = QFileDialog::getExistingDirectory(
+ this, tr("Select portable instance folder"),
+ QStandardPaths::writableLocation(QStandardPaths::HomeLocation));
+
+ if (dir.isEmpty()) {
+ return;
+ }
+
+ const QString ini = QDir(dir).filePath("ModOrganizer.ini");
+ if (!QFileInfo::exists(ini)) {
+ QMessageBox::warning(
+ this, tr("Not an instance"),
+ tr("The selected folder does not contain a ModOrganizer.ini file."));
+ return;
+ }
+
+ // Switch directly to this portable instance
+ InstanceManager::singleton().setCurrentInstance(dir);
+
+ if (m_restartOnSelect) {
+ ExitModOrganizer(Exit::Restart);
+ }
+
+ accept();
+}
diff --git a/src/src/instancemanagerdialog.h b/src/src/instancemanagerdialog.h
index f6e2db6..4df9cdc 100644
--- a/src/src/instancemanagerdialog.h
+++ b/src/src/instancemanagerdialog.h
@@ -108,6 +108,10 @@ private:
//
void createNew();
+ // opens a file dialog to browse to an existing portable instance
+ //
+ void openExistingPortable();
+
// shows a confirmation to the user before switching
//
bool confirmSwitch(const Instance& to);
diff --git a/src/src/instancemanagerdialog.ui b/src/src/instancemanagerdialog.ui
index 44f4ff1..eab814c 100644
--- a/src/src/instancemanagerdialog.ui
+++ b/src/src/instancemanagerdialog.ui
@@ -41,6 +41,17 @@
</widget>
</item>
<item>
+ <widget class="QPushButton" name="openExisting">
+ <property name="text">
+ <string>Open existing portable</string>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/open_folder</normaloff>:/MO/gui/open_folder</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp
index 68f6510..b77570e 100644
--- a/src/src/moapplication.cpp
+++ b/src/src/moapplication.cpp
@@ -30,7 +30,9 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "sanitychecks.h"
#include "settings.h"
#ifndef _WIN32
+#include "fluorineconfig.h"
#include "fuseconnector.h"
+#include "wineprefix.h"
#include <cerrno>
#endif
#include <filesystem>
@@ -397,6 +399,30 @@ int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect)
log::info("checking for stale FUSE mount on '{}'", dataDir);
FuseConnector::tryCleanupStaleMount(dataDir);
}
+
+ // Restore any stale INI/save backups left by a previous crash.
+ // This ensures the documents directory is clean before we do anything else.
+ {
+ auto prefixPath = FluorineConfig::prefixPath();
+ if (!prefixPath || prefixPath->isEmpty()) {
+ QSettings instanceSettings(m_settings->filename(), QSettings::IniFormat);
+ for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path",
+ "Proton/prefix_path", "fluorine/prefix_path"}) {
+ const QString value = instanceSettings.value(key).toString().trimmed();
+ if (!value.isEmpty()) {
+ prefixPath = value;
+ break;
+ }
+ }
+ }
+ if (prefixPath && !prefixPath->isEmpty()) {
+ WinePrefix prefix(*prefixPath);
+ if (prefix.isValid()) {
+ log::info("checking for stale backup files in prefix '{}'", *prefixPath);
+ prefix.restoreStaleBackups();
+ }
+ }
+ }
#endif
m_core->createDefaultProfile();
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index a239447..9fba748 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -36,6 +36,7 @@
#include <uibase/game_features/scriptextender.h>
#include <uibase/report.h>
#include <uibase/scopeguard.h>
+#include <uibase/filesystemutilities.h>
#include <uibase/utility.h>
#ifdef _WIN32
#include <usvfs/usvfs.h>
@@ -2162,9 +2163,9 @@ bool OrganizerCore::beforeRun(
for (const QString& iniFile : managedGame()->iniFiles()) {
const QString sourceIni =
m_CurrentProfile->absoluteIniFilePath(iniFile);
- const QString targetIni = QFileInfo(managedGame()->documentsDirectory(),
- iniFile)
- .absoluteFilePath();
+ const QString targetIni = MOBase::resolveFileCaseInsensitive(
+ QFileInfo(managedGame()->documentsDirectory(), iniFile)
+ .absoluteFilePath());
log::debug("INI deploy check: source='{}' exists={}, target='{}'",
sourceIni, QFileInfo::exists(sourceIni), targetIni);
if (QFileInfo::exists(sourceIni) &&
@@ -2253,9 +2254,9 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode)
for (const QString& iniFile : managedGame()->iniFiles()) {
const QString profileIni =
m_CurrentProfile->absoluteIniFilePath(iniFile);
- const QString targetIni = QFileInfo(managedGame()->documentsDirectory(),
- iniFile)
- .absoluteFilePath();
+ const QString targetIni = MOBase::resolveFileCaseInsensitive(
+ QFileInfo(managedGame()->documentsDirectory(), iniFile)
+ .absoluteFilePath());
iniMappings.append({profileIni, targetIni});
log::debug("Sync profile INI '{}' <- '{}'", profileIni, targetIni);
}
diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp
index ed50470..1397aad 100644
--- a/src/src/plugincontainer.cpp
+++ b/src/src/plugincontainer.cpp
@@ -21,6 +21,43 @@ using namespace MOShared;
namespace bf = boost::fusion;
+#ifndef _WIN32
+// Ensure the instance plugin directory contains symlinks to all bundled plugins.
+// Real user files (not matching any bundled plugin name) are left untouched.
+// Stale copies of bundled plugins are replaced with symlinks so that RPATH
+// resolution works correctly (critical for Flatpak where libs live in /app/).
+static void ensureBundledPluginsLinked(const QString& bundledDir,
+ const QString& instanceDir)
+{
+ QDir().mkpath(instanceDir);
+ QDirIterator it(bundledDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ while (it.hasNext()) {
+ it.next();
+ const QString target = QDir(instanceDir).filePath(it.fileName());
+ const QFileInfo targetInfo(target);
+
+ if (targetInfo.isSymLink()) {
+ // Already a symlink — check if it points to the right place.
+ if (targetInfo.symLinkTarget() == QFileInfo(it.filePath()).absoluteFilePath()) {
+ continue; // correct symlink, nothing to do
+ }
+ // Stale symlink pointing elsewhere — replace it.
+ QFile::remove(target);
+ } else if (targetInfo.exists()) {
+ // Real file/dir with the same name as a bundled plugin — this is a
+ // stale copy (not a user plugin). Replace with a symlink so RPATH works.
+ if (targetInfo.isDir()) {
+ QDir(target).removeRecursively();
+ } else {
+ QFile::remove(target);
+ }
+ }
+
+ QFile::link(it.filePath(), target);
+ }
+}
+#endif
+
// Welcome to the wonderful world of MO2 plugin management!
//
// We'll start by the C++ side.
@@ -401,9 +438,10 @@ QStringList PluginContainer::pluginFileNames() const
}
std::vector<IPluginProxy*> proxyList = bf::at_key<IPluginProxy>(m_Plugins);
for (IPluginProxy* proxy : proxyList) {
- QStringList proxiedPlugins =
- proxy->pluginList(AppConfig::basePath() + "/" +
- ToQString(AppConfig::pluginPath()));
+ QStringList proxiedPlugins = proxy->pluginList(
+ m_PluginPath.isEmpty()
+ ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()))
+ : m_PluginPath);
result.append(proxiedPlugins);
}
return result;
@@ -604,9 +642,10 @@ IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepat
bf::at_key<IPluginProxy>(m_Plugins).push_back(proxy);
emit pluginRegistered(proxy);
- QStringList filepaths =
- proxy->pluginList(QCoreApplication::applicationDirPath() + "/" +
- ToQString(AppConfig::pluginPath()));
+ QStringList filepaths = proxy->pluginList(
+ m_PluginPath.isEmpty()
+ ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()))
+ : m_PluginPath);
for (const QString& filepath : filepaths) {
loadProxied(filepath, proxy);
}
@@ -921,8 +960,10 @@ void PluginContainer::loadPlugin(QString const& filepath)
} else {
// We need to check if this can be handled by a proxy.
for (auto* proxy : this->plugins<IPluginProxy>()) {
- auto filepaths = proxy->pluginList(QCoreApplication::applicationDirPath() + "/" +
- ToQString(AppConfig::pluginPath()));
+ auto filepaths = proxy->pluginList(
+ m_PluginPath.isEmpty()
+ ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()))
+ : m_PluginPath);
if (filepaths.contains(filepath)) {
plugins = loadProxied(filepath, proxy);
break;
@@ -1139,6 +1180,23 @@ void PluginContainer::loadPlugins()
QString pluginPath =
AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath());
+
+#ifndef _WIN32
+ // Per-instance plugin directory: symlink bundled plugins, then load from there.
+ // This allows each instance to have its own set of plugins (bundled + custom).
+ if (m_Organizer) {
+ QString instancePluginPath =
+ QDir(QDir::fromNativeSeparators(m_Organizer->basePath())).filePath("plugins");
+ if (QDir::cleanPath(instancePluginPath) != QDir::cleanPath(pluginPath)) {
+ ensureBundledPluginsLinked(pluginPath, instancePluginPath);
+ pluginPath = instancePluginPath;
+ log::debug("Using per-instance plugin directory: {}",
+ QDir::toNativeSeparators(pluginPath));
+ }
+ }
+#endif
+
+ m_PluginPath = pluginPath;
log::debug("looking for plugins in {}", QDir::toNativeSeparators(pluginPath));
// Linux is case-sensitive; keep only the canonical Fallout NV plugin filename.
diff --git a/src/src/plugincontainer.h b/src/src/plugincontainer.h
index f9824be..4ae1744 100644
--- a/src/src/plugincontainer.h
+++ b/src/src/plugincontainer.h
@@ -500,6 +500,9 @@ private:
PreviewGenerator m_PreviewGenerator;
QFile m_PluginsCheck;
+
+ // Resolved plugin path (may be per-instance on Linux global installs)
+ QString m_PluginPath;
};
#endif // PLUGINCONTAINER_H
diff --git a/src/src/profile.cpp b/src/src/profile.cpp
index be22520..fdc3852 100644
--- a/src/src/profile.cpp
+++ b/src/src/profile.cpp
@@ -19,7 +19,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "profile.h"
-#include "filesystemutilities.h"
+#include <uibase/filesystemutilities.h>
#include "game_features.h"
#include "modinfo.h"
#include "modinfoforeign.h"
@@ -61,43 +61,14 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include <stdexcept>
#include <utility> // for find
-using namespace MOBase;
-using namespace MOShared;
-
-namespace
-{
-QString resolveExistingFileCaseInsensitive(const QString& path)
-{
-#ifdef _WIN32
- return QDir::cleanPath(path);
-#else
- const QFileInfo info(path);
- if (info.exists()) {
- return info.absoluteFilePath();
- }
-
- QDir dir(info.path());
- if (!dir.exists()) {
- return QDir::cleanPath(path);
- }
-
- const QString target = info.fileName();
- const QStringList entries =
- dir.entryList(QDir::Files | QDir::Readable | QDir::Hidden | QDir::System);
- for (const QString& entry : entries) {
- if (entry.compare(target, Qt::CaseInsensitive) == 0) {
- return dir.absoluteFilePath(entry);
- }
- }
-
- return QDir::cleanPath(path);
-#endif
-}
-} // namespace
-
-void Profile::touchFile(QString fileName)
-{
- QFile modList(m_Directory.filePath(fileName));
+using namespace MOBase;
+using namespace MOShared;
+
+// MOBase::resolveFileCaseInsensitive moved to MOBase::resolveFileCaseInsensitive
+
+void Profile::touchFile(QString fileName)
+{
+ QFile modList(m_Directory.filePath(fileName));
if (!modList.open(QIODevice::ReadWrite)) {
throw std::runtime_error(QObject::tr("failed to create %1")
.arg(m_Directory.filePath(fileName))
@@ -1035,8 +1006,8 @@ QString Profile::getIniFileName() const
return m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName());
}
-QString Profile::absoluteIniFilePath(QString iniFile) const
-{
+QString Profile::absoluteIniFilePath(QString iniFile) const
+{
// This is the file to which the given iniFile would be mapped, as
// an absolute file path:
QFileInfo targetIniFile(m_GamePlugin->documentsDirectory(), iniFile);
@@ -1050,16 +1021,16 @@ QString Profile::absoluteIniFilePath(QString iniFile) const
}
}
- // Local-settings are not enabled, or the iniFile is not in the list of INI
- // files for the current game.
- if (!localSettingsEnabled() || !isGameIni) {
- return resolveExistingFileCaseInsensitive(targetIniFile.absoluteFilePath());
- }
-
- // If we reach here, the file is in the profile:
- return resolveExistingFileCaseInsensitive(
- m_Directory.absoluteFilePath(targetIniFile.fileName()));
-}
+ // Local-settings are not enabled, or the iniFile is not in the list of INI
+ // files for the current game.
+ if (!localSettingsEnabled() || !isGameIni) {
+ return MOBase::resolveFileCaseInsensitive(targetIniFile.absoluteFilePath());
+ }
+
+ // If we reach here, the file is in the profile:
+ return MOBase::resolveFileCaseInsensitive(
+ m_Directory.absoluteFilePath(targetIniFile.fileName()));
+}
QString Profile::getProfileTweaks() const
{
diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui
index 9fdf4e3..cd869a5 100644
--- a/src/src/settingsdialog.ui
+++ b/src/src/settingsdialog.ui
@@ -1741,17 +1741,17 @@ If you disable this feature, MO will only display official DLCs this way. Please
<item row="1" column="0">
<widget class="QLabel" name="label_63">
<property name="text">
- <string>Prefix Path:</string>
+ <string>Prefix Location:</string>
</property>
</widget>
</item>
- <item row="1" column="1" colspan="3">
- <widget class="QLabel" name="prefixPathValueLabel">
+ <item row="1" column="1" colspan="2">
+ <widget class="QLineEdit" name="prefixLocationEdit"/>
+ </item>
+ <item row="1" column="3">
+ <widget class="QPushButton" name="prefixLocationBrowseButton">
<property name="text">
- <string>(none)</string>
- </property>
- <property name="wordWrap">
- <bool>true</bool>
+ <string>Browse...</string>
</property>
</widget>
</item>
@@ -1793,6 +1793,16 @@ If you disable this feature, MO will only display official DLCs this way. Please
</property>
</widget>
</item>
+ <item row="3" column="2" colspan="2">
+ <widget class="QPushButton" name="winetricksButton">
+ <property name="text">
+ <string>Winetricks</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the Winetricks GUI to install Windows libraries and components into the prefix.</string>
+ </property>
+ </widget>
+ </item>
<item row="4" column="0">
<widget class="QLabel" name="label_64">
<property name="text">
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 3062794..8c9c225 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -6,14 +6,25 @@
#include <QtConcurrent/QtConcurrentRun>
#include <nak_ffi.h>
#include <atomic>
+#include <QComboBox>
#include <QCoreApplication>
#include <QDateTime>
+#include <QDialog>
+#include <QDialogButtonBox>
#include <QDir>
+#include <QFileDialog>
#include <QFileInfo>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QPushButton>
#include <QMetaObject>
#include <QProcess>
#include <QSettings>
#include <QScopeGuard>
+#include <QStandardPaths>
+#include <QVBoxLayout>
namespace
{
@@ -69,6 +80,10 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
&ProtonSettingsTab::onOpenPrefixFolder);
QObject::connect(ui->fixGameRegistriesButton, &QPushButton::clicked, this,
&ProtonSettingsTab::onFixGameRegistries);
+ QObject::connect(ui->winetricksButton, &QPushButton::clicked, this,
+ &ProtonSettingsTab::onWinetricks);
+ QObject::connect(ui->prefixLocationBrowseButton, &QPushButton::clicked, this,
+ &ProtonSettingsTab::onBrowsePrefixLocation);
QObject::connect(&m_installWatcher, &QFutureWatcher<InstallResult>::finished, this,
&ProtonSettingsTab::onInstallFinished);
@@ -127,13 +142,24 @@ void ProtonSettingsTab::refreshState()
ui->protonProgressBar->setVisible(false);
}
- ui->prefixPathValueLabel->setText(active ? *prefix : tr("(none)"));
+ if (active) {
+ ui->prefixLocationEdit->setText(*prefix);
+ ui->prefixLocationEdit->setReadOnly(true);
+ } else {
+ ui->prefixLocationEdit->setReadOnly(false);
+ if (ui->prefixLocationEdit->text().isEmpty()) {
+ ui->prefixLocationEdit->setText(
+ QDir::homePath() + "/.var/app/com.fluorine.manager/Prefix");
+ }
+ }
+ ui->prefixLocationBrowseButton->setEnabled(!m_busy && !active);
ui->createPrefixButton->setEnabled(!m_busy && !active);
ui->deletePrefixButton->setEnabled(!m_busy && active);
ui->recreatePrefixButton->setEnabled(!m_busy && active);
ui->openPrefixFolderButton->setEnabled(!m_busy && active);
ui->fixGameRegistriesButton->setEnabled(!m_busy && active);
+ ui->winetricksButton->setEnabled(!m_busy && active);
ui->protonVersionCombo->setEnabled(!m_busy);
}
@@ -164,28 +190,22 @@ void ProtonSettingsTab::onCreatePrefix()
return;
}
- setBusy(true);
- ui->protonStatusLabel->setText(tr("Creating Steam shortcut..."));
-
- const QByteArray protonNameUtf8 = protonName.toUtf8();
- NakShortcutResult result = nak_add_mod_manager_shortcut(
- "Fluorine Manager", "/usr/bin/true", "/tmp", protonNameUtf8.constData());
-
- const QString prefixPath =
- QString::fromUtf8(result.prefix_path ? result.prefix_path : "");
- const QString error = QString::fromUtf8(result.error ? result.error : "");
- const uint32_t appId = result.app_id;
-
- nak_shortcut_result_free(result);
+ const QString basePath = ui->prefixLocationEdit->text().trimmed();
+ if (basePath.isEmpty()) {
+ ui->protonStatusLabel->setText(tr("Select a prefix location first"));
+ return;
+ }
- if (!error.isEmpty() || prefixPath.isEmpty()) {
- setBusy(false);
- ui->protonStatusLabel->setText(error.isEmpty() ? tr("Failed to create prefix")
- : tr("Error: %1").arg(error));
+ const QString pfxPath = QDir(basePath).filePath("pfx");
+ if (!QDir().mkpath(pfxPath)) {
+ ui->protonStatusLabel->setText(tr("Failed to create prefix directory"));
return;
}
- startInstallTask(appId, prefixPath, protonName, protonPath,
+ setBusy(true);
+ ui->protonStatusLabel->setText(tr("Creating prefix..."));
+
+ startInstallTask(0, pfxPath, protonName, protonPath,
ui->umuCheckBox->isChecked(),
ui->umuSystemCheckBox->isChecked(),
ui->steamRunCheckBox->isChecked());
@@ -203,12 +223,9 @@ void ProtonSettingsTab::onDeletePrefix()
return;
}
- if (char* error = nak_remove_steam_shortcut(cfg->app_id); error != nullptr) {
- nak_string_free(error);
- }
-
cfg->destroyPrefix();
+ ui->prefixLocationEdit->clear();
ui->protonStatusLabel->setText(tr("No Prefix"));
refreshState();
}
@@ -253,12 +270,162 @@ void ProtonSettingsTab::onOpenPrefixFolder()
QProcess::startDetached("xdg-open", {*path});
}
+void ProtonSettingsTab::onBrowsePrefixLocation()
+{
+ const QString dir = QFileDialog::getExistingDirectory(
+ parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text());
+ if (!dir.isEmpty()) {
+ ui->prefixLocationEdit->setText(dir);
+ }
+}
+
+QString ProtonSettingsTab::ensureWinetricks()
+{
+ const QString nakWinetricks = QDir::homePath() + "/.config/nak/bin/winetricks";
+ if (QFileInfo::exists(nakWinetricks)) {
+ return nakWinetricks;
+ }
+
+ const QString systemWinetricks = QStandardPaths::findExecutable("winetricks");
+ if (!systemWinetricks.isEmpty()) {
+ return systemWinetricks;
+ }
+
+ const QString nakBinDir = QDir::homePath() + "/.config/nak/bin";
+ QDir().mkpath(nakBinDir);
+
+ QString downloadTool;
+ QStringList downloadArgs;
+
+ if (!QStandardPaths::findExecutable("curl").isEmpty()) {
+ downloadTool = "curl";
+ downloadArgs = {"-L", "-o", nakWinetricks,
+ "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/"
+ "winetricks"};
+ } else if (!QStandardPaths::findExecutable("wget").isEmpty()) {
+ downloadTool = "wget";
+ downloadArgs = {"-O", nakWinetricks,
+ "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/"
+ "winetricks"};
+ } else {
+ return {};
+ }
+
+ QProcess proc;
+ proc.start(downloadTool, downloadArgs);
+ proc.waitForFinished(30000);
+
+ if (proc.exitCode() != 0 || !QFileInfo::exists(nakWinetricks)) {
+ return {};
+ }
+
+ QFile::setPermissions(nakWinetricks,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner | QFileDevice::ReadGroup |
+ QFileDevice::ExeGroup | QFileDevice::ReadOther |
+ QFileDevice::ExeOther);
+
+ return nakWinetricks;
+}
+
+QString ProtonSettingsTab::findProtonWine(const QString& protonPath)
+{
+ QString wine = QDir(protonPath).filePath("files/bin/wine");
+ if (QFileInfo::exists(wine)) {
+ return wine;
+ }
+
+ wine = QDir(protonPath).filePath("dist/bin/wine");
+ if (QFileInfo::exists(wine)) {
+ return wine;
+ }
+
+ return {};
+}
+
+void ProtonSettingsTab::onWinetricks()
+{
+ auto cfg = FluorineConfig::load();
+ if (!cfg.has_value() || !cfg->prefixExists()) {
+ ui->protonStatusLabel->setText(tr("No existing prefix"));
+ refreshState();
+ return;
+ }
+
+ const QString winetricksPath = ensureWinetricks();
+ if (winetricksPath.isEmpty()) {
+ QMessageBox::warning(
+ parentWidget(), tr("Winetricks Not Found"),
+ tr("Could not find or download winetricks.\n\n"
+ "Please install winetricks manually:\n"
+ " Arch: pacman -S winetricks\n"
+ " Ubuntu: apt install winetricks\n"
+ " Fedora: dnf install winetricks"));
+ return;
+ }
+
+ // Build env vars for winetricks
+ QStringList envFlags;
+ envFlags << QStringLiteral("WINEPREFIX=%1").arg(cfg->prefix_path);
+
+ const QString protonWine = findProtonWine(cfg->proton_path);
+ if (!protonWine.isEmpty()) {
+ envFlags << QStringLiteral("WINE=%1").arg(protonWine);
+ const QString wineserver =
+ QFileInfo(protonWine).dir().filePath("wineserver");
+ if (QFileInfo::exists(wineserver)) {
+ envFlags << QStringLiteral("WINESERVER=%1").arg(wineserver);
+ }
+ }
+
+ // In Flatpak, wine/winetricks must run on the host via flatpak-spawn --host
+ // because Proton binaries need the host's linker and Steam Runtime libs.
+ const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info"));
+
+ QString program;
+ QStringList arguments;
+
+ if (flatpak) {
+ program = QStringLiteral("flatpak-spawn");
+ arguments << QStringLiteral("--host");
+ for (const QString& flag : envFlags) {
+ arguments << QStringLiteral("--env=%1").arg(flag);
+ }
+ arguments << winetricksPath << QStringLiteral("--gui");
+ } else {
+ program = winetricksPath;
+ arguments << QStringLiteral("--gui");
+
+ // For non-Flatpak, set env vars directly on the process
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ for (const QString& flag : envFlags) {
+ const int eq = flag.indexOf('=');
+ if (eq > 0) {
+ env.insert(flag.left(eq), flag.mid(eq + 1));
+ }
+ }
+ QProcess proc;
+ proc.setProcessEnvironment(env);
+ proc.setProgram(program);
+ proc.setArguments(arguments);
+ proc.startDetached();
+ return;
+ }
+
+ QProcess::startDetached(program, arguments);
+}
+
void ProtonSettingsTab::onFixGameRegistries()
{
if (m_busy) {
return;
}
+ showGameRegistryDialog();
+}
+
+void ProtonSettingsTab::showGameRegistryDialog()
+{
auto cfg = FluorineConfig::load();
if (!cfg.has_value() || !cfg->prefixExists()) {
ui->protonStatusLabel->setText(tr("No existing prefix"));
@@ -266,40 +433,132 @@ void ProtonSettingsTab::onFixGameRegistries()
return;
}
+ QDialog dialog(parentWidget());
+ dialog.setWindowTitle(tr("Fix Game Registries"));
+ dialog.setMinimumWidth(500);
+
+ auto* layout = new QVBoxLayout(&dialog);
+
+ layout->addWidget(new QLabel(tr("Select the game to apply registry settings for:"),
+ &dialog));
+
+ auto* gameCombo = new QComboBox(&dialog);
+
+ size_t knownCount = 0;
+ const NakKnownGame* knownGames = nak_get_known_games(&knownCount);
+
+ for (size_t i = 0; i < knownCount; ++i) {
+ const QString name =
+ QString::fromUtf8(knownGames[i].name ? knownGames[i].name : "");
+ if (!name.isEmpty()) {
+ gameCombo->addItem(name);
+ }
+ }
+
+ layout->addWidget(gameCombo);
+
+ // Game installation path
+ layout->addWidget(new QLabel(tr("Game installation path:"), &dialog));
+
+ auto* pathLayout = new QHBoxLayout();
+ auto* pathEdit = new QLineEdit(&dialog);
+ pathEdit->setPlaceholderText(tr("e.g. /home/user/.local/share/Steam/steamapps/common/Skyrim Special Edition"));
+ auto* browseBtn = new QPushButton(tr("Browse..."), &dialog);
+ pathLayout->addWidget(pathEdit);
+ pathLayout->addWidget(browseBtn);
+ layout->addLayout(pathLayout);
+
+ QObject::connect(browseBtn, &QPushButton::clicked, &dialog, [&dialog, pathEdit]() {
+ const QString dir = QFileDialog::getExistingDirectory(
+ &dialog, QObject::tr("Select Game Installation Folder"),
+ pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text());
+ if (!dir.isEmpty()) {
+ pathEdit->setText(dir);
+ }
+ });
+
+ // Try to auto-detect path when game selection changes
+ auto autoDetect = [pathEdit, gameCombo]() {
+ const QString gameName = gameCombo->currentText();
+ if (gameName.isEmpty()) return;
+
+ // Use nak_detect_all_games to find the install path
+ NakGameList gameList = nak_detect_all_games();
+ for (size_t i = 0; i < gameList.count; ++i) {
+ const QString detected =
+ QString::fromUtf8(gameList.games[i].name ? gameList.games[i].name : "");
+ if (detected.contains(gameName, Qt::CaseInsensitive) ||
+ gameName.contains(detected, Qt::CaseInsensitive)) {
+ const QString path = QString::fromUtf8(
+ gameList.games[i].install_path ? gameList.games[i].install_path : "");
+ if (!path.isEmpty()) {
+ pathEdit->setText(path);
+ break;
+ }
+ }
+ }
+ nak_game_list_free(gameList);
+ };
+
+ QObject::connect(gameCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
+ &dialog, autoDetect);
+ // Auto-detect for initially selected game
+ autoDetect();
+
+ auto* buttons =
+ new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
+ layout->addWidget(buttons);
+
+ QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
+ QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
+
+ if (dialog.exec() != QDialog::Accepted) {
+ return;
+ }
+
+ const QString selectedGame = gameCombo->currentText();
+ const QString gamePath = pathEdit->text();
+ const QString prefixPath = cfg->prefix_path;
+ const QString protonName = cfg->proton_name;
+ const QString protonPath = cfg->proton_path;
+
+ if (gamePath.isEmpty()) {
+ QMessageBox::warning(parentWidget(), tr("No Path"),
+ tr("Please provide the game installation path."));
+ return;
+ }
+
setBusy(true);
ui->protonStatusLabel->setText(tr("Fixing game registries..."));
- const QString prefixPath = cfg->prefix_path;
- const QString protonName = cfg->proton_name;
- const QString protonPath = cfg->proton_path;
- const uint32_t appId = cfg->app_id;
-
g_activeInstallTab.store(this);
- m_pendingAppId = appId;
m_pendingPrefixPath = prefixPath;
m_pendingProtonName = protonName;
m_pendingProtonPath = protonPath;
- m_installWatcher.setFuture(QtConcurrent::run([prefixPath, protonName,
- protonPath,
- appId]() -> InstallResult {
- const QByteArray prefixPathUtf8 = prefixPath.toUtf8();
- const QByteArray protonNameUtf8 = protonName.toUtf8();
- const QByteArray protonPathUtf8 = protonPath.toUtf8();
+ m_installWatcher.setFuture(
+ QtConcurrent::run([prefixPath, protonName, protonPath,
+ selectedGame, gamePath]() -> InstallResult {
+ const QByteArray prefixUtf8 = prefixPath.toUtf8();
+ const QByteArray protonNmUtf8 = protonName.toUtf8();
+ const QByteArray protonPthUtf8 = protonPath.toUtf8();
+ const QByteArray gameUtf8 = selectedGame.toUtf8();
+ const QByteArray pathUtf8 = gamePath.toUtf8();
- char* error = nak_apply_wine_registry_settings(
- prefixPathUtf8.constData(), protonNameUtf8.constData(),
- protonPathUtf8.constData(), &ProtonSettingsTab::logCallback, appId);
+ char* error = nak_apply_registry_for_game_path(
+ prefixUtf8.constData(), protonNmUtf8.constData(),
+ protonPthUtf8.constData(), gameUtf8.constData(),
+ pathUtf8.constData(), &ProtonSettingsTab::logCallback);
- InstallResult r;
- if (error != nullptr) {
- r.error = QString::fromUtf8(error);
- nak_string_free(error);
- }
+ InstallResult r;
+ if (error != nullptr) {
+ r.error = QString::fromUtf8(error);
+ nak_string_free(error);
+ }
- return r;
- }));
+ return r;
+ }));
}
void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPath,
@@ -342,11 +601,41 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa
qunsetenv("NAK_BUNDLED_UMU_RUN");
}
- const auto restoreNakEnv = qScopeGuard([] {
+ // Set WINEPREFIX so NAK (and its child processes like winetricks) always
+ // target the correct prefix, even if Proton init via umu-run fails.
+ qputenv("WINEPREFIX", prefixPathUtf8);
+
+ // Point WINE/WINESERVER at Proton's binaries so winetricks and regedit
+ // use Proton's wine instead of falling back to system wine.
+ QByteArray protonWineUtf8;
+ QByteArray protonWineserverUtf8;
+ for (const char* subdir : {"files/bin", "dist/bin"}) {
+ const QString candidate = QDir(protonPath).filePath(
+ QString::fromLatin1(subdir) + "/wine");
+ if (QFileInfo::exists(candidate)) {
+ protonWineUtf8 = candidate.toUtf8();
+ protonWineserverUtf8 =
+ QDir(protonPath)
+ .filePath(QString::fromLatin1(subdir) + "/wineserver")
+ .toUtf8();
+ break;
+ }
+ }
+ if (!protonWineUtf8.isEmpty()) {
+ qputenv("WINE", protonWineUtf8);
+ qputenv("WINESERVER", protonWineserverUtf8);
+ }
+
+ const auto restoreNakEnv = qScopeGuard([protonWineUtf8] {
qunsetenv("NAK_USE_UMU_FOR_PREFIX");
qunsetenv("NAK_PREFER_SYSTEM_UMU");
qunsetenv("NAK_USE_STEAM_RUN");
qunsetenv("NAK_BUNDLED_UMU_RUN");
+ qunsetenv("WINEPREFIX");
+ if (!protonWineUtf8.isEmpty()) {
+ qunsetenv("WINE");
+ qunsetenv("WINESERVER");
+ }
});
int cancelFlag = 0;
diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h
index 555ab4f..b3499d3 100644
--- a/src/src/settingsdialogproton.h
+++ b/src/src/settingsdialogproton.h
@@ -31,6 +31,12 @@ private:
void onRecreatePrefix();
void onOpenPrefixFolder();
void onFixGameRegistries();
+ void onWinetricks();
+ void onBrowsePrefixLocation();
+
+ void showGameRegistryDialog();
+ QString ensureWinetricks();
+ QString findProtonWine(const QString& protonPath);
void startInstallTask(uint32_t appId, const QString& prefixPath,
const QString& protonName, const QString& protonPath,
diff --git a/src/src/shared/util.cpp b/src/src/shared/util.cpp
index cdcfc47..2c982dc 100644
--- a/src/src/shared/util.cpp
+++ b/src/src/shared/util.cpp
@@ -322,10 +322,7 @@ Version createVersionInfo()
#else
Version createVersionInfo()
{
- // On Linux, return a development version stub
- std::vector<std::variant<int, Version::ReleaseType>> prereleases;
- prereleases.push_back(Version::Development);
- return Version(2, 5, 0, 0, std::move(prereleases));
+ return Version(2, 5, 2, 0);
}
#endif
diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp
index 85b04bb..64f5aa1 100644
--- a/src/src/wineprefix.cpp
+++ b/src/src/wineprefix.cpp
@@ -1,11 +1,13 @@
#include "wineprefix.h"
+#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QTextStream>
#include <log.h>
+#include <uibase/filesystemutilities.h>
namespace
{
@@ -76,6 +78,27 @@ bool restoreBackedUpIni(const QString& liveIni, const QString& backupIni)
return QFile::rename(backupIni, liveIni);
}
+
+// Find all files in the same directory that match the filename case-insensitively.
+// E.g. for "skyrimprefs.ini" returns {"skyrimprefs.ini", "SkyrimPrefs.ini"} if both exist.
+QStringList findCaseVariants(const QString& path)
+{
+ QFileInfo info(path);
+ QDir dir(info.path());
+ if (!dir.exists()) {
+ return {};
+ }
+
+ QStringList result;
+ const QString target = info.fileName();
+ for (const QString& entry :
+ dir.entryList(QDir::Files | QDir::Hidden | QDir::System)) {
+ if (entry.compare(target, Qt::CaseInsensitive) == 0) {
+ result.append(dir.absoluteFilePath(entry));
+ }
+ }
+ return result;
+}
} // namespace
WinePrefix::WinePrefix(const QString& prefixPath)
@@ -156,14 +179,29 @@ bool WinePrefix::deployProfileIni(const QString& sourceIniPath,
}
const QString destination = QDir::cleanPath(targetIniPath);
- const QString backup = destination + BackupIniSuffix;
- if (!restoreBackedUpIni(destination, backup)) {
- return false;
+ // Back up ALL case-insensitive variants (e.g. both skyrimprefs.ini and
+ // SkyrimPrefs.ini). Linux is case-sensitive, so the game may create a
+ // different-case file alongside ours. Backing up all variants ensures
+ // a clean deploy and correct restore later.
+ const QStringList variants = findCaseVariants(destination);
+ for (const QString& variant : variants) {
+ const QString backup = variant + BackupIniSuffix;
+ if (!restoreBackedUpIni(variant, backup)) {
+ return false;
+ }
+ if (QFile::exists(variant) && !QFile::rename(variant, backup)) {
+ return false;
+ }
}
- if (QFile::exists(destination) && !QFile::rename(destination, backup)) {
- return false;
+ // If the exact-case file wasn't among the variants (didn't exist yet),
+ // still restore any stale backup for it.
+ if (!variants.contains(destination)) {
+ const QString backup = destination + BackupIniSuffix;
+ if (!restoreBackedUpIni(destination, backup)) {
+ return false;
+ }
}
return copyFileWithParents(iniInfo.absoluteFilePath(), destination);
@@ -259,6 +297,56 @@ bool WinePrefix::syncSavesBack(const QString& profileSaveDir, const QString& gam
return copied;
}
+void WinePrefix::restoreStaleBackups() const
+{
+ if (!isValid()) {
+ return;
+ }
+
+ // Scan the entire prefix for stale .mo2linux_backup INI files.
+ // These are left behind when MO2 crashes after deploying profile INIs.
+ QDirIterator it(driveC(), QDir::Files | QDir::Hidden, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ it.next();
+ if (!it.fileName().endsWith(BackupIniSuffix)) {
+ continue;
+ }
+
+ const QString backupPath = it.filePath();
+ const QString livePath =
+ backupPath.left(backupPath.length() - QString(BackupIniSuffix).length());
+
+ MOBase::log::info("Restoring stale INI backup '{}' -> '{}'", backupPath, livePath);
+ if (!restoreBackedUpIni(livePath, backupPath)) {
+ MOBase::log::warn("Failed to restore stale INI backup '{}'", backupPath);
+ }
+ }
+
+ // Also restore stale save backups
+ const QString myGames = myGamesPath();
+ if (QDir(myGames).exists()) {
+ QDirIterator gameIt(myGames, QDir::Dirs | QDir::NoDotAndDotDot);
+ while (gameIt.hasNext()) {
+ gameIt.next();
+ const QString gameRoot = gameIt.filePath();
+ const QString backupUp = QDir(gameRoot).filePath(BackupSavesUpper);
+ const QString backupLow = QDir(gameRoot).filePath(BackupSavesLower);
+
+ if (QDir(backupUp).exists() || QDir(backupLow).exists()) {
+ MOBase::log::info("Restoring stale save backups in '{}'", gameRoot);
+
+ // Determine the live save dirs (uppercase "Saves" preferred)
+ const QString liveUpper = QDir(gameRoot).filePath("Saves");
+ const QString liveLower = QDir(gameRoot).filePath("saves");
+
+ if (!restoreBackedUpSaves(liveUpper, liveLower, backupUp, backupLow)) {
+ MOBase::log::warn("Failed to restore stale save backups in '{}'", gameRoot);
+ }
+ }
+ }
+ }
+}
+
bool WinePrefix::syncProfileInisBack(
const QList<QPair<QString, QString>>& iniMappings) const
{
@@ -266,21 +354,51 @@ bool WinePrefix::syncProfileInisBack(
for (const auto& mapping : iniMappings) {
const QString profileIniPath = QDir::cleanPath(mapping.first);
const QString prefixIniPath = QDir::cleanPath(mapping.second);
- const QString backupIniPath = prefixIniPath + BackupIniSuffix;
- if (!QFileInfo::exists(prefixIniPath)) {
+ // Find ALL case-insensitive variants of the INI file (e.g. both
+ // skyrimprefs.ini and SkyrimPrefs.ini may exist on Linux).
+ // Pick the most recently modified one — that's the file the game wrote to.
+ const QStringList variants = findCaseVariants(prefixIniPath);
+
+ QString newestVariant;
+ QDateTime newestTime;
+ for (const QString& variant : variants) {
+ const QFileInfo fi(variant);
+ if (fi.lastModified() > newestTime) {
+ newestTime = fi.lastModified();
+ newestVariant = variant;
+ }
+ }
+
+ if (newestVariant.isEmpty()) {
+ // No INI file found at all — try to restore from any backup.
+ const QString backupIniPath = prefixIniPath + BackupIniSuffix;
if (!restoreBackedUpIni(prefixIniPath, backupIniPath)) {
allCopied = false;
}
continue;
}
- if (!copyFileWithParents(prefixIniPath, profileIniPath)) {
+ // Sync the game's version back to the profile.
+ if (!copyFileWithParents(newestVariant, profileIniPath)) {
allCopied = false;
}
- if (!restoreBackedUpIni(prefixIniPath, backupIniPath)) {
- allCopied = false;
+ // Remove ALL variants (including stale deployed copies), then
+ // restore ALL backed-up originals.
+ for (const QString& variant : variants) {
+ QFile::remove(variant);
+ }
+
+ // Restore all backups (there may be multiple from different case variants).
+ const QStringList backupVariants =
+ findCaseVariants(prefixIniPath + BackupIniSuffix);
+ for (const QString& backup : backupVariants) {
+ const QString livePath =
+ backup.left(backup.length() - QString(BackupIniSuffix).length());
+ if (!restoreBackedUpIni(livePath, backup)) {
+ allCopied = false;
+ }
}
}
diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h
index 7b54d4b..e1ad839 100644
--- a/src/src/wineprefix.h
+++ b/src/src/wineprefix.h
@@ -31,6 +31,10 @@ public:
bool syncProfileInisBack(
const QList<QPair<QString, QString>>& iniMappings) const;
+ // Restore any stale .mo2linux_backup INI/save files left by a crash.
+ // Should be called at startup before any game runs.
+ void restoreStaleBackups() const;
+
private:
QString m_prefixPath;
};