From 8a10147bbb1fe6589c4205bcfd26d6d696512ced Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 19:04:11 -0600 Subject: Add Root Builder plugin, Linux game detection, and OMOD installer fix - New native Root Builder plugin (src/plugins/rootbuilder.py) with copy and symlink deploy modes, auto-deploy hooks, backup/restore, and third-party conflict detection that moves incompatible plugins to DisabledPlugins/. In link mode, .exe/.dll are always copied to avoid Wine/Proton path resolution issues with symlinked executables. - Fix basic_games detection on Linux: steam_utils now checks native Steam paths (~/.local/share/Steam, Flatpak, Snap), epic_utils uses correct Linux Heroic config paths, gog_utils falls back to Heroic GOG installed.json when Windows registry is unavailable. - Fix OMOD installer crash: isArchiveSupported now handles both IFileTree (from mod list refresh) and string (from install check) arguments. - Add flatpak manifest step to copy source-tree Python plugins (src/plugins/*.py) into the build output. Co-Authored-By: Claude Opus 4.6 --- src/plugins/installer_omod.py | 272 +++++++++++++++++++++++ src/plugins/rootbuilder.py | 396 ++++++++++++++++++++++++++++++++++ src/src/createinstancedialog.cpp | 21 +- src/src/createinstancedialog.ui | 2 +- src/src/createinstancedialogpages.cpp | 159 +++++++------- src/src/fuseconnector.cpp | 29 ++- src/src/instancemanager.cpp | 11 +- src/src/instancemanagerdialog.cpp | 43 +++- src/src/instancemanagerdialog.h | 4 + src/src/instancemanagerdialog.ui | 11 + src/src/moapplication.cpp | 26 +++ src/src/organizercore.cpp | 13 +- src/src/plugincontainer.cpp | 74 ++++++- src/src/plugincontainer.h | 3 + src/src/profile.cpp | 71 ++---- src/src/settingsdialog.ui | 24 ++- src/src/settingsdialogproton.cpp | 387 ++++++++++++++++++++++++++++----- src/src/settingsdialogproton.h | 6 + src/src/shared/util.cpp | 5 +- src/src/wineprefix.cpp | 138 +++++++++++- src/src/wineprefix.h | 4 + 21 files changed, 1463 insertions(+), 236 deletions(-) create mode 100644 src/plugins/installer_omod.py create mode 100644 src/plugins/rootbuilder.py (limited to 'src') 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(" 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(" 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 @@ true - 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. + A portable instance stores everything in a single folder. The default location is %1, but you can choose any location on the next page. 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> 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 InstanceManager::currentInstance() const return std::make_unique(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(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 +#include #include #include #include @@ -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 @@ -40,6 +40,17 @@ + + + + Open existing portable + + + + :/MO/gui/open_folder:/MO/gui/open_folder + + + 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 . #include "sanitychecks.h" #include "settings.h" #ifndef _WIN32 +#include "fluorineconfig.h" #include "fuseconnector.h" +#include "wineprefix.h" #include #endif #include @@ -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 #include #include +#include #include #ifdef _WIN32 #include @@ -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 proxyList = bf::at_key(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(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()) { - 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 . #include "profile.h" -#include "filesystemutilities.h" +#include #include "game_features.h" #include "modinfo.h" #include "modinfoforeign.h" @@ -61,43 +61,14 @@ along with Mod Organizer. If not, see . #include #include // 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 - Prefix Path: + Prefix Location: - - + + + + + - (none) - - - true + Browse... @@ -1793,6 +1793,16 @@ If you disable this feature, MO will only display official DLCs this way. Please + + + + Winetricks + + + Open the Winetricks GUI to install Windows libraries and components into the prefix. + + + 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 #include #include +#include #include #include +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include 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::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::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(); - - char* error = nak_apply_wine_registry_settings( - prefixPathUtf8.constData(), protonNameUtf8.constData(), - protonPathUtf8.constData(), &ProtonSettingsTab::logCallback, appId); - - InstallResult r; - if (error != nullptr) { - r.error = QString::fromUtf8(error); - nak_string_free(error); - } - - return r; - })); + 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_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); + } + + 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> 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 #include #include #include #include #include #include +#include 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>& 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>& 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; }; -- cgit v1.3.1