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 /libs | |
| 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>
Diffstat (limited to 'libs')
| -rw-r--r-- | libs/basic_games/epic_utils.py | 15 | ||||
| -rw-r--r-- | libs/basic_games/gog_utils.py | 43 | ||||
| -rw-r--r-- | libs/basic_games/steam_utils.py | 23 | ||||
| -rw-r--r-- | libs/game_bethesda/src/gamebryo/gamegamebryo.cpp | 35 | ||||
| -rw-r--r-- | libs/installer_fomod/src/fomodinstallerdialog.cpp | 13 | ||||
| -rw-r--r-- | libs/installer_fomod/src/fomodinstallerdialog.h | 3 | ||||
| -rw-r--r-- | libs/installer_fomod/src/installerfomod.cpp | 3 | ||||
| -rw-r--r-- | libs/nak_ffi/Cargo.lock | 2 | ||||
| -rw-r--r-- | libs/nak_ffi/Cargo.toml | 4 | ||||
| -rw-r--r-- | libs/nak_ffi/include/nak_ffi.h | 12 | ||||
| -rw-r--r-- | libs/nak_ffi/src/lib.rs | 52 | ||||
| -rw-r--r-- | libs/uibase/include/uibase/filesystemutilities.h | 12 | ||||
| -rw-r--r-- | libs/uibase/src/filesystemutilities.cpp | 30 |
13 files changed, 202 insertions, 45 deletions
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 |
