diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 19:04:11 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 19:04:11 -0600 |
| commit | 8a10147bbb1fe6589c4205bcfd26d6d696512ced (patch) | |
| tree | 66fe7403114e2d63fa48ceee14352bee4dbddae0 | |
| parent | a03455028d8b79a1196bffc2f80c2455c41db065 (diff) | |
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 <noreply@anthropic.com>
37 files changed, 1676 insertions, 279 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh index efbb3d7..a96cf98 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -108,6 +108,11 @@ for d in basic_games data libs dlls; do done rm -f "${OUT_DIR}/plugins/FNIS"*.py +# Source-tree Python plugins (OMOD installer, etc.). +for f in /src/src/plugins/*.py; do + [ -f "${f}" ] && cp -f "${f}" "${OUT_DIR}/plugins/" +done + # ── 7z runtime ── SO7="build/src/src/dlls/7z.so" if [ -f "${SO7}" ]; then diff --git a/flatpak/com.fluorine.manager.yml b/flatpak/com.fluorine.manager.yml index a0e2122..0265b90 100644 --- a/flatpak/com.fluorine.manager.yml +++ b/flatpak/com.fluorine.manager.yml @@ -314,6 +314,12 @@ modules: done rm -f /app/lib/fluorine/plugins/FNIS*.py + # Source-tree Python plugins (rootbuilder, omod installer, etc.). + - | + for f in src/plugins/*.py; do + [ -f "${f}" ] && cp -f "${f}" /app/lib/fluorine/plugins/ + done + # 7z runtime. - | if [ -f _build/src/src/dlls/7z.so ]; then diff --git a/flatpak/flatpak-install.sh b/flatpak/flatpak-install.sh index e52497e..f5cdfda 100755 --- a/flatpak/flatpak-install.sh +++ b/flatpak/flatpak-install.sh @@ -34,7 +34,9 @@ if [ "${MODE}" = "bundle" ]; then echo "Building Flatpak bundle (this may take a while)..." flatpak-builder --repo="${PROJECT_DIR}/flatpak-repo" --force-clean \ "${BUILD_DIR}" "${MANIFEST}" - flatpak build-bundle "${PROJECT_DIR}/flatpak-repo" "${PROJECT_DIR}/fluorine-manager.flatpak" "${APP_ID}" + flatpak build-bundle \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ + "${PROJECT_DIR}/flatpak-repo" "${PROJECT_DIR}/fluorine-manager.flatpak" "${APP_ID}" BUNDLE_SIZE="$(du -sh fluorine-manager.flatpak | cut -f1)" echo "" echo "=== Bundle created: fluorine-manager.flatpak (${BUNDLE_SIZE}) ===" diff --git a/libs/basic_games/epic_utils.py b/libs/basic_games/epic_utils.py index 94ae6a9..afdccd5 100644 --- a/libs/basic_games/epic_utils.py +++ b/libs/basic_games/epic_utils.py @@ -87,6 +87,21 @@ def find_legendary_games( def find_heroic_games(errors: ErrorList | None = None): + # Linux: Heroic stores config in ~/.config/heroic/ (or Flatpak equivalent). + for candidate in ( + Path.home() / ".config" / "heroic" / "legendaryConfig", + Path.home() + / ".var" + / "app" + / "com.heroicgameslauncher.hgl" + / "config" + / "heroic" + / "legendaryConfig", + ): + if candidate.is_dir(): + return find_legendary_games(str(candidate), errors) + + # Windows fallback. return find_legendary_games( os.path.expandvars(r"%AppData%\heroic\legendaryConfig"), errors ) diff --git a/libs/basic_games/gog_utils.py b/libs/basic_games/gog_utils.py index e5e6d24..de66ec2 100644 --- a/libs/basic_games/gog_utils.py +++ b/libs/basic_games/gog_utils.py @@ -1,12 +1,50 @@ # Code adapted from EzioTheDeadPoet / erri120: # https://github.com/ModOrganizer2/modorganizer-basic_games/pull/5 +from __future__ import annotations + +import json +import sys import winreg from pathlib import Path +def _find_heroic_gog_games() -> dict[str, Path]: + """Detect GOG games installed via Heroic Launcher on Linux.""" + games: dict[str, Path] = {} + + for candidate in ( + Path.home() / ".config" / "heroic" / "gog_store" / "installed.json", + Path.home() + / ".var" + / "app" + / "com.heroicgameslauncher.hgl" + / "config" + / "heroic" + / "gog_store" + / "installed.json", + ): + if not candidate.is_file(): + continue + try: + with open(candidate, encoding="utf-8") as f: + data = json.load(f) + for game in data.get("installed", []): + app_name = game.get("appName", "") + install_path = game.get("install_path") or game.get("installPath", "") + if app_name and install_path: + games[str(app_name)] = Path(install_path) + except (json.JSONDecodeError, OSError) as e: + print( + f'Unable to parse Heroic GOG installed games from "{candidate}": {e}', + file=sys.stderr, + ) + + return games + + def find_games() -> dict[str, Path]: - # List the game IDs from the registry: + # List the game IDs from the registry (Windows): game_ids: list[str] = [] try: with winreg.OpenKey( @@ -18,7 +56,8 @@ def find_games() -> dict[str, Path]: if game_key.isdigit(): game_ids.append(game_key) except FileNotFoundError: - return {} + # Windows registry not available; try Heroic GOG on Linux. + return _find_heroic_gog_games() # For each game, query the path: games: dict[str, Path] = {} diff --git a/libs/basic_games/steam_utils.py b/libs/basic_games/steam_utils.py index d1d4a06..a3daeaf 100644 --- a/libs/basic_games/steam_utils.py +++ b/libs/basic_games/steam_utils.py @@ -148,7 +148,28 @@ def find_steam_path() -> Path | None: value = winreg.QueryValueEx(key, "SteamExe") return Path(value[0].replace("/", "\\")).parent except FileNotFoundError: - return None + pass + + # Linux: check common Steam install locations. + for candidate in ( + Path.home() / ".local" / "share" / "Steam", + Path.home() / ".steam" / "steam", + Path.home() + / ".var" + / "app" + / "com.valvesoftware.Steam" + / ".local" + / "share" + / "Steam", + Path.home() / "snap" / "steam" / "common" / ".local" / "share" / "Steam", + ): + if ( + candidate.is_dir() + and candidate.joinpath("steamapps", "libraryfolders.vdf").exists() + ): + return candidate + + return None def find_games() -> dict[str, Path]: diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp index ac9fe22..bcb29f2 100644 --- a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp +++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp @@ -1,5 +1,7 @@ #include "gamegamebryo.h" +#include <uibase/filesystemutilities.h> + #include "bsainvalidation.h" #include "dataarchives.h" #include "gamebryomoddatacontent.h" @@ -44,37 +46,8 @@ #include <string> #include <vector> -// Case-insensitive file path resolution for Linux. -// On Windows (case-insensitive FS), just returns the input path. -// On Linux, if the exact path doesn't exist, searches the parent directory -// for a file matching the name case-insensitively. -static QString resolveFileCaseInsensitive(const QString& filePath) -{ -#ifdef _WIN32 - return filePath; -#else - if (QFileInfo::exists(filePath)) { - return filePath; - } - - QFileInfo info(filePath); - QDir dir(info.path()); - if (!dir.exists()) { - return filePath; - } - - const QString target = info.fileName(); - const QStringList entries = - dir.entryList(QDir::Files | QDir::Hidden | QDir::System); - for (const QString& entry : entries) { - if (entry.compare(target, Qt::CaseInsensitive) == 0) { - return dir.absoluteFilePath(entry); - } - } - - return filePath; -#endif -} +// Local resolveFileCaseInsensitive moved to MOBase::resolveFileCaseInsensitive +using MOBase::resolveFileCaseInsensitive; GameGamebryo::GameGamebryo() {} diff --git a/libs/installer_fomod/src/fomodinstallerdialog.cpp b/libs/installer_fomod/src/fomodinstallerdialog.cpp index ef6c44f..0fa6bfc 100644 --- a/libs/installer_fomod/src/fomodinstallerdialog.cpp +++ b/libs/installer_fomod/src/fomodinstallerdialog.cpp @@ -77,12 +77,13 @@ bool PagesDescending(QGroupBox* LHS, QGroupBox* RHS) FomodInstallerDialog::FomodInstallerDialog( InstallerFomod* installer, const GuessedValue<QString>& modName, - const QString& fomodPath, + const QString& fomodPath, const QString& fomodDirName, const std::function<MOBase::IPluginList::PluginStates(const QString&)>& fileCheck, QWidget* parent) : QDialog(parent), ui(new Ui::FomodInstallerDialog), m_Installer(installer), - m_ModName(modName), m_ModID(-1), m_FomodPath(fomodPath), m_Manual(false), - m_FileCheck(fileCheck), m_FileSystemItemSequence() + m_ModName(modName), m_ModID(-1), m_FomodPath(fomodPath), + m_FomodDirName(fomodDirName), m_Manual(false), m_FileCheck(fileCheck), + m_FileSystemItemSequence() { ui->setupUi(this); setWindowFlags(windowFlags() | Qt::WindowMaximizeButtonHint); @@ -235,7 +236,7 @@ void FomodInstallerDialog::readXml(QFile& file, void FomodInstallerDialog::readInfoXml() { - QFile file(QDir::tempPath() + "/" + m_FomodPath + "/fomod/info.xml"); + QFile file(QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/info.xml"); // We don't need a info.xml file, so we just return if we cannot open it: if (!file.open(QIODevice::ReadOnly)) { @@ -246,7 +247,7 @@ void FomodInstallerDialog::readInfoXml() void FomodInstallerDialog::readModuleConfigXml() { - QFile file(QDir::tempPath() + "/" + m_FomodPath + "/fomod/ModuleConfig.xml"); + QFile file(QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/ModuleConfig.xml"); if (!file.open(QIODevice::ReadOnly)) { throw Exception(tr("%1 missing.").arg(file.fileName())); } @@ -261,7 +262,7 @@ void FomodInstallerDialog::initData(IOrganizer* moInfo) readInfoXml(); QString screenshotPath = - QDir::tempPath() + "/" + m_FomodPath + "/fomod/screenshot.png"; + QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/screenshot.png"; if (!QImage(screenshotPath).isNull()) { ui->screenshotLabel->setScalableResource(screenshotPath); ui->screenshotExpand->setVisible(false); diff --git a/libs/installer_fomod/src/fomodinstallerdialog.h b/libs/installer_fomod/src/fomodinstallerdialog.h index bd9f975..1ebfe60 100644 --- a/libs/installer_fomod/src/fomodinstallerdialog.h +++ b/libs/installer_fomod/src/fomodinstallerdialog.h @@ -209,7 +209,7 @@ class FomodInstallerDialog : public QDialog, public IConditionTester public: explicit FomodInstallerDialog( InstallerFomod* installer, const MOBase::GuessedValue<QString>& modName, - const QString& fomodPath, + const QString& fomodPath, const QString& fomodDirName, const std::function<MOBase::IPluginList::PluginStates(const QString&)>& fileCheck, QWidget* parent = 0); ~FomodInstallerDialog(); @@ -453,6 +453,7 @@ private: int m_ModID; QString m_FomodPath; + QString m_FomodDirName; bool m_Manual; FileDescriptorList m_RequiredFiles; diff --git a/libs/installer_fomod/src/installerfomod.cpp b/libs/installer_fomod/src/installerfomod.cpp index 3558748..8be3ca3 100644 --- a/libs/installer_fomod/src/installerfomod.cpp +++ b/libs/installer_fomod/src/installerfomod.cpp @@ -217,8 +217,9 @@ InstallerFomod::install(GuessedValue<QString>& modName, std::shared_ptr<const IFileTree> fomodTree = findFomodDirectory(tree); QString fomodPath = fomodTree->parent()->path(); + QString fomodDirName = fomodTree->name(); FomodInstallerDialog dialog( - this, modName, fomodPath, + this, modName, fomodPath, fomodDirName, std::bind(&InstallerFomod::fileState, this, std::placeholders::_1)); dialog.initData(m_MOInfo); if (!dialog.getVersion().isEmpty()) { diff --git a/libs/nak_ffi/Cargo.lock b/libs/nak_ffi/Cargo.lock index af0ac00..83d19cc 100644 --- a/libs/nak_ffi/Cargo.lock +++ b/libs/nak_ffi/Cargo.lock @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "nak_rust" version = "4.4.1" -source = "git+https://github.com/SulfurNitride/NaK.git?branch=main#ceea41b64e6cafb8375978d7982e2c46fe826047" +source = "git+https://github.com/SulfurNitride/NaK.git?branch=main#41915b5a095da6bc5b4960eac2c9e55771caa7c4" dependencies = [ "chrono", "rand", diff --git a/libs/nak_ffi/Cargo.toml b/libs/nak_ffi/Cargo.toml index 000297c..fc667e0 100644 --- a/libs/nak_ffi/Cargo.toml +++ b/libs/nak_ffi/Cargo.toml @@ -6,5 +6,5 @@ edition = "2021" [lib]
crate-type = ["cdylib"]
-[dependencies] -nak_rust = { git = "https://github.com/SulfurNitride/NaK.git", branch = "main", default-features = false, features = ["installer"] } +[dependencies]
+nak_rust = { git = "https://github.com/SulfurNitride/NaK.git", branch = "main", default-features = false, features = ["installer"] }
diff --git a/libs/nak_ffi/include/nak_ffi.h b/libs/nak_ffi/include/nak_ffi.h index c6010d4..df4d47b 100644 --- a/libs/nak_ffi/include/nak_ffi.h +++ b/libs/nak_ffi/include/nak_ffi.h @@ -194,6 +194,18 @@ char *nak_apply_wine_registry_settings( uint32_t app_id
);
+/** Apply a game's registry entry with a custom install path.
+ * Looks up game_name in KNOWN_GAMES and writes registry pointing to install_path.
+ * Returns NULL on success, or an error message (free with nak_string_free). */
+char *nak_apply_registry_for_game_path(
+ const char *prefix_path,
+ const char *proton_name,
+ const char *proton_path,
+ const char *game_name,
+ const char *install_path,
+ NakLogCallback log_cb
+);
+
/* ========================================================================
* Tier 7: Prefix Symlinks
* ======================================================================== */
diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs index 0e7b4f3..2e19af7 100644 --- a/libs/nak_ffi/src/lib.rs +++ b/libs/nak_ffi/src/lib.rs @@ -659,6 +659,58 @@ pub unsafe extern "C" fn nak_apply_wine_registry_settings( }
}
+/// Apply a game's registry entry with a custom install path.
+///
+/// Looks up the game by name in KNOWN_GAMES, writes the registry entry
+/// pointing to `install_path`. Returns null on success, or an error message.
+#[no_mangle]
+pub unsafe extern "C" fn nak_apply_registry_for_game_path(
+ prefix_path: *const c_char,
+ proton_name: *const c_char,
+ proton_path: *const c_char,
+ game_name: *const c_char,
+ install_path: *const c_char,
+ log_cb: NakLogCallback,
+) -> *mut c_char {
+ let prefix = unsafe { from_cstr(prefix_path) };
+ let _proton_name = unsafe { from_cstr(proton_name) };
+ let proton_path_str = unsafe { from_cstr(proton_path) };
+ let game = unsafe { from_cstr(game_name) };
+ let install = unsafe { from_cstr(install_path) };
+
+ let protons = nak_rust::steam::find_steam_protons();
+ let proton = match protons
+ .iter()
+ .find(|p| p.path.to_string_lossy() == proton_path_str)
+ {
+ Some(p) => p.clone(),
+ None => {
+ return to_cstring(&format!(
+ "Proton not found at path: {}",
+ proton_path_str
+ ));
+ }
+ };
+
+ let log_fn = move |msg: String| {
+ if let Some(cb) = log_cb {
+ let c = CString::new(msg).unwrap_or_default();
+ unsafe { cb(c.as_ptr()) };
+ }
+ };
+
+ match nak_rust::installers::apply_registry_for_game_path(
+ Path::new(prefix),
+ &proton,
+ game,
+ Path::new(install),
+ &log_fn,
+ ) {
+ Ok(()) => ptr::null_mut(),
+ Err(e) => to_cstring(&e),
+ }
+}
+
// ============================================================================
// Tier 7: Prefix Symlinks
// ============================================================================
diff --git a/libs/uibase/include/uibase/filesystemutilities.h b/libs/uibase/include/uibase/filesystemutilities.h index ae074c6..a98ba7b 100644 --- a/libs/uibase/include/uibase/filesystemutilities.h +++ b/libs/uibase/include/uibase/filesystemutilities.h @@ -31,6 +31,18 @@ QDLLEXPORT QString sanitizeFileName(const QString& name, **/ QDLLEXPORT bool validFileName(const QString& name); +/** + * @brief Resolve a file path case-insensitively on Linux. + * + * On Windows (case-insensitive FS), returns the input path as-is. + * On Linux, if the exact path doesn't exist, searches the parent directory + * for a file matching the name case-insensitively. + * + * @param path the absolute file path to resolve + * @return the resolved path (with correct case) or the original path if not found + */ +QDLLEXPORT QString resolveFileCaseInsensitive(const QString& path); + } // namespace MOBase #endif // FILESYSTEM_H diff --git a/libs/uibase/src/filesystemutilities.cpp b/libs/uibase/src/filesystemutilities.cpp index 6a01324..b027f5f 100644 --- a/libs/uibase/src/filesystemutilities.cpp +++ b/libs/uibase/src/filesystemutilities.cpp @@ -1,5 +1,7 @@ #include <uibase/filesystemutilities.h> +#include <QDir> +#include <QFileInfo> #include <QRegularExpression> #include <QString> @@ -65,4 +67,32 @@ bool validFileName(const QString& name) return (name == sanitizeFileName(name)); } +QString resolveFileCaseInsensitive(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 MOBase 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; }; |
