diff options
147 files changed, 15007 insertions, 2802 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a688f7..2cf6d19 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,6 +175,7 @@ endif() add_subdirectory(libs/game_features) add_subdirectory(libs/game_bethesda) add_subdirectory(libs/installer_fomod) +add_subdirectory(libs/installer_fomod_plus) add_subdirectory(libs/installer_bain) add_subdirectory(libs/installer_bundle) add_subdirectory(libs/installer_manual) diff --git a/flatpak/com.fluorine.manager.yml b/flatpak/com.fluorine.manager.yml index 0265b90..834d8e9 100644 --- a/flatpak/com.fluorine.manager.yml +++ b/flatpak/com.fluorine.manager.yml @@ -13,7 +13,9 @@ finish-args: - --socket=wayland - --device=all - --filesystem=home - - --filesystem=/run/media:ro + - --filesystem=/mnt + - --filesystem=/media + - --filesystem=/run/media - --filesystem=~/.steam:ro - --filesystem=~/.local/share/Steam - --filesystem=~/.var/app/com.valvesoftware.Steam:ro @@ -292,6 +294,7 @@ modules: find _build/libs -type f \( \ -name "libgame_*.so" -o \ -name "libinstaller_*.so" -o \ + -name "libfomod_plus_*.so" -o \ -name "libpreview_*.so" -o \ -name "libdiagnose_*.so" -o \ -name "libcheck_*.so" -o \ @@ -305,7 +308,7 @@ modules: # Python plugin payload. - | - for f in libplugin_python.so lzokay.py winreg.py pyCfg.py \ + for f in libplugin_python.so lzokay.py winreg.py \ DDSPreview.py Form43Checker.py ScriptExtenderPluginChecker.py; do [ -f "_build/src/src/plugins/${f}" ] && cp -f "_build/src/src/plugins/${f}" /app/lib/fluorine/plugins/ done @@ -320,6 +323,9 @@ modules: [ -f "${f}" ] && cp -f "${f}" /app/lib/fluorine/plugins/ done + # Stylesheets / themes. + - cp -a src/src/stylesheets /app/lib/fluorine/stylesheets + # 7z runtime. - | if [ -f _build/src/src/dlls/7z.so ]; then diff --git a/flatpak/flatpak-install.sh b/flatpak/flatpak-install.sh index f5cdfda..53c0645 100755 --- a/flatpak/flatpak-install.sh +++ b/flatpak/flatpak-install.sh @@ -32,7 +32,7 @@ if [ "${MODE}" = "bundle" ]; then # ── Build a distributable .flatpak file ── echo "" echo "Building Flatpak bundle (this may take a while)..." - flatpak-builder --repo="${PROJECT_DIR}/flatpak-repo" --force-clean \ + flatpak-builder --repo="${PROJECT_DIR}/flatpak-repo" --force-clean --ccache \ "${BUILD_DIR}" "${MANIFEST}" flatpak build-bundle \ --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ @@ -47,7 +47,7 @@ else # ── Build and install locally ── echo "" echo "Building and installing Flatpak locally (this may take a while)..." - flatpak-builder --install --user --force-clean \ + flatpak-builder --install --user --force-clean --ccache \ "${BUILD_DIR}" "${MANIFEST}" echo "" echo "=== Flatpak installed successfully ===" diff --git a/libs/basic_games/basic_game.py b/libs/basic_games/basic_game.py index e0f7ba5..b3ff73d 100644 --- a/libs/basic_games/basic_game.py +++ b/libs/basic_games/basic_game.py @@ -1,11 +1,13 @@ from __future__ import annotations +import os +import platform import shutil import sys from pathlib import Path from typing import Callable, Generic, TypeVar -from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths, qWarning from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QMessageBox @@ -17,6 +19,49 @@ from .basic_features.basic_save_game_info import ( ) +def _find_wine_userprofile() -> str | None: + """On Linux, find the Wine/Proton user profile directory inside the prefix. + + Returns the host path equivalent of %USERPROFILE% (e.g. + ``<prefix>/drive_c/users/steamuser``) or *None* if no valid prefix is + found. + """ + if platform.system() == "Windows": + return None + + candidates: list[str] = [] + + # 1. Flatpak data prefix (most common for Fluorine) + flatpak_pfx = os.path.expanduser( + "~/.var/app/com.fluorine.manager/Prefix/pfx" + ) + candidates.append(flatpak_pfx) + + # 2. Fluorine config prefix_path + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(str(Path.home()), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if os.path.isfile(cfg_path): + import json + + with open(cfg_path, "r") as f: + cfg = json.load(f) + pfx = cfg.get("prefix_path", "") + if pfx: + candidates.append(pfx) + except Exception: + pass + + for pfx in candidates: + user_dir = os.path.join(pfx, "drive_c", "users", "steamuser") + if os.path.isdir(user_dir): + return user_dir + + return None + + def replace_variables(value: str, game: BasicGame) -> str: """Replace special paths in the given value.""" @@ -28,12 +73,23 @@ def replace_variables(value: str, game: BasicGame) -> str: ), ) if value.find("%USERPROFILE%") != -1: - value = value.replace( - "%USERPROFILE%", - QStandardPaths.writableLocation( - QStandardPaths.StandardLocation.HomeLocation - ), - ) + if platform.system() != "Windows": + wine_profile = _find_wine_userprofile() + if wine_profile: + value = value.replace("%USERPROFILE%", wine_profile) + else: + qWarning( + "No Wine/Proton prefix found. " + "Ensure a prefix is configured in Settings > Proton." + ) + value = value.replace("%USERPROFILE%", "") + else: + value = value.replace( + "%USERPROFILE%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ), + ) if value.find("%GAME_DOCUMENTS%") != -1: value = value.replace( "%GAME_DOCUMENTS%", game.documentsDirectory().absolutePath() @@ -41,6 +97,9 @@ def replace_variables(value: str, game: BasicGame) -> str: if value.find("%GAME_PATH%") != -1: value = value.replace("%GAME_PATH%", game.gameDirectory().absolutePath()) + # Normalize Windows backslash path separators for Linux. + value = value.replace("\\", "/") + return value @@ -634,8 +693,11 @@ class BasicGame(mobase.IPluginGame): return QDir(self._gamePath) def dataDirectory(self) -> QDir: + data_path = self._mappings.dataDirectory.get() + if data_path and QDir.isAbsolutePath(data_path): + return QDir(data_path) return QDir( - self.gameDirectory().absoluteFilePath(self._mappings.dataDirectory.get()) + self.gameDirectory().absoluteFilePath(data_path) ) def setGamePath(self, path: Path | str) -> None: diff --git a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py index 0ae6ef8..e2c54e9 100644 --- a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py +++ b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py @@ -1,5 +1,6 @@ import functools import os +import platform from pathlib import Path from typing import Callable, Optional @@ -10,9 +11,12 @@ import mobase from . import bg3_utils +_IS_LINUX = platform.system() != "Windows" + class BG3FileMapper(mobase.IPluginFileMapper): current_mappings: list[mobase.Mapping] = [] + _linux_symlinks: list[Path] = [] def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]): super().__init__() @@ -26,6 +30,8 @@ class BG3FileMapper(mobase.IPluginFileMapper): def mappings(self) -> list[mobase.Mapping]: qInfo("creating custom bg3 mappings") self.current_mappings.clear() + if _IS_LINUX: + self._cleanup_linux_symlinks() active_mods = self._utils.active_mods() if not active_mods: return [] @@ -121,6 +127,18 @@ class BG3FileMapper(mobase.IPluginFileMapper): def create_mapping(self, file: Path, dest: Path): bg3_utils.create_dir_if_needed(dest) + # On Linux, FUSE cannot handle file mappings outside the game's data + # directory. Create real symlinks so the game (via Proton) can find + # mod files in the documents/prefix directory. + if _IS_LINUX and not file.is_dir(): + try: + if dest.is_symlink() or dest.exists(): + dest.unlink() + dest.symlink_to(file) + self._linux_symlinks.append(dest) + except OSError as e: + qWarning(f"BG3: failed to symlink {dest} -> {file}: {e}") + self.current_mappings.append( mobase.Mapping( source=str(file), @@ -129,3 +147,18 @@ class BG3FileMapper(mobase.IPluginFileMapper): create_target=True, ) ) + + def _cleanup_linux_symlinks(self): + """Remove symlinks created by previous mappings() calls.""" + for link in self._linux_symlinks: + try: + if link.is_symlink(): + link.unlink() + except OSError: + pass + self._linux_symlinks.clear() + + def cleanup(self): + """Public cleanup method — call from onFinishedRun.""" + if _IS_LINUX: + self._cleanup_linux_symlinks() diff --git a/libs/basic_games/games/baldursgate3/lslib_retriever.py b/libs/basic_games/games/baldursgate3/lslib_retriever.py index ee1122c..8b0e1dc 100644 --- a/libs/basic_games/games/baldursgate3/lslib_retriever.py +++ b/libs/basic_games/games/baldursgate3/lslib_retriever.py @@ -15,29 +15,41 @@ class LSLibRetriever: def __init__(self, utils: bg3_utils.BG3Utils): self._utils = utils + # Files that MUST be present for Divine to work. + _REQUIRED_FILES = { + "Divine.dll", + "Divine.exe", + "LSLib.dll", + } + + # Additional files that may or may not be in a given LSLib release. + _OPTIONAL_FILES = { + "CommandLineArgumentsParser.dll", + "Divine.dll.config", + "Divine.runtimeconfig.json", + "K4os.Compression.LZ4.dll", + "K4os.Compression.LZ4.Streams.dll", + "LSLibNative.dll", + "LZ4.dll", + "Newtonsoft.Json.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + @cached_property def _needed_lslib_files(self): return { self._utils.tools_dir / x - for x in { - "CommandLineArgumentsParser.dll", - "Divine.dll", - "Divine.dll.config", - "Divine.exe", - "Divine.runtimeconfig.json", - "K4os.Compression.LZ4.dll", - "K4os.Compression.LZ4.Streams.dll", - "LSLib.dll", - "LSLibNative.dll", - "LZ4.dll", - "Newtonsoft.Json.dll", - "System.IO.Hashing.dll", - "ZstdSharp.dll", - } + for x in self._REQUIRED_FILES | self._OPTIONAL_FILES } def download_lslib_if_missing(self, force: bool = False) -> bool: - if not force and all(x.exists() for x in self._needed_lslib_files): + # Only check required files to avoid re-downloading when optional + # files were not present in the archive. + required_paths = { + self._utils.tools_dir / x for x in self._REQUIRED_FILES + } + if not force and all(x.exists() for x in required_paths): return True try: self._utils.tools_dir.mkdir(exist_ok=True, parents=True) @@ -133,21 +145,40 @@ class LSLibRetriever: win_title, len(self._needed_lslib_files), msg=dialog_message ) with zipfile.ZipFile(zip_path, "r") as zip_ref: + # Build a lookup from basename → zip entry so we can handle + # any zip directory layout (Packed/Tools/, Tools/, flat, etc.) + zip_names = zip_ref.namelist() + entry_map: dict[str, str] = {} + for zn in zip_names: + base = zn.rsplit("/", 1)[-1] if "/" in zn else zn + if base: + entry_map[base] = zn + for file in self._needed_lslib_files: if downloaded or not file.exists(): - shutil.move( - zip_ref.extract( - f"Packed/Tools/{file.name}", self._utils.tools_dir - ), - file, - ) + zip_entry = entry_map.get(file.name) + if zip_entry is None: + if file.name in self._REQUIRED_FILES: + raise KeyError( + f"Required file '{file.name}' not found in archive" + ) + qDebug(f"LSLib: optional file '{file.name}' not in archive, skipping") + continue + extracted = zip_ref.extract(zip_entry, self._utils.tools_dir) + # Move from nested zip path to flat tools_dir + if extracted != str(file): + shutil.move(extracted, file) x_progress.setValue(x_progress.value() + 1) QApplication.processEvents() if x_progress.wasCanceled(): qWarning("processing canceled by user") return False x_progress.close() - shutil.rmtree(self._utils.tools_dir / "Packed", ignore_errors=True) + # Clean up any nested directories left from extraction + for subdir in ("Packed", "Tools"): + nested = self._utils.tools_dir / subdir + if nested.is_dir(): + shutil.rmtree(nested, ignore_errors=True) except Exception as e: qDebug(f"Extraction failed: {e}") err = QMessageBox(self._utils.main_window) diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index 18598a9..4ebc83c 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import configparser import hashlib import os +import platform import re import shutil import subprocess @@ -38,7 +41,39 @@ class BG3PakParser: @cached_property def _divine_command(self): - return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" + divine_exe = self._utils.tools_dir / "Divine.exe" + if platform.system() != "Windows": + wine = self._find_proton_wine() + if wine: + return f'"{wine}" "{divine_exe}" -g bg3 -l info' + qWarning( + "BG3: could not find Proton/Wine to run Divine.exe. " + "Ensure a Proton version is configured in Settings > Proton." + ) + return f'"{divine_exe}" -g bg3 -l info' + + def _find_proton_wine(self) -> str | None: + """Locate the wine binary from the configured Proton installation.""" + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(Path.home(), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if not os.path.isfile(cfg_path): + return None + import json as _json + with open(cfg_path, "r") as f: + cfg = _json.load(f) + proton_path = cfg.get("proton_path", "") + if not proton_path: + return None + for subdir in ("files/bin/wine", "dist/bin/wine"): + candidate = os.path.join(proton_path, subdir) + if os.path.isfile(candidate): + return candidate + except Exception as e: + qDebug(f"BG3: failed to read Fluorine config for Proton wine: {e}") + return None @cached_property def _folder_pattern(self): @@ -198,18 +233,70 @@ class BG3PakParser: qWarning(traceback.format_exc()) return "" + @staticmethod + def _to_wine_path(path: str) -> str: + """Convert a Linux absolute path to a Wine Z: drive path.""" + if path.startswith("/"): + return "Z:" + path.replace("/", "\\") + return path + def run_divine( self, action: str, source: Path | str ) -> subprocess.CompletedProcess[str]: - command = f'{self._divine_command} -a {action} -s "{source}"' - result = subprocess.run( - command, - creationflags=subprocess.CREATE_NO_WINDOW, + if platform.system() != "Windows": + # Divine.exe is a .NET Windows app — it needs Windows-style paths. + # Wine maps Z:\ to the Linux root, so /home/... becomes Z:\home\... + wine_source = self._to_wine_path(str(source)) + wine_action = re.sub( + r'"(/[^"]*)"', + lambda m: '"' + self._to_wine_path(m.group(1)) + '"', + action, + ) + command = f'{self._divine_command} -a {wine_action} -s "{wine_source}"' + else: + command = f'{self._divine_command} -a {action} -s "{source}"' + kwargs: dict = dict( check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) + if platform.system() == "Windows": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + kwargs["shell"] = True + result = subprocess.run(command, **kwargs) + else: + # Set WINEPREFIX so Proton's wine uses the game's prefix. + wine_prefix = "" + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(Path.home(), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if os.path.isfile(cfg_path): + import json as _json + with open(cfg_path, "r") as f: + cfg = _json.load(f) + wine_prefix = cfg.get("prefix_path", "") + except Exception: + pass + + if os.path.exists("/.flatpak-info"): + # In Flatpak, host wine binaries can't execute directly + # (missing host libraries). Use flatpak-spawn to run on host. + spawn_cmd = ["flatpak-spawn", "--host"] + if wine_prefix: + spawn_cmd.append(f"--env=WINEPREFIX={wine_prefix}") + spawn_cmd.extend(["bash", "-c", command]) + result = subprocess.run(spawn_cmd, **kwargs) + else: + env = os.environ.copy() + if wine_prefix: + env["WINEPREFIX"] = wine_prefix + kwargs["env"] = env + kwargs["shell"] = True + result = subprocess.run(command, **kwargs) + if result.returncode: qWarning( f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" diff --git a/libs/basic_games/games/game_baldursgate3.py b/libs/basic_games/games/game_baldursgate3.py index 4898696..718d4f3 100644 --- a/libs/basic_games/games/game_baldursgate3.py +++ b/libs/basic_games/games/game_baldursgate3.py @@ -170,6 +170,7 @@ class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper): def _on_finished_run(self, exec_path: str, exit_code: int): if "bin/bg3" not in exec_path: return + self.cleanup() # Remove symlinks created by the file mapper on Linux cat = QLoggingCategory.defaultCategory() self.utils.log_dir.mkdir(parents=True, exist_ok=True) if ( diff --git a/libs/basic_games/games/game_oblivion_remaster.py b/libs/basic_games/games/game_oblivion_remaster.py index 218bb04..32d1723 100644 --- a/libs/basic_games/games/game_oblivion_remaster.py +++ b/libs/basic_games/games/game_oblivion_remaster.py @@ -238,10 +238,10 @@ class OblivionRemasteredGame( ) -> None: if settings & mobase.ProfileSetting.CONFIGURATION: game_ini_file = self.gameDirectory().absoluteFilePath( - r"OblivionRemastered\Content\Dev\ObvData\Oblivion.ini" + "OblivionRemastered/Content/Dev/ObvData/Oblivion.ini" ) game_default_ini = self.gameDirectory().absoluteFilePath( - r"OblivionRemastered\Content\Dev\ObvData\Oblivion_default.ini" + "OblivionRemastered/Content/Dev/ObvData/Oblivion_default.ini" ) profile_ini = directory.absoluteFilePath( QFileInfo("Oblivion.ini").fileName() diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py index 51875b2..2105796 100644 --- a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py +++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py @@ -181,7 +181,7 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be # automatically moved into the appropriate directory structures to avoid needless iteration. - exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + exe_dir = filetree.find("OblivionRemastered/Binaries/Win64") if isinstance(exe_dir, mobase.IFileTree): gamesettings_dir = exe_dir.find("GameSettings") if isinstance(gamesettings_dir, mobase.IFileTree): diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py index c43cec0..e4bfc44 100644 --- a/libs/basic_games/games/oblivion_remaster/paks/model.py +++ b/libs/basic_games/games/oblivion_remaster/paks/model.py @@ -39,7 +39,7 @@ class PaksModel(QAbstractItemModel): profile = QDir(self._organizer.profilePath()) paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) if paks_txt.exists(): - with open(paks_txt.absoluteFilePath(), "r") as paks_file: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8", errors="replace") as paks_file: index = 0 for line in paks_file: self.paks[index] = (line, "", "", "") diff --git a/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py index efc3038..40e21eb 100644 --- a/libs/basic_games/games/oblivion_remaster/paks/widget.py +++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py @@ -52,7 +52,7 @@ class PaksTabWidget(QWidget): paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) paks_list: list[str] = [] if paks_txt.exists(): - with open(paks_txt.absoluteFilePath(), "r") as paks_file: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8", errors="replace") as paks_file: for line in paks_file: paks_list.append(line.strip()) return paks_list diff --git a/libs/basic_games/games/oblivion_remaster/script_extender.py b/libs/basic_games/games/oblivion_remaster/script_extender.py index 571e675..6f38c09 100644 --- a/libs/basic_games/games/oblivion_remaster/script_extender.py +++ b/libs/basic_games/games/oblivion_remaster/script_extender.py @@ -17,7 +17,7 @@ class OblivionRemasteredScriptExtender(mobase.ScriptExtender): def loaderPath(self) -> str: return ( self._game.gameDirectory().absolutePath() - + "\\OblivionRemastered\\Binaries\\Win64\\" + + "/OblivionRemastered/Binaries/Win64/" + self.loaderName() ) diff --git a/libs/basic_games/winreg.py b/libs/basic_games/winreg.py index e1303dc..2644088 100644 --- a/libs/basic_games/winreg.py +++ b/libs/basic_games/winreg.py @@ -10,5 +10,16 @@ def OpenKey(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") +OpenKeyEx = OpenKey + + def QueryValueEx(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") + + +def QueryInfoKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") + + +def EnumKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp index bcb29f2..caeeed0 100644 --- a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp +++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp @@ -484,7 +484,11 @@ void GameGamebryo::copyToProfile(QString const& sourcePath, QString const& sourceFileName, QString const& destinationFileName) { - QString filePath = destinationDirectory.absoluteFilePath(destinationFileName); + // Use case-insensitive check so we don't create duplicates on Linux + // (e.g. a profile with "SkyrimPrefs.ini" from Windows is not overwritten + // by a new "skyrimprefs.ini" copy). + QString filePath = resolveFileCaseInsensitive( + destinationDirectory.absoluteFilePath(destinationFileName)); if (!QFileInfo(filePath).exists()) { QString sourceFile = sourcePath + "/" + sourceFileName; // On Linux, try case-insensitive match if the exact source doesn't exist diff --git a/libs/installer_fomod_plus/.clang-format b/libs/installer_fomod_plus/.clang-format new file mode 100644 index 0000000..37702ff --- /dev/null +++ b/libs/installer_fomod_plus/.clang-format @@ -0,0 +1,4 @@ +BasedOnStyle: WebKit +AlignConsecutiveAssignments: Consecutive +AccessModifierOffset: -2 +ColumnLimit: 120
\ No newline at end of file diff --git a/libs/installer_fomod_plus/.clangd b/libs/installer_fomod_plus/.clangd new file mode 100644 index 0000000..f22e5c1 --- /dev/null +++ b/libs/installer_fomod_plus/.clangd @@ -0,0 +1,16 @@ +CompileFlags: + Add: + - "-ID:/var/mo2-fomod-plus/installer" + - "-ID:/var/mo2-fomod-plus/scanner" + - "-ID:/var/mo2-fomod-plus/share" + - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/pugixml-src/src" + - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/json-src/include" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase/game_features" + - "-IC:/Qt/6.7.3/msvc2022_64/include" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtCore" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtGui" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtWidgets" + - "-std=c++20" + diff --git a/libs/installer_fomod_plus/.editorconfig b/libs/installer_fomod_plus/.editorconfig new file mode 100644 index 0000000..a915e4f --- /dev/null +++ b/libs/installer_fomod_plus/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*.cpp] +indent_style = space +indent_size = 2 +insert_final_newline = true + +[*.h] +indent_style = space +indent_size = 2 +insert_final_newline = true + +[*.ui] +indent_style = space +indent_size = 2 +insert_final_newline = true diff --git a/libs/installer_fomod_plus/CMakeLists.txt b/libs/installer_fomod_plus/CMakeLists.txt new file mode 100644 index 0000000..3367145 --- /dev/null +++ b/libs/installer_fomod_plus/CMakeLists.txt @@ -0,0 +1,36 @@ +# mo2-fomod-plus — vendored from github.com/aglowinthefield/mo2-fomod-plus +# Commit: da6c07ed4bbe235759910c14c2450e1bfe8fe25e (2026-01-27) +# +# Pure C++20 FOMOD installer replacement (no .NET dependency). +# 3 plugin targets: installer, scanner, patch wizard. + +include(FetchContent) + +# Force pugixml to build as a static library so plugins don't need libpugixml.so at runtime. +# (The global BUILD_SHARED_LIBS is ON for MO2's own archive lib, which would make pugixml shared.) +set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +# Compatibility: sub-CMakeLists call mo2_install_target but Fluorine defines mo2_install_plugin +if (NOT COMMAND mo2_install_target AND COMMAND mo2_install_plugin) + function(mo2_install_target target) + mo2_install_plugin(${target}) + endfunction() +endif () + +# Compute include dirs from existing targets for sub-CMakeLists that reference these variables +get_target_property(MO2_UIBASE_INCLUDE_DIRS mo2::uibase INTERFACE_INCLUDE_DIRECTORIES) +foreach(dir ${MO2_UIBASE_INCLUDE_DIRS}) + list(APPEND MO2_UIBASE_INCLUDE_DIRS "${dir}/uibase" "${dir}/uibase/game_features") +endforeach() +get_target_property(MO2_ARCHIVE_INCLUDE_DIRS mo2::archive INTERFACE_INCLUDE_DIRECTORIES) +foreach(dir ${MO2_ARCHIVE_INCLUDE_DIRS}) + list(APPEND MO2_ARCHIVE_INCLUDE_DIRS "${dir}/archive") +endforeach() + +add_subdirectory(installer) +add_subdirectory(scanner) +add_subdirectory(patchwizard) + +# Restore BUILD_SHARED_LIBS so subsequent targets aren't affected. +set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED} CACHE BOOL "" FORCE) diff --git a/libs/installer_fomod_plus/cmake/patch_pugixml.cmake b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake new file mode 100644 index 0000000..01f1a68 --- /dev/null +++ b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake @@ -0,0 +1,20 @@ +if(NOT DEFINED PUGIXML_CMAKELISTS) + message(FATAL_ERROR "PUGIXML_CMAKELISTS not set; cannot patch pugixml.") +endif() + +if(NOT EXISTS "${PUGIXML_CMAKELISTS}") + message(FATAL_ERROR "Pugixml CMakeLists.txt not found at '${PUGIXML_CMAKELISTS}'.") +endif() + +file(READ "${PUGIXML_CMAKELISTS}" _pugixml_content) +string(REGEX REPLACE + "cmake_minimum_required\\(VERSION [^)]+\\)" + "cmake_minimum_required(VERSION 3.10)" + _pugixml_patched "${_pugixml_content}") + +if(_pugixml_content STREQUAL _pugixml_patched) + message(STATUS "pugixml CMakeLists already uses a modern cmake_minimum_required.") +else() + file(WRITE "${PUGIXML_CMAKELISTS}" "${_pugixml_patched}") + message(STATUS "Updated pugixml cmake_minimum_required to 3.10 to silence deprecation warnings.") +endif() diff --git a/libs/installer_fomod_plus/installer/CMakeLists.txt b/libs/installer_fomod_plus/installer/CMakeLists.txt new file mode 100644 index 0000000..70018ff --- /dev/null +++ b/libs/installer_fomod_plus/installer/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.16) +include(FetchContent) + +file(GLOB_RECURSE INSTALLER_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) +file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp") + +add_library(fomod_plus_installer SHARED ${INSTALLER_SOURCES} ${SHARE_SOURCES}) + +target_include_directories( + fomod_plus_installer + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${MO2_UIBASE_INCLUDE_DIRS} + ${MO2_ARCHIVE_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +FetchContent_Declare( + pugixml + GIT_REPOSITORY https://github.com/zeux/pugixml + GIT_TAG v1.14 + PATCH_COMMAND ${CMAKE_COMMAND} + -DPUGIXML_CMAKELISTS=<SOURCE_DIR>/CMakeLists.txt + -P ${CMAKE_CURRENT_LIST_DIR}/../cmake/patch_pugixml.cmake +) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_MakeAvailable(pugixml json) + +set(project_type plugin) +project(fomod_plus_installer) + +target_link_libraries(fomod_plus_installer PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json) +if (WIN32) + target_link_libraries(fomod_plus_installer PRIVATE dbghelp) +endif () +mo2_configure_plugin(fomod_plus_installer NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS) + +if (MSVC) + target_compile_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>) + target_link_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>) + install(FILES $<TARGET_PDB_FILE:fomod_plus_installer> + DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO} + CONFIGURATIONS RelWithDebInfo + OPTIONAL) +endif () + +mo2_install_target(fomod_plus_installer) diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp new file mode 100644 index 0000000..e5f2e18 --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp @@ -0,0 +1,840 @@ +#include "FomodInstallerWindow.h" + +#include "ui/FomodImageViewer.h" + +#include "ui/ScaleLabel.h" +#include "ui/UIHelper.h" +#include "xml/ModuleConfiguration.h" + +#include <QButtonGroup> +#include <QCheckBox> +#include <QComboBox> +#include <QCompleter> +#include <QGroupBox> +#include <QLabel> +#include <QRadioButton> +#include <QScrollArea> +#include <QScrollBar> +#include <QSettings> +#include <QSizePolicy> +#include <QSplitter> +#include <QVBoxLayout> +#include <utility> + +#include "ui/FomodViewModel.h" + +#include <unordered_set> + +/** + * + * @param installer + * @param modName + * @param tree + * @param fomodPath + * @param viewModel + * @param fomodJson + * @param parent + */ +FomodInstallerWindow::FomodInstallerWindow( + FomodPlusInstaller* installer, + GuessedValue<QString>& modName, + const std::shared_ptr<IFileTree>& tree, + QString fomodPath, + const std::shared_ptr<FomodViewModel>& viewModel, + const nlohmann::json& fomodJson, + QWidget* parent): QDialog(parent), + mInstaller(installer), + mFomodPath(std::move(fomodPath)), + mModName(modName), + mTree(tree), + mViewModel(viewModel), + mFomodJson(fomodJson) +{ + setupUi(); + + mInstallStepStack = new QStackedWidget(this); + + // Handle legacy FOMODs with no steps + if (mViewModel->getSteps().empty()) { + // Create a simple "Install" widget for legacy FOMODs + auto* legacyWidget = new QWidget(this); + auto* layout = new QVBoxLayout(legacyWidget); + auto* label = new QLabel("This mod will install all files automatically.", legacyWidget); + layout->addWidget(label); + mInstallStepStack->addWidget(legacyWidget); + } else { + updateInstallStepStack(); + stylePreviouslySelectedOptions(); + stylePreviouslyDeselectedOptions(); + } + + const auto containerLayout = createContainerLayout(); + setLayout(containerLayout); + + updateButtons(); + restoreGeometryAndState(); + + if (!mViewModel->getSteps().empty()) { + populatePluginMap(); + if (mInstaller->shouldAutoRestoreChoices()) { + onSelectPreviousClicked(); + } + } else { + // For empty FOMODs, set default description + mDescriptionBox->setText("This mod will install all files automatically."); + } +} + +void FomodInstallerWindow::closeEvent(QCloseEvent* event) +{ + saveGeometryAndState(); + QDialog::closeEvent(event); +} + +void FomodInstallerWindow::saveGeometryAndState() const +{ + const auto cwd = QDir::currentPath(); + QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat); + settings.setValue("windowGeometry", saveGeometry()); + settings.setValue("centerSplitState", mCenterRow->saveState()); + settings.setValue("leftSplitState", mLeftPane->saveState()); +} + +void FomodInstallerWindow::restoreGeometryAndState() +{ + const auto cwd = QDir::currentPath(); + const QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat); + restoreGeometry(settings.value("windowGeometry").toByteArray()); + mCenterRow->restoreState(settings.value("centerSplitState").toByteArray()); + mLeftPane->restoreState(settings.value("leftSplitState").toByteArray()); +} + +void FomodInstallerWindow::populatePluginMap() +{ + const auto checkboxes = findChildren<QCheckBox*>(); + const auto radioButtons = findChildren<QRadioButton*>(); + + for (const auto& step : mViewModel->getSteps()) { + for (const auto& group : step->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + + const auto name = createObjectName(plugin, group); + + for (auto* checkbox : checkboxes) { + if (checkbox->objectName() == name) { + mPluginMap.insert({ name, { plugin, checkbox } }); + } + } + for (auto* radioButton : radioButtons) { + if (radioButton->objectName() == name) { + mPluginMap.insert({ name, { plugin, radioButton } }); + } + } + } + } + } +} + +void FomodInstallerWindow::onNextClicked() +{ + // For legacy FOMODs with no steps, always install + if (mViewModel->getSteps().empty()) { + onInstallClicked(); + return; + } + + if (!mViewModel->isLastVisibleStep()) { + mViewModel->stepForward(); + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateButtons(); + updateDisplayForActivePlugin(); + } else { + onInstallClicked(); + } +} + +void FomodInstallerWindow::updateCheckboxStates() const +{ + // PluginMap presumed to be populated + for (const auto& [objectName, pluginData] : mPluginMap) { + if (pluginData.plugin->isSelected() != pluginData.uiElement->isChecked()) { + const auto widgetType = pluginData.uiElement->metaObject()->className(); + if (objectName != nullptr) { + logMessage(DEBUG, + "Updating " + objectName.toStdString() + " to state: " + (pluginData.plugin->isSelected() + ? "TRUE" + : "FALSE") + " because " + widgetType + + " selection state is " + (pluginData.uiElement->isChecked() ? "TRUE" : "FALSE")); + } + pluginData.uiElement->setChecked(pluginData.plugin->isSelected()); + } + + if (pluginData.plugin->isEnabled() != pluginData.uiElement->isEnabled()) { + logMessage(DEBUG, "[WINDOW] Changing enabled state of element " + objectName.toStdString() + " to " + + (pluginData.plugin->isEnabled() ? "TRUE" : "FALSE")); + pluginData.uiElement->setEnabled(pluginData.plugin->isEnabled()); + } + } +} + +void FomodInstallerWindow::onPluginToggled(const bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const +{ + logMessage(INFO, + "onPluginToggled called with " + plugin->getName() + " in " + group->getName() + ": " + + std::to_string(selected)); + if (mViewModel->togglePlugin(group, plugin, selected)) { + updateCheckboxStates(); + } + if (mNextInstallButton != nullptr) { + updateButtons(); + } +} + +void FomodInstallerWindow::onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const +{ + logMessage(INFO, "onPluginManuallyUnchecked called with " + plugin->getName()); + mViewModel->markManuallySet(plugin); +} + +void FomodInstallerWindow::onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const +{ + mViewModel->setActivePlugin(plugin); + updateDisplayForActivePlugin(); +} + +void FomodInstallerWindow::onBackClicked() const +{ + mViewModel->stepBack(); + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateButtons(); + updateDisplayForActivePlugin(); +} + +void FomodInstallerWindow::onInstallClicked() +{ + saveGeometryAndState(); + + logMessage(DEBUG, "Installing mod: " + mModName->toStdString()); + mModName.update(mModNameInput->currentText(), GUESS_USER); + mViewModel->preinstall(mTree, mFomodPath); + // now the installer is available in the outer scope + // the outer scope should call getFileInstaller() and install there. + this->accept(); +} + +void FomodInstallerWindow::updateButtons() const +{ + // For legacy FOMODs with no steps, always show Install + if (mViewModel->getSteps().empty()) { + mBackButton->setEnabled(false); + mNextInstallButton->setText(tr("Install")); + return; + } + + if (mViewModel->isFirstVisibleStep()) { + mBackButton->setEnabled(false); + } else { + mBackButton->setEnabled(true); + } + + if (mViewModel->isLastVisibleStep()) { + mNextInstallButton->setText(tr("Install")); + } else { + mNextInstallButton->setText(tr("Next")); + } +} + +void FomodInstallerWindow::setupUi() +{ + setWindowIcon(QIcon(":/fomod/hat")); + setWindowFlags(Qt::Window); // Allows OS-controlled resizing, including snapping + setMinimumSize(UiConstants::WINDOW_MIN_WIDTH, UiConstants::WINDOW_MIN_HEIGHT); + setWindowTitle(mModName); + setWindowModality(Qt::NonModal); // To allow scrolling modlist without closing the window +} + +// mInstallStepStack must be initialized before calling this +void FomodInstallerWindow::updateInstallStepStack() +{ + if (!mInstallStepStack) { + logMessage(ERR, "updateInstallStepStack called with no initialized mInstallStepStack. tf?"); + return; + } + // Create the widgets for each step. Not sure if we need these as member variables. Try it like this for now. + for (const auto& steps = mViewModel->getSteps(); const auto& installStep : steps) { + mInstallStepStack->addWidget(createStepWidget(installStep)); + } + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); +} + +/* ++-------------------------------------------------------------------+ +| +----------------------------------------------------------------+| +| | || +| | Metadata and Name Input || +| | || +| +----------------------------------------------------------------+| +| +------------------------------++--------------------------------+| +| | || || +| | || || +| | Description || || +| | || || +| | || Step/Group/Plugins || +| | || || +| +------------------------------+| || +| +------------------------------+| || +| | || || +| | || || +| | Image || || +| | || || +| | || || +| | || || +| +----------------------------------------------------------------+| +| | || +| | Bottom Bar || +| +----------------------------------------------------------------+| ++-------------------------------------------------------------------+ +*/ +QBoxLayout* FomodInstallerWindow::createContainerLayout() +{ + const auto layout = new QVBoxLayout(this); + + mTopRow = createTopRow(); + mCenterRow = createCenterRow(); + mBottomRow = createBottomRow(); + + layout->addWidget(mTopRow); + layout->addWidget(mCenterRow, 1); // stretch 1 here so the others are static size + layout->addWidget(mBottomRow); + + if (mInstaller->shouldShowNotifications()) { + mNotificationsPanel = createNotificationPanel(); + layout->addWidget(mNotificationsPanel); + // Set a default welcome message + addNotification("FOMOD Plus notification panel initialized :)", INFO); + } + return layout; +} + +QSplitter* FomodInstallerWindow::createCenterRow() +{ + mLeftPane = createLeftPane(); // Instance var to persist the geometry + const auto centerRow = new QSplitter(Qt::Horizontal, this); + const auto rightPane = createRightPane(); + centerRow->addWidget(mLeftPane); + centerRow->addWidget(rightPane); + centerRow->setSizes({ width() / 2, width() / 2 }); + return centerRow; +} + +QWidget* FomodInstallerWindow::createTopRow() +{ + const auto topRow = new QWidget(this); + + auto* mainHLayout = new QHBoxLayout(topRow); + + // Holds the name (label), author, version, and website + auto* metadataLayout = new QHBoxLayout(); + + // left side metadata. just the titles of the metadata + auto* labelsColumn = new QVBoxLayout(); + QLabel* nameLabel = UIHelper::createLabel(tr("Name:"), topRow); + QLabel* authorLabel = UIHelper::createLabel(tr("Author:"), topRow); + QLabel* versionLabel = UIHelper::createLabel(tr("Version:"), topRow); + QLabel* websiteLabel = UIHelper::createLabel(tr("Website:"), topRow); + + labelsColumn->addWidget(nameLabel); + labelsColumn->addWidget(authorLabel); + labelsColumn->addWidget(versionLabel); + labelsColumn->addWidget(websiteLabel); + + UIHelper::reduceLabelPadding(labelsColumn); + UIHelper::setGlobalAlignment(labelsColumn, Qt::AlignTop); + + // the values of the metadata MINUS the search box + auto* valuesColumn = new QVBoxLayout(); + QLabel* emptyLabel = UIHelper::createLabel("", topRow); + QLabel* authorValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getAuthor().c_str(), topRow); + QLabel* versionValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getVersion().c_str(), topRow); + QLabel* websiteValueLabel = UIHelper::createHyperlink(mViewModel->getInfoViewModel()->getWebsite().c_str(), topRow); + + valuesColumn->addWidget(emptyLabel); + valuesColumn->addWidget(authorValueLabel); + valuesColumn->addWidget(versionValueLabel); + valuesColumn->addWidget(websiteValueLabel); + + // We want these cleanup fns to be at the layout level directly containing the labels. + // Since we aren't recursing down the UI forever we can't just call it for mainHLayout. + UIHelper::reduceLabelPadding(valuesColumn); + UIHelper::setGlobalAlignment(valuesColumn, Qt::AlignTop); + + metadataLayout->addLayout(labelsColumn); + metadataLayout->addLayout(valuesColumn, 1); // To push the right column close to the edge of the left. + + mainHLayout->addLayout(metadataLayout, 1); + + // Now make the search bar layout + auto* modNameComboBox = createModNameComboBox(); + mainHLayout->addWidget(modNameComboBox, 4); + UIHelper::setGlobalAlignment(mainHLayout, Qt::AlignTop); + + // Extra stuff + topRow->setLayout(mainHLayout); + topRow->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + return topRow; +} + +QComboBox* FomodInstallerWindow::createModNameComboBox() +{ + mModNameInput = new QComboBox(this); + mModNameInput->setEditable(true); + + mModNameInput->addItem(mModName); + + for (const auto& variant : mModName.variants()) { + if (variant.toStdString() != mModName->toStdString()) { + mModNameInput->addItem(variant); + } + } + mModNameInput->completer()->setCaseSensitivity(Qt::CaseSensitive); + return mModNameInput; +} + +QWidget* FomodInstallerWindow::createBottomRow() +{ + // In vanilla FOMOD installer, left has the Manual button, right has back, next/install, and cancel buttons + const auto bottomRow = new QWidget(this); + auto* layout = new QHBoxLayout(bottomRow); + + // Manual on far left + mManualButton = UIHelper::createButton(tr("Manual"), bottomRow); + mSelectPreviousButton = UIHelper::createButton(tr("Restore Previous Choices"), bottomRow); + mResetChoicesButton = UIHelper::createButton(tr("Reset Choices"), bottomRow); + + layout->addWidget(mManualButton); + layout->addWidget(mSelectPreviousButton); + layout->addWidget(mResetChoicesButton); + + // Space to push remaining buttons right + layout->addStretch(); + + mBackButton = UIHelper::createButton(tr("Back"), bottomRow); + mNextInstallButton = UIHelper::createButton(tr("Next"), bottomRow); + mCancelButton = UIHelper::createButton(tr("Cancel"), bottomRow); + + mNextInstallButton->setDefault(true); + mNextInstallButton->setAutoDefault(true); + + connect(mManualButton, SIGNAL(clicked()), this, SLOT(onManualClicked())); + connect(mNextInstallButton, SIGNAL(clicked()), this, SLOT(onNextClicked())); + connect(mBackButton, SIGNAL(clicked()), this, SLOT(onBackClicked())); + connect(mCancelButton, SIGNAL(clicked()), this, SLOT(onCancelClicked())); + connect(mSelectPreviousButton, SIGNAL(clicked()), this, SLOT(onSelectPreviousClicked())); + connect(mResetChoicesButton, SIGNAL(clicked()), this, SLOT(onResetChoicesClicked())); + // connect(mHideImagesButton, SIGNAL(clicked()), this, SLOT(toggleImagesShown())); + + layout->addWidget(mBackButton); + layout->addWidget(mNextInstallButton); + layout->addWidget(mCancelButton); + + bottomRow->setLayout(layout); + return bottomRow; +} + +QSplitter* FomodInstallerWindow::createLeftPane() +{ + const auto leftPane = new QSplitter(Qt::Vertical, this); + + // Add description box + // Initialize with defaults (the first plugin's description (which defaults to the module image otherwise)) + auto* scrollArea = new QScrollArea(leftPane); + scrollArea->setWidgetResizable(true); + + mDescriptionBox = new QLabel("", leftPane); + mDescriptionBox->setTextFormat(Qt::RichText); + mDescriptionBox->setTextInteractionFlags(Qt::TextBrowserInteraction); + mDescriptionBox->setOpenExternalLinks(true); + mDescriptionBox->setWordWrap(true); + mDescriptionBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + mDescriptionBox->setAlignment(Qt::AlignTop | Qt::AlignLeft); + + scrollArea->setWidget(mDescriptionBox); + leftPane->addWidget(scrollArea); + + // Add image + // Initialize with defaults (the first plugin's image) + mImageLabel = new ScaleLabel(leftPane); + mImageLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + connect(mImageLabel, &ScaleLabel::clicked, this, [this] { + if (!mImageLabel->hasResource()) { + return; + } + const auto viewer = new FomodImageViewer(this, mFomodPath, mViewModel->getActiveStep(), + mViewModel->getActivePlugin()); + viewer->showMaximized(); + }); + + if (!mInstaller->shouldShowImages()) { + mImageLabel->hide(); + } + + leftPane->addWidget(mImageLabel); + leftPane->setSizes({ height() / 2, height() / 2 }); + + updateDisplayForActivePlugin(); + + return leftPane; +} + +QWidget* FomodInstallerWindow::createRightPane() +{ + const auto rightPane = new QWidget(this); + auto* layout = new QVBoxLayout(rightPane); + + layout->addWidget(mInstallStepStack); + + return rightPane; +} + +QTextEdit* FomodInstallerWindow::createNotificationPanel() +{ + auto* panel = new QTextEdit(this); + panel->setReadOnly(true); + panel->setMaximumHeight(100); // Limit height + panel->setStyleSheet("font-family: monospace; font-size: 9pt;"); + + return panel; +} + +QWidget* FomodInstallerWindow::createStepWidget(const std::shared_ptr<StepViewModel>& installStep) +{ + const auto stepBox = new QGroupBox(QString::fromStdString(installStep->getName()), this); + const auto stepBoxLayout = new QVBoxLayout(stepBox); + + auto* scrollArea = new QScrollArea(stepBox); + scrollArea->setWidgetResizable(true); + + const auto scrollAreaContent = new QWidget(scrollArea); + auto* scrollAreaLayout = new QVBoxLayout(scrollAreaContent); + + for (const auto& group : installStep->getGroups()) { + const auto groupSection = renderGroup(group); + scrollAreaLayout->addWidget(groupSection); + } + + scrollAreaContent->setLayout(scrollAreaLayout); + scrollArea->setWidget(scrollAreaContent); + + stepBoxLayout->addWidget(scrollArea); + stepBox->setLayout(stepBoxLayout); + return stepBox; +} + +QWidget* FomodInstallerWindow::renderGroup(const std::shared_ptr<GroupViewModel>& group) +{ + const auto groupBox = new QGroupBox(QString::fromStdString(group->getName()), this); + const auto groupBoxLayout = new QVBoxLayout(groupBox); + + switch (group->getType()) { + case SelectAtLeastOne: + case SelectAny: + case SelectAll: + renderCheckboxGroup(groupBox, groupBoxLayout, group); + break; + case SelectExactlyOne: + case SelectAtMostOne: + renderSelectExactlyOne(groupBox, groupBoxLayout, group); + break; + default: ; + } + + groupBox->setLayout(groupBoxLayout); + return groupBox; +} + +QString FomodInstallerWindow::createObjectName(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group) +{ + const std::string objectName = std::format("[{}:{}] {}-{}", + group->getStepIndex(), group->getOwnIndex(), + group->getName(), plugin->getName()); + + return QString::fromStdString(objectName); +} + +QRadioButton* FomodInstallerWindow::createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, + QWidget* parent) +{ + auto* radioButton = new QRadioButton(QString::fromStdString(plugin->getName()), parent); + radioButton->setObjectName(createObjectName(plugin, group)); + auto* hoverFilter = new HoverEventFilter(plugin, this); + radioButton->installEventFilter(hoverFilter); + connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered); + + connect(radioButton, &QRadioButton::toggled, this, [this, radioButton, group, plugin](const bool checked) { + logMessage(INFO, + "Received toggled signal for radio: " + plugin->getName() + ": " + (checked ? "TRUE" : "FALSE") + + " Radio is now: " + (radioButton->isChecked() ? "TRUE" : "FALSE")); + onPluginToggled(checked, group, plugin); + }); + + radioButton->setEnabled(plugin->isEnabled()); + radioButton->setChecked(plugin->isSelected()); + return radioButton; +} + +QCheckBox* FomodInstallerWindow::createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent) +{ + auto* checkBox = new QCheckBox(QString::fromStdString(plugin->getName()), parent); + checkBox->setObjectName(createObjectName(plugin, group)); + + // Make the hover stuff work + auto* hoverFilter = new HoverEventFilter(plugin, this); + checkBox->installEventFilter(hoverFilter); + connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered); + + // Install Ctrl+click event filter + auto* ctrlClickFilter = new CtrlClickEventFilter(plugin, group, this); + checkBox->installEventFilter(ctrlClickFilter); + + checkBox->setEnabled(plugin->isEnabled()); + checkBox->setChecked(plugin->isSelected()); + connect(checkBox, &QCheckBox::clicked, this, [this, plugin](const bool checked) { + // Send a message to viewModel saying the user deactivated the plugin manually. + // This may get overridden later by automatic checking, but we'll reconcile that at JSON serialization time. + if (!checked) { + onPluginManuallyUnchecked(plugin); + } + }); + connect(checkBox, &QCheckBox::toggled, this, [this, checkBox, group, plugin](const bool checked) { + logMessage(INFO, + "Received toggled signal for checkbox: " + plugin->getName() + ": " + (checked ? "true" : "false") + + " Checkbox was previously " + (checkBox->isChecked() ? "true" : "false")); + onPluginToggled(checked, group, plugin); + }); + return checkBox; +} + +void FomodInstallerWindow::renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + // This is for parity with the legacy installer. Both styles are functionally equivalent + // for a group size of 1, but they chose checkbox. + if (group->getPlugins().size() == 1) { + renderCheckboxGroup(parent, parentLayout, group); + } else { + renderRadioGroup(parent, parentLayout, group); + } +} + +void FomodInstallerWindow::renderCheckboxGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + for (const auto& plugin : group->getPlugins()) { + auto* checkbox = createPluginCheckBox(plugin, group, parent); + parentLayout->addWidget(checkbox); + } +} + +QButtonGroup* FomodInstallerWindow::renderRadioGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + auto* buttonGroup = new QButtonGroup(parent); + buttonGroup->setExclusive(true); // Ensure only one button can be selected + + for (const auto& plugin : group->getPlugins()) { + auto* radioButton = createPluginRadioButton(plugin, group, parent); + buttonGroup->addButton(radioButton); + parentLayout->addWidget(radioButton); + } + return buttonGroup; +} + +void FomodInstallerWindow::addNotification(const QString& message, const LogLevel level) const +{ + if (!mNotificationsPanel) { + return; + } + + const QString timestamp = QDateTime::currentDateTime().toString("hh:mm:ss"); + const QString formattedMsg = QString("<span>[%2] [%3] %4</span>") + .arg(timestamp).arg(logLevelToString(level)).arg(message); + + mNotificationsPanel->append(formattedMsg); + + // Auto-scroll to bottom + QScrollBar* scrollbar = mNotificationsPanel->verticalScrollBar(); + scrollbar->setValue(scrollbar->maximum()); +} + +[[deprecated]] +void FomodInstallerWindow::toggleImagesShown() const +{ + logMessage(DEBUG, "Toggling image visibility"); + mInstaller->toggleShouldShowImages(); + if (mInstaller->shouldShowImages()) { + logMessage(DEBUG, "Turning images ON"); + mImageLabel->show(); + mHideImagesButton->setText(tr("Hide Images")); + } else { + logMessage(DEBUG, "Turning images OFF"); + mImageLabel->hide(); + mHideImagesButton->setText(tr("Show Images")); + } +} + + +// Updates the image and description field for a given plugin. Also use this on initialization of those widgets. +void FomodInstallerWindow::updateDisplayForActivePlugin() const +{ + // Skip if no steps (legacy FOMOD) + if (mViewModel->getSteps().empty()) { + return; + } + + auto plugin = mViewModel->getActivePlugin(); + if (!plugin) { + const auto activeStep = mViewModel->getActiveStep(); + if (!activeStep || activeStep->getGroups().empty() || activeStep->getGroups().front()->getPlugins().empty()) { + mDescriptionBox->setText(tr("Select a plugin to see its description.")); + mImageLabel->clear(); + return; + } + // Fall back to the first plugin in the active step when no active plugin is set. + plugin = activeStep->getGroups().front()->getPlugins().front(); + mViewModel->setActivePlugin(plugin); + } + + const QString description = formatPluginDescription(QString::fromStdString(plugin->getDescription())); + mDescriptionBox->setText(description); + + const auto image = mViewModel->getDisplayImage(); + if (image.empty()) { + mImageLabel->clear(); + return; + } + + const auto imagePath = UIHelper::getFullImagePath(mFomodPath, QString::fromStdString(image)); + mImageLabel->setScalableResource(imagePath); +} + +/** + * + * @param pluginSelector For now either 'plugins', or 'deselected'. The key of the member of 'groups' to iterate over. + * @param fn The callback for each plugin in the chosen group member. + */ +void FomodInstallerWindow::applyFnFromJson(const std::string& pluginSelector, + const std::function<void(QAbstractButton*)>& fn) +{ + if (mFomodJson.empty()) { + return; + } + + const auto jsonSteps = mFomodJson["steps"]; + // for each step in JSON, create a <group>-<plugin> string out of the { groups: [ { plugins... } ] } array + vector<std::string> selectedPlugins; + + // TODO: Can groups have the same name within a step, or across steps? How do we account for that? + for (int stepIndex = 0; stepIndex < jsonSteps.size(); ++stepIndex) { + const auto& step = jsonSteps[stepIndex]; + for (int groupIndex = 0; groupIndex < step["groups"].size(); ++groupIndex) { + const auto& group = step["groups"][groupIndex]; + + if (!group.contains(pluginSelector)) { + continue; + } + + for (int pluginIndex = 0; pluginIndex < group[pluginSelector].size(); ++pluginIndex) { + const auto& plugin = group[pluginSelector][pluginIndex]; + + std::string name = std::format("[{}:{}] {}-{}", + stepIndex, groupIndex, group["name"].get<std::string>(), plugin.get<std::string>()); + selectedPlugins.push_back(name); + } + } + } + + const auto checkboxes = findChildren<QCheckBox*>(); + const auto radioButtons = findChildren<QRadioButton*>(); + + for (auto* checkbox : checkboxes) { + for (const auto& selectedPlugin : selectedPlugins) { + if (checkbox->objectName().toStdString() == selectedPlugin) { + fn(checkbox); + } + } + } + for (auto* radio : radioButtons) { + for (const auto& selectedPlugin : selectedPlugins) { + if (radio->objectName().toStdString() == selectedPlugin) { + fn(radio); + } + } + } +} + +void FomodInstallerWindow::stylePreviouslySelectedOptions() +{ + const auto stylesheet = getColorStyle(UiColors::ColorApplication::BACKGROUND); + + const auto tooltip = "You previously selected this plugin when installing this mod."; + + logMessage(INFO, "Styling previously selected choices with stylesheet " + stylesheet.toStdString(), true); + applyFnFromJson("plugins", [stylesheet, tooltip](QAbstractButton* button) { + button->setStyleSheet(stylesheet); + button->setToolTip(tooltip); + }); +} + +void FomodInstallerWindow::stylePreviouslyDeselectedOptions() +{ + const auto stylesheet = getColorStyle(UiColors::ColorApplication::BORDER); + const auto tooltip = "You previously unchecked this plugin when installing this mod."; + applyFnFromJson("deselected", [stylesheet, tooltip](QAbstractButton* button) { + button->setStyleSheet(stylesheet); + button->setToolTip(tooltip); + }); +} + +void FomodInstallerWindow::selectPreviouslySelectedOptions() const +{ + logMessage(INFO, "Selecting previously selected choices", true); + logMessage(INFO, "Existing JSON provided: " + mFomodJson.dump(4)); + if (mFomodJson.empty()) { + return; + } + try { + mViewModel->selectFromJson(mFomodJson); + } catch (Exception& e) { + logMessage(ERR, std::string("Error selecting previously selected options: ") + e.what(), true); + } + updateCheckboxStates(); +} + +void FomodInstallerWindow::onResetChoicesClicked() +{ + logMessage(INFO, "Resetting choices to author defaults", true); + try { + mViewModel->resetToDefaults(); + } catch (Exception& e) { + logMessage(ERR, std::string("Error resetting choices: ") + e.what(), true); + return; + } + + // Reset the UI to show the first step + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateCheckboxStates(); + updateButtons(); + updateDisplayForActivePlugin(); +} + +QString FomodInstallerWindow::getColorStyle(const UiColors::ColorApplication color_application) const +{ + const auto selectedColor = mInstaller->getSelectedColor(); + logMessage(DEBUG, "Selected color: " + selectedColor.toStdString()); + return getStyle(selectedColor, color_application); +} diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.h b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h new file mode 100644 index 0000000..bca2215 --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h @@ -0,0 +1,214 @@ +#ifndef FOMODINSTALLERWINDOW_H +#define FOMODINSTALLERWINDOW_H + +#include <qboxlayout.h> +#include <qbuttongroup.h> +#include <qcheckbox.h> +#include <qcombobox.h> +#include <qtextedit.h> + +#include "FomodPlusInstaller.h" +#include "xml/ModuleConfiguration.h" + +#include <QDialog> +#include <QStackedWidget> +#include <qradiobutton.h> +#include <ui/ScaleLabel.h> + +#include "FomodInstallerWindow.h" +#include "lib/FileInstaller.h" +#include "ui/FomodViewModel.h" +#include "ui/Colors.h" + +#include <QSplitter> + +using namespace MOBase; + +struct PluginData { + std::shared_ptr<PluginViewModel> plugin; + QAbstractButton* uiElement; +}; + + +class FomodPlusInstaller; +/** + * @class FomodInstallerWindow + * @brief This class represents a window for the FOMOD installer. + * + * The FomodInstallerWindow class is designed to handle and manage the FOMOD installation + * process. It integrates functionalities specific to FOMOD package installations. + * By inheriting from QObject, it supports signal-slot mechanisms, enabling interaction + * and communication with other components in the application. + * + * This class is primarily intended to provide a user interface and functionality + * to process and run the FOMOD installer in a structured manner. + */ +class FomodInstallerWindow final : public QDialog { + Q_OBJECT + +public: + FomodInstallerWindow(FomodPlusInstaller* installer, + GuessedValue<QString>& modName, + const std::shared_ptr<IFileTree>& tree, + QString fomodPath, + const std::shared_ptr<FomodViewModel>& viewModel, + const nlohmann::json& fomodJson, + QWidget* parent = nullptr); + + void closeEvent(QCloseEvent* event) override; + + void saveGeometryAndState() const; + + void restoreGeometryAndState(); + + void populatePluginMap(); + + + // So FomodPlusInstaller can check if the user wants to manually install + [[nodiscard]] bool isManualInstall() const + { + return mIsManualInstall; + } + + [[nodiscard]] std::shared_ptr<FileInstaller> getFileInstaller() const { return mViewModel->getFileInstaller(); } + +private slots: + void onNextClicked(); + + void updateCheckboxStates() const; + + void onPluginToggled(bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const; + + void onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const; + + void onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const; + + void onSelectPreviousClicked() const { this->selectPreviouslySelectedOptions(); } + + void onResetChoicesClicked(); + + void onBackClicked() const; + + void onCancelClicked() + { + this->saveGeometryAndState(); + this->reject(); + } + + void onManualClicked() + { + mIsManualInstall = true; + this->saveGeometryAndState(); + this->reject(); + } + + void onInstallClicked(); + + [[deprecated]] void toggleImagesShown() const; + +private: + Logger& log = Logger::getInstance(); + FomodPlusInstaller* mInstaller; + QString mFomodPath; + GuessedValue<QString>& mModName; + std::shared_ptr<IFileTree> mTree; + std::shared_ptr<FomodViewModel> mViewModel; + bool mInitialized{ false }; + std::unordered_map<QString, PluginData> mPluginMap; + + + // Meta + bool mIsManualInstall{}; + nlohmann::json mFomodJson; + + // Buttons + QPushButton* mNextInstallButton{}; + QPushButton* mBackButton{}; + QPushButton* mCancelButton{}; + QPushButton* mManualButton{}; + QPushButton* mSelectPreviousButton{}; + QPushButton* mResetChoicesButton{}; + QPushButton* mHideImagesButton{}; + + // Widgets + QComboBox* mModNameInput{}; + QLabel* mDescriptionBox{}; + QSplitter* mCenterRow{}; + QSplitter* mLeftPane{}; + QStackedWidget* mInstallStepStack{}; + QTextEdit* mNotificationsPanel{}; + QWidget* mBottomRow{}; + QWidget* mTopRow{}; + ScaleLabel* mImageLabel{}; + + // Fn + void setupUi(); + + void updateButtons() const; + + void updateInstallStepStack(); + + void updateDisplayForActivePlugin() const; + + void applyFnFromJson(const std::string& pluginSelector, const std::function<void(QAbstractButton*)> &fn); + + void stylePreviouslySelectedOptions(); + + void stylePreviouslyDeselectedOptions(); + + void selectPreviouslySelectedOptions() const; + + [[nodiscard]] QString getColorStyle(UiColors::ColorApplication color_application) const; + + [[nodiscard]] QBoxLayout* createContainerLayout(); + + [[nodiscard]] QSplitter* createCenterRow(); + + [[nodiscard]] QWidget* createTopRow(); + + [[nodiscard]] QComboBox* createModNameComboBox(); + + [[nodiscard]] QWidget* createBottomRow(); + + [[nodiscard]] QSplitter* createLeftPane(); + + [[nodiscard]] QWidget* createRightPane(); + + [[nodiscard]] QTextEdit* createNotificationPanel(); + + [[nodiscard]] QWidget* createStepWidget(const std::shared_ptr<StepViewModel>& installStep); + + [[nodiscard]] QWidget* renderGroup(const std::shared_ptr<GroupViewModel>& group); + + static QString createObjectName(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group); + + QRadioButton* createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent); + + QCheckBox* createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent); + + void renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, const std::shared_ptr<GroupViewModel>& group); + + void renderCheckboxGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group); + + QButtonGroup* renderRadioGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group); + + void addNotification(const QString& message, LogLevel level) const; + + void logMessage(const LogLevel level, const std::string& message, const bool asNotification = true) const + { + log.logMessage(level, "[WINDOW] " + message); + if (asNotification) { + addNotification(QString::fromStdString(message), level); + } + } + +}; + + +#endif //FOMODINSTALLERWINDOW_H diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp new file mode 100644 index 0000000..690bdbb --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp @@ -0,0 +1,512 @@ +#include "FomodPlusInstaller.h" + +#include <igamefeatures.h> +#include <iinstallationmanager.h> +#include <iplugingame.h> +#include <QEventLoop> +#include <QTreeWidget> +#include <xml/FomodInfoFile.h> +#include <xml/ModuleConfiguration.h> +#include <xml/XmlParseException.h> + +#include "FomodInstallerWindow.h" +#include "stringutil.h" +#include "integration/FomodDataContent.h" +#include "ui/Colors.h" +#include "ui/FomodViewModel.h" +#include "lib/CrashHandler.h" + +#include <QMessageBox> +#include <QSettings> + +using namespace Qt::Literals::StringLiterals; + +/* +-------------------------------------------------------------------------------- + Init +-------------------------------------------------------------------------------- +*/ +#pragma region Initialization + +bool FomodPlusInstaller::init(IOrganizer* organizer) +{ + CrashHandler::initialize(); + mOrganizer = organizer; + mFomodContent = make_shared<FomodDataContent>(organizer); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log"); + std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl; + std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl; + + // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it. + mFomodDb = std::make_unique<FomodDB>(mOrganizer->basePath().toStdString()); + setupUiInjection(); + return true; +} + +void FomodPlusInstaller::setupUiInjection() const +{ + if (shouldShowSidebarFilter()) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } + + mOrganizer->onPluginSettingChanged([this](const QString& pluginName, const QString& key, const QVariant& oldValue, const QVariant& newValue) { + if (pluginName == name() && key == "show_fomod_filter") { + toggleFeature(newValue.toBool()); + } + }); +} + +void FomodPlusInstaller::toggleFeature(const bool enabled) const +{ + if (enabled) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } else { + mOrganizer->gameFeatures()->unregisterFeature(mFomodContent); + } + mOrganizer->refresh(); +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Settings +-------------------------------------------------------------------------------- +*/ +#pragma region Settings +bool FomodPlusInstaller::shouldFallbackToLegacyInstaller() const +{ + return mOrganizer->pluginSetting(name(), "fallback_to_legacy").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowImages() const +{ + return mOrganizer->pluginSetting(name(), "show_images").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowNotifications() const +{ + return mOrganizer->pluginSetting(name(), "show_notifications").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowSidebarFilter() const +{ + return mOrganizer->pluginSetting(name(), "show_fomod_filter").value<bool>(); +} + +bool FomodPlusInstaller::shouldAutoRestoreChoices() const +{ + return mOrganizer->pluginSetting(name(), "always_restore_choices").value<bool>(); +} + +bool FomodPlusInstaller::isWizardIntegrated() const +{ + return mOrganizer->pluginSetting(name(), "wizard_integration").value<bool>(); +} + +void FomodPlusInstaller::toggleShouldShowImages() const +{ + const bool showImages = shouldShowImages(); + mOrganizer->setPluginSetting(name(), "show_images", !showImages); +} + +QString FomodPlusInstaller::getSelectedColor() const +{ + const auto colorName = mOrganizer->pluginSetting(name(), "color_theme").toString(); + const auto it = UiColors::colorStyles.find(colorName); + return it != UiColors::colorStyles.end() ? it->first : "Blue"; +} + +std::vector<std::shared_ptr<const IPluginRequirement> > FomodPlusInstaller::requirements() const +{ + return { Requirements::gameDependency( + { u"Morrowind"_s, u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s, + u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s, + u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s }) }; +} + +QList<PluginSetting> FomodPlusInstaller::settings() const +{ + return { + { u"fallback_to_legacy"_s, u"When hitting cancel, fall back to the legacy FOMOD installer."_s, false }, + { u"always_restore_choices"_s, u"Restore previous choices without clicking the magic button"_s, false }, + { u"show_images"_s, u"Show image previews and the image carousel in installer windows."_s, true }, + { u"color_theme"_s, u"Select the color theme for the installer"_s, QString("Blue") }, // Default color name + { u"show_notifications"_s, u"Show the notifications panel"_s, false }, //WIP + { u"wizard_integration"_s, u"Integrate the installer with patch wizard."_s, true }, //WIP + { u"show_fomod_filter"_s, u"Show the filter in the sidebar (may break other content filters)"_s, true } + }; +} + +#pragma endregion + +bool FomodPlusInstaller::isArchiveSupported(std::shared_ptr<const IFileTree> tree) const +{ + tree = findFomodDirectory(tree); + if (tree != nullptr) { + return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE); + } + return false; +} + + +std::pair<nlohmann::json, IModInterface*> FomodPlusInstaller::getExistingFomodJson(const GuessedValue<QString>& modName, + const int& nexusId, + const int& stepsInCurrentFomod) const +{ + logMessage(DEBUG, "FomodPlusInstaller::getExistingFomodJson - modName: " + modName->toStdString() + + " nexusId: " + std::to_string(nexusId)); + + // Need to have better mod matching based on presence of FOMOD plugin data. + struct ModMatch { + MOBase::IModInterface* mod; + bool hasFomodData; + int stepCount; + nlohmann::json fomodJson; + }; + std::vector<ModMatch> matches; + + auto parseStepCount = [](const QVariant& fomodData) -> std::pair<bool, int> { + try { + if (fomodData.isValid() && !fomodData.isNull()) { + if (auto json = nlohmann::json::parse(fomodData.toString().toStdString()); json.contains("steps") && json["steps"].is_array()) { + return { true, static_cast<int>(json["steps"].size()) }; + } + } + } catch (...) { + // Ignore parsing errors + } + return { false, 0 }; + }; + + // Check exact match first + const auto modList = mOrganizer->modList(); + if (modList == nullptr) { + return {}; + } + + if (const auto exactMod = modList->getMod(modName)) { + const auto fomodData = exactMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + exactMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + exactMod, + false, + 0, + nlohmann::json() + }); + } + } + + // Check all variants + for (const auto& variant : modName.variants()) { + if (const auto variantMod = modList->getMod(variant)) { + const auto fomodData = variantMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + variantMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + variantMod, + false, + 0, + nlohmann::json() + }); + } + } + } + + // No matches found + if (matches.empty()) { + return {}; + } + + // First try to find exact step count match + const auto exactMatch = ranges::find_if(matches, + [stepsInCurrentFomod](const ModMatch& match) { + return match.hasFomodData && match.stepCount == stepsInCurrentFomod; + }); + + if (exactMatch != matches.end()) { + logMessage(DEBUG, "Found exact step count match in mod: " + + exactMatch->mod->name().toStdString() + + " with " + std::to_string(exactMatch->stepCount) + " steps"); + return std::make_pair(exactMatch->fomodJson, exactMatch->mod); + } + + // Find the closest step count among mods with FOMOD data + const auto closestMatch = ranges::min_element(matches, + [stepsInCurrentFomod](const ModMatch& a, const ModMatch& b) { + if (!a.hasFomodData || !b.hasFomodData) + return false; + // The min difference between the step counts + return std::abs(a.stepCount - stepsInCurrentFomod) < + std::abs(b.stepCount - stepsInCurrentFomod); + }); + + if (closestMatch != matches.end() && closestMatch->hasFomodData) { + logMessage(DEBUG, "Using closest step count match from mod: " + + closestMatch->mod->name().toStdString() + + " with " + std::to_string(closestMatch->stepCount) + " steps"); + return std::make_pair(closestMatch->fomodJson, closestMatch->mod); + } + + // Fallback to first mod with any FOMOD data + const auto anyFomod = ranges::find_if(matches, + [](const ModMatch& match) { return match.hasFomodData; }); + + if (anyFomod != matches.end()) { + logMessage(DEBUG, "Using first available FOMOD data from mod: " + + anyFomod->mod->name().toStdString()); + return std::make_pair(anyFomod->fomodJson, anyFomod->mod); + } + + logMessage(DEBUG, "No matching FOMOD data found"); + return {}; +} + +void FomodPlusInstaller::clearPriorInstallData() +{ + mInstallerUsed = false; + mFomodJson = nullptr; + mFomodPath = ""; +} + +/** + * + * @param modName + * @param tree + * @param version + * @param nexusID + * @return + */ +IPluginInstaller::EInstallResult FomodPlusInstaller::install(GuessedValue<QString>& modName, + std::shared_ptr<IFileTree>& tree, QString& version, + int& nexusID) +{ + clearPriorInstallData(); + + logMessage(INFO, std::format("FomodPlusInstaller::install - modName: {}, version: {}, nexusID: {}", + modName->toStdString(), + version.toStdString(), + nexusID + )); + logMessage(INFO, std::format("FomodPlusInstaller::install - tree size: {}", tree->size())); + + auto [infoFile, moduleConfigFile, filePaths] = parseFomodFiles(tree); + + if (infoFile == nullptr || moduleConfigFile == nullptr) { + return RESULT_FAILED; + } + + std::vector<QString> pluginPaths = {}; + for (const auto& filePath : filePaths) { + if (isPluginFile(filePath)) { + pluginPaths.emplace_back(filePath); + } + } + + const auto dbEntry = mFomodDb->getEntryFromFomod(moduleConfigFile.get(), pluginPaths, nexusID); + + // create ui & pass xml classes to ui + auto [json, matchMod] = getExistingFomodJson(modName, nexusID, static_cast<int>(moduleConfigFile->installSteps.installSteps.size())); + if (matchMod != nullptr) { + modName.update(matchMod->name(), GUESS_USER); + } + auto fomodViewModel = FomodViewModel::create(mOrganizer, std::move(moduleConfigFile), std::move(infoFile)); + const auto window = std::make_shared<FomodInstallerWindow>(this, modName, tree, mFomodPath, fomodViewModel, json); + + // ReSharper disable once CppTooWideScopeInitStatement + const QDialog::DialogCode result = showInstallerWindow(window); + if (result == QDialog::Accepted) { + // modname was updated in window + mInstallerUsed = true; + const std::shared_ptr<IFileTree> installTree = window->getFileInstaller()->install(); + tree = installTree; + mFomodJson = std::make_shared<nlohmann::json>(window->getFileInstaller()->generateFomodJson()); + + try { + mFomodDb->addEntry(dbEntry); + mFomodDb->saveToFile(); + } catch ([[maybe_unused]] Exception& e) { + logMessage(ERR, "Failed to add FomodDB entries."); + logMessage(ERR, e.what()); + } + return RESULT_SUCCESS; + } + if (window->isManualInstall()) { + return RESULT_MANUALREQUESTED; + } + if (shouldFallbackToLegacyInstaller()) { + return RESULT_NOTATTEMPTED; + } + return RESULT_CANCELED; +} + + +/** + * + * @param tree + * @return + */ +ParsedFilesTuple FomodPlusInstaller::parseFomodFiles(const std::shared_ptr<IFileTree>& tree) +{ + const auto emptyResult = std::make_tuple(nullptr, nullptr, QStringList()); + + const auto fomodDir = findFomodDirectory(tree); + if (fomodDir == nullptr) { + logMessage(ERR, "FomodPlusInstaller::install - fomod directory not found"); + return emptyResult; + } + + // This is a strange place to set this value but okay for now. + mFomodPath = fomodDir->parent()->path(); + + const auto infoXML = fomodDir->find( + StringConstants::FomodFiles::INFO_XML.data(), + FileTreeEntry::FILE + ); + const auto moduleConfig = fomodDir->find( + StringConstants::FomodFiles::MODULE_CONFIG.data(), + FileTreeEntry::FILE + ); + + // Extract files first. + vector<std::shared_ptr<const FileTreeEntry> > toExtract = {}; + if (moduleConfig) { + toExtract.push_back(moduleConfig); + } else { + logMessage(ERR, "FomodPlusInstaller::install - error parsing moduleConfig.xml: Not Present"); + return emptyResult; + } + if (infoXML) { + toExtract.push_back(infoXML); + } + appendImageFiles(toExtract, tree); + appendPluginFiles(toExtract, tree); // For patch wizard data collection + auto paths = manager()->extractFiles(toExtract); + // Normalize backslash separators from MO2's internal file tree to forward slashes for Linux + for (auto& p : paths) { + p.replace('\\', '/'); + } + + auto moduleConfiguration = std::make_unique<ModuleConfiguration>(); + try { + moduleConfiguration->deserialize(paths.at(0)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing moduleConfig.xml: {}", e.what())); + return emptyResult; + } + + auto infoFile = std::make_unique<FomodInfoFile>(); + if (infoXML) { + try { + infoFile->deserialize(paths.at(1)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing info.xml: {}", e.what())); + } + } + + return std::make_tuple(std::move(infoFile), std::move(moduleConfiguration), paths); +} + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp#L123 +void FomodPlusInstaller::appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree) +{ + static std::set<QString, FileNameComparator> imageSuffixes{ "png", "jpg", "jpeg", "gif", "bmp" }; + for (auto entry : *tree) { + if (entry->isDir()) { + appendImageFiles(entries, entry->astree()); + } else if (imageSuffixes.contains(entry->suffix())) { + entries.push_back(entry); + } + } +} + +void FomodPlusInstaller::appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree) +{ + // Don't bother with this stuff if the user doesn't care about the wizard. + // It may slightly bloat the temp file directory if not needed. + if (isWizardIntegrated()) { + for (auto entry : *tree) { + if (entry->isDir()) { + appendPluginFiles(entries, entry->astree()); + } else if (isPluginFile(entry->suffix())) { + entries.push_back(entry); + } + } + } +} + +void FomodPlusInstaller::onInstallationStart(QString const& archive, const bool reinstallation, + IModInterface* currentMod) +{ + IPluginInstallerSimple::onInstallationStart(archive, reinstallation, currentMod); +} + +void FomodPlusInstaller::onInstallationEnd(const EInstallResult result, IModInterface* newMod) +{ + IPluginInstallerSimple::onInstallationEnd(result, newMod); + + // Update the meta.ini file with the fomod information + if (mFomodJson != nullptr && result == RESULT_SUCCESS && newMod != nullptr && mInstallerUsed) { + newMod->setPluginSetting(this->name(), "fomod", mFomodJson->dump().c_str()); + mOrganizer->refresh(); + } + clearPriorInstallData(); +} + +// Borrowed from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp +std::shared_ptr<const IFileTree> FomodPlusInstaller::findFomodDirectory(const std::shared_ptr<const IFileTree>& tree) +{ + // ReSharper disable once CppTooWideScopeInitStatement + const auto entry = tree->find(StringConstants::FomodFiles::FOMOD_DIR.data(), FileTreeEntry::DIRECTORY); + + if (entry != nullptr) { + return entry->astree(); + } + + if (tree->size() == 1 && tree->at(0)->isDir()) { + return findFomodDirectory(tree->at(0)->astree()); + } + return nullptr; +} + +QDialog::DialogCode FomodPlusInstaller::showInstallerWindow(const std::shared_ptr<FomodInstallerWindow>& window) +{ + QEventLoop loop; + connect(window.get(), SIGNAL(accepted()), &loop, SLOT(quit())); + connect(window.get(), SIGNAL(rejected()), &loop, SLOT(quit())); + window->show(); + loop.exec(); + return static_cast<QDialog::DialogCode>(window->result()); +} + +void showError(const Exception& e) +{ + const QString errorText = "Mod Name: \n" "Nexus ID: " "\n" "Exception: " + QString(e.what()) + "\n"; + + QMessageBox msgBox; + msgBox.setWindowTitle("FOMOD Plus Error :("); + msgBox.setTextFormat(Qt::RichText); + msgBox.setText("Sorry this happened. Please copy the following error and report it to me on Nexus or GitHub.\n" + "<pre style='background-color: #f0f0f0; padding: 10px;'>" + + errorText + + "</pre>" + ); + msgBox.setIcon(QMessageBox::Critical); + msgBox.exec(); + +} diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.h b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h new file mode 100644 index 0000000..494ad8e --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h @@ -0,0 +1,115 @@ +#pragma once + +#include "stringutil.h" + +#include <iplugin.h> +#include <iplugininstaller.h> +#include <iplugininstallersimple.h> + +#include <nlohmann/json.hpp> +#include "FomodInstallerWindow.h" +#include "lib/Logger.h" +#include "xml/FomodInfoFile.h" +#include "xml/ModuleConfiguration.h" + +#include <QDialog> +#include <integration/FomodDataContent.h> +#include <FOMODData/FomodDB.h> + +class FomodInstallerWindow; + +using namespace MOBase; +using namespace std; + +using ParsedFilesTuple = std::tuple<std::unique_ptr<FomodInfoFile>, std::unique_ptr<ModuleConfiguration>, QStringList>; + +class FomodPlusInstaller final : public IPluginInstallerSimple { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlus" FILE "fomodplus.json") + +public: + bool init(IOrganizer* organizer) override; + + // constant values + [[nodiscard]] QString name() const override { return StringConstants::Plugin::NAME.data(); } + [[nodiscard]] QString author() const override { return StringConstants::Plugin::AUTHOR.data(); } + [[nodiscard]] QString description() const override { return StringConstants::Plugin::DESCRIPTION.data(); } + [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_FINAL }; } + + [[nodiscard]] unsigned int priority() const override + { + return 999; /* Above installer_fomod's highest priority. */ + } + + [[nodiscard]] std::vector<std::shared_ptr<const IPluginRequirement> > requirements() const override; + + [[nodiscard]] bool isManualInstaller() const override { return false; } + + [[nodiscard]] bool isArchiveSupported(std::shared_ptr<const IFileTree> tree) const override; + + [[nodiscard]] QList<PluginSetting> settings() const override; + + std::pair<nlohmann::json, IModInterface*> getExistingFomodJson(const GuessedValue<QString>& modName, + const int& nexusId, const int& stepsInCurrentFomod) const; + + void clearPriorInstallData(); + + EInstallResult install(GuessedValue<QString>& modName, std::shared_ptr<IFileTree>& tree, QString& version, + int& nexusID) override; + + void onInstallationStart(QString const& archive, bool reinstallation, IModInterface* currentMod) override; + + void onInstallationEnd(EInstallResult result, IModInterface* newMod) override; + + [[nodiscard]] bool shouldShowImages() const; + + [[nodiscard]] bool shouldShowNotifications() const; + bool shouldShowSidebarFilter() const; + + [[nodiscard]] bool shouldAutoRestoreChoices() const; + + [[nodiscard]] bool isWizardIntegrated() const; + + void toggleShouldShowImages() const; + + QString getSelectedColor() const; + +private: + Logger& log = Logger::getInstance(); + IOrganizer* mOrganizer = nullptr; + QString mFomodPath{}; + std::shared_ptr<nlohmann::json> mFomodJson{ nullptr }; + bool mInstallerUsed{ false }; + std::shared_ptr<FomodDataContent> mFomodContent{ nullptr }; + std::unique_ptr<FomodDB> mFomodDb; + + /** + * @brief Retrieve the tree entry corresponding to the fomod directory. + * + * @param tree Tree to look-up the directory in. + * + * @return the entry corresponding to the fomod directory in the tree, or a null + * pointer if the entry was not found. + */ + [[nodiscard]] static shared_ptr<const IFileTree> findFomodDirectory(const shared_ptr<const IFileTree>& tree); + + [[nodiscard]] static QDialog::DialogCode showInstallerWindow(const shared_ptr<FomodInstallerWindow>& window); + + [[nodiscard]] ParsedFilesTuple parseFomodFiles(const shared_ptr<IFileTree>& tree); + + static void appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree); + + void appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, const shared_ptr<const IFileTree>& tree); + + void setupUiInjection() const; + void toggleFeature(bool enabled) const; + + [[nodiscard]] bool shouldFallbackToLegacyInstaller() const; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[INSTALLER] " + message); + } +}; diff --git a/libs/installer_fomod_plus/installer/fomod_plus_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_de.ts new file mode 100644 index 0000000..1552582 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_de.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_en.ts new file mode 100644 index 0000000..bc6d6e7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_en.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts new file mode 100644 index 0000000..ad7bfed --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="194"/> + <source>Installieren</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="196"/> + <location filename="FomodInstallerWindow.cpp" line="370"/> + <source>Weiter</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="288"/> + <source>Name:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="289"/> + <source>Autor:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="290"/> + <source>Version:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="291"/> + <source>Webseite:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="357"/> + <source>Manuell</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="358"/> + <source>Alle Vorherige Optionen Auswählen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="359"/> + <source>Reset Choices</source> + <translation>Optionen Zurücksetzen</translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="361"/> + <location filename="FomodInstallerWindow.cpp" line="586"/> + <source>Bilder Ausblenden</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="369"/> + <source>Zurück</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="371"/> + <source>Abbrechen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="360"/> + <location filename="FomodInstallerWindow.cpp" line="590"/> + <source>Bilder Einblenden</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts new file mode 100644 index 0000000..5cbe0a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="231"/> + <location filename="FomodInstallerWindow.cpp" line="242"/> + <source>Install</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="244"/> + <location filename="FomodInstallerWindow.cpp" line="423"/> + <source>Next</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="342"/> + <source>Name:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="343"/> + <source>Author:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="344"/> + <source>Version:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="345"/> + <source>Website:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="411"/> + <source>Manual</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="412"/> + <source>Restore Previous Choices</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="413"/> + <source>Reset Choices</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="422"/> + <source>Back</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="424"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="682"/> + <source>Hide Images</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="686"/> + <source>Show Images</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="703"/> + <source>Select a plugin to see its description.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts new file mode 100644 index 0000000..f2769e6 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="194"/> + <source>安装</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="196"/> + <location filename="FomodInstallerWindow.cpp" line="370"/> + <source>下一步</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="288"/> + <source>名称</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="289"/> + <source>作者</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="290"/> + <source>版本</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="291"/> + <source>网址</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="357"/> + <source>手动</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="358"/> + <source>选中所有上次安装的选择</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="359"/> + <source>Reset Choices</source> + <translation>重置选择</translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="361"/> + <location filename="FomodInstallerWindow.cpp" line="586"/> + <source>隐藏图片</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="369"/> + <source>上一步</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="371"/> + <source>取消</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="360"/> + <location filename="FomodInstallerWindow.cpp" line="590"/> + <source>显示图片</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts new file mode 100644 index 0000000..e5ca8aa --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomodplus.json b/libs/installer_fomod_plus/installer/fomodplus.json new file mode 100644 index 0000000..dde5a05 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomodplus.json @@ -0,0 +1,4 @@ +{ + "Id": "FOMODPlus", + "Version" : "1.0.0" +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp new file mode 100644 index 0000000..ac4508d --- /dev/null +++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp @@ -0,0 +1,50 @@ +#include "FomodDataContent.h" + +#include <ifiletree.h> + +#include "stringutil.h" + +#include <iplugingame.h> + +FomodDataContent::FomodDataContent(MOBase::IOrganizer* organizer) : mOrganizer(organizer) +{ +} + +std::vector<MOBase::ModDataContent::Content> FomodDataContent::getAllContents() const +{ + static const std::vector<Content> contents = { + {FomodDataContentConstants::FOMOD_CONTENT_ID, "FOMOD", ":/fomod/hat", false} + }; + + return contents; +} + +// Confirmed working, no need to update +std::vector<int> FomodDataContent::getContentsFor(const std::shared_ptr<const MOBase::IFileTree> fileTree) const +{ + std::vector<int> contents; + if (!mOrganizer || !fileTree) { + return contents; + } + + const auto modList = mOrganizer->modList(); + if (!modList) { + return contents; + } + + const auto mod = modList->getMod(fileTree->name()); + if (modHasFomodContent(mod)) { + contents.emplace_back(FomodDataContentConstants::FOMOD_CONTENT_ID); + } + return contents; +} + +bool FomodDataContent::modHasFomodContent(const MOBase::IModInterface* mod) +{ + if (!mod) { + return false; + } + const auto pluginName = QString::fromStdString(StringConstants::Plugin::NAME.data()); + const auto fomodMeta = mod->pluginSetting(pluginName, "fomod", 0); + return fomodMeta != 0; +} diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.h b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h new file mode 100644 index 0000000..4ffa3d7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h @@ -0,0 +1,21 @@ +#pragma once + +#include <imoinfo.h> +#include <moddatacontent.h> + +namespace FomodDataContentConstants { +constexpr int FOMOD_CONTENT_ID = 400400; +} + +class FomodDataContent final : public MOBase::ModDataContent { +public: + explicit FomodDataContent(MOBase::IOrganizer* organizer); + + [[nodiscard]] std::vector<Content> getAllContents() const override; + + [[nodiscard]] std::vector<int> getContentsFor(std::shared_ptr<const MOBase::IFileTree> fileTree) const override; + +private: + MOBase::IOrganizer* mOrganizer; + static bool modHasFomodContent(const MOBase::IModInterface* mod); +}; diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp new file mode 100644 index 0000000..c35bf47 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp @@ -0,0 +1,176 @@ +#include "ConditionTester.h" + +#include "ui/FomodViewModel.h" + +#include <iplugingame.h> +#include <ipluginlist.h> + +std::string setToString(const std::set<int> &set) +{ + std::string str; + for (const auto& i : set) { + str += std::to_string(i) + ", "; + } + return str; +} + +bool ConditionTester::isStepVisible(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency, + const int stepIndex, + const std::vector<std::shared_ptr<StepViewModel>>& steps) const +{ + + // first things first: is it visible? + if (!testCompositeDependency(flags, compositeDependency)) { + return false; + } + + const auto flagDependencies = compositeDependency.flagDependencies; + if (flagDependencies.empty()) { + return true; + } + + std::set<int> stepsThatSetThisFlag; + + for (const auto& flagDependency : flagDependencies) { + // for this flag, find the plugins that set it + for (int i = stepIndex - 1; i >= 0; --i) { + for (const auto& group : steps[i]->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + if (std::ranges::any_of(plugin->getPlugin()->conditionFlags.flags, + [&flagDependency](const ConditionFlag& flag) { + return flag.name == flagDependency.flag && flag.value == flagDependency.value; + })) { + stepsThatSetThisFlag.insert(i); + } + } + } + } + } + const auto anyVisible = std::ranges::any_of(stepsThatSetThisFlag, [this, &steps, &flags](const int index) { + return isStepVisible(flags, steps[index]->getVisibilityConditions(), index, steps); + }); + if (!anyVisible) { + log.logMessage(DEBUG, "Step " + steps[stepIndex]->getName() + " has no dependent steps that are visible."); + log.logMessage(DEBUG, "Steps that set this flag: " + setToString(stepsThatSetThisFlag)); + } + return anyVisible; + +} + +bool ConditionTester::testCompositeDependency(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency) const +{ + const auto fileDependencies = compositeDependency.fileDependencies; + const auto flagDependencies = compositeDependency.flagDependencies; + const auto gameDependencies = compositeDependency.gameDependencies; + const auto nestedDependencies = compositeDependency.nestedDependencies; + const auto globalOperatorType = compositeDependency.operatorType; + + // For the globalOperatorType + // Evaluate all conditions and store the results in a vector<bool>, then return based on operator. + // These aren't expensive to calculate so rather than do some fancy logic to short-circuit, just calculate all of 'em. + std::vector<bool> results; + for (const auto& fileDependency : fileDependencies) { + results.emplace_back(testFileDependency(fileDependency)); + } + for (const auto& flagDependency : flagDependencies) { + results.emplace_back(testFlagDependency(flags, flagDependency)); + } + for (const auto& gameDependency : gameDependencies) { + results.emplace_back(testGameDependency(gameDependency)); + } + for (const auto& nestedDependency : nestedDependencies) { + results.emplace_back(testCompositeDependency(flags, nestedDependency)); + } + + if (globalOperatorType == OperatorTypeEnum::AND) { + return std::ranges::all_of(results, [](const bool result) { return result; }); + } + return std::ranges::any_of(results, [](const bool result) { return result; }); +} + + +bool ConditionTester::testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency) +{ + // Every instance of this flag being set in the map. + const auto flagList = flags->getFlagsByKey(flagDependency.flag); + + // Find the first instance of this flag being set (in the order specified by getFlagsByKey) + if (flagList.empty()) { + // If the dependency value is an empty string, it means this flag should be unset. + // So if we don't have any value for this flag, the result is true. + return flagDependency.value.empty(); + } + + return flagList.front().second == flagDependency.value; +} + +bool ConditionTester::testFileDependency(const FileDependency& fileDependency) const +{ + const std::string& pluginName = fileDependency.file; + const auto pluginState = getFileDependencyStateForPlugin(pluginName); + return pluginState == fileDependency.state; +} + +bool ConditionTester::testGameDependency(const GameDependency& gameDependency) const +{ + const auto gameVersion = mOrganizer->managedGame()->gameVersion().toStdString(); + log.logMessage(DEBUG, "Comparing condition version " + gameDependency.version + " against " + gameVersion); + if ( gameDependency.version <= gameVersion) { + log.logMessage(DEBUG, "Version matches!"); + } + return gameDependency.version <= gameVersion; +} + +FileDependencyTypeEnum ConditionTester::getFileDependencyStateForPlugin(const std::string& pluginName) const +{ + if (const auto it = pluginStateCache.find(pluginName); it != pluginStateCache.end()) { + return it->second; + } + + const QFlags<MOBase::IPluginList::PluginState> pluginState = mOrganizer->pluginList()->state( + QString::fromStdString(pluginName)); + + FileDependencyTypeEnum state; + + if (pluginState == MOBase::IPluginList::STATE_MISSING) { + state = FileDependencyTypeEnum::Missing; + } else if (pluginState == MOBase::IPluginList::STATE_INACTIVE) { + state = FileDependencyTypeEnum::Inactive; + } else if (pluginState == MOBase::IPluginList::STATE_ACTIVE) { + state = FileDependencyTypeEnum::Active; + } else { + state = FileDependencyTypeEnum::UNKNOWN_STATE; + } + + pluginStateCache[pluginName] = state; + return state; +} + +PluginTypeEnum ConditionTester::getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin, + const std::shared_ptr<FlagMap>& flags) const +{ + // NOTE: A plugin's ConditionFlags aren't the same thing as a step visibility one. + // A plugin's ConditionFlags are toggled based on the selection state of the plugin + // We only evaluate the typeDescriptor here. + + // We will return the 'winning' type or the default. If multiple conditions are met, + // ...well, I'm not sure. + // ReSharper disable once CppTooWideScopeInitStatement + const auto& dependencyType = plugin->typeDescriptor.dependencyType; + for (const auto& pattern : dependencyType.patterns.patterns) { + if (testCompositeDependency(flags, pattern.dependencies)) { + return pattern.type; + } + } + + // Sometimes authors do this. + if (plugin->typeDescriptor.type != PluginTypeEnum::Optional) { + return plugin->typeDescriptor.type; + } + if (plugin->typeDescriptor.dependencyType.defaultType.has_value()) { + return plugin->typeDescriptor.dependencyType.defaultType.value(); + } + return PluginTypeEnum::Optional; +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.h b/libs/installer_fomod_plus/installer/lib/ConditionTester.h new file mode 100644 index 0000000..09aa26e --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.h @@ -0,0 +1,42 @@ +#pragma once + +#include <imoinfo.h> + +#include "FlagMap.h" +#include "Logger.h" +#include "xml/ModuleConfiguration.h" + +class CompositeDependency; +class StepViewModel; + +class ConditionTester { +public: + explicit ConditionTester(MOBase::IOrganizer* organizer) : mOrganizer(organizer) {} + + bool isStepVisible(const std::shared_ptr<FlagMap>& flags, const CompositeDependency& compositeDependency, + int stepIndex, + const std::vector<std::shared_ptr<StepViewModel>>& steps) const; + + bool testCompositeDependency(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency) const; + + static bool testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency); + + [[nodiscard]] bool testFileDependency(const FileDependency& fileDependency) const; + + bool testGameDependency(const GameDependency& gameDependency) const; + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* mOrganizer; + + friend class FomodViewModel; + + [[nodiscard]] FileDependencyTypeEnum getFileDependencyStateForPlugin(const std::string& pluginName) const; + + PluginTypeEnum getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin, + const std::shared_ptr<FlagMap>& flags) const; + + mutable std::unordered_map<std::string, FileDependencyTypeEnum> pluginStateCache; + +}; diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp new file mode 100644 index 0000000..98ed1f3 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp @@ -0,0 +1,122 @@ +#include "CrashHandler.h" +#include <iostream> + +#ifdef _WIN32 +#include <windows.h> +#include <dbghelp.h> +#include <sstream> +#include <fstream> + +LPTOP_LEVEL_EXCEPTION_FILTER CrashHandler::previousFilter = nullptr; +PVOID CrashHandler::vectoredHandler = nullptr; +#endif + +void CrashHandler::initialize() { +#ifdef _WIN32 + std::cout << "CrashHandler initializing..." << std::endl; + vectoredHandler = AddVectoredExceptionHandler(1, vectoredExceptionHandler); + previousFilter = SetUnhandledExceptionFilter(unhandledExceptionFilter); + std::cout << "CrashHandler initialized successfully" << std::endl; +#else + // No-op on Linux — Windows crash handler not needed. +#endif +} + +void CrashHandler::cleanup() { +#ifdef _WIN32 + if (vectoredHandler) { + RemoveVectoredExceptionHandler(vectoredHandler); + vectoredHandler = nullptr; + } + if (previousFilter) { + SetUnhandledExceptionFilter(previousFilter); + previousFilter = nullptr; + } +#endif +} + +#ifdef _WIN32 +LONG WINAPI CrashHandler::vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo) { + // Log all exceptions but only create dumps for serious ones + std::cout << "Exception caught: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl; + + // Only create dumps for serious crashes, not benign exceptions + if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION || + exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW || + exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) { + + // For stack overflow, we need to handle it on a different thread with its own stack + if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW) { + // Create a new thread to handle the crash dump since our stack is corrupted + HANDLE dumpThread = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD { + auto* exceptionInfo = static_cast<EXCEPTION_POINTERS*>(param); + logCrashInfo(exceptionInfo); + + HANDLE file = CreateFileA("fomod_plus_stack_overflow.dmp", GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0}; + dumpInfo.ThreadId = GetCurrentThreadId(); + dumpInfo.ExceptionPointers = exceptionInfo; + dumpInfo.ClientPointers = FALSE; + + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, + MiniDumpNormal, &dumpInfo, nullptr, nullptr); + CloseHandle(file); + + std::cout << "Stack overflow crash dump written to fomod_plus_stack_overflow.dmp" << std::endl; + } + return 0; + }, exceptionInfo, 0, nullptr); + + if (dumpThread) { + WaitForSingleObject(dumpThread, 5000); // Wait up to 5 seconds + CloseHandle(dumpThread); + } + } else { + logCrashInfo(exceptionInfo); + + // Generate minidump + HANDLE file = CreateFileA("fomod_plus_crash.dmp", GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0}; + dumpInfo.ThreadId = GetCurrentThreadId(); + dumpInfo.ExceptionPointers = exceptionInfo; + dumpInfo.ClientPointers = FALSE; + + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, + MiniDumpNormal, &dumpInfo, nullptr, nullptr); + CloseHandle(file); + + std::cout << "Crash dump written to fomod_plus_crash.dmp" << std::endl; + } + } + } + + return EXCEPTION_CONTINUE_SEARCH; // Let other handlers process it +} + +LONG WINAPI CrashHandler::unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo) { + logCrashInfo(exceptionInfo); + return EXCEPTION_EXECUTE_HANDLER; +} + +void CrashHandler::logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo) { + std::ostringstream oss; + oss << "FOMOD PLUS CRASH DETECTED!" << std::endl; + oss << "Exception Code: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl; + oss << "Exception Address: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionAddress << std::endl; + + // Write to stdout immediately + std::cout << oss.str() << std::endl; + + // Also write to crash log file + std::ofstream crashLog("fomod_plus_crash.log", std::ios::app); + if (crashLog.is_open()) { + crashLog << oss.str() << std::endl; + } +} +#endif diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.h b/libs/installer_fomod_plus/installer/lib/CrashHandler.h new file mode 100644 index 0000000..78c8552 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef _WIN32 +#include <windows.h> +#endif + +class CrashHandler { +public: + static void initialize(); + static void cleanup(); + +private: +#ifdef _WIN32 + static LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo); + static LONG WINAPI vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo); + static void logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo); + static LPTOP_LEVEL_EXCEPTION_FILTER previousFilter; + static PVOID vectoredHandler; +#endif +}; diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp new file mode 100644 index 0000000..e47e088 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp @@ -0,0 +1,274 @@ +#include "FileInstaller.h" + +#include <utility> + +#include "ui/FomodViewModel.h" + +using namespace MOBase; + +FileInstaller::FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr<IFileTree>& fileTree, + std::unique_ptr<ModuleConfiguration> fomodFile, + const std::shared_ptr<FlagMap>& flagMap, + const std::vector<std::shared_ptr<StepViewModel> >& steps) : mOrganizer(organizer), + mFomodPath(std::move(fomodPath)), + mFileTree(fileTree), + mFomodFile(std::move(fomodFile)), + mFlagMap(flagMap), + mConditionTester(organizer), mSteps(steps) +{ + const auto requiredCount = + (mFomodFile != nullptr) ? mFomodFile->requiredInstallFiles.files.size() : 0; + const auto stepCount = mSteps.size(); + logMessage(DEBUG, + "FileInstaller constructed. steps=" + std::to_string(stepCount) + ", required files=" + + std::to_string(requiredCount) + ", fomodPath=" + mFomodPath.toStdString()); +} + +std::shared_ptr<IFileTree> FileInstaller::install() const +{ + logMessage(DEBUG, "Starting FileInstaller::install()"); + const auto filesToInstall = collectFilesToInstall(); + logMessage(INFO, "Installing " + std::to_string(filesToInstall.size()) + " files"); + logMessage(INFO, "FlagMap"); + logMessage(INFO, mFlagMap->toString()); + + // update the file tree with the new files + const std::shared_ptr<IFileTree> installTree = mFileTree->createOrphanTree(); + + for (const auto& file : filesToInstall) { + + logMessage(DEBUG, + "Processing install entry source=" + file.source + ", destination=" + + (file.destination.has_value() ? file.destination.value() : "<default>") + + ", priority=" + std::to_string(file.priority)); + const auto sourcePath = getQualifiedFilePath(file.source); + const auto sourceNode = mFileTree->find(QString::fromStdString(sourcePath)); + if (sourceNode == nullptr) { + logMessage(ERR, "Could not find source: " + file.source); + continue; + } + const auto targetPath = file.destination.has_value() + ? QString::fromStdString(file.destination.value()) + : QString::fromStdString(sourcePath); + + // If it's a folder, copy the contents of the folder, not the folder itself. + if (sourceNode->isDir()) { + logMessage(DEBUG, + "Copying directory '" + file.source + "' into target '" + targetPath.toStdString() + + "'"); + // TODO: Check if target path is literally undefined/null + const auto& tree = sourceNode->astree(); + for (auto it = tree->begin(); it != tree->end(); ++it) { + const auto& entry = *it; + const auto path = targetPath.isEmpty() ? entry->name() : targetPath + "/" + entry->name(); + logMessage(DEBUG, + " Copying entry '" + entry->name().toStdString() + "' to '" + + path.toStdString() + "'"); + installTree->copy(entry, path, IFileTree::InsertPolicy::MERGE); + } + } else { + logMessage(DEBUG, + "Copying file '" + file.source + "' to '" + targetPath.toStdString() + "'"); + installTree->copy(sourceNode, targetPath, IFileTree::InsertPolicy::MERGE); + } + } + + // This file will be written by the InstallationManager later. + const auto jsonFilePath = "fomod.json"; + installTree->addFile(QString::fromStdString(jsonFilePath), true); + logMessage(DEBUG, "Added fomod.json placeholder file to install tree."); + + logMessage(DEBUG, "FileInstaller::install completed."); + return installTree; +} + +nlohmann::json FileInstaller::generateFomodJson() const +{ + nlohmann::json fomodJson; + logMessage(DEBUG, "Generating fomod.json representation for " + std::to_string(mSteps.size()) + + " steps."); + + fomodJson["steps"] = nlohmann::json::array(); + for (const auto& stepViewModel : mSteps) { + auto stepJson = nlohmann::json::object(); + stepJson["name"] = stepViewModel->getName(); + stepJson["groups"] = nlohmann::json::array(); + logMessage(DEBUG, "Serializing step '" + stepViewModel->getName() + "'"); + + for (const auto& groupViewModel : stepViewModel->getGroups()) { + auto groupJson = nlohmann::json::object(); + groupJson["name"] = groupViewModel->getName(); + auto pluginArray = nlohmann::json::array(); + auto deselectedArray = nlohmann::json::array(); + logMessage(DEBUG, + " Serializing group '" + groupViewModel->getName() + + "' with " + std::to_string(groupViewModel->getPlugins().size()) + " plugins"); + + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + logMessage(DEBUG, + " Plugin '" + pluginViewModel->getName() + "' selected=" + + (pluginViewModel->isSelected() ? "true" : "false") + ", manually-set=" + + (pluginViewModel->wasManuallySet() ? "true" : "false")); + if (pluginViewModel->isSelected()) { + pluginArray.emplace_back(pluginViewModel->getName()); + } + // Add deselected plugins here. (TODO: This will be replaced with an embedded db.) + if (!pluginViewModel->isSelected() && pluginViewModel->wasManuallySet()) { + deselectedArray.emplace_back(pluginViewModel->getName()); + } + } + groupJson["plugins"] = pluginArray; + groupJson["deselected"] = deselectedArray; + stepJson["groups"].emplace_back(groupJson); + } + fomodJson["steps"].emplace_back(stepJson); + } + return fomodJson; +} + +std::string FileInstaller::getQualifiedFilePath(const std::string& treePath) const +{ + // We need to prepend the fomod path to whatever source we reference. Guess we're passing that path around. + const auto qualifiedPath = mFomodPath.toStdString() + "/" + treePath; + logMessage(DEBUG, "Qualifying tree path '" + treePath + "' to '" + qualifiedPath + "'"); + return qualifiedPath; +} + +std::vector<std::string> FileInstaller::collectPositiveFileNamesFromDependencyPatterns( + const std::vector<DependencyPattern>& patterns) +{ + std::vector<std::string> usableFileDependencyPluginNames = {}; + logMessage(DEBUG, + "Collecting positive file names from " + std::to_string(patterns.size()) + " patterns."); + + for (const auto& pattern : patterns) { + if (pattern.type == PluginTypeEnum::NotUsable) { + logMessage(DEBUG, "Skipping pattern marked as NotUsable."); + continue; + } + + if (pattern.dependencies.fileDependencies.empty() && pattern.dependencies.nestedDependencies.empty()) { + logMessage(DEBUG, "Skipping pattern with no dependencies."); + continue; + } + + const auto fileDependencies = pattern.dependencies.fileDependencies; + const auto nestedDependencies = pattern.dependencies.nestedDependencies; + + for (const auto& fileDependency : fileDependencies) { + if (fileDependency.state != FileDependencyTypeEnum::Active) { + continue; + } + usableFileDependencyPluginNames.emplace_back(fileDependency.file); + logMessage(DEBUG, "Adding active file dependency: " + fileDependency.file); + } + + for (const auto& nestedDependency : nestedDependencies) { + for (const auto& fileDependency : nestedDependency.fileDependencies) { + if (fileDependency.state != FileDependencyTypeEnum::Active) { + continue; + } + usableFileDependencyPluginNames.emplace_back(fileDependency.file); + logMessage(DEBUG, "Adding nested active file dependency: " + fileDependency.file); + } + } + // Not handling twice-nested dependencies now. IDK if that's even feasible. + } + + logMessage(DEBUG, + "CollectPositiveFileNamesFromDependencyPatterns found " + + std::to_string(usableFileDependencyPluginNames.size()) + " entries."); + return usableFileDependencyPluginNames; +} + +// Generic vector appender +void FileInstaller::addFiles(std::vector<File>& main, std::vector<File> toAdd) const +{ + for (const auto& add : toAdd) { + logMessage(INFO, "Adding file with source: " + add.source); + } + main.insert(main.end(), toAdd.begin(), toAdd.end()); + logMessage(DEBUG, + "addFiles merged " + std::to_string(toAdd.size()) + " files. Total is now " + + std::to_string(main.size())); +} + +// TODO: Unclear if we're copying. oh well. +// TODO: Rebuild flagmap and step indeces before installing +std::vector<File> FileInstaller::collectFilesToInstall() const +{ + logMessage(DEBUG, "collectFilesToInstall started."); + std::vector<File> allFiles; + + // Required files from FOMOD + const FileList requiredInstallFiles = mFomodFile->requiredInstallFiles; + logMessage(DEBUG, + "Adding " + std::to_string(requiredInstallFiles.files.size()) + + " required install files from fomod."); + addFiles(allFiles, requiredInstallFiles.files); + + // Selected files from visible steps + for (const auto& stepViewModel : mSteps) { + if (!mConditionTester.testCompositeDependency(mFlagMap, stepViewModel->getVisibilityConditions())) { + logMessage(DEBUG, + "Skipping invisible step '" + stepViewModel->getName() + "'"); + continue; + } + logMessage(DEBUG, + "Processing visible step '" + stepViewModel->getName() + "' with " + + std::to_string(stepViewModel->getGroups().size()) + " groups."); + for (const auto& groupViewModel : stepViewModel->getGroups()) { + logMessage(DEBUG, + " Processing group '" + groupViewModel->getName() + "' with " + + std::to_string(groupViewModel->getPlugins().size()) + " plugins."); + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + if (pluginViewModel->isSelected()) { + logMessage(DEBUG, + " Adding selected plugin '" + pluginViewModel->getName() + + "' with " + + std::to_string(pluginViewModel->getPlugin()->files.files.size()) + + " files."); + addFiles(allFiles, pluginViewModel->getPlugin()->files.files); + } else { + logMessage(DEBUG, + " Skipping plugin '" + pluginViewModel->getName() + + "' (not selected)."); + } + } + } + } + + // ConditionalInstall files + for (const auto conditionals = mFomodFile->conditionalFileInstalls; const auto& pattern : conditionals.patterns) { + //<folder source="CR\Dagi-Raht LL\VLrn_Custom Race - Dagi-Raht LL" destination="" priority="2" /> + + if (mConditionTester.testCompositeDependency(mFlagMap, pattern.dependencies)) { + // also check if the plugins setting these flags are visible. at least one + + addFiles(allFiles, pattern.files.files); + logMessage(DEBUG, + "Conditional install pattern matched; added " + + std::to_string(pattern.files.files.size()) + " files."); + } else { + logMessage(DEBUG, "Conditional install pattern did not match."); + } + } + + // Files will all have a default priority of 0 if not specified, so the order should also be informed by the + // order they appear within XML. That's why we put conditionalFileInstalls after. + logMessage(DEBUG, "Sorting " + std::to_string(allFiles.size()) + " files by priority."); + std::ranges::sort(allFiles, [](const auto& a, const auto& b) { + return a.priority < b.priority; + }); + + for (const auto& toInstall : allFiles) { + logMessage(DEBUG, "File to install: " + toInstall.source); + } + logMessage(DEBUG, + "collectFilesToInstall completed with " + std::to_string(allFiles.size()) + " files."); + + return allFiles; +} diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.h b/libs/installer_fomod_plus/installer/lib/FileInstaller.h new file mode 100644 index 0000000..1c122a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.h @@ -0,0 +1,94 @@ +#pragma once + +#include <ifiletree.h> +#include <nlohmann/json.hpp> + +#include "ConditionTester.h" +#include "FlagMap.h" +#include "Logger.h" +#include "xml/ModuleConfiguration.h" + + +/* This is what legacy fomodInstaller does: + modName.update(dialog.getName(), GUESS_USER); + return dialog.updateTree(tree); + + This is a link to the legacy updateTree method: + https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/fc263f2d923c704b4853c11ed4f8b8cf3920f30d/src/fomodinstallerdialog.cpp#L546 + +*/ + +// Things this should do: +// - Copy all requiredInstall files +// - Copy all conditionalInstall files +// - Copy all selected files from steps that were visible to the user at the point of install + +using namespace MOBase; + +using FileGlobalIndex = int; +using FileDescriptor = std::pair<File, FileGlobalIndex>; + +class StepViewModel; + +class FileInstaller { +public: + FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr<IFileTree>& fileTree, + std::unique_ptr<ModuleConfiguration> fomodFile, + const std::shared_ptr<FlagMap>& flagMap, + const std::vector<std::shared_ptr<StepViewModel> >& steps); + + std::shared_ptr<IFileTree> install() const; + + /** + * @brief Create a 'fomod.json' file to add to the base of the installTree. Functionally similar to MO2's meta.ini. + * + * Until there's more utility in the JSON structure itself, it will simply be of this format: + * @code + * { + * "steps": [ + * { + * "name": "Step 1", + * "groups": [ + * { + * "name": "Group 1", + * "plugins: [ + * "Plugin1.esp", + * "Plugin2.esp" + * ] + * } + * ] + * } + * ] + * } + * @endcode + * + * @return nhlohmann::json + */ + nlohmann::json generateFomodJson() const; + + std::string getQualifiedFilePath(const std::string& treePath) const; + + std::vector<std::string> collectPositiveFileNamesFromDependencyPatterns(const std::vector<DependencyPattern> &patterns); + + void addFiles(std::vector<File>& main, std::vector<File> toAdd) const; + +private: + IOrganizer* mOrganizer; + Logger& log = Logger::getInstance(); + QString mFomodPath; + std::shared_ptr<IFileTree> mFileTree; + std::unique_ptr<ModuleConfiguration> mFomodFile; + std::shared_ptr<FlagMap> mFlagMap; + ConditionTester mConditionTester; + std::vector<std::shared_ptr<StepViewModel> > mSteps; // TODO: Maybe this is nasty. Idk. + + std::vector<File> collectFilesToInstall() const; + + void logMessage(LogLevel level, const std::string& message) const + { + log.logMessage(level, "[INSTALLER] " + message); + } +}; diff --git a/libs/installer_fomod_plus/installer/lib/FlagMap.h b/libs/installer_fomod_plus/installer/lib/FlagMap.h new file mode 100644 index 0000000..4959cec --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FlagMap.h @@ -0,0 +1,113 @@ +#pragma once + +#include "ViewModels.h" +#include "stringutil.h" + +#include <ranges> +#include <string> +#include <unordered_map> + +using Flag = std::pair<std::string, std::string>; +using FlagList = std::vector<Flag>; + +class FlagMap { +public: + + [[nodiscard]] std::vector<std::shared_ptr<PluginViewModel>> getPluginsSettingFlag(const std::string& key, const std::string& value) const + { + std::vector<std::shared_ptr<PluginViewModel>> result; + for (const auto& [plugin, theseFlags] : flags) { + for (const auto& [fst, snd] : theseFlags) { + if (toLower(fst) == toLower(key) && snd == value) { + result.emplace_back(plugin); + } + } + } + return result; + } + + /** + * + * @param key The flag key + * @return A list of flags currently set in this map with the given key. The list is ordered by step descending, then plugin ascending. + * So if steps 1, 2, and 3 set flag X in their first two plugins, it'll be ordered [3:1, 3:2, 2:1, 2:2, 1:2, 1:1] + */ + [[nodiscard]] FlagList getFlagsByKey(const std::string& key) const + { + FlagList result; + std::vector<std::pair<int, std::shared_ptr<PluginViewModel>>> orderedPlugins; + + // Collect all plugins with their stepIndex and ownIndex + for (const auto& plugin : flags | std::views::keys) { + orderedPlugins.emplace_back(plugin->getStepIndex(), plugin); + } + + // Sort plugins by stepIndex and ownIndex, stepIndex descending and ownIndex ascending + // TODO: Might need group sorting too. How can I just do the natural order?? + std::ranges::sort(orderedPlugins, [](const auto& a, const auto& b) { + return a.first > b.first || (a.first == b.first && a.second->getOwnIndex() < b.second->getOwnIndex()); + }); + + // Collect flags in the sorted order + for (const auto& plugin : orderedPlugins | std::views::values) { + for (const auto& flag : flags.at(plugin)) { + if (toLower(flag.first) == toLower(key)) { + result.emplace_back(flag); + } + } + } + return result; + } + + void setFlagsForPlugin(PluginRef plugin) + { + // Don't clutter the map with empty key-vals + if (plugin->getConditionFlags().empty()) { + return; + } + unsetFlagsForPlugin(plugin); + + FlagList flagList; + for (const auto& conditionFlag : plugin->getConditionFlags()) { + flagList.emplace_back(toLower(conditionFlag.name), conditionFlag.value); + } + flags[plugin] = flagList; + } + + void unsetFlagsForPlugin(PluginRef plugin) + { + if (flags.contains(plugin)) { + flags.erase(plugin); + } + } + + std::string toString() + { + auto result = std::string(); + result += "FlagMap:\n"; + + for (const auto& [plugin, theseFlags] : flags) { + result += plugin->getName() + " ["; + for (const auto& [fst, snd] : theseFlags) { + result += fst + ": " + snd + ", "; + } + result.erase(result.size() - 2); + result += "]\n"; + } + return result; + } + + void clearAll() + { + flags.clear(); + } + + [[nodiscard]] size_t getFlagCount() const + { + return flags.size(); + } + + +private: + std::unordered_map<std::shared_ptr<PluginViewModel>, FlagList> flags; +}; diff --git a/libs/installer_fomod_plus/installer/lib/Logger.h b/libs/installer_fomod_plus/installer/lib/Logger.h new file mode 100644 index 0000000..fdeb1f0 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/Logger.h @@ -0,0 +1,118 @@ +#pragma once +#include <fstream> +#include <iostream> +#include <mutex> +#include <regex> + + +// Log levels +enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERR = 3 +}; + +// Convert LogLevel to string +inline const char* logLevelToString(const LogLevel level) { + switch (level) { + case DEBUG: return "DEBUG"; + case INFO: return "INFO"; + case WARN: return "WARN"; + case ERR: return "ERROR"; + default: return "UNKNOWN"; + } +} + + +class Logger { +public: + static Logger& getInstance() + { + static Logger instance; + return instance; + } + + void setLogFilePath(const std::string& filePath) + { + std::lock_guard lock(mMutex); + if (mLogFile.is_open()) { + mLogFile.close(); + } + mLogFile.open(filePath, std::ios::out); + // std::ios::app is an option for appending but dont wanna grow it forever. + } + + void setDebugMode(bool debug) + { + std::lock_guard lock(mMutex); + mDebugMode = debug; + } + + void logMessage(const LogLevel level, const std::string& message) + { + +#if defined(__GNUC__) || defined(__clang__) + std::string functionName = __PRETTY_FUNCTION__; +#elif defined(_MSC_VER) + std::string functionName = __FUNCSIG__; +#else + std::string functionName = "UnknownFunction"; +#endif + + std::regex classNameRegex(R"((\w+)::\w+\()"); + std::smatch match; + std::string className = "UnknownClass"; + + if (std::regex_search(functionName, match, classNameRegex) && match.size() > 1) { + className = match.str(1); + } + + std::lock_guard lock(mMutex); + + auto writeLog = [&](std::ostream& stream) { + switch (level) { + case DEBUG: + stream << "[DEBUG] " << message << std::endl; + break; + case INFO: + stream << "[INFO] " << message << std::endl; + break; + case WARN: + stream << "[WARN] " << message << std::endl; + break; + case ERR: + stream << "[ERROR] " << message << std::endl; + break; + } + }; + + if (mLogFile.is_open()) { + writeLog(mLogFile); + } + + writeLog(std::cout); + } + + Logger& operator=(const Logger&) = delete; + +private: + Logger() = default; + + ~Logger() + { + if (mLogFile.is_open()) { + mLogFile.close(); + } + } + + Logger(const Logger&) = delete; + + std::ofstream mLogFile; + std::mutex mMutex; +#if !defined(NDEBUG) || defined(CMAKE_BUILD_TYPE_RELWITHDEBINFO) + bool mDebugMode = true; // Auto-enable in debug/RelWithDebInfo builds +#else + bool mDebugMode = false; // Disable in release builds +#endif +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/lib/ViewModels.h b/libs/installer_fomod_plus/installer/lib/ViewModels.h new file mode 100644 index 0000000..062445a --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ViewModels.h @@ -0,0 +1,120 @@ +#pragma once + +#include <memory> +#include <vector> + +#include "xml/ModuleConfiguration.h" + + +template <typename T> +using shared_ptr_list = std::vector<std::shared_ptr<T> >; + + +/* +-------------------------------------------------------------------------------- + Plugins +-------------------------------------------------------------------------------- +*/ +class PluginViewModel { +public: + PluginViewModel(const std::shared_ptr<Plugin>& plugin_, const bool selected, bool, const int index) + : ownIndex(index), selected(selected), enabled(true), manuallySet(false), plugin(plugin_) {} + + void setSelected(const bool selected) { this->selected = selected; } + void setEnabled(const bool enabled) { this->enabled = enabled; } + [[nodiscard]] std::string getName() const { return plugin ? plugin->name : std::string(); } + [[nodiscard]] std::string getDescription() const { return plugin ? plugin->description : std::string(); } + [[nodiscard]] std::string getImagePath() const { return plugin ? plugin->image.path : std::string(); } + [[nodiscard]] bool isSelected() const { return selected; } + [[nodiscard]] bool isEnabled() const { return enabled; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] std::vector<ConditionFlag> getConditionFlags() const { return plugin->conditionFlags.flags; } + [[nodiscard]] PluginTypeEnum getCurrentPluginType() const { return currentPluginType; } + [[nodiscard]] bool wasManuallySet() const { return manuallySet; } + + void setCurrentPluginType(const PluginTypeEnum type) { currentPluginType = type; } + void setStepIndex(const int stepIndex) { this->stepIndex = stepIndex; } + void setGroupIndex(const int groupIndex) { this->groupIndex = groupIndex; } + + [[nodiscard]] int getStepIndex() const { return stepIndex; } + [[nodiscard]] int getGroupIndex() const { return groupIndex; } + + friend class FomodViewModel; + friend class FileInstaller; + friend class ConditionTester; + +protected: + [[nodiscard]] std::shared_ptr<Plugin> getPlugin() const { return plugin; } + +private: + int ownIndex; + bool selected; + bool enabled; + bool manuallySet; + PluginTypeEnum currentPluginType = PluginTypeEnum::UNKNOWN; + std::shared_ptr<Plugin> plugin; + + int stepIndex{ -1 }; + int groupIndex{ -1 }; +}; + +/* +-------------------------------------------------------------------------------- + Groups +-------------------------------------------------------------------------------- +*/ +class GroupViewModel { +public: + GroupViewModel(const std::shared_ptr<Group>& group_, const shared_ptr_list<PluginViewModel>& plugins, + const int index, const int stepIndex) + : plugins(plugins), group(group_), ownIndex(index), stepIndex(stepIndex) {} + + void addPlugin(const std::shared_ptr<PluginViewModel>& plugin) { plugins.emplace_back(plugin); } + + [[nodiscard]] std::string getName() const { return group->name; } + [[nodiscard]] GroupTypeEnum getType() const { return group->type; } + [[nodiscard]] const shared_ptr_list<PluginViewModel>& getPlugins() const { return plugins; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] int getStepIndex() const { return stepIndex; } + +private: + shared_ptr_list<PluginViewModel> plugins; + std::shared_ptr<Group> group; + int ownIndex; + int stepIndex; +}; + +/* +-------------------------------------------------------------------------------- + Steps +-------------------------------------------------------------------------------- +*/ +class StepViewModel { +public: + StepViewModel(const std::shared_ptr<InstallStep>& installStep_, const shared_ptr_list<GroupViewModel>& groups, + const int index) + : installStep(installStep_), groups(groups), ownIndex(index) {} + + [[nodiscard]] CompositeDependency& getVisibilityConditions() const { return installStep->visible; } + [[nodiscard]] std::string getName() const { return installStep->name; } + [[nodiscard]] const shared_ptr_list<GroupViewModel>& getGroups() const { return groups; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] bool getHasVisited() const { return visited; } + void setVisited(const bool visited) { this->visited = visited; } + +private: + bool visited{ false }; + std::shared_ptr<InstallStep> installStep; + shared_ptr_list<GroupViewModel> groups; + int ownIndex; +}; + + +/* +-------------------------------------------------------------------------------- + Outbound Types +-------------------------------------------------------------------------------- +*/ +using StepRef = const std::shared_ptr<StepViewModel>&; +using GroupRef = const std::shared_ptr<GroupViewModel>&; +using PluginRef = const std::shared_ptr<PluginViewModel>&; diff --git a/libs/installer_fomod_plus/installer/resources.qrc b/libs/installer_fomod_plus/installer/resources.qrc new file mode 100644 index 0000000..6c583a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources.qrc @@ -0,0 +1,14 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="previous">resources/left-chevron.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="next">resources/right-chevron.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="close">resources/delete.png</file> + </qresource> +</RCC>
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/resources/delete.png b/libs/installer_fomod_plus/installer/resources/delete.png Binary files differnew file mode 100644 index 0000000..f6502b1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/delete.png diff --git a/libs/installer_fomod_plus/installer/resources/fomod_icon.png b/libs/installer_fomod_plus/installer/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/installer/resources/image_icon.png b/libs/installer_fomod_plus/installer/resources/image_icon.png Binary files differnew file mode 100644 index 0000000..b300751 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/image_icon.png diff --git a/libs/installer_fomod_plus/installer/resources/left-chevron.png b/libs/installer_fomod_plus/installer/resources/left-chevron.png Binary files differnew file mode 100644 index 0000000..28222a1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/left-chevron.png diff --git a/libs/installer_fomod_plus/installer/resources/right-chevron.png b/libs/installer_fomod_plus/installer/resources/right-chevron.png Binary files differnew file mode 100644 index 0000000..1a0af49 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/right-chevron.png diff --git a/libs/installer_fomod_plus/installer/ui/ClickableWidget.h b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h new file mode 100644 index 0000000..881600f --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h @@ -0,0 +1,24 @@ +#pragma once + +#include <QMouseEvent> +#include <QWidget> + +class ClickableWidget final : public QWidget { + Q_OBJECT + +public: + explicit ClickableWidget(QWidget* parent = nullptr) : QWidget(parent) { + setCursor(Qt::PointingHandCursor); + } + + signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QWidget::mousePressEvent(event); + } +}; diff --git a/libs/installer_fomod_plus/installer/ui/Colors.h b/libs/installer_fomod_plus/installer/ui/Colors.h new file mode 100644 index 0000000..c3262eb --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/Colors.h @@ -0,0 +1,155 @@ +#pragma once + +#include <QString> +#include <map> + +namespace UiColors { + +enum class ColorApplication { + BACKGROUND, + BORDER, + TEXT, + ALL +}; + +// Color values +namespace Colors { + // Light + const QString Light0 = "251, 241, 199"; + const QString Light1 = "235, 219, 178"; + const QString Light2 = "213, 196, 161"; + const QString Light3 = "189, 174, 147"; + + // Dark + const QString Dark0 = "40, 40, 40"; + const QString Dark1 = "60, 56, 54"; + const QString Dark2 = "80, 73, 69"; + const QString Dark3 = "102, 92, 84"; + + // Red + const QString Red = "204, 36, 29"; + const QString RedBright = "251, 73, 52"; + + // Green + const QString Green = "152, 151, 26"; + const QString GreenBright = "184, 187, 38"; + + // Yellow + const QString Yellow = "215, 153, 33"; + const QString YellowBright = "250, 189, 47"; + + // Blue + const QString Blue = "69, 133, 136"; + const QString BlueBright = "131, 165, 152"; + + // Purple + const QString Purple = "177, 98, 134"; + const QString PurpleBright = "211, 134, 155"; + + // Aqua + const QString Aqua = "104, 157, 106"; + const QString AquaBright = "142, 192, 124"; + + // Orange + const QString Orange = "214, 93, 14"; + const QString OrangeBright = "254, 128, 25"; +} + +// Helper function to generate style strings based on color and application +inline QString generateStyle(const QString& color, const ColorApplication application, const float opacity = 0.4, + const int borderWidth = 1) +{ + QString style; + + switch (application) { + case ColorApplication::BACKGROUND: + style = QString("QCheckBox { background-color: rgba(%1, %2); } " + "QRadioButton { background-color: rgba(%1, %2); }") + .arg(color).arg(opacity); + break; + + case ColorApplication::BORDER: + style = QString("QCheckBox { border: %1px dashed rgb(%2); } " + "QRadioButton { border: %1px dashed rgb(%2); }") + .arg(borderWidth).arg(color); + break; + + case ColorApplication::TEXT: + style = QString("QCheckBox { color: rgb(%1); } " + "QRadioButton { color: rgb(%1); }") + .arg(color); + break; + + case ColorApplication::ALL: + style = QString("QCheckBox { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); } " + "QRadioButton { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); }") + .arg(color).arg(opacity).arg(borderWidth); + break; + } + + return style; +} + +// Main function to get style for a color name and application +inline QString getStyle(const QString& colorName, const ColorApplication application = ColorApplication::BACKGROUND, + const float opacity = 0.4, const int borderWidth = 1) +{ + static const std::map<QString, QString> colorValues = { + { "Light0", Colors::Light0 }, + { "Light1", Colors::Light1 }, + { "Light2", Colors::Light2 }, + { "Light3", Colors::Light3 }, + { "Dark0", Colors::Dark0 }, + { "Dark1", Colors::Dark1 }, + { "Dark2", Colors::Dark2 }, + { "Dark3", Colors::Dark3 }, + { "Red", Colors::Red }, + { "Red Bright", Colors::RedBright }, + { "Green", Colors::Green }, + { "Green Bright", Colors::GreenBright }, + { "Yellow", Colors::Yellow }, + { "Yellow Bright", Colors::YellowBright }, + { "Blue", Colors::Blue }, + { "Blue Bright", Colors::BlueBright }, + { "Purple", Colors::Purple }, + { "Purple Bright", Colors::PurpleBright }, + { "Aqua", Colors::Aqua }, + { "Aqua Bright", Colors::AquaBright }, + { "Orange", Colors::Orange }, + { "Orange Bright", Colors::OrangeBright } + }; + + if (const auto it = colorValues.find(colorName); it != colorValues.end()) { + return generateStyle(it->second, application, opacity, borderWidth); + } + + return {}; +} + +// For backward compatibility +const static std::map<QString, QString> colorStyles = { + { "Light0", getStyle("Light0", ColorApplication::BACKGROUND) }, + { "Light1", getStyle("Light1", ColorApplication::BACKGROUND) }, + { "Light2", getStyle("Light2", ColorApplication::BACKGROUND) }, + { "Light3", getStyle("Light3", ColorApplication::BACKGROUND) }, + { "Dark0", getStyle("Dark0", ColorApplication::BACKGROUND) }, + { "Dark1", getStyle("Dark1", ColorApplication::BACKGROUND) }, + { "Dark2", getStyle("Dark2", ColorApplication::BACKGROUND) }, + { "Dark3", getStyle("Dark3", ColorApplication::BACKGROUND) }, + { "Red", getStyle("Red", ColorApplication::BACKGROUND) }, + { "Red Bright", getStyle("Red Bright", ColorApplication::BACKGROUND) }, + { "Green", getStyle("Green", ColorApplication::BACKGROUND) }, + { "Green Bright", getStyle("Green Bright", ColorApplication::BACKGROUND) }, + { "Yellow", getStyle("Yellow", ColorApplication::BACKGROUND) }, + { "Yellow Bright", getStyle("Yellow Bright", ColorApplication::BACKGROUND) }, + { "Blue", getStyle("Blue", ColorApplication::BACKGROUND) }, + { "Blue Bright", getStyle("Blue Bright", ColorApplication::BACKGROUND) }, + { "Purple", getStyle("Purple", ColorApplication::BACKGROUND) }, + { "Purple Bright", getStyle("Purple Bright", ColorApplication::BACKGROUND) }, + { "Aqua", getStyle("Aqua", ColorApplication::BACKGROUND) }, + { "Aqua Bright", getStyle("Aqua Bright", ColorApplication::BACKGROUND) }, + { "Orange", getStyle("Orange", ColorApplication::BACKGROUND) }, + { "Orange Bright", getStyle("Orange Bright", ColorApplication::BACKGROUND) } +}; + +} diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp new file mode 100644 index 0000000..cb99d27 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp @@ -0,0 +1,284 @@ +#include "FomodImageViewer.h" + +#include "ScaleLabel.h" +#include "UIHelper.h" + +#include <QLabel> +#include <QScrollArea> +#include <QtConcurrent/QtConcurrent> + +constexpr int PREVIEW_IMAGE_WIDTH = 160; +constexpr int PREVIEW_IMAGE_HEIGHT = 90; + +/* ++----------------------------------------------------------+ +|n/N X | ++---+--------------------------------------------------+---+ +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| < | Image | > | +| | | | +| | | | +| | | | +| | | | +| | label | | ++------+------+------+---------------------------------+---+ +| | | | ...previews | +| | | | | ++------+------+------+-------------------------------------+ +*/ +constexpr auto BUTTON_STYLE = + "font-size: 16px; font-weight: bold; color: white; background-color: black; padding: 5px; border-radius: 1px solid black;"; + +FomodImageViewer::FomodImageViewer(QWidget* parent, + const QString& fomodPath, + const std::shared_ptr<StepViewModel>& activeStep, + const std::shared_ptr<PluginViewModel>& activePlugin) : QDialog(parent), mFomodPath(fomodPath), + mActiveStep(activeStep), + mActivePlugin(activePlugin) +{ + + setWindowFlags(Qt::FramelessWindowHint | Qt::Window); + setAttribute(Qt::WA_TranslucentBackground); + setStyleSheet("background-color: rgba(0, 0, 0, 150);"); + + const QScreen* screen = this->screen(); + const QRect availableGeometry = screen->availableGeometry(); + setFixedSize(availableGeometry.width(), availableGeometry.height()); + move(availableGeometry.x(), availableGeometry.y()); + + collectImages(); + mMainImageWrapper = createSinglePhotoPane(this); + mTopBar = createTopBar(this); + mPreviewImages = createPreviewImages(this); + mCenterRow = createCenterRow(this); + + const auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); // Remove margins + layout->setSpacing(0); // Remove spacing between widgets + layout->addWidget(mTopBar); + layout->addWidget(mCenterRow, 1); + layout->addWidget(mPreviewImages); + setLayout(layout); + + select(mCurrentIndex); + + setFocusPolicy(Qt::StrongFocus); // so we can receive key events +} + +void FomodImageViewer::collectImages() +{ + mLabelsAndImages.clear(); + for (const auto& groupViewModel : mActiveStep->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + if (pluginViewModel->getImagePath().empty()) { + continue; + } + QString imagePath = UIHelper::getFullImagePath(mFomodPath, + QString::fromStdString(pluginViewModel->getImagePath())); + mLabelsAndImages.emplace_back(QString::fromStdString(pluginViewModel->getName()), imagePath); + + if (pluginViewModel == mActivePlugin) { + mCurrentIndex = static_cast<int>(mLabelsAndImages.size()) - 1; + } + } + } +} + +QWidget* FomodImageViewer::createCenterRow(QWidget* parent) +{ + const auto centerRow = new QWidget(parent); + const auto layout = new QHBoxLayout(centerRow); + + mBackButton = createBackButton(centerRow); + mForwardButton = createForwardButton(centerRow); + + mBackButton->setFocusPolicy(Qt::NoFocus); + mForwardButton->setFocusPolicy(Qt::NoFocus); + + layout->addWidget(mBackButton); + layout->addWidget(mMainImageWrapper, 1); + layout->addWidget(mForwardButton); + + return centerRow; +} + +// ReSharper disable once CppMemberFunctionMayBeStatic +QWidget* FomodImageViewer::createSinglePhotoPane(QWidget* parent) +{ + const auto singlePhotoPane = new QWidget(parent); + const auto layout = new QVBoxLayout(singlePhotoPane); + + // const auto [labelText, imagePath] = pair; + + mMainImage = new ScaleLabel(singlePhotoPane); + mMainImage->setAlignment(Qt::AlignCenter); + layout->addWidget(mMainImage, 1); + + mLabel = new QLabel(singlePhotoPane); + // mLabel->setText(labelText); + mLabel->setAlignment(Qt::AlignCenter); + mLabel->setStyleSheet("color: white; font-size: 20px;"); + layout->addWidget(mLabel); + + return singlePhotoPane; +} + +QScrollArea* FomodImageViewer::createPreviewImages(QWidget* parent) +{ + mImagePanes.clear(); + const auto previewImages = new QScrollArea(parent); + const auto widget = new QWidget(previewImages); + const auto layout = new QHBoxLayout(previewImages); + + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + for (int i = 0; i < mLabelsAndImages.size(); i++) { + const auto imageLabel = new ScaleLabel(previewImages); + imageLabel->setAlignment(Qt::AlignCenter); + imageLabel->setFixedSize(PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT); + imageLabel->setFocusPolicy(Qt::NoFocus); + connect(imageLabel, &ScaleLabel::clicked, this, [this, i] { + select(i); + }); + layout->addWidget(imageLabel); + mImagePanes.emplace_back(imageLabel); + + // imageLabel->setScalableResource(mLabelsAndImages[i].second); + const auto imagePath = mLabelsAndImages[i].second; + + QThreadPool::globalInstance()->start([imageLabel, imagePath]() { + QMetaObject::invokeMethod(imageLabel, [imageLabel, imagePath]() { + imageLabel->setScalableResource(imagePath); + }, Qt::QueuedConnection); + }); + } + + widget->setLayout(layout); + previewImages->setFixedHeight(PREVIEW_IMAGE_HEIGHT + 10); + previewImages->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + previewImages->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + previewImages->setFocusPolicy(Qt::NoFocus); + + previewImages->setWidget(widget); + previewImages->setStyleSheet("QScrollArea { border: none; }"); + + return previewImages; +} + +QPushButton* FomodImageViewer::createBackButton(QWidget* parent) const +{ + const auto backButton = new QPushButton(parent); + backButton->setText("<"); + backButton->setStyleSheet(BUTTON_STYLE); + connect(backButton, &QPushButton::clicked, this, &FomodImageViewer::goBack); + return backButton; +} + +QPushButton* FomodImageViewer::createForwardButton(QWidget* parent) const +{ + const auto forwardButton = new QPushButton(parent); + forwardButton->setText(">"); + forwardButton-> + setStyleSheet(BUTTON_STYLE); + connect(forwardButton, &QPushButton::clicked, this, &FomodImageViewer::goForward); + return forwardButton; +} + +QWidget* FomodImageViewer::createTopBar(QWidget* parent) +{ + const auto topBar = new QWidget(parent); + const auto layout = new QHBoxLayout(topBar); + + // counter, spacer, close button + mCounter = new QLabel(topBar); + mCounter->setStyleSheet(BUTTON_STYLE); + layout->addWidget(mCounter); + + layout->addStretch(); + + mCloseButton = createCloseButton(topBar); + layout->addWidget(mCloseButton); + return topBar; +} + +QPushButton* FomodImageViewer::createCloseButton(QWidget* parent) +{ + const auto closeButton = new QPushButton(parent); + // const QIcon icon(":/fomod/close"); + // closeButton->setIcon(icon); + closeButton->setText("X"); + closeButton->setStyleSheet("color: white; background-color: black; padding: 5px; border-radius: 1px solid black;"); + connect(closeButton, &QPushButton::clicked, this, &FomodImageViewer::close); + return closeButton; +} + +void FomodImageViewer::updateCounterText() const +{ + mCounter->setText(QString::number(mCurrentIndex + 1) + "/" + QString::number(mLabelsAndImages.size())); +} + +void FomodImageViewer::goBack() +{ + if (mCurrentIndex == 0) { + return; + } + select(--mCurrentIndex); +} + +void FomodImageViewer::goForward() +{ + if (mCurrentIndex == mLabelsAndImages.size() - 1) { + return; + } + select(++mCurrentIndex); +} + +void FomodImageViewer::select(const int index) +{ + if (index < 0 || index >= mLabelsAndImages.size()) { + return; + } + + // Remove border from previously selected image + mCurrentIndex = index; // check for bounds? + updateCounterText(); + + for (int i = 0; i < mImagePanes.size(); i++) { + if (i == mCurrentIndex) { + mImagePanes[i]->setStyleSheet("border: 2px solid white;"); + } else { + mImagePanes[i]->setStyleSheet(""); + } + } + + const auto& imagePath = mLabelsAndImages[index].second; + const auto& labelText = mLabelsAndImages[index].first; + mLabel->setText(labelText); + mMainImage->setScalableResource(imagePath); +} + +void FomodImageViewer::keyPressEvent(QKeyEvent* event) +{ + switch (event->key()) { + case Qt::Key_Left: + goBack(); + break; + case Qt::Key_Right: + goForward(); + break; + default: + QDialog::keyPressEvent(event); + } +} + +void FomodImageViewer::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + setFocus(); +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h new file mode 100644 index 0000000..73a06f7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h @@ -0,0 +1,92 @@ +#pragma once + +#include "FomodViewModel.h" + +#include <QDialog> +#include <QKeyEvent> +#include <QLabel> +#include <QScrollArea> + +class ScaleLabel; +using LabelImagePair = std::pair<QString, QString>; + + +/* ++----------------------------------------------------------+ +|n/N X | ++---+--------------------------------------------------+---+ +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| < | Image | > | +| | | | +| | | | +| | | | +| | | | +| | label | | ++------+------+------+---------------------------------+---+ +| | | | ...previews | +| | | | | ++------+------+------+-------------------------------------+ +*/ + +class FomodImageViewer final : public QDialog { + Q_OBJECT + +public: + explicit FomodImageViewer(QWidget* parent, + const QString& fomodPath, + const std::shared_ptr<StepViewModel>& activeStep, + const std::shared_ptr<PluginViewModel>& activePlugin); + +private: + void collectImages(); + + QWidget* createCenterRow(QWidget* parent); + + QWidget* createSinglePhotoPane(QWidget* parent); + + QScrollArea* createPreviewImages(QWidget* parent); + + QPushButton* createBackButton(QWidget* parent) const; + + QPushButton* createForwardButton(QWidget* parent) const; + + QWidget* createTopBar(QWidget* parent); + + QPushButton* createCloseButton(QWidget* parent); + + void updateCounterText() const; + + void goBack(); + + void goForward(); + + void select(int index); + + void keyPressEvent(QKeyEvent* event) override; + + void showEvent(QShowEvent* event) override; + + std::vector<LabelImagePair> mLabelsAndImages; + std::vector<QWidget*> mImagePanes{}; + int mCurrentIndex{ 0 }; + + QString mFomodPath; + const std::shared_ptr<StepViewModel>& mActiveStep; + const std::shared_ptr<PluginViewModel>& mActivePlugin; + + QWidget* mCenterRow{ nullptr }; + QWidget* mTopBar{ nullptr }; + QWidget* mCloseButton{ nullptr }; + QPushButton* mBackButton{ nullptr }; + QPushButton* mForwardButton{ nullptr }; + QLabel* mCounter{ nullptr }; + QWidget* mMainImageWrapper{ nullptr }; + ScaleLabel* mMainImage{ nullptr }; + QLabel* mLabel{ nullptr }; + QScrollArea* mPreviewImages{ nullptr }; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp new file mode 100644 index 0000000..b11674c --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp @@ -0,0 +1,746 @@ +#include "FomodViewModel.h" +#include "xml/ModuleConfiguration.h" +#include "lib/Logger.h" + +using GroupCallback = std::function<void(GroupRef)>; +using PluginCallback = std::function<void(GroupRef, PluginRef)>; + + +/* +-------------------------------------------------------------------------------- + Helpers +-------------------------------------------------------------------------------- +*/ +#pragma region Helpers + +bool isRadioLike(GroupRef group) +{ + return group->getType() == SelectExactlyOne + || (group->getType() == SelectAtMostOne && group->getPlugins().size() > 1); +} + +bool moreThanOneSelected(GroupRef group) +{ + auto selectedPlugins = group->getPlugins() | std::views::filter([](const auto& plugin) { + return plugin->isSelected(); + }); + return std::ranges::distance(selectedPlugins) > 1; +} + +bool anySelected(GroupRef group) +{ + return std::ranges::any_of(group->getPlugins(), [](const auto& plugin) { return plugin->isSelected(); }); +} + +std::string pluginTypeEnumToString(const PluginTypeEnum type) +{ + switch (type) { + case PluginTypeEnum::Recommended: + return "Recommended"; + case PluginTypeEnum::Required: + return "Required"; + case PluginTypeEnum::Optional: + return "Optional"; + case PluginTypeEnum::NotUsable: + return "NotUsable"; + case PluginTypeEnum::CouldBeUsable: + return "CouldBeUsable"; + default: + return "Unknown"; + } +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Lifecycle +-------------------------------------------------------------------------------- +*/ +#pragma region ViewModel Lifecycle + +/** + * @brief FomodViewModel constructor + * + * @note DO NOT USE DIRECTLY. We should only use FomodViewModel::create() to create a new instance. + * @see FomodViewModel::create + * + * @param organizer The organizer instance passed from the IInstaller + * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file + * @param infoFile The FomodInfoFile instance created from the raw info.xml file + * + * @return A FomodViewModel instance + */ +FomodViewModel::FomodViewModel(MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile) + : mOrganizer(organizer), mFomodFile(std::move(fomodFile)), mInfoFile(std::move(infoFile)), + mConditionTester(organizer), + mInfoViewModel(std::make_shared<InfoViewModel>(mInfoFile)) +{ + mFlags = std::make_shared<FlagMap>(); +} + +/** + * + * @param organizer The organizer instance passed from the IInstaller + * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file + * @param infoFile The FomodInfoFile instance created from the raw info.xml file + * @return A shared pointer to the FomodViewModel instance + */ +std::shared_ptr<FomodViewModel> FomodViewModel::create(MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile) +{ + auto viewModel = std::make_shared<FomodViewModel>(organizer, std::move(fomodFile), std::move(infoFile)); + if (viewModel->mFlags == nullptr) { + viewModel->mFlags = std::make_shared<FlagMap>(); + } + viewModel->createStepViewModels(); + + // Handle FOMODs with no steps + if (viewModel->mSteps.empty()) { + viewModel->mInitialized = true; + viewModel->logMessage(INFO, "FOMOD with no steps - initialization complete"); + return viewModel; + } + + viewModel->processPluginConditions(-1); // please dont judge me. ill fix this someday. + viewModel->enforceGroupConstraints(); + viewModel->updateVisibleSteps(); + viewModel->mInitialized = true; + viewModel->mCurrentStepIndex = viewModel->mVisibleStepIndices.front(); + viewModel->mActiveStep = viewModel->mSteps.at(viewModel->mVisibleStepIndices.front()); + viewModel->mActivePlugin = viewModel->getFirstPluginForActiveStep(); + viewModel->getActiveStep()->setVisited(true); + viewModel->logMessage(DEBUG, "VIEWMODEL INITIALIZED"); + viewModel->logMessage(DEBUG, viewModel->toString()); + return viewModel; +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Traversal Functions +-------------------------------------------------------------------------------- +*/ +#pragma region Traversal Functions + +void FomodViewModel::forEachGroup(const GroupCallback& callback) const +{ + for (const auto& stepViewModel : mSteps) { + for (const auto& groupViewModel : stepViewModel->getGroups()) { + callback(groupViewModel); + } + } +} + +void FomodViewModel::forEachPlugin(const PluginCallback& callback) const +{ + for (const auto& stepViewModel : mSteps) { + for (const auto& groupViewModel : stepViewModel->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + callback(groupViewModel, pluginViewModel); + } + } + } +} + +void FomodViewModel::forEachFuturePlugin(const int fromStepIndex, const PluginCallback& callback) const +{ + for (int i = fromStepIndex + 1; i < mSteps.size(); ++i) { + for (const auto& groupViewModel : mSteps[i]->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + callback(groupViewModel, pluginViewModel); + } + } + } +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Initialization +-------------------------------------------------------------------------------- +*/ +#pragma region Initializers +void FomodViewModel::createStepViewModels() +{ + shared_ptr_list<StepViewModel> stepViewModels; + + // Handle legacy FOMODs with no install steps + if (mFomodFile->installSteps.installSteps.empty()) { + logMessage(INFO, "No install steps found - creating default step for legacy FOMOD"); + return; + } + + for (int stepIndex = 0; stepIndex < mFomodFile->installSteps.installSteps.size(); ++stepIndex) { + const auto& installStep = mFomodFile->installSteps.installSteps[stepIndex]; + shared_ptr_list<GroupViewModel> groupViewModels; + + for (int groupIndex = 0; groupIndex < installStep.optionalFileGroups.groups.size(); ++groupIndex) { + const auto& group = installStep.optionalFileGroups.groups[groupIndex]; + shared_ptr_list<PluginViewModel> pluginViewModels; + + for (int pluginIndex = 0; pluginIndex < group.plugins.plugins.size(); ++pluginIndex) { + const auto& plugin = group.plugins.plugins[pluginIndex]; + auto pluginViewModel = std::make_shared<PluginViewModel>(std::make_shared<Plugin>(plugin), false, true, + pluginIndex); + + pluginViewModel->setStepIndex(stepIndex); + pluginViewModel->setGroupIndex(groupIndex); + pluginViewModels.emplace_back(pluginViewModel); // Assuming default values for selected and enabled + } + auto groupViewModel = std::make_shared<GroupViewModel>(std::make_shared<Group>(group), pluginViewModels, + groupIndex, stepIndex); + if (groupViewModel->getType() == SelectAtMostOne && groupViewModel->getPlugins().size() > 1) { + createNonePluginForGroup(groupViewModel); + } + groupViewModels.emplace_back(groupViewModel); + } + auto stepViewModel = std::make_shared<StepViewModel>(std::make_shared<InstallStep>(installStep), + std::move(groupViewModels), stepIndex); + stepViewModels.emplace_back(stepViewModel); + + } + mSteps = std::move(stepViewModels); +} + +void FomodViewModel::createNonePluginForGroup(GroupRef group) +{ + const auto nonePlugin = std::make_shared<Plugin>(); + nonePlugin->name = "None"; + nonePlugin->typeDescriptor.type = PluginTypeEnum::Optional; + const int newIndex = static_cast<int>(group->getPlugins().size()); + const auto nonePluginViewModel = std::make_shared<PluginViewModel>(nonePlugin, true, true, newIndex); + group->addPlugin(nonePluginViewModel); +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Group Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Group Constraints + +void FomodViewModel::enforceRadioGroupConstraints(GroupRef group) const +{ + if (!isRadioLike(group)) { + return; + } + + logMessage(INFO, "Enforcing group constraints for group " + group->getName()); + + if (group->getType() == SelectExactlyOne && group->getPlugins().size() == 1) { + logMessage(INFO, + "Disabling " + group->getPlugins().at(0)->getName() + " because it's the only plugin."); + group->getPlugins().at(0)->setEnabled(false); + } + + if (moreThanOneSelected(group)) { + logMessage(ERR, "More than one plugin is selected in a SelectExactlyOne group. Deselecting all."); + for (const auto& plugin : group->getPlugins()) { + plugin->setSelected(false); // don't call toggle here, that'll do the radio stuff. + } + } + + if (anySelected(group)) { + logMessage(INFO, "At least one plugin is selected. Nothing to enforce."); + return; + } + + // First, try to select the first Recommended plugin + for (const auto& plugin : group->getPlugins()) { + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) == PluginTypeEnum::Recommended) { + logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first recommended plugin."); + togglePlugin(group, plugin, true); + return; + } + } + + // If no Recommended plugin is found, select the first one that isn't NotUsable + for (const auto& plugin : group->getPlugins()) { + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) { + logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first usable plugin."); + togglePlugin(group, plugin, true); + return; + } + } +} + +void FomodViewModel::enforceSelectAllConstraint(GroupRef groupViewModel) const +{ + if (groupViewModel->getType() != SelectAll) { + return; + } + + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + togglePlugin(groupViewModel, pluginViewModel, true); + pluginViewModel->setEnabled(false); + } + +} + +void FomodViewModel::enforceSelectAtLeastOneConstraint(GroupRef group) const +{ + if (group->getType() != SelectAtLeastOne || group->getPlugins().size() != 1) { + return; + } + + const auto plugin = group->getPlugins().front(); + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) { + logMessage(DEBUG, "Selecting " + plugin->getName() + " because it's the only plugin in a SelectAtLeastOne."); + togglePlugin(group, plugin, true); + plugin->setEnabled(false); + } +} + +void FomodViewModel::enforceGroupConstraints() const +{ + forEachGroup([this](const auto& groupViewModel) { + enforceRadioGroupConstraints(groupViewModel); + enforceSelectAllConstraint(groupViewModel); + enforceSelectAtLeastOneConstraint(groupViewModel); + }); +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Plugin Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Plugin Constraints + +void FomodViewModel::processPlugin(GroupRef group, PluginRef plugin) const +{ + if (group->getType() == SelectAll) { + return; + } + const auto typeDescriptor = mConditionTester.getPluginTypeDescriptorState(plugin->plugin, mFlags); + + if (typeDescriptor == plugin->getCurrentPluginType()) { + return; + } + + logMessage(DEBUG, + "Plugin " + plugin->getName() + " in group " + std::to_string(group->getOwnIndex()) + " has changed type from " + + pluginTypeEnumToString(plugin->getCurrentPluginType()) + " to " + pluginTypeEnumToString(typeDescriptor)); + + plugin->setCurrentPluginType(typeDescriptor); + + const bool isOnlyPlugin = group->getPlugins().size() == 1 + && (group->getType() == SelectExactlyOne || group->getType() == SelectAtLeastOne); + + // check if step hasVisited, if it hasn't been, set it to unchecked if it's optional. + const auto stepNotVisitedYet = !mSteps[group->getStepIndex()]->getHasVisited(); + + switch (typeDescriptor) { + case PluginTypeEnum::Recommended: + plugin->setEnabled(true); + if (!plugin->isSelected()) { + togglePlugin(group, plugin, true); + } + break; + case PluginTypeEnum::Required: + plugin->setEnabled(false); + if (!plugin->isSelected()) { + togglePlugin(group, plugin, true); + } + break; + case PluginTypeEnum::Optional: + if (!isOnlyPlugin) { + plugin->setEnabled(true); + } + // In the case where we're changing flags to make something optional from Recommended, set it back to unchecked. + if (plugin->isSelected() & stepNotVisitedYet && group->getType() == SelectAny) { + togglePlugin(group, plugin, false); + } + break; + case PluginTypeEnum::NotUsable: + plugin->setEnabled(false); + if (plugin->isSelected()) { + togglePlugin(group, plugin, false); + } + break; + case PluginTypeEnum::CouldBeUsable: + plugin->setEnabled(true); + break; + default: ; + } +} + +void FomodViewModel::processPluginConditions(const int fromStepIndex) const +{ + // We only want to update plugins that haven't been seen yet. Otherwise, we could undo manual selections by the user. + if (fromStepIndex >= 0) { + logMessage(DEBUG, "Processing plugins from step " + std::to_string(fromStepIndex)); + forEachFuturePlugin(fromStepIndex, [this](const auto& groupViewModel, const auto& pluginViewModel) { + processPlugin(groupViewModel, pluginViewModel); + }); + } else { + forEachPlugin([this](const auto& groupViewModel, const auto& pluginViewModel) { + processPlugin(groupViewModel, pluginViewModel); + }); + } +} + +void FomodViewModel::setFlagForPluginState(PluginRef plugin) const +{ + if (plugin->isSelected()) { + mFlags->setFlagsForPlugin(plugin); + } else { + mFlags->unsetFlagsForPlugin(plugin); + } +} + +/* + * In an exclusive group, this gets called for the deselected plugin and then the selected plugin. + * So if we're unselecting modB to select modA, we will get calls like + * togglePlugin(group, modB, false) + * togglePlugin(group, modA, true) + */ +bool FomodViewModel::togglePlugin(GroupRef group, PluginRef plugin, const bool selected) const +{ + if (plugin->isSelected() == selected) { + logMessage(DEBUG, "Plugin " + plugin->getName() + " is already " + (selected ? "selected" : "deselected")); + return false; + } + + // Disable other radio options first. + if (selected && isRadioLike(group)) { + for (const auto& otherPlugin : group->getPlugins()) { + if (otherPlugin != plugin && plugin->isSelected()) { + logMessage(DEBUG, + "Deselecting " + otherPlugin->getName() + " because " + plugin->getName() + " was selected."); + otherPlugin->setSelected(false); + setFlagForPluginState(otherPlugin); + } + } + } + + const auto stepIndex = group->getStepIndex(); + + logMessage(INFO, "Toggling " + plugin->getName() + " to " + (selected ? "true" : "false")); + plugin->setSelected(selected); + setFlagForPluginState(plugin); + + if (mInitialized) { + mActivePlugin = plugin; + } + processPluginConditions(stepIndex); + updateVisibleSteps(); + return true; +} + +void FomodViewModel::markManuallySet(PluginRef plugin) +{ + plugin->manuallySet = true; +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Step Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Step Constraints + +void FomodViewModel::updateVisibleSteps() const +{ + mVisibleStepIndices.clear(); + mFlags->clearAll(); + + for (int i = 0; i < mSteps.size(); ++i) { + if (i == 0) { + rebuildConditionFlagsForStep(i); + } + + // This also depends on previous flags that may have set this particular flag. + if (mConditionTester.isStepVisible(mFlags, mSteps[i]->getVisibilityConditions(), i, mSteps)) { + mVisibleStepIndices.push_back(i); + rebuildConditionFlagsForStep(i); + } + } + if (mFlags->getFlagCount() > 0) { + logMessage(DEBUG, mFlags->toString()); + } +} + +void FomodViewModel::rebuildConditionFlagsForStep(const int stepIndex) const +{ + for (const auto& group : mSteps[stepIndex]->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + setFlagForPluginState(plugin); + } + } +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Navigation/UI +-------------------------------------------------------------------------------- +*/ +#pragma region Navigation/UI + +void FomodViewModel::stepBack() +{ + if (mSteps.empty()) { + return; // No steps to move back to + } + + logMessage(DEBUG, "Stepping back from step " + std::to_string(mCurrentStepIndex)); + const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex); + if (it != mVisibleStepIndices.end() && it != mVisibleStepIndices.begin()) { + mCurrentStepIndex = *std::prev(it); + mActiveStep = mSteps[mCurrentStepIndex]; + mActivePlugin = getFirstPluginForActiveStep(); + } + logMessage(DEBUG, "Stepped back to step " + std::to_string(mCurrentStepIndex)); +} + +void FomodViewModel::stepForward() +{ + if (mSteps.empty()) { + return; // No steps to move forward to + } + + logMessage(DEBUG, "Stepping forward from step " + std::to_string(mCurrentStepIndex)); + const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex); + if (it != mVisibleStepIndices.end() && std::next(it) != mVisibleStepIndices.end()) { + mCurrentStepIndex = *std::next(it); + mActiveStep = mSteps[mCurrentStepIndex]; + mActivePlugin = getFirstPluginForActiveStep(); + } + mActiveStep->setVisited(true); + logMessage(DEBUG, "Stepped forward to step " + std::to_string(mCurrentStepIndex)); +} + +bool FomodViewModel::isLastVisibleStep() const +{ + if (mSteps.empty()) { + return true; // Legacy FOMODs are always "last step" + } + return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.back(); +} + +bool FomodViewModel::isFirstVisibleStep() const +{ + if (mSteps.empty()) { + return true; // Legacy FOMODs are always "first step" + } + return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.front(); +} + +void FomodViewModel::preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath) +{ + mFileInstaller = std::make_shared< + FileInstaller>(mOrganizer, fomodPath, tree, std::move(mFomodFile), mFlags, mSteps); +} + + +std::string FomodViewModel::getDisplayImage() const +{ + // if the active plugin has an image, return it + if (mActivePlugin && !mActivePlugin->getImagePath().empty()) { + return mActivePlugin->getImagePath(); + } + return mCurrentStepIndex == 0 ? mFomodFile->moduleImage.path : ""; +} + +std::shared_ptr<PluginViewModel> FomodViewModel::getFirstPluginForActiveStep() const +{ + if (!mActiveStep) { + return nullptr; + } + + const auto& groups = mActiveStep->getGroups(); + if (groups.empty()) { + return nullptr; + } + + const auto& plugins = groups.front()->getPlugins(); + if (plugins.empty()) { + return nullptr; + } + + return plugins.front(); +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Utility +-------------------------------------------------------------------------------- +*/ +#pragma region Utility +std::string FomodViewModel::toString() const +{ + std::string viewModel = "\n"; + for (const auto& step : mSteps) { + + const auto isVisible = std::ranges::find(mVisibleStepIndices, step->getOwnIndex()) != mVisibleStepIndices.end(); + viewModel += "Step " + std::to_string(step->getOwnIndex()) + ": " + step->getName() + "[Visible: " + + std::to_string(isVisible) + "]\n"; + + for (const auto& group : step->getGroups()) { + + viewModel += "\tGroup " + std::to_string(group->getOwnIndex()) + ": " + group->getName() + "\n"; + + for (const auto& plugin : group->getPlugins()) { + viewModel += "\t\tPlugin: " + plugin->getName() + "[Selected: " + (plugin->isSelected() + ? "TRUE" + : "FALSE") + "]\n"; + } + } + } + viewModel += "\n"; + std::ostringstream oss; + std::ranges::transform(mVisibleStepIndices, + std::ostream_iterator<std::string>(oss, ", "), + [](const int i) { return std::to_string(i); }); + + std::string stepList = oss.str(); + stepList.erase(stepList.length() - 2); + + viewModel += "Visible Steps: [" + stepList + "]\n"; + viewModel += mFlags->toString(); + return viewModel; +} + + +void FomodViewModel::resetToDefaults() +{ + logMessage(INFO, "Resetting all choices to author defaults"); + + // Clear all flags first + mFlags->clearAll(); + + // Reset all plugins to deselected and clear visited states + for (const auto& step : mSteps) { + step->setVisited(false); + for (const auto& group : step->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + plugin->setSelected(false); + plugin->setEnabled(true); + plugin->manuallySet = false; + plugin->setCurrentPluginType(PluginTypeEnum::UNKNOWN); + } + } + } + + // Re-run the initial constraint enforcement to restore author defaults + processPluginConditions(-1); + enforceGroupConstraints(); + updateVisibleSteps(); + + // Reset to first step + mCurrentStepIndex = mVisibleStepIndices.empty() ? 0 : mVisibleStepIndices.front(); + mActiveStep = mSteps.empty() ? nullptr : mSteps.at(mCurrentStepIndex); + mActivePlugin = getFirstPluginForActiveStep(); + if (mActiveStep) { + mActiveStep->setVisited(true); + } + + logMessage(DEBUG, "Reset complete. Current state:\n" + toString()); +} + +void FomodViewModel::selectFromJson(nlohmann::json json) const +{ + const auto jsonSteps = json["steps"]; + const auto stepCount = jsonSteps.size(); + + for (int stepIndex = 0; stepIndex < stepCount; ++stepIndex) { + + if (stepIndex > mSteps.size() - 1) { + logMessage(ERR, "Step index " + std::to_string(stepIndex) + " is out of bounds."); + continue; + } + + const auto currentStep = mSteps[stepIndex]; + const auto step = jsonSteps[stepIndex]; + const auto groupCount = step["groups"].size(); + + logMessage(DEBUG, "Selecting plugins for step " + std::to_string(stepIndex)); + logMessage(DEBUG, "There are " + std::to_string(groupCount) + " groups."); + + for (int groupIndex = 0; groupIndex < groupCount; ++groupIndex) { + if (groupIndex > currentStep->getGroups().size() - 1) { + logMessage(ERR, "Group index " + std::to_string(groupIndex) + " is out of bounds."); + continue; + } + + const auto group = step["groups"][groupIndex]; + const auto currentGroup = currentStep->getGroups()[groupIndex]; + + for (const auto jsonPlugin : group["plugins"]) { + + const auto& allPlugins = currentGroup->getPlugins(); + const auto searchName = jsonPlugin.get<std::string>(); + + logMessage(DEBUG, "Looking for plugin " + searchName); + + const auto currentPlugin = std::ranges::find_if(allPlugins, + [searchName](PluginRef p) { + return p->getName() == searchName; + }); + + if (currentPlugin == allPlugins.end()) { + logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName()); + continue; + } + + if ((*currentPlugin)->isSelected()) { + logMessage(DEBUG, "Plugin " + searchName + " is already selected."); + continue; + } + logMessage(DEBUG, "Toggle plugin " + searchName + " to selected."); + if (!(*currentPlugin)->isEnabled()) { + logMessage(DEBUG, "Plugin " + searchName + " is not enabled."); + continue; + } + togglePlugin(currentGroup, *currentPlugin, true); + } + + if (!group.contains("deselected")) { + continue; + } + + // Do the opposite of the above. For unchecked plugins, disable them. + for (const auto jsonPlugin : group["deselected"]) { + + const auto& allPlugins = currentGroup->getPlugins(); + const auto searchName = jsonPlugin.get<std::string>(); + + logMessage(DEBUG, "Looking for plugin to disable: " + searchName); + + const auto currentPlugin = std::ranges::find_if(allPlugins, + [searchName](PluginRef p) { + return p->getName() == searchName; + }); + + if (currentPlugin == allPlugins.end()) { + logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName()); + continue; + } + + if (!(*currentPlugin)->isSelected()) { + logMessage(DEBUG, "Plugin " + searchName + " is already deselected."); + continue; + } + logMessage(DEBUG, "Toggle plugin " + searchName + " to deselected."); + if (!(*currentPlugin)->isEnabled()) { + logMessage(DEBUG, "Plugin " + searchName + " is not enabled."); + continue; + } + togglePlugin(currentGroup, *currentPlugin, false); + (*currentPlugin)->manuallySet = true; // To preserve this state when serializing JSON. + } + } + } +} +#pragma endregion diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.h b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h new file mode 100644 index 0000000..f0ce070 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h @@ -0,0 +1,154 @@ +#pragma once + +#include <imoinfo.h> +#include <string> + +#include "lib/ConditionTester.h" +#include "lib/FlagMap.h" +#include "lib/FileInstaller.h" +#include "lib/ViewModels.h" +#include "xml/FomodInfoFile.h" + +/* +-------------------------------------------------------------------------------- + Info +-------------------------------------------------------------------------------- +*/ +class InfoViewModel { +public: + explicit InfoViewModel(const std::unique_ptr<FomodInfoFile>& infoFile) + { + if (infoFile) { + // Copy the necessary members from FomodInfoFile to InfoViewModel + mName = infoFile->getName(); + mVersion = infoFile->getVersion(); + mAuthor = infoFile->getAuthor(); + mWebsite = infoFile->getWebsite(); + } + } + + // Accessor methods + [[nodiscard]] std::string getName() const { return mName; } + [[nodiscard]] std::string getVersion() const { return mVersion; } + [[nodiscard]] std::string getAuthor() const { return mAuthor; } + [[nodiscard]] std::string getWebsite() const { return mWebsite; } + +private: + std::string mName; + std::string mVersion; + std::string mAuthor; + std::string mWebsite; +}; + +/* +-------------------------------------------------------------------------------- + View Model +-------------------------------------------------------------------------------- +*/ +class FomodViewModel { +public: + FomodViewModel( + MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile); + + static std::shared_ptr<FomodViewModel> create( + MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile); + + void forEachGroup(const std::function<void(GroupRef)>& callback) const; + + void forEachPlugin(const std::function<void(GroupRef, PluginRef)>& callback) const; + + void forEachFuturePlugin(int fromStepIndex, const std::function<void(GroupRef, PluginRef)>& callback) const; + + void selectFromJson(nlohmann::json json) const; + + void resetToDefaults(); + + [[nodiscard]] std::shared_ptr<PluginViewModel> getFirstPluginForActiveStep() const; + + // Steps + [[nodiscard]] shared_ptr_list<StepViewModel> getSteps() const { return mSteps; } + [[nodiscard]] StepRef getActiveStep() const { return mActiveStep; } + [[nodiscard]] int getCurrentStepIndex() const { return mCurrentStepIndex; } + [[deprecated]] void setCurrentStepIndex(const int index) { mCurrentStepIndex = index; } + + void updateVisibleSteps() const; + + void rebuildConditionFlagsForStep(int stepIndex) const; + + void preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath); + + std::shared_ptr<FileInstaller> getFileInstaller() { return mFileInstaller; } + + std::string getDisplayImage() const; + + // Plugins + [[nodiscard]] PluginRef getActivePlugin() const { return mActivePlugin; } + + // Info + [[nodiscard]] std::shared_ptr<InfoViewModel> getInfoViewModel() const { return mInfoViewModel; } + + // Interactions + void stepBack(); + + void stepForward(); + + bool isLastVisibleStep() const; + + bool isFirstVisibleStep() const; + + bool togglePlugin(const GroupRef, const PluginRef, bool selected) const; + + bool ctrlTogglePlugin(const GroupRef, const PluginRef, bool selected) const; + + void setActivePlugin(const PluginRef plugin) const { mActivePlugin = plugin; } + + static void markManuallySet(PluginRef plugin); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* mOrganizer = nullptr; + std::unique_ptr<ModuleConfiguration> mFomodFile; + std::unique_ptr<FomodInfoFile> mInfoFile; + std::shared_ptr<FlagMap> mFlags{ nullptr }; + ConditionTester mConditionTester; + std::shared_ptr<InfoViewModel> mInfoViewModel; + std::vector<std::shared_ptr<StepViewModel> > mSteps; + mutable std::shared_ptr<PluginViewModel> mActivePlugin{ nullptr }; + mutable std::shared_ptr<StepViewModel> mActiveStep{ nullptr }; + mutable std::vector<int> mVisibleStepIndices; + std::shared_ptr<FileInstaller> mFileInstaller{ nullptr }; + bool mInitialized{ false }; + + void createStepViewModels(); + + void setFlagForPluginState(const std::shared_ptr<PluginViewModel>& plugin) const; + + static void createNonePluginForGroup(const std::shared_ptr<GroupViewModel>& group); + + void processPlugin(const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const; + + void enforceRadioGroupConstraints(const std::shared_ptr<GroupViewModel>& group) const; + + void enforceSelectAllConstraint(const std::shared_ptr<GroupViewModel>& groupViewModel) const; + + void enforceSelectAtLeastOneConstraint(const std::shared_ptr<GroupViewModel>& group) const; + + void enforceGroupConstraints() const; + + void processPluginConditions(int fromStepIndex) const; + + // Indices + int mCurrentStepIndex{ 0 }; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[VIEWMODEL] " + message); + } + + std::string toString() const; +}; diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp new file mode 100644 index 0000000..3a33640 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp @@ -0,0 +1,120 @@ +#include "ScaleLabel.h" +#include <QResizeEvent> +#include <iostream> + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h +static bool isResourceMovie(const QString& path) +{ + const auto formats = QMovie::supportedFormats(); + return std::ranges::any_of(formats, [&path](const QByteArray& format) { + return path.endsWith("." + QString::fromUtf8(format)); + }); +} + +void ScaleLabel::setScalableResource(const QString& path) +{ + if (const auto m = movie()) { + setMovie(nullptr); + delete m; + mOriginalMovieSize = QSize(); + } + if (!pixmap().isNull()) { + setPixmap(QPixmap()); + mUnscaledImage = QImage(); + } + + if (path.isEmpty()) { + return; + } + + if (isResourceMovie(path)) { + setScalableMovie(path); + } else { + setScalableImage(path); + } +} + +void ScaleLabel::setStatic(const bool isStatic) +{ + misStatic = isStatic; + + if (const auto m = movie()) { + if (isStatic) { + m->stop(); + } else { + m->start(); + } + } +} + +void ScaleLabel::setScalableMovie(const QString& path) +{ + const auto m = new QMovie(path); + if (!m->isValid()) { + qWarning(">%s< is an invalid movie. Reason: %s", qUtf8Printable(path), + m->lastErrorString().toStdString().c_str()); + delete m; + return; + } + + m->setParent(this); + setMovie(m); + m->start(); + m->stop(); + mOriginalMovieSize = m->currentImage().size(); + + m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio)); + if (!misStatic) { + m->start(); + } + mHasResource = true; +} + +void ScaleLabel::setScalableImage(const QString& path) +{ + if (const QImage image(path); image.isNull()) { + qWarning(">%s< is a null image", qUtf8Printable(path)); + } else { + mUnscaledImage = image; + setPixmap(QPixmap::fromImage(image).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + mHasResource = true; + } +} + +void ScaleLabel::resizeEvent(QResizeEvent* event) +{ + if (const auto m = movie()) { + m->stop(); + m->setScaledSize(mOriginalMovieSize.scaled(event->size(), Qt::KeepAspectRatio)); + m->start(); + + // We can't just skip the start() above since that is what triggers the label to + // resize the movie The only way to resize the movie but keep it paused is to start + // and then re-stop it + if (misStatic) { + m->stop(); + } + } + if (const auto p = pixmap(); !p.isNull()) { + setPixmap( + QPixmap::fromImage(mUnscaledImage).scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +} + +void ScaleLabel::showEvent(QShowEvent* event) +{ + QLabel::showEvent(event); + + if (const auto m = movie()) { + m->stop(); + m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio)); + m->start(); + + if (misStatic) { + m->stop(); + } + } + if (const auto p = pixmap(); !p.isNull()) { + setPixmap(QPixmap::fromImage(mUnscaledImage).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.h b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h new file mode 100644 index 0000000..f4fb5f2 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h @@ -0,0 +1,45 @@ +#pragma once + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h +#include <QLabel> +#include <QMouseEvent> +#include <QMovie> + + +class ScaleLabel final : public QLabel { + Q_OBJECT + +public: + explicit ScaleLabel(QWidget* parent = nullptr) : QLabel(parent) + { + setCursor(Qt::PointingHandCursor); + } + + void setScalableResource(const QString& path); + void setStatic(bool isStatic); + [[nodiscard]] bool hasResource() const { return mHasResource; } + +signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QLabel::mousePressEvent(event); + } + + void resizeEvent(QResizeEvent* event) override; + void showEvent(QShowEvent* event) override; + +private: + void setScalableMovie(const QString& path); + void setScalableImage(const QString& path); + + QImage mUnscaledImage; + QSize mOriginalMovieSize; + bool mHasResource = false; + bool misStatic = false; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp new file mode 100644 index 0000000..2312fd1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp @@ -0,0 +1,95 @@ +#include "UIHelper.h" + +#include <qcoreevent.h> +#include <QDir> +#include <QMouseEvent> + +HoverEventFilter::HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent) + : QObject(parent), mPlugin(plugin) {} + +bool HoverEventFilter::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::HoverEnter) { + emit hovered(mPlugin); + return true; + } + return QObject::eventFilter(obj, event); +} + +CtrlClickEventFilter::CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QObject* parent) + : QObject(parent), mPlugin(plugin), mGroup(group) {} + +bool CtrlClickEventFilter::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::MouseButtonPress) { + const QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event); + if (mouseEvent->button() == Qt::LeftButton && + mouseEvent->modifiers() & Qt::ControlModifier) { + // TODO: Add Ctrl+click handler logic here + std::cout << "Ctrl+click detected on plugin: " << mPlugin->getName() << " in group: " << mGroup->getName() << std::endl; + // For now, just fall through to default behavior + } + } + return QObject::eventFilter(obj, event); +} + +QPushButton* UIHelper::createButton(const QString& text, QWidget* parent = nullptr) +{ + const auto button = new QPushButton(text, parent); + return button; +} + +QLabel* UIHelper::createLabel(const QString& text, QWidget* parent = nullptr) +{ + const auto label = new QLabel(text, parent); + return label; +} + +QLabel* UIHelper::createHyperlink(const QString& url, QWidget* parent = nullptr) +{ + if (url.isEmpty() || !QUrl(url).isValid()) { + return createLabel(url, parent); + } + const auto label = new QLabel(url, parent); + const QString hyperlink = QString("<a href=\"%1\">%2</a>").arg(url, "Link"); + label->setText(hyperlink); + label->setOpenExternalLinks(true); + label->setTextFormat(Qt::RichText); + return label; +} + +QString UIHelper::getFullImagePath(const QString& fomodPath, const QString& imagePath) +{ + return QDir::tempPath() + "/" + fomodPath + "/" + imagePath; +} + +void UIHelper::setGlobalAlignment(QBoxLayout* layout, const Qt::Alignment alignment) +{ + for (int i = 0; i < layout->count(); ++i) { + if (const QLayoutItem* item = layout->itemAt(i); item->widget()) { + layout->setAlignment(item->widget(), alignment); + } + } +} + +void UIHelper::setDebugBorders(QWidget* widget) +{ + widget->setStyleSheet("border: 1px solid red;"); + for (auto* child : widget->findChildren<QWidget*>()) { + child->setStyleSheet("border: 1px solid red;"); + } +} + +void UIHelper::reduceLabelPadding(const QLayout* layout) +{ + for (int i = 0; i < layout->count(); ++i) { + const QLayoutItem* item = layout->itemAt(i); + if (QWidget* widget = item->widget()) { + if (const auto label = qobject_cast<QLabel*>(widget)) { + label->setContentsMargins(0, 0, 0, 0); + label->setStyleSheet("padding: 0px; margin: 0px;"); + } + } + } +} diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.h b/libs/installer_fomod_plus/installer/ui/UIHelper.h new file mode 100644 index 0000000..1007e7c --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/UIHelper.h @@ -0,0 +1,80 @@ +#pragma once + +#include <QPushButton> +#include <QVBoxLayout> +#include <QLabel> + +#include "FomodViewModel.h" + +class HoverEventFilter final : public QObject { + Q_OBJECT + +public: + explicit HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent = nullptr); + +signals: + void hovered(const std::shared_ptr<PluginViewModel>& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr<PluginViewModel> mPlugin; +}; + +class CtrlClickEventFilter final : public QObject { + Q_OBJECT + +public: + explicit CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QObject* parent = nullptr); + +signals: + void ctrlClicked(bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr<PluginViewModel> mPlugin; + std::shared_ptr<GroupViewModel> mGroup; +}; + + +namespace UiConstants { +constexpr int WINDOW_MIN_WIDTH = 900; +constexpr int WINDOW_MIN_HEIGHT = 600; +} + +class UIHelper { +public: + /* + -------------------------------------------------------------------------------- + Widgets & Events + -------------------------------------------------------------------------------- + */ + static QPushButton* createButton(const QString& text, QWidget* parent); + + static QLabel* createLabel(const QString& text, QWidget* parent); + + static QLabel* createHyperlink(const QString& url, QWidget* parent); + + /* + -------------------------------------------------------------------------------- + Helpers + -------------------------------------------------------------------------------- + */ + static QString getFullImagePath(const QString& fomodPath, const QString& imagePath); + + static void setGlobalAlignment(QBoxLayout* layout, Qt::Alignment alignment); + + static void reduceLabelPadding(const QLayout* layout); + + /* + -------------------------------------------------------------------------------- + Development + -------------------------------------------------------------------------------- + */ + static void setDebugBorders(QWidget* widget); +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/CMakeLists.txt b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt new file mode 100644 index 0000000..be46b32 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.16) +project(fomod_plus_patch_wizard) + +include(FetchContent) +set(project_type plugin) + +file(GLOB_RECURSE PATCHWIZARD_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) +file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp") + +add_library(fomod_plus_patch_wizard SHARED ${PATCHWIZARD_SOURCES} ${SHARE_SOURCES}) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14) +FetchContent_MakeAvailable(pugixml json) + +target_include_directories( + fomod_plus_patch_wizard + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData + ${CMAKE_CURRENT_SOURCE_DIR}/../share/xml + ${MO2_ARCHIVE_INCLUDE_DIRS} +) + +if (MSVC) + target_compile_options( + fomod_plus_patch_wizard + PRIVATE + /bigobj + /W4 + /WX + /wd4201 + /wd4458 + ) +endif () + +target_link_libraries(fomod_plus_patch_wizard PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json) +mo2_configure_plugin(fomod_plus_patch_wizard NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive) +mo2_install_target(fomod_plus_patch_wizard) diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp new file mode 100644 index 0000000..00be8d5 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp @@ -0,0 +1,181 @@ +#include "FomodPlusPatchWizard.h" + +#include <QApplication> +#include <QDialog> +#include <QHBoxLayout> +#include <QLabel> +#include <QMessageBox> +#include <QProgressDialog> +#include <QPushButton> +#include <QVBoxLayout> + +#include "lib/PatchFinder.h" +#include <FomodRescan.h> + +bool FomodPlusPatchWizard::init(IOrganizer* organizer) +{ + mOrganizer = organizer; + mDialog = new QDialog(); + mDialog->setWindowTitle(tr("Patch Wizard")); + mDialog->setMinimumSize(400, 200); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus-patchwizard.log"); + + mOrganizer->onUserInterfaceInitialized([this](QMainWindow*) { + logMessage(DEBUG, "patches populated."); + mPatchFinder = std::make_unique<PatchFinder>(mOrganizer); + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches: " + std::to_string(mAvailablePatches.size())); + }); + + return true; +} + +void FomodPlusPatchWizard::display() const +{ + // Clear any existing layout + if (mDialog->layout() != nullptr) { + QLayoutItem* item; + while ((item = mDialog->layout()->takeAt(0)) != nullptr) { + delete item->widget(); + delete item; + } + delete mDialog->layout(); + } + + if (mAvailablePatches.empty()) { + setupEmptyState(); + } else { + setupPatchList(); + } + + mDialog->exec(); +} + +void FomodPlusPatchWizard::setupEmptyState() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + mainLayout->setAlignment(Qt::AlignCenter); + + auto* contentWidget = new QWidget(mDialog); + auto* contentLayout = new QHBoxLayout(contentWidget); + contentLayout->setAlignment(Qt::AlignCenter); + contentLayout->setSpacing(16); + + auto* imageLabel = new QLabel(contentWidget); + imageLabel->setPixmap(QPixmap(":/fomod/infoscroll").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + auto* textLabel = new QLabel( + tr("Nothing of interest yet. The wizard gets wiser as you\ninstall FOMODs, so check back later!"), + contentWidget + ); + textLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); + + contentLayout->addWidget(imageLabel); + contentLayout->addWidget(textLabel); + + mainLayout->addWidget(contentWidget); + + auto* rescanButton = new QPushButton(tr("Rescan Load Order"), mDialog); + // Use const_cast since setupEmptyState is const but onRescanClicked modifies state + connect(rescanButton, &QPushButton::clicked, const_cast<FomodPlusPatchWizard*>(this), + &FomodPlusPatchWizard::onRescanClicked); + mainLayout->addWidget(rescanButton, 0, Qt::AlignCenter); +} + +void FomodPlusPatchWizard::onRescanClicked() +{ + const auto confirmResult = QMessageBox::question( + mDialog, + tr("Rescan Load Order"), + tr("Rescanning will populate as many existing choices and options as we can, " + "but if some downloads are deleted it may be missing things! " + "It may take a few minutes depending on the size of your load order and all that."), + QMessageBox::Ok | QMessageBox::Cancel, + QMessageBox::Cancel + ); + + if (confirmResult != QMessageBox::Ok) { + return; + } + + logMessage(DEBUG, "Rescan requested by user"); + + // Create progress dialog + QProgressDialog progress(tr("Scanning mods..."), tr("Cancel"), 0, 100, mDialog); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + progress.setValue(0); + + bool cancelled = false; + + // Perform the rescan + FomodRescan rescan(mOrganizer, mPatchFinder->mFomodDb.get()); + auto result = rescan.scanAllModsWithChoices([&](int current, int total, const QString& modName) { + if (progress.wasCanceled()) { + cancelled = true; + return; + } + const int percent = total > 0 ? (current * 100 / total) : 0; + progress.setValue(percent); + progress.setLabelText(tr("Scanning: %1 (%2/%3)").arg(modName).arg(current).arg(total)); + QApplication::processEvents(); + }); + + progress.setValue(100); + + if (cancelled) { + QMessageBox::information( + mDialog, + tr("Rescan Cancelled"), + tr("The rescan was cancelled. Partial results may have been saved.") + ); + logMessage(INFO, "Rescan cancelled by user"); + } else { + // Show result summary + QString summary = tr("Rescan complete!\n\n" + "Mods processed: %1\n" + "Successfully scanned: %2\n" + "Missing archives: %3\n" + "Parse errors: %4") + .arg(result.totalModsProcessed) + .arg(result.successfullyScanned) + .arg(result.missingArchives) + .arg(result.parseErrors); + + if (!result.failedMods.empty() && result.failedMods.size() <= 10) { + summary += tr("\n\nFailed mods:"); + for (const auto& mod : result.failedMods) { + summary += QString("\n- %1").arg(QString::fromStdString(mod)); + } + } else if (result.failedMods.size() > 10) { + summary += tr("\n\n%1 mods failed (see log for details)").arg(result.failedMods.size()); + for (const auto& mod : result.failedMods) { + logMessage(INFO, "Failed mod: " + mod); + } + } + + QMessageBox::information(mDialog, tr("Rescan Complete"), summary); + + logMessage(INFO, "Rescan complete: " + std::to_string(result.successfullyScanned) + + "/" + std::to_string(result.totalModsProcessed) + " successful"); + } + + // Refresh available patches + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches after rescan: " + std::to_string(mAvailablePatches.size())); + + // Refresh the UI + display(); +} + +void FomodPlusPatchWizard::setupPatchList() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + + auto* label = new QLabel(tr("Available patches: %1").arg(mAvailablePatches.size()), mDialog); + mainLayout->addWidget(label); + + // TODO: Implement actual patch list UI +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h new file mode 100644 index 0000000..2324fb0 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h @@ -0,0 +1,54 @@ +#pragma once +#include "../installer/lib/Logger.h" +#include "lib/PatchFinder.h" + +#include <iplugintool.h> +#include <qtmetamacros.h> + +using namespace MOBase; + +class FomodPlusPatchWizard final : public IPluginTool { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusPatchWizard" FILE "fomodpluspatchwizard.json") +#endif + +public: + bool init(IOrganizer* organizer) override; + + [[nodiscard]] QString name() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString author() const override { return "clearing"; }; + + [[nodiscard]] QString description() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_BETA }; }; + + [[nodiscard]] QList<PluginSetting> settings() const override { return {}; }; + + [[nodiscard]] QString displayName() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString tooltip() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); } + + void display() const override; + +private: + Logger& log = Logger::getInstance(); + QDialog* mDialog{ nullptr }; + IOrganizer* mOrganizer{ nullptr }; + std::unique_ptr<PatchFinder> mPatchFinder{ nullptr }; + std::vector<AvailablePatch> mAvailablePatches; + + void setupEmptyState() const; + void setupPatchList() const; + void onRescanClicked(); + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts new file mode 100644 index 0000000..7da04f1 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodPlusPatchWizard</name> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="19"/> + <location filename="FomodPlusPatchWizard.h" line="20"/> + <location filename="FomodPlusPatchWizard.h" line="30"/> + <source>Patch Wizard</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="69"/> + <source>Nothing of interest yet. The wizard gets wiser as you +install FOMODs, so check back later!</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="79"/> + <location filename="FomodPlusPatchWizard.cpp" line="90"/> + <source>Rescan Load Order</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="91"/> + <source>Rescanning will populate as many existing choices and options as we can, but if some downloads are deleted it may be missing things! It may take a few minutes depending on the size of your load order and all that.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="105"/> + <source>Scanning mods...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="105"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="121"/> + <source>Scanning: %1 (%2/%3)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="130"/> + <source>Rescan Cancelled</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="131"/> + <source>The rescan was cancelled. Partial results may have been saved.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="136"/> + <source>Rescan complete! + +Mods processed: %1 +Successfully scanned: %2 +Missing archives: %3 +Parse errors: %4</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="147"/> + <source> + +Failed mods:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="152"/> + <source> + +%1 mods failed (see log for details)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="158"/> + <source>Rescan Complete</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="177"/> + <source>Available patches: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.h" line="24"/> + <location filename="FomodPlusPatchWizard.h" line="32"/> + <source>Find missing patches from FOMODs in your load order.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts new file mode 100644 index 0000000..aaa26e2 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodPlusPatchWizard</name> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="26"/> + <location filename="FomodPlusPatchWizard.cpp" line="51"/> + <location filename="FomodPlusPatchWizard.cpp" line="74"/> + <source>Patch Wizard</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="36"/> + <source>Finde Patches, die du aus den FOMODs in deiner Modliste installieren möchtest.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json new file mode 100644 index 0000000..2d6025f --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json @@ -0,0 +1,8 @@ +{ + "author": "clearing", + "date": "2025/02/06", + "name": "FOMOD Plus - Patch Wizard", + "version": "1.0.0", + "des": "Find missing patches in your modlist", + "dependencies": [] +} diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp new file mode 100644 index 0000000..cd06b3a --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp @@ -0,0 +1,86 @@ +#include "PatchFinder.h" + +std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForMod(const MOBase::IModInterface* mod) +{ + std::vector<AvailablePatch> available_patches = {}; + + // Verify that the mod has plugins in some shape or form. + if (const auto it = m_installedPlugins.find(mod); it == m_installedPlugins.end()) { + return available_patches; + } + + // Look through the database + for (const auto& fomodDbEntries = mFomodDb->getEntries(); const auto& entry : fomodDbEntries) { + for (const auto& option : entry->getOptions()) { + // Skip options that are already selected/installed + if (option.selectionState == SelectionState::Selected) { + continue; + } + + // Extract just the filename from the path (handle both / and \) + const auto fileName = option.fileName.substr(option.fileName.find_last_of("/\\") + 1); + + // Skip if already installed in the modlist + if (m_installedPluginsCacheSet.contains(fileName)) { + continue; + } + + // Technically without this check, we're doing a lookup for the entire modlist, which actually + // kinda works, but isn't the intention of this function. Might want to consider reworking + // it to map to mod names with one pass of the DB instead of a DB pass per mod. + if (!std::ranges::any_of(option.masters, [&mod](const std::string& master) { + return master == mod->name().toStdString(); + })) { + continue; + } + + // If we have all of this patch's masters in our modlist, and it's not installed, add it to the results. + if (std::ranges::all_of(option.masters, [this](const std::string& master) { + return m_installedPluginsCacheSet.contains(master); + })) { + AvailablePatch patch{ + option, + entry->getDisplayName(), + mod->name().toStdString(), + false, // not installed + false, // not hidden + option.selectionState == SelectionState::Deselected // userDeselected + }; + available_patches.push_back(patch); + } + } + } + + return available_patches; +} + +std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForModList() +{ + std::vector<AvailablePatch> available_patches = {}; + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + if (mod == nullptr) { + continue; + } + for (const auto& available_patch : getAvailablePatchesForMod(mod)) { + available_patches.emplace_back(available_patch); + } + } + + return available_patches; +} + +void PatchFinder::populateInstalledPlugins() +{ + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + const auto mod_tree = mod->fileTree(); + for (auto it = mod_tree->begin(); it != mod_tree->end(); ++it) { + if ((*it)->isFile() && isPluginFile((*it)->name())) { + std::cout << "Plugin: " << (*it)->name().toStdString() << std::endl; + m_installedPlugins[mod].emplace_back((*it)->name().toStdString()); + m_installedPluginsCacheSet.insert((*it)->name().toStdString()); + } + } + } +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h new file mode 100644 index 0000000..02327e3 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h @@ -0,0 +1,49 @@ +#pragma once + +#include "../../installer/lib/Logger.h" + +#include <FomodDB.h> +#include <imodinterface.h> +#include <imoinfo.h> +#include <ifiletree.h> + +struct AvailablePatch { + FomodOption fomod_option; + std::string installer_name; + std::string patch_for_mod; + bool installed = false; + bool hidden = false; + bool userDeselected = false; // True if user explicitly chose not to install +}; + +class PatchFinder { +friend class FomodPlusPatchWizard; + +public: + explicit PatchFinder(MOBase::IOrganizer* m_organizer) : m_organizer(m_organizer) + { + mFomodDb = std::make_unique<FomodDB>(m_organizer->basePath().toStdString()); + logMessage(DEBUG, "mFomodDb loaded."); + } + + std::vector<AvailablePatch> getAvailablePatchesForMod(const MOBase::IModInterface* mod); + std::vector<AvailablePatch> getAvailablePatchesForModList(); + +protected: + void populateInstalledPlugins(); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* m_organizer; + std::unique_ptr<FomodDB> mFomodDb; + + // Map of { pluginPtr: [1.esp, 2.esp, 3.esp] } + std::unordered_map<const MOBase::IModInterface*, std::vector<std::string> > m_installedPlugins; + std::unordered_set<std::string> m_installedPluginsCacheSet; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/resources.qrc b/libs/installer_fomod_plus/patchwizard/resources.qrc new file mode 100644 index 0000000..fe6971e --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources.qrc @@ -0,0 +1,6 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + <file alias="infoscroll">resources/infoscroll.png</file> + </qresource> +</RCC> diff --git a/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png Binary files differnew file mode 100644 index 0000000..0c31777 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png diff --git a/libs/installer_fomod_plus/scanner/CMakeLists.txt b/libs/installer_fomod_plus/scanner/CMakeLists.txt new file mode 100644 index 0000000..daea477 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.16) +project(fomod_plus_scanner) + +include(FetchContent) +set(project_type plugin) + +file(GLOB_RECURSE SCANNER_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) + +add_library(fomod_plus_scanner SHARED ${SCANNER_SOURCES}) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14) +FetchContent_MakeAvailable(pugixml json) + +target_include_directories( + fomod_plus_scanner + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData + ${MO2_UIBASE_INCLUDE_DIRS} + ${MO2_ARCHIVE_INCLUDE_DIRS} +) + +if (MSVC) + target_compile_options( + fomod_plus_scanner + PRIVATE + /bigobj + /W4 + /WX + /wd4201 + /wd4458 + ) +endif () + +target_link_libraries(fomod_plus_scanner PRIVATE mo2::uibase mo2::archive pugixml nlohmann_json::nlohmann_json) +mo2_configure_plugin(fomod_plus_scanner NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive) + +if (MSVC) + target_compile_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>) + target_link_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>) + install(FILES $<TARGET_PDB_FILE:fomod_plus_scanner> + DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO} + CONFIGURATIONS RelWithDebInfo + OPTIONAL) +endif () + +mo2_install_target(fomod_plus_scanner) diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp new file mode 100644 index 0000000..4770178 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp @@ -0,0 +1,128 @@ +#include "FomodPlusScanner.h" + +#include "archiveparser.h" +#include "FomodDBEntry.h" + +#include <QDialog> +#include <QPushButton> +#include <QProgressBar> +#include <QVBoxLayout> +#include <QMessageBox> +#include <archive.h> + +#include <QLabel> +#include <QMovie> +#include <iostream> + +#include "stringutil.h" + +using ScanCallbackFn = std::function<bool(IModInterface*, ScanResult result)>; + +bool FomodPlusScanner::init(IOrganizer* organizer) +{ + mOrganizer = organizer; + + mDialog = new QDialog(); + mDialog->setWindowTitle(tr("FOMOD Scanner")); + + const auto layout = new QVBoxLayout(mDialog); + + const QString description = tr("Greetings, traveler.\n" + "This tool will scan your load order for mods installed via FOMOD.\n" + "It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :) \n\n" + "Safe travels, and may your load order be free of conflicts."); + + const auto descriptionLabel = new QLabel(description, mDialog); + descriptionLabel->setWordWrap(true); // Enable word wrap for large text + descriptionLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + descriptionLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); // Align text to the top-left corner + + const auto gifLabel = new QLabel(mDialog); + const auto movie = new QMovie(":/fomod/wizard"); + gifLabel->setMovie(movie); + movie->start(); + + mProgressBar = new QProgressBar(mDialog); + mProgressBar->setRange(0, mOrganizer->modList()->allMods().size()); + mProgressBar->setVisible(false); + + const auto scanButton = new QPushButton(tr("Scan"), mDialog); + const auto cancelButton = new QPushButton(tr("Cancel"), mDialog); + + layout->addWidget(descriptionLabel, 1); + layout->addWidget(gifLabel, 1); + layout->addWidget(mProgressBar, 1); + layout->addWidget(scanButton, 1); + layout->addWidget(cancelButton, 1); + + connect(cancelButton, &QPushButton::clicked, mDialog, &QDialog::reject); + connect(scanButton, &QPushButton::clicked, this, &FomodPlusScanner::onScanClicked); + connect(mDialog, &QDialog::finished, this, &FomodPlusScanner::cleanup); + + mDialog->setLayout(layout); + mDialog->setMinimumSize(400, 300); + mDialog->adjustSize(); + descriptionLabel->adjustSize(); + return true; +} + +void FomodPlusScanner::onScanClicked() const +{ + mProgressBar->setVisible(true); + const int added = scanLoadOrder(setFomodInfoForMod); + mDialog->accept(); + QMessageBox::information(mDialog, tr("Scan Complete"), + tr("The load order scan is complete. Updated filter info for ") + QString::number(added) + tr(" mods.")); + mOrganizer->refresh(); +} + +void FomodPlusScanner::cleanup() const +{ + mProgressBar->reset(); + mProgressBar->setVisible(false); +} + +void FomodPlusScanner::display() const +{ + mDialog->exec(); +} + +int FomodPlusScanner::scanLoadOrder(const ScanCallbackFn& callback) const +{ + int progress = 0; + int modified = 0; + for (const auto& modName : mOrganizer->modList()->allMods()) { + const auto mod = mOrganizer->modList()->getMod(modName); + if (const ScanResult result = openInstallationArchive(mod); callback(mod, result)) { + modified++; + } + mProgressBar->setValue(++progress); + } + return modified; +} + +ScanResult FomodPlusScanner::openInstallationArchive(const IModInterface* mod) const +{ + const auto downloadsDir = mOrganizer->downloadsPath(); + const auto installationFilePath = mod->installationFile(); + return ArchiveParser::scanForFomodFiles(downloadsDir, installationFilePath, mod->name()); +} + +bool FomodPlusScanner::setFomodInfoForMod(IModInterface* mod, const ScanResult result) +{ + const auto pluginName = "FOMOD Plus"; + const auto setting = mod->pluginSetting(pluginName, "fomod", 0); + if (setting == 0 && ScanResult::HAS_FOMOD == result) { + return mod->setPluginSetting(pluginName, "fomod", "{}"); + } + if (setting != 0 && ScanResult::NO_FOMOD == result) { + return mod->setPluginSetting(pluginName, "fomod", 0); + } + return false; +} + +bool FomodPlusScanner::removeFomodInfoFromMod(IModInterface* mod, ScanResult) +{ + const auto pluginName = QString::fromStdString("FOMOD Plus"); + return mod->setPluginSetting(pluginName, "fomod", 0); +} diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.h b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h new file mode 100644 index 0000000..b7893f7 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h @@ -0,0 +1,64 @@ +#ifndef FOMODPLUSSCANNER_H +#define FOMODPLUSSCANNER_H + +#include "archiveparser.h" + +#include <iplugin.h> +#include <iplugintool.h> + +#include <QDialog> +#include <QProgressBar> + +using namespace MOBase; + +class FomodPlusScanner final : public IPluginTool { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusScanner" FILE "fomodplusscanner.json") +#endif + +public: + ~FomodPlusScanner() override + { + delete mDialog; + mDialog = nullptr; + mProgressBar = nullptr; + } + + bool init(IOrganizer* organizer) override; + + void onScanClicked() const; + + void cleanup() const; + + [[nodiscard]] QString name() const override { return "FOMOD Scanner"; } // This should not be translated + [[nodiscard]] QString author() const override { return "clearing"; } + [[nodiscard]] QString description() const override { return tr("Scans modlist for files installed via FOMOD"); } + [[nodiscard]] VersionInfo version() const override { return {1, 0, 0, VersionInfo::RELEASE_FINAL}; } + + [[nodiscard]] QList<PluginSetting> settings() const override { return {}; } + + [[nodiscard]] QString displayName() const override { return tr("FOMOD Scanner"); } + + [[nodiscard]] QString tooltip() const override { return tr("Scan modlist for files installed via FOMOD"); } + + [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); } + + void display() const override; + + int scanLoadOrder(const std::function<bool(IModInterface*, ScanResult result)> &callback) const; + + ScanResult openInstallationArchive(const IModInterface* mod) const; + + static bool setFomodInfoForMod(IModInterface *mod, ScanResult result); + + static bool removeFomodInfoFromMod(IModInterface *mod, ScanResult); + +private: + QDialog* mDialog{ nullptr }; + QProgressBar* mProgressBar{ nullptr }; + IOrganizer* mOrganizer{ nullptr }; +}; + +#endif // FOMODPLUSSCANNER_H
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/archiveparser.h b/libs/installer_fomod_plus/scanner/archiveparser.h new file mode 100644 index 0000000..4058d43 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/archiveparser.h @@ -0,0 +1,98 @@ +#pragma once +#include "stringutil.h" + +#include <QDir> +#include <QString> +#include <archive.h> +#include <iostream> +#include <ostream> + +inline std::ostream& operator<<(std::ostream& os, const Archive::Error& error) +{ + switch (error) { + case Archive::Error::ERROR_NONE: + os << "No error"; + break; + case Archive::Error::ERROR_ARCHIVE_NOT_FOUND: + os << "File not found"; + break; + case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE: + os << "Failed to open file"; + break; + case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT: + os << "Invalid archive format"; + break; + default: + os << "Unknown error??"; + } + return os; +} + +inline bool hasFomodFiles(const std::vector<FileData*>& files) +{ + bool hasModuleXml = false; + bool hasInfoXml = false; + + for (const auto* file : files) { + if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_MODULE_CONFIG.data())) { + hasModuleXml = true; + } + if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_INFO_XML.data())) { + hasInfoXml = true; + } + } + return hasModuleXml && hasInfoXml; +} + +/* This class can do the following: + * 1.) Detects if an archive has FOMOD files in it (without extracting) + * - This is used by the FOMOD scanner to set content flags + * + * 2.) It may support extracting ESPs and FOMODs, but this might be better served + * by a separate class. Maybe it could return a ModuleConfiguration to use like + * the installer. + */ + +enum class ScanResult { + HAS_FOMOD, + NO_FOMOD, + NO_ARCHIVE +}; + +class ArchiveParser { +public: + static ScanResult scanForFomodFiles(const QString& downloadsPath, const QString& installationFilePath, + const QString& modName) + { + if (installationFilePath.isEmpty()) { + return ScanResult::NO_ARCHIVE; + } + const auto qualifiedInstallerPath = QDir(installationFilePath).isAbsolute() + ? installationFilePath + : downloadsPath + "/" + installationFilePath; + + const auto archive = CreateArchive(); + + if (!archive->isValid()) { + logErrorForMod(modName, "Failed to load the archive module ", archive); + return ScanResult::NO_ARCHIVE; + } + if (!archive->open(qualifiedInstallerPath.toStdWString(), nullptr)) { + logErrorForMod(modName, "Failed to open archive [" + qualifiedInstallerPath + "]", archive); + return ScanResult::NO_ARCHIVE; + } + + if (hasFomodFiles(archive->getFileList())) { + std::cout << "Found FOMOD files in " << qualifiedInstallerPath.toStdString() << std::endl; + return ScanResult::HAS_FOMOD; + } + return ScanResult::NO_FOMOD; + } + +private: + static void logErrorForMod(const QString& modName, const QString& message, const std::unique_ptr<Archive>& archive) + { + std::cerr << "[" << modName.toStdString() << "] " << message.toStdString() << " (" << archive->getLastError() << + ")" << std::endl; + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts new file mode 100644 index 0000000..759824f --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="23"/> + <location filename="FomodPlusScanner.h" line="47"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="27"/> + <source>Sei gegrüßt, Reisender. +Dieses Tool scannt deine Ladereihenfolge nach Mods, die über einen FOMOD installiert wurden. +Es wird außerdem fehlerhafte FOMOD-Selektierungen aus früheren Versionen von FOMOD Plus beheben. :) + +Gute Reise, und möge deine Ladereihenfolge frei von Konflikten sein.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="46"/> + <source>Scannen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="47"/> + <source>Abbrechen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="71"/> + <source>Scan Abgeschlossen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source>Der Scan der Ladereihenfolge ist abgeschlossen. Aktualisierte Filterinformationen für </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source> Mods.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="42"/> + <source>Scannt die Modliste nach Dateien, die über einen FOMOD installiert wurden.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="49"/> + <source>Modliste nach Dateien durchsuchen, die über einen FOMOD installiert wurden.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts new file mode 100644 index 0000000..83a310b --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="26"/> + <location filename="FomodPlusScanner.h" line="42"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="30"/> + <source>Greetings, traveler. +This tool will scan your load order for mods installed via FOMOD. +It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :) + +Safe travels, and may your load order be free of conflicts.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="49"/> + <source>Scan</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="50"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="74"/> + <source>Scan Complete</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="75"/> + <source>The load order scan is complete. Updated filter info for </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="75"/> + <source> mods.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="37"/> + <source>Scans modlist for files installed via FOMOD</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="44"/> + <source>Scan modlist for files installed via FOMOD</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts new file mode 100644 index 0000000..01f84c8 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="23"/> + <location filename="FomodPlusScanner.h" line="47"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="27"/> + <source>你好,旅行者。 +该工具将扫描模组列表来查找通过 FOMOD 安装的模组, +并修正旧版本错误的 FOMOD 标志(需保留安装包才能被检测到):) + +一路平安,祝您的 load order 没有冲突。</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="46"/> + <source>扫描</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="47"/> + <source>取消</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="71"/> + <source>扫描完成</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source>模组扫描已完成。更新了 </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source> 个模组筛选框信息。</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="42"/> + <source>扫描模组列表查找通过 FOMOD 安装的文件</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="49"/> + <source>扫描模组列表查找通过 FOMOD 安装的文件</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomodplusscanner.json b/libs/installer_fomod_plus/scanner/fomodplusscanner.json new file mode 100644 index 0000000..f6643ea --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomodplusscanner.json @@ -0,0 +1,8 @@ +{ + "author" : "clearing", + "date" : "2025/02/06", + "name" : "FOMOD Plus - Scanner", + "version" : "1.0.0", + "des" : "Scans load order for FOMOD installations", + "dependencies" : [] +} diff --git a/libs/installer_fomod_plus/scanner/resources.qrc b/libs/installer_fomod_plus/scanner/resources.qrc new file mode 100644 index 0000000..df7c48c --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources.qrc @@ -0,0 +1,6 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + <file alias="wizard">resources/wizard.gif</file> + </qresource> +</RCC>
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/resources/fomod_icon.png b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/scanner/resources/wizard.gif b/libs/installer_fomod_plus/scanner/resources/wizard.gif Binary files differnew file mode 100644 index 0000000..c1f75d3 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources/wizard.gif diff --git a/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h new file mode 100644 index 0000000..65cd0ae --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h @@ -0,0 +1,160 @@ +#pragma once + +#include "stringutil.h" + +#include <QDir> +#include <QFileInfo> +#include <QString> +#include <QTemporaryDir> +#include <archive.h> +#include <filesystem> +#include <functional> +#include <iostream> +#include <memory> +#include <optional> + +struct ExtractionResult { + bool success = false; + QString moduleConfigPath; + std::vector<QString> pluginPaths; + QString errorMessage; + std::unique_ptr<QTemporaryDir> tempDir; // Owns the temp directory lifetime +}; + +/** + * Utility class to extract FOMOD data from archives without being in an installer context. + * Used by the Patch Wizard's rescan functionality. + */ +class ArchiveExtractor { +public: + using ProgressCallback = std::function<void(const QString& fileName)>; + + /** + * Extract ModuleConfig.xml and plugin files from an archive. + * @param archiveFilePath Full path to the archive file + * @param progressCallback Optional callback for progress updates + * @return ExtractionResult containing paths to extracted files + */ + static ExtractionResult extractFomodData( + const QString& archiveFilePath, + const ProgressCallback& progressCallback = nullptr) + { + ExtractionResult result; + result.tempDir = std::make_unique<QTemporaryDir>(); + + if (!result.tempDir->isValid()) { + result.errorMessage = "Failed to create temporary directory"; + return result; + } + + const auto archive = CreateArchive(); + if (!archive->isValid()) { + result.errorMessage = "Failed to load archive module"; + return result; + } + + if (!archive->open(archiveFilePath.toStdWString(), nullptr)) { + result.errorMessage = QString("Failed to open archive (error %1)") + .arg(static_cast<int>(archive->getLastError())); + return result; + } + + // Get file list and mark files for extraction + const auto& fileList = archive->getFileList(); + QString moduleConfigInArchive; + + for (auto* fileData : fileList) { + const auto entryPath = QString::fromStdWString(fileData->getArchiveFilePath()); + + // Check for ModuleConfig.xml + if (entryPath.toLower().endsWith("fomod/moduleconfig.xml") || + entryPath.toLower().endsWith("fomod\\moduleconfig.xml")) { + moduleConfigInArchive = entryPath; + // Set output path relative to output directory for extract() + fileData->addOutputFilePath(L"ModuleConfig.xml"); + result.moduleConfigPath = result.tempDir->filePath("ModuleConfig.xml"); + } + // Check for plugin files + else if (isPluginFile(entryPath)) { + const auto fileName = QFileInfo(entryPath).fileName(); + const auto relativePath = QString("plugins/") + fileName; + fileData->addOutputFilePath(relativePath.toStdWString()); + result.pluginPaths.push_back(result.tempDir->filePath(relativePath)); + } + } + + if (moduleConfigInArchive.isEmpty()) { + result.errorMessage = "No ModuleConfig.xml found in archive"; + return result; + } + + // Create plugins subdirectory + QDir(result.tempDir->path()).mkpath("plugins"); + + // Extract the files + Archive::FileChangeCallback fileChangeCallback = [&progressCallback]( + Archive::FileChangeType, const std::wstring& fileName) { + if (progressCallback) { + progressCallback(QString::fromStdWString(fileName)); + } + }; + + Archive::ErrorCallback errorCallback = [&result](const std::wstring& error) { + result.errorMessage = QString::fromStdWString(error); + }; + + const bool extractSuccess = archive->extract( + result.tempDir->path().toStdWString(), + Archive::ProgressCallback{}, // progress callback + fileChangeCallback, + errorCallback + ); + + if (!extractSuccess) { + if (result.errorMessage.isEmpty()) { + result.errorMessage = "Extraction failed"; + } + return result; + } + + // Verify ModuleConfig.xml was extracted + if (!QFile::exists(result.moduleConfigPath)) { + result.errorMessage = "ModuleConfig.xml extraction failed"; + return result; + } + + // Filter to only existing plugin files + std::vector<QString> existingPlugins; + for (const auto& path : result.pluginPaths) { + if (QFile::exists(path)) { + existingPlugins.push_back(path); + } + } + result.pluginPaths = std::move(existingPlugins); + + result.success = true; + return result; + } + + /** + * Check if an archive contains FOMOD files without extracting. + * @param archiveFilePath Full path to the archive file + * @return true if the archive contains fomod/ModuleConfig.xml + */ + static bool hasFomodFiles(const QString& archiveFilePath) + { + const auto archive = CreateArchive(); + if (!archive->isValid() || !archive->open(archiveFilePath.toStdWString(), nullptr)) { + return false; + } + + for (const auto* fileData : archive->getFileList()) { + const auto path = QString::fromStdWString(fileData->getArchiveFilePath()); + if (path.toLower().endsWith("fomod/moduleconfig.xml") || + path.toLower().endsWith("fomod\\moduleconfig.xml")) { + return true; + } + } + return false; + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h new file mode 100644 index 0000000..d1a9aa2 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h @@ -0,0 +1,146 @@ +#pragma once + +#include <fstream> +#include <stringutil.h> + +#include "FomodDBEntry.h" + +#include <xml/ModuleConfiguration.h> + +#include "PluginReader.h" + +using FOMODDBEntries = std::vector<std::shared_ptr<FomodDbEntry> >; + +constexpr std::string FOMOD_DB_FILE = "fomod.db"; + +class FomodDB { +public: + /** + * + * @param moBasePath The organizer instance's basePath() value + * @param dbName The filename of the db. Only settable for testing purposes. + */ + explicit FomodDB(const std::string &moBasePath, const std::string &dbName = FOMOD_DB_FILE) { + dbFilePath = (std::filesystem::path(moBasePath) / dbName).string(); + loadFromFile(); + } + + // TODO: Also pull from non install steps (requiredInstallFiles or whatever, and optional); + static std::shared_ptr<FomodDbEntry> getEntryFromFomod(ModuleConfiguration *fomod, std::vector<QString> pluginPaths, + int modId) { + std::vector<FomodOption> options; + for (const auto &installStep: fomod->installSteps.installSteps) { + for (const auto &group: installStep.optionalFileGroups.groups) { + for (const auto &plugin: group.plugins.plugins) { + // Create a DB entry for the given plugin if it has an ESP + std::cout << "\nPlugin: " << plugin.name << std::endl; + + for (auto file: plugin.files.files) { + if (file.isFolder || !isPluginFile(file.source)) { + continue; + } + + // Find the path in pluginPaths that ends with this path + // PluginPaths is gathered from the archive contents. + auto it = std::ranges::find_if(pluginPaths, [&file](const QString &path) { + return path.endsWith(file.source.c_str()); + }); + if (it == pluginPaths.end()) { + continue; + } + const auto &pluginPath = *it; + const auto masters = PluginReader::readMasters(pluginPath.toStdString(), true); + options.emplace_back( + plugin.name, + file.source, + masters, + installStep.name, + group.name + ); + } + } + } + } + return std::make_shared<FomodDbEntry>(modId, fomod->moduleName, options); + } + + void addEntry(const std::shared_ptr<FomodDbEntry> &entry, const bool upsert = true) { + // TODO: Test this upsert. + if (upsert) { + const auto it = std::ranges::find_if(entries, [&entry](const std::shared_ptr<FomodDbEntry> &e) { + return e->getModId() == entry->getModId(); + }); + if (it != entries.end()) { + *it = entry; + } else { + entries.emplace_back(entry); + } + } else { + entries.emplace_back(entry); + } + } + + [[nodiscard]] const FOMODDBEntries &getEntries() { return entries; } + + void saveToFile() const { + try { + std::ofstream file(dbFilePath); + if (!file.is_open()) { + return; + } + + file << toJson().dump(2); // Pretty-print with 2-space indentation + file.close(); + } catch ([[maybe_unused]] const std::exception &e) { + // Handle saving errors + } + } + + [[nodiscard]] nlohmann::json toJson() const { + nlohmann::json jsonArray = nlohmann::json::array(); + + for (const auto &entry: entries) { + jsonArray.push_back(entry->toJson()); + } + + return jsonArray; + } + +private: + FOMODDBEntries entries; + std::string dbFilePath; + + void loadFromFile() { + entries.clear(); + + // Create empty file if it doesn't exist + if (!std::filesystem::exists(dbFilePath)) { + std::ofstream file(dbFilePath); + file << "[]"; // Empty JSON array + file.close(); + return; // No entries to load + } + + try { + // Read and parse the JSON file + std::ifstream file(dbFilePath); + if (!file.is_open()) { + return; + } + + nlohmann::json jsonArray = nlohmann::json::parse(file); + + // Ensure it's an array + if (!jsonArray.is_array()) { + return; + } + + // Process each entry in the array + for (const auto &entryJson: jsonArray) { + entries.push_back(std::make_unique<FomodDbEntry>(entryJson)); + } + } catch ([[maybe_unused]] const std::exception &e) { + // Handle parsing errors (leave entries empty) + } + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h new file mode 100644 index 0000000..5d39f95 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h @@ -0,0 +1,129 @@ +#pragma once + +#include <string> +#include <utility> +#include <vector> +#include <nlohmann/json.hpp> + +/* +The following JSON will be part of an array of similar objects in the root level "JSON DB" for FOMOD Plus. +It contains information to resolve the identity of a given mod (names can change), and then the options +in the FOMOD with their respective masters. +{ + modId: 12345, + displayName: "Lux (Patch Hub)", + options: [ + { + "name" "JK's The Hag's Cure", + "fileName": "Lux - JK's The Hag's Cure patch.esp", + "masters": [ + "Skyrim.esm", + "JK's The Hag's Cure.esp", + "Lux - Resources.esp", + "Lux.esp" + ], + "step": "Page One", + "group": "Group One", + "selectionState": "Available" + } + ] +} +*/ + +enum class SelectionState { + Unknown, // Not yet matched to choices + Selected, // User selected this option + Deselected, // User manually deselected + Available // Present but user didn't interact (or choices not recorded) +}; + +inline std::string selectionStateToString(SelectionState state) { + switch (state) { + case SelectionState::Unknown: return "Unknown"; + case SelectionState::Selected: return "Selected"; + case SelectionState::Deselected: return "Deselected"; + case SelectionState::Available: return "Available"; + default: return "Unknown"; + } +} + +inline SelectionState stringToSelectionState(const std::string& str) { + if (str == "Selected") return SelectionState::Selected; + if (str == "Deselected") return SelectionState::Deselected; + if (str == "Available") return SelectionState::Available; + return SelectionState::Unknown; +} + +struct FomodOption { + std::string name; + std::string fileName; + std::vector<std::string> masters; + std::string step; + std::string group; + SelectionState selectionState = SelectionState::Unknown; + + FomodOption(std::string n, std::string fn, std::vector<std::string> m, std::string s, std::string g, + SelectionState state = SelectionState::Unknown) + : name(std::move(n)), fileName(std::move(fn)), masters(std::move(m)), step(std::move(s)), group(std::move(g)), + selectionState(state) {} +}; + +class FomodDbEntry { +public: + explicit FomodDbEntry(nlohmann::json json) { + modId = json["modId"]; + displayName = json["displayName"]; + for (auto &option: json["options"]) { + // create an option from this object + SelectionState state = SelectionState::Unknown; + if (option.contains("selectionState")) { + state = stringToSelectionState(option["selectionState"]); + } + FomodOption fomodOption( + option["name"], + option["fileName"], + option["masters"], + option["step"], + option["group"], + state + ); + options.push_back(fomodOption); + } + } + + explicit FomodDbEntry(const int modId, std::string displayName, const std::vector<FomodOption> &options) + : modId(modId), displayName(std::move(displayName)), options(options) { + } + + + [[nodiscard]] int getModId() const { return modId; } + [[nodiscard]] std::string getDisplayName() const { return displayName; } + [[nodiscard]] const std::vector<FomodOption>& getOptions() const { return options; } + [[nodiscard]] std::vector<FomodOption>& getOptionsMutable() { return options; } + + [[nodiscard]] nlohmann::json toJson() const { + nlohmann::json result; + result["modId"] = modId; + result["displayName"] = displayName; + + nlohmann::json optionsArray = nlohmann::json::array(); + for (const auto &[name, fileName, masters, step, group, selectionState]: options) { + nlohmann::json optionJson; + optionJson["name"] = name; + optionJson["fileName"] = fileName; + optionJson["masters"] = masters; + optionJson["step"] = step; + optionJson["group"] = group; + optionJson["selectionState"] = selectionStateToString(selectionState); + optionsArray.push_back(optionJson); + } + + result["options"] = optionsArray; + return result; + } + +private: + int modId; + std::string displayName; + std::vector<FomodOption> options; +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h new file mode 100644 index 0000000..c7d53a2 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h @@ -0,0 +1,277 @@ +#pragma once + +#include "ArchiveExtractor.h" +#include "FomodDB.h" +#include "stringutil.h" +#include "xml/ModuleConfiguration.h" + +#include <QDir> +#include <QString> +#include <functional> +#include <imodinterface.h> +#include <imoinfo.h> +#include <nlohmann/json.hpp> + +struct RescanResult { + int totalModsProcessed = 0; + int successfullyScanned = 0; + int missingArchives = 0; + int parseErrors = 0; + std::vector<std::string> failedMods; +}; + +/** + * Orchestrates rescanning of all mods with stored FOMOD choices to repopulate the database. + * Used when fomod.db is missing or needs to be regenerated from existing installations. + */ +class FomodRescan { +public: + using ProgressCallback = std::function<void(int current, int total, const QString& modName)>; + + FomodRescan(MOBase::IOrganizer* organizer, FomodDB* db) + : mOrganizer(organizer), mFomodDb(db) {} + + /** + * Scan all mods that have stored FOMOD Plus choices and repopulate the database. + * @param progressCallback Optional callback for progress updates + * @return RescanResult with statistics about the scan + */ + RescanResult scanAllModsWithChoices(const ProgressCallback& progressCallback = nullptr) + { + RescanResult result; + + const auto modList = mOrganizer->modList(); + if (!modList) { + return result; + } + + // First pass: gather all mods with stored choices + std::vector<MOBase::IModInterface*> modsWithChoices; + for (const auto& modName : modList->allMods()) { + auto* mod = modList->getMod(modName); + if (mod && hasStoredChoices(mod)) { + modsWithChoices.push_back(mod); + } + } + + result.totalModsProcessed = static_cast<int>(modsWithChoices.size()); + + // Second pass: process each mod + int current = 0; + for (auto* mod : modsWithChoices) { + current++; + if (progressCallback) { + progressCallback(current, result.totalModsProcessed, mod->name()); + } + + const auto scanResult = processMod(mod); + switch (scanResult) { + case ScanOutcome::Success: + result.successfullyScanned++; + break; + case ScanOutcome::MissingArchive: + result.missingArchives++; + result.failedMods.push_back(mod->name().toStdString() + " (missing archive)"); + break; + case ScanOutcome::ParseError: + result.parseErrors++; + result.failedMods.push_back(mod->name().toStdString() + " (parse error)"); + break; + case ScanOutcome::NoFomod: + result.failedMods.push_back(mod->name().toStdString() + " (no FOMOD)"); + break; + } + } + + // Save the database + mFomodDb->saveToFile(); + + return result; + } + +private: + MOBase::IOrganizer* mOrganizer; + FomodDB* mFomodDb; + + enum class ScanOutcome { + Success, + MissingArchive, + ParseError, + NoFomod + }; + + /** + * Check if a mod has stored FOMOD Plus choices (non-zero pluginSetting). + */ + bool hasStoredChoices(MOBase::IModInterface* mod) const + { + const auto fomodData = mod->pluginSetting( + StringConstants::Plugin::NAME.data(), "fomod", 0); + + if (!fomodData.isValid() || fomodData.isNull()) { + return false; + } + + // Check if it's actually valid JSON with steps + try { + const auto json = nlohmann::json::parse(fomodData.toString().toStdString()); + return json.contains("steps") && json["steps"].is_array() && !json["steps"].empty(); + } catch (...) { + return false; + } + } + + /** + * Get the stored choices JSON from a mod's pluginSetting. + */ + nlohmann::json getStoredChoices(MOBase::IModInterface* mod) const + { + const auto fomodData = mod->pluginSetting( + StringConstants::Plugin::NAME.data(), "fomod", 0); + + try { + return nlohmann::json::parse(fomodData.toString().toStdString()); + } catch (...) { + return nlohmann::json(); + } + } + + /** + * Process a single mod: extract archive, parse FOMOD, create DB entry with selection states. + */ + ScanOutcome processMod(MOBase::IModInterface* mod) + { + // Get the archive path + const auto installationFile = mod->installationFile(); + if (installationFile.isEmpty()) { + return ScanOutcome::MissingArchive; + } + + const auto downloadsPath = mOrganizer->downloadsPath(); + const auto archivePath = QDir(installationFile).isAbsolute() + ? installationFile + : downloadsPath + "/" + installationFile; + + if (!QFile::exists(archivePath)) { + return ScanOutcome::MissingArchive; + } + + // Extract FOMOD data from archive + auto extractionResult = ArchiveExtractor::extractFomodData(archivePath); + if (!extractionResult.success) { + return ScanOutcome::ParseError; + } + + // Parse ModuleConfiguration + auto moduleConfig = std::make_unique<ModuleConfiguration>(); + try { + if (!moduleConfig->deserialize(extractionResult.moduleConfigPath)) { + return ScanOutcome::ParseError; + } + } catch (...) { + return ScanOutcome::ParseError; + } + + // Get the mod's Nexus ID + const int modId = mod->nexusId(); + + // Create FomodDbEntry using existing logic + auto entry = FomodDB::getEntryFromFomod( + moduleConfig.get(), + extractionResult.pluginPaths, + modId + ); + + if (!entry || entry->getOptions().empty()) { + return ScanOutcome::NoFomod; + } + + // Apply selection states from stored choices + const auto choices = getStoredChoices(mod); + applySelectionsToEntry(*entry, choices); + + // Add to database (upsert) + mFomodDb->addEntry(entry, true); + + return ScanOutcome::Success; + } + + /** + * Apply user selection states to a FomodDbEntry based on stored choices JSON. + * + * Choices JSON format: + * { + * "steps": [{ + * "name": "Step Name", + * "groups": [{ + * "name": "Group Name", + * "plugins": ["Selected Plugin 1"], + * "deselected": ["Manually Deselected Plugin"] + * }] + * }] + * } + */ + void applySelectionsToEntry(FomodDbEntry& entry, const nlohmann::json& choices) + { + if (!choices.contains("steps") || !choices["steps"].is_array()) { + // No choices data - mark all as Available + for (auto& option : entry.getOptionsMutable()) { + option.selectionState = SelectionState::Available; + } + return; + } + + // Build a lookup map for quick matching: stepName/groupName/pluginName -> state + struct PluginState { + bool selected = false; + bool deselected = false; + }; + std::map<std::string, PluginState> stateMap; + + for (const auto& step : choices["steps"]) { + if (!step.contains("name") || !step.contains("groups")) continue; + const std::string stepName = step["name"]; + + for (const auto& group : step["groups"]) { + if (!group.contains("name")) continue; + const std::string groupName = group["name"]; + + // Process selected plugins + if (group.contains("plugins") && group["plugins"].is_array()) { + for (const auto& plugin : group["plugins"]) { + const std::string pluginName = plugin; + const auto key = stepName + "/" + groupName + "/" + pluginName; + stateMap[key].selected = true; + } + } + + // Process deselected plugins + if (group.contains("deselected") && group["deselected"].is_array()) { + for (const auto& plugin : group["deselected"]) { + const std::string pluginName = plugin; + const auto key = stepName + "/" + groupName + "/" + pluginName; + stateMap[key].deselected = true; + } + } + } + } + + // Apply states to options + for (auto& option : entry.getOptionsMutable()) { + const auto key = option.step + "/" + option.group + "/" + option.name; + + if (auto it = stateMap.find(key); it != stateMap.end()) { + if (it->second.selected) { + option.selectionState = SelectionState::Selected; + } else if (it->second.deselected) { + option.selectionState = SelectionState::Deselected; + } else { + option.selectionState = SelectionState::Available; + } + } else { + // Plugin not found in choices - mark as Available + option.selectionState = SelectionState::Available; + } + } + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/PluginReader.h b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h new file mode 100644 index 0000000..dade7e1 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h @@ -0,0 +1,119 @@ +#pragma once + +#include <fstream> +#include <string> +#include <vector> +#include <cstdint> +#include <unordered_set> + +static const std::unordered_set<std::string> VANILLA_MASTERS = { + "Skyrim.esm", + "Update.esm", + "Dawnguard.esm", + "HearthFires.esm", + "Dragonborn.esm" +}; + +class PluginReader { +public: + + /** + * Reads the master files from a Bethesda plugin file (ESP/ESM/ESL) + * @param filePath Path to the plugin file + * @param trimVanilla Exclude the vanilla game masters or not. Mostly to save DB space. + * @return Vector of master filenames + */ + static std::vector<std::string> readMasters(const std::string& filePath, const bool trimVanilla = false) + { + std::vector<std::string> masters; + std::ifstream file(filePath, std::ios::binary); + + if (!file) { + return masters; + } + + // Check TES4 record signature + char signature[4]; + file.read(signature, 4); + if (strncmp(signature, "TES4", 4) != 0) { + return masters; + } + + // Read record size + uint32_t recordSize; + file.read(reinterpret_cast<char*>(&recordSize), 4); + + constexpr uint32_t skipSize = sizeof(uint32_t) // flags + + sizeof(uint32_t) // formId + + sizeof(uint16_t) // timestamp + + sizeof(uint16_t) // version control + + sizeof(uint16_t) // internal version + + sizeof(uint16_t); // unknown + + // Skip header flags, formID, etc. (total 8 bytes) + file.seekg(skipSize, std::ios::cur); + + // Calculate where the TES4 record ends + std::streampos recordEnd = file.tellg() + static_cast<std::streampos>(recordSize); + + // Read subrecords until we reach the end of the TES4 record + while (file && file.tellg() < recordEnd) { + char subRecordType[4]; + uint16_t subRecordSize; + + // Read subrecord type and size + file.read(subRecordType, 4); + file.read(reinterpret_cast<char*>(&subRecordSize), 2); + + if (strncmp(subRecordType, "MAST", 4) == 0) { + // Read master filename (null-terminated string) + std::string masterName; + masterName.resize(subRecordSize); + file.read(masterName.data(), subRecordSize); + + // Remove null terminator if present + if (!masterName.empty() && masterName.back() == '\0') { + masterName.pop_back(); + } + + // Only add if it's not a vanilla master or if we're not trimming + if (!trimVanilla || !VANILLA_MASTERS.contains(masterName)) { + masters.push_back(masterName); + } + + // Each MAST is followed by a DATA subrecord + char dataType[4]; + uint16_t dataSize; + file.read(dataType, 4); + file.read(reinterpret_cast<char*>(&dataSize), 2); + + // Skip DATA content (usually an 8-byte value) + file.seekg(dataSize, std::ios::cur); + } else { + // Skip other subrecord types + file.seekg(subRecordSize, std::ios::cur); + } + } + + return masters; + } + + /** + * Checks if a file is a valid Bethesda plugin (ESP/ESM/ESL) + * @param filePath Path to the file + * @return True if the file is a valid plugin + */ + static bool isValidPlugin(const std::string& filePath) + { + std::ifstream file(filePath, std::ios::binary); + + if (!file) { + return false; + } + + char signature[4]; + file.read(signature, 4); + + return strncmp(signature, "TES4", 4) == 0; + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/stringutil.h b/libs/installer_fomod_plus/share/stringutil.h new file mode 100644 index 0000000..c5d2fb8 --- /dev/null +++ b/libs/installer_fomod_plus/share/stringutil.h @@ -0,0 +1,150 @@ +#ifndef STRINGCONSTANTS_H +#define STRINGCONSTANTS_H +#include <algorithm> +#include <regex> +#include <string> +#include <vector> +#include <QString> + + +namespace StringConstants +{ + namespace Plugin + { + constexpr std::string_view NAME = "FOMOD Plus"; + constexpr std::string_view AUTHOR = "clearing"; + constexpr std::string_view DESCRIPTION = + "Extends the capabilities of the FOMOD installer for advanced users.\n\n" + "Available colors (enter exactly): \n" + "'Light0'\t'Light1'\t'Light2'\t'Light3'\n" + "'Dark0'\t'Dark1'\t'Dark2'\t'Dark3'\n" + "'Red'\t'Red Bright'\n" + "'Green'\t'Green Bright'\n" + "'Yellow'\t'Yellow Bright'\n" + "'Blue'\t'Blue Bright'\n" + "'Purple'\t'Purple Bright'\n" + "'Aqua'\t'Aqua Bright'\n" + "'Orange'\t'Orange Bright'\n"; + constexpr std::wstring_view W_NAME = L"FOMOD Plus"; + constexpr std::wstring_view W_AUTHOR = L"clearing"; + constexpr std::wstring_view W_DESCRIPTION = + L"Extends the capabilities of the FOMOD installer for advanced users."; + } + + namespace FomodFiles + { + constexpr std::string_view FOMOD_DIR = "fomod"; + constexpr std::string_view INFO_XML = "info.xml"; + constexpr std::string_view MODULE_CONFIG = "ModuleConfig.xml"; + + // Wide string versions for archive API + constexpr std::wstring_view W_FOMOD_DIR = L"fomod"; + constexpr std::wstring_view W_INFO_XML = L"fomod/info.xml"; + constexpr std::wstring_view W_MODULE_CONFIG = L"fomod/ModuleConfig.xml"; + + constexpr std::string_view TYPE_REQUIRED = "Required"; + constexpr std::string_view TYPE_OPTIONAL = "Optional"; + constexpr std::string_view TYPE_RECOMMENDED = "Recommended"; + constexpr std::string_view TYPE_NOT_USABLE = "NotUsable"; + constexpr std::string_view TYPE_COULD_BE_USABLE = "CouldBeUsable"; + } +} + +// Convert narrow string_view to wstring (for ASCII strings only) +inline std::wstring toWide(std::string_view sv) +{ + return std::wstring(sv.begin(), sv.end()); +} + +// trim from start (in place) +inline void ltrim(std::string& s) +{ + s.erase(s.begin(), std::ranges::find_if(s, [](const unsigned char ch) + { + return !std::isspace(ch); + })); +} + +// trim from end (in place) +inline void rtrim(std::string& s) +{ + s.erase(std::find_if(s.rbegin(), s.rend(), [](const unsigned char ch) + { + return !std::isspace(ch); + }).base(), s.end()); +} + +// trim from both ends (in place) +inline std::string& trim(std::string& s) +{ + ltrim(s); + rtrim(s); + return s; +} + +inline void trim(const std::vector<std::string>& strings) +{ + for (auto s : strings) { trim(s); } +} + +inline std::wstring toLower(const std::wstring& str) +{ + std::wstring lowerStr = str; + std::ranges::transform(lowerStr, lowerStr.begin(), towlower); + return lowerStr; +} + +inline std::string toLower(const std::string& str) +{ + std::string lowerStr = str; + std::ranges::transform(lowerStr, lowerStr.begin(), tolower); + return lowerStr; +} + +inline bool endsWithCaseInsensitive(const std::wstring& str, const std::wstring& suffix) +{ + const std::wstring lowerStr = toLower(str); + if (const std::wstring lowerSuffix = toLower(suffix); lowerStr.length() >= lowerSuffix.length()) + { + return 0 == lowerStr.compare(lowerStr.length() - lowerSuffix.length(), lowerSuffix.length(), lowerSuffix); + } + return false; +} + +inline QString formatPluginDescription(const QString& text) +{ + std::string formattedText = text.toStdString(); + // Replace URLs with <a href> tags + const std::regex + urlRegex(R"((http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-]))"); + formattedText = std::regex_replace(formattedText, urlRegex, R"(<a href="$&">$&</a>)"); + + // Replace line breaks + formattedText = std::regex_replace(formattedText, std::regex(" "), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\r\\n"), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\r"), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\n"), "<br>"); + + return QString::fromStdString(formattedText); +} + +// NOTE: This isn't perfect. Sometimes we have whole filenames, sometimes we're just passing +// the suffix. It should be fine as long as no one names a file like..."Testesl". Idk what that +// would do anyway. +inline bool isPluginFile(const QString& file) +{ + return file.toLower().endsWith("esl") + || file.toLower().endsWith("esp") + || file.toLower().endsWith("esm"); +} + +inline bool isPluginFile(const std::string& file) +{ + const auto lower = toLower(file); + return lower.ends_with("esl") + || lower.ends_with("esp") + || lower.ends_with("esm"); +} + + +#endif diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp new file mode 100644 index 0000000..d4fed69 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp @@ -0,0 +1,44 @@ +#include "FomodInfoFile.h" +#include "XmlParseException.h" +#include <format> +#include <pugixml.hpp> + +#include "stringutil.h" + +#include <QFile> +#include <QString> + +bool FomodInfoFile::deserialize(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString())); + } + const QByteArray content = file.readAll(); + file.close(); + + pugi::xml_document doc; + if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) { + throw XmlParseException(std::format("XML parsed with errors: {}", result.description())); + } + + const pugi::xml_node fomodNode = doc.child("fomod"); + if (!fomodNode) { + throw XmlParseException("No <config> node found"); + } + + name = fomodNode.child("Name").text().as_string(); + author = fomodNode.child("Author").text().as_string(); + version = fomodNode.child("Version").text().as_string(); + website = fomodNode.child("Website").text().as_string(); + description = fomodNode.child("Description").text().as_string(); + + trim({ name, author, version, website, description }); + + for (pugi::xml_node groupNode : fomodNode.child("Groups").children("element")) { + groups.emplace_back(groupNode.text().as_string()); + } + + return true; + +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.h b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h new file mode 100644 index 0000000..d2c2a23 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h @@ -0,0 +1,25 @@ +#pragma once + +#include <qstring.h> +#include <string> +#include <vector> + +class FomodInfoFile { +public: + bool deserialize(const QString &filePath); + + [[nodiscard]] const std::string& getName() const { return name; } + [[nodiscard]] const std::string& getAuthor() const { return author; } + [[nodiscard]] const std::string& getVersion() const { return version; } + [[nodiscard]] const std::string& getWebsite() const { return website; } + [[nodiscard]] const std::string& getDescription() const { return description; } + [[nodiscard]] const std::vector<std::string>& getGroups() const { return groups; } + +private: + std::string name; + std::string author; + std::string version; + std::string website; + std::string description; + std::vector<std::string> groups; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp new file mode 100644 index 0000000..64d6b0b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp @@ -0,0 +1,352 @@ +#include "ModuleConfiguration.h" + +#include <format> + +#include "XmlHelper.h" +#include "XmlParseException.h" +#include "stringutil.h" + +#include <QFile> +#include <QString> + +using namespace StringConstants::FomodFiles; + +static GroupTypeEnum groupTypeFromString(const std::string& groupType) +{ + if (groupType == "SelectAny") + return SelectAny; + if (groupType == "SelectAll") + return SelectAll; + if (groupType == "SelectExactlyOne") + return SelectExactlyOne; + if (groupType == "SelectAtMostOne") + return SelectAtMostOne; + if (groupType == "SelectAtLeastOne") + return SelectAtLeastOne; + return SelectAny; // is this a sane default? probably +} + +PluginTypeEnum pluginTypeFromString(const std::string& typeStr) +{ + if (typeStr == TYPE_REQUIRED) + return PluginTypeEnum::Required; + if (typeStr == TYPE_OPTIONAL) + return PluginTypeEnum::Optional; + if (typeStr == TYPE_RECOMMENDED) + return PluginTypeEnum::Recommended; + if (typeStr == TYPE_NOT_USABLE) + return PluginTypeEnum::NotUsable; + if (typeStr == TYPE_COULD_BE_USABLE) + return PluginTypeEnum::CouldBeUsable; + return PluginTypeEnum::Optional; +} + +template <typename T> +bool deserializeList(pugi::xml_node& node, const char* childName, std::vector<T>& list) +{ + for (pugi::xml_node childNode : node.children(childName)) { + T item; + item.deserialize(childNode); + list.push_back(item); + } + return true; +} + +/* + * NOTE: We call 'trim()' on all error-prone fields that rely on user-input. I'm assuming these FOMODs are created with + * the FOMOD creation tool, so I won't trim things like flags that the tool sets for the user. + */ + +bool FileDependency::deserialize(pugi::xml_node& node) +{ + file = node.attribute("file").as_string(); + trim(file); + // ReSharper disable once CppTooWideScopeInitStatement + // Do not use 'auto' for these. it will break equality checks + const std::string stateStr = node.attribute("state").as_string(); + + if (stateStr == "Missing") + state = FileDependencyTypeEnum::Missing; + else if (stateStr == "Inactive") + state = FileDependencyTypeEnum::Inactive; + else if (stateStr == "Active") + state = FileDependencyTypeEnum::Active; + return true; +} + +bool FlagDependency::deserialize(pugi::xml_node& node) +{ + flag = node.attribute("flag").as_string(); + value = node.attribute("value").as_string(); + trim({ flag, value }); + return true; +} + +bool GameDependency::deserialize(pugi::xml_node& node) +{ + version = node.attribute("version").as_string(); + trim(version); + return true; +} + +bool CompositeDependency::deserialize(pugi::xml_node& node) +{ + + // this could EITHER have a dependencies child or the dependencies are here. + // turns out they could have both. + pugi::xml_node possibleNode = node; + + // If the dependencies are all right inside, just use the root node as the dependency base. + // This looks hacky but accommodates both _nested_ dependencies for plugins, and extremely simple ones for step visibility. + if (node.child("dependencies") && !node.child("fileDependency") && !node.child("flagDependency") &&!node.child("gameDependency")) { + possibleNode = node.child("dependencies"); + } + + deserializeList(possibleNode, "fileDependency", fileDependencies); + deserializeList(possibleNode, "flagDependency", flagDependencies); + deserializeList(possibleNode, "gameDependency", gameDependencies); + deserializeList(possibleNode, "dependencies", nestedDependencies); + + operatorType = OperatorTypeEnum::AND; // safest default. + + if (const std::string operatorStr = possibleNode.attribute("operator").as_string(); operatorStr == "Or") { + operatorType = OperatorTypeEnum::OR; + } + + return true; +} + +bool DependencyPattern::deserialize(pugi::xml_node& node) +{ + if (!node) + return false; + pugi::xml_node dependenciesNode = node.child("dependencies"); + dependencies.deserialize(dependenciesNode); + + const pugi::xml_node typeNode = node.child("type"); + type = pluginTypeFromString(typeNode.attribute("name").as_string()); + return true; +} + +bool DependencyPatternList::deserialize(pugi::xml_node& node) +{ + return deserializeList(node, "pattern", patterns); +} + +bool DependencyPluginType::deserialize(pugi::xml_node& node) +{ + pugi::xml_node patternsNode = node.child("patterns"); + const pugi::xml_node defaultTypeNode = node.child("defaultType"); + defaultType = pluginTypeFromString(defaultTypeNode.attribute("name").as_string()); + patterns.deserialize(patternsNode); + return true; +} + +bool TypeDescriptor::deserialize(pugi::xml_node& node) +{ + pugi::xml_node dependencyTypeNode = node.child("dependencyType"); + dependencyType.deserialize(dependencyTypeNode); + const pugi::xml_node typeNode = node.child("type"); + type = pluginTypeFromString(typeNode.attribute("name").as_string()); + return true; +} + +bool Image::deserialize(pugi::xml_node& node) +{ + path = node.attribute("path").as_string(); + return true; +} + +bool HeaderImage::deserialize(pugi::xml_node& node) +{ + path = node.attribute("path").as_string(); + showImage = node.attribute("showImage").as_bool(); + showFade = node.attribute("showFade").as_bool(); + height = node.attribute("height").as_int(); + return true; +} + +bool FileList::deserialize(pugi::xml_node& node) +{ + for (pugi::xml_node childNode : node.children()) { + if (std::string(childNode.name()) == "folder" + || std::string(childNode.name()) == "file") { + File file; + file.deserialize(childNode); + files.emplace_back(file); + } + } + return true; +} + +bool ConditionalFileInstallPattern::deserialize(pugi::xml_node& node) +{ + pugi::xml_node dependenciesNode = node.child("dependencies"); + pugi::xml_node filesNode = node.child("files"); + + dependencies.deserialize(dependenciesNode); + files.deserialize(filesNode); + + return true; +} + +// <flag name="2">On</flag> +bool ConditionFlag::deserialize(pugi::xml_node& node) +{ + name = node.attribute("name").as_string(); + value = node.child_value(); // + return true; +} + +bool ConditionFlagList::deserialize(pugi::xml_node& node) +{ + return deserializeList(node, "flag", flags); +} + +bool File::deserialize(pugi::xml_node& node) +{ + source = node.attribute("source").as_string(); + // destination = node.attribute("destination").as_string(); + priority = node.attribute("priority").as_int(); + isFolder = strcmp(node.name(), "folder") == 0; + if (auto attr = node.attribute("destination"); attr) { + destination = attr.as_string(); + } else { + destination = std::nullopt; + } + return true; +} + + +bool Plugin::deserialize(pugi::xml_node& node) +{ + pugi::xml_node imageNode = node.child("image"); + pugi::xml_node typeDescriptorNode = node.child("typeDescriptor"); + pugi::xml_node conditionFlagsNode = node.child("conditionFlags"); + pugi::xml_node filesNode = node.child("files"); + + // Description is optional in the schema; guard against null C strings from pugixml. + if (const pugi::xml_node descNode = node.child("description")) { + if (const char* rawDesc = descNode.text().as_string()) { + description = rawDesc; + } + } + description = trim(description); // Find a better way to do this eventually. + image.deserialize(imageNode); + typeDescriptor.deserialize(typeDescriptorNode); + name = node.attribute("name").as_string(); + name = trim(name); + conditionFlags.deserialize(conditionFlagsNode); + files.deserialize(filesNode); + return true; +} + +bool PluginList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "plugin", plugins); + order = XmlHelper::getOrderType(node.attribute("order").as_string(), OrderTypeEnum::Ascending); + + // Sort the plugins based on the specified order + std::ranges::sort(plugins, [this](const Plugin& a, const Plugin& b) { + if (order == OrderTypeEnum::Ascending) { + return a.name < b.name; + } + if (order == OrderTypeEnum::Descending) { + return a.name > b.name; + } + return false; // Default case, no sorting + }); + + return true; +} + +bool Group::deserialize(pugi::xml_node& node) +{ + pugi::xml_node pluginsNode = node.child("plugins"); + plugins.deserialize(pluginsNode); + name = node.attribute("name").as_string(); + type = groupTypeFromString(node.attribute("type").as_string()); + return true; +} + +bool GroupList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "group", groups); + order = XmlHelper::getOrderType(node.attribute("order").as_string()); + + // Sort the groups based on the specified order + std::ranges::sort(groups, [this](const Group& a, const Group& b) { + if (order == OrderTypeEnum::Ascending) { + return a.name < b.name; + } + if (order == OrderTypeEnum::Descending) { + return a.name > b.name; + } + return false; // Default case, no sorting + }); + return true; +} + +bool InstallStep::deserialize(pugi::xml_node& node) +{ + pugi::xml_node visibleNode = node.child("visible"); + pugi::xml_node optionalFileGroupsNode = node.child("optionalFileGroups"); + visible.deserialize(visibleNode); + optionalFileGroups.deserialize(optionalFileGroupsNode); + name = node.attribute("name").as_string(); + return true; +} + +bool ConditionalFileInstall::deserialize(pugi::xml_node& node) +{ + pugi::xml_node patternsNode = node.child("patterns"); + deserializeList(patternsNode, "pattern", patterns); + return true; +} + +bool StepList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "installStep", installSteps); + order = XmlHelper::getOrderType(node.attribute("order").as_string()); + return true; +} + +bool ModuleConfiguration::deserialize(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString())); + } + const QByteArray content = file.readAll(); + file.close(); + + pugi::xml_document doc; + if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) { + throw XmlParseException(std::format("XML parsed with errors: {}", result.description())); + } + + const pugi::xml_node configNode = doc.child("config"); + if (!configNode) { + throw XmlParseException("No <config> node found"); + } + + moduleName = configNode.child("moduleName").text().as_string(); + + moduleImage = HeaderImage(); + pugi::xml_node moduleImageNode = configNode.child("moduleImage"); + moduleImage.deserialize(moduleImageNode); + + pugi::xml_node moduleDependenciesNode = configNode.child("moduleDependencies"); + moduleDependencies.deserialize(moduleDependenciesNode); + + pugi::xml_node requiredInstallFilesNode = configNode.child("requiredInstallFiles"); + requiredInstallFiles.deserialize(requiredInstallFilesNode); + + pugi::xml_node installStepsNode = configNode.child("installSteps"); + installSteps.deserialize(installStepsNode); + + pugi::xml_node conditionalFileInstallsNode = configNode.child("conditionalFileInstalls"); + conditionalFileInstalls.deserialize(conditionalFileInstallsNode); + + return true; +} diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h new file mode 100644 index 0000000..a0a017b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h @@ -0,0 +1,305 @@ +#pragma once + +#include <iostream> +#include <optional> +#include <pugixml.hpp> +#include <qstring.h> +#include <string> +#include <vector> + +class XmlDeserializable { +public: + virtual ~XmlDeserializable() = default; + + virtual bool deserialize(pugi::xml_node& node) = 0; + +protected: + XmlDeserializable() = default; +}; + +enum GroupTypeEnum { + SelectAny, + SelectAll, + SelectExactlyOne, + SelectAtMostOne, + SelectAtLeastOne +}; + +enum class OperatorTypeEnum { + AND, + OR +}; + +enum class OrderTypeEnum { + Explicit, + Ascending, + Descending +}; + +enum class FileDependencyTypeEnum { + Missing, + Inactive, + Active, + UNKNOWN_STATE +}; + +template <typename T> +class OrderedContents { +public: + OrderTypeEnum order; + + OrderedContents() : order(OrderTypeEnum::Ascending) {} + explicit OrderedContents(const OrderTypeEnum orderType): order(orderType) {} + + template <typename Accessor> + bool compare(const T& a, const T& b, Accessor accessor) const + { + switch (order) { + case OrderTypeEnum::Ascending: + return accessor(a) < accessor(b); + case OrderTypeEnum::Descending: + return accessor(a) > accessor(b); + case OrderTypeEnum::Explicit: + default: + return false; // No sorting for explicit order + } + } +}; + +enum class PluginTypeEnum { + Recommended, + Required, + Optional, + NotUsable, + CouldBeUsable, + UNKNOWN +}; + + +inline std::ostream& operator<<(std::ostream& os, const PluginTypeEnum& type) +{ + switch (type) { + case PluginTypeEnum::Recommended: + os << "Recommended"; + break; + case PluginTypeEnum::Required: + os << "Required"; + break; + case PluginTypeEnum::Optional: + os << "Optional"; + break; + case PluginTypeEnum::NotUsable: + os << "NotUsable"; + break; + case PluginTypeEnum::CouldBeUsable: + os << "CouldBeUsable"; + break; + default: ; + } + return os; +} + +class PluginType final : public XmlDeserializable { +public: + PluginTypeEnum name = PluginTypeEnum::Optional; // sane default + + bool deserialize(pugi::xml_node& node) override; +}; + +class FileDependency final : public XmlDeserializable { +public: + std::string file; + FileDependencyTypeEnum state = FileDependencyTypeEnum::UNKNOWN_STATE; + + bool deserialize(pugi::xml_node& node) override; +}; + +class FlagDependency final : public XmlDeserializable { +public: + std::string flag; + std::string value; + + bool deserialize(pugi::xml_node& node) override; +}; + +class GameDependency final : public XmlDeserializable { +public: + std::string version; + + bool deserialize(pugi::xml_node& node) override; +}; + +class CompositeDependency final : public XmlDeserializable { +public: + std::vector<FileDependency> fileDependencies; + std::vector<FlagDependency> flagDependencies; + std::vector<GameDependency> gameDependencies; + std::vector<CompositeDependency> nestedDependencies; + OperatorTypeEnum operatorType = OperatorTypeEnum::AND; // safest default. + + bool deserialize(pugi::xml_node& node) override; +}; + +class DependencyPattern final : public XmlDeserializable { +public: + CompositeDependency dependencies; + PluginTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + + +class DependencyPatternList final : public XmlDeserializable { +public: + std::vector<DependencyPattern> patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class DependencyPluginType final : public XmlDeserializable { +public: + std::optional<PluginTypeEnum> defaultType; + DependencyPatternList patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class TypeDescriptor final : public XmlDeserializable { +public: + DependencyPluginType dependencyType; + PluginTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Image final : public XmlDeserializable { +public: + std::string path; + + bool deserialize(pugi::xml_node& node) override; +}; + +class HeaderImage final : public XmlDeserializable { +public: + std::string path; + bool showImage; + bool showFade; + int height; + + bool deserialize(pugi::xml_node& node) override; +}; + +class File final : public XmlDeserializable { +public: + std::string source; + std::optional<std::string> destination; + int priority{ 0 }; + bool isFolder; + + bool deserialize(pugi::xml_node& node) override; +}; + + +class FileList final : public XmlDeserializable { +public: + std::vector<File> files; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionalFileInstallPattern final : public XmlDeserializable { +public: + CompositeDependency dependencies; + FileList files; + + bool deserialize(pugi::xml_node& node) override; +}; + +// <flag name="2">On</flag> +class ConditionFlag final : public XmlDeserializable { +public: + std::string name; + std::string value; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionFlagList final : public XmlDeserializable { +public: + std::vector<ConditionFlag> flags; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Plugin final : public XmlDeserializable { +public: + std::string description; + Image image; + TypeDescriptor typeDescriptor; + std::string name; + ConditionFlagList conditionFlags; + FileList files; + + bool deserialize(pugi::xml_node& node) override; +}; + +class PluginList final : public XmlDeserializable, public OrderedContents<Plugin> { +public: + std::vector<Plugin> plugins; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Group final : public XmlDeserializable { +public: + PluginList plugins; + std::string name; + GroupTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + +class GroupList final : public XmlDeserializable, public OrderedContents<Group> { +public: + std::vector<Group> groups; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class InstallStep final : public XmlDeserializable { +public: + CompositeDependency visible; + GroupList optionalFileGroups; + std::string name; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionalFileInstall final : public XmlDeserializable { +public: + std::vector<ConditionalFileInstallPattern> patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class StepList final : public XmlDeserializable, public OrderedContents<InstallStep> { +public: + std::vector<InstallStep> installSteps; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ModuleConfiguration { +public: + std::string moduleName; + HeaderImage moduleImage; + CompositeDependency moduleDependencies; + FileList requiredInstallFiles; + StepList installSteps; + ConditionalFileInstall conditionalFileInstalls; + + bool deserialize(const QString& filePath); +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/XmlHelper.h b/libs/installer_fomod_plus/share/xml/XmlHelper.h new file mode 100644 index 0000000..6934a86 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/XmlHelper.h @@ -0,0 +1,17 @@ +#pragma once + +#include "ModuleConfiguration.h" + +class XmlHelper { +public: + static OrderTypeEnum getOrderType(const std::string& orderType, OrderTypeEnum defaultOrder = OrderTypeEnum::Explicit) + { + if (orderType == "Explicit") + return OrderTypeEnum::Explicit; + if (orderType == "Ascending") + return OrderTypeEnum::Ascending; + if (orderType == "Descending") + return OrderTypeEnum::Descending; + return defaultOrder; // Ascending for plugins, Explicit for groups + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/XmlParseException.h b/libs/installer_fomod_plus/share/xml/XmlParseException.h new file mode 100644 index 0000000..6406f3b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/XmlParseException.h @@ -0,0 +1,10 @@ +#pragma once + +#include <stdexcept> +#include <string> + +class XmlParseException final : public std::runtime_error { +public: + explicit XmlParseException(const std::string& message) + : std::runtime_error(message) {} +}; diff --git a/libs/nak/Cargo.lock b/libs/nak/Cargo.lock new file mode 100644 index 0000000..5bb63b4 --- /dev/null +++ b/libs/nak/Cargo.lock @@ -0,0 +1,901 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nak_rust" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "ureq", + "walkdir", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/libs/nak/Cargo.toml b/libs/nak/Cargo.toml new file mode 100644 index 0000000..3b3c460 --- /dev/null +++ b/libs/nak/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nak_rust" +version = "0.1.0" +edition = "2021" + +[lib] +name = "nak_rust" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +walkdir = "2" +chrono = "0.4" +ureq = "2" diff --git a/libs/nak/src/config.rs b/libs/nak/src/config.rs new file mode 100644 index 0000000..62f9fc5 --- /dev/null +++ b/libs/nak/src/config.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +fn get_home() -> String { + std::env::var("HOME").unwrap_or_default() +} + +/// Normalize a path for compatibility with pressure-vessel/Steam container. +/// +/// On Fedora Atomic/Bazzite/Silverblue, $HOME is `/var/home/user` but `/home` +/// is a symlink to `/var/home`. Pressure-vessel exposes `/home` but may not +/// properly handle paths that explicitly use `/var/home/`. This function +/// converts such paths to use `/home/` instead for maximum compatibility. +pub fn normalize_path_for_steam(path: &str) -> String { + // Convert /var/home/user/... to /home/user/... + if let Some(stripped) = path.strip_prefix("/var/home/") { + format!("/home/{}", stripped) + } else { + path.to_string() + } +} + +fn default_data_path() -> String { + format!("{}/NaK", get_home()) +} + +// ============================================================================ +// Main App Config - stored in ~/.config/nak/config.json +// ============================================================================ + +#[derive(Serialize, Deserialize, Clone)] +pub struct AppConfig { + pub selected_proton: Option<String>, + /// Whether the first-run setup has been completed + #[serde(default)] + pub first_run_completed: bool, + /// Path to NaK data folder (legacy ~/NaK - used for migration detection) + #[serde(default = "default_data_path")] + pub data_path: String, + /// Whether the Steam-native migration popup has been shown + #[serde(default)] + pub steam_migration_shown: bool, + /// Custom cache location (for downloads, tmp files during install) + /// If empty/not set, uses ~/.cache/nak/ + #[serde(default)] + pub cache_location: String, + /// Selected Steam account ID (Steam3 format, e.g., "910757758") + /// If empty/not set, uses the most recently active account + #[serde(default)] + pub selected_steam_account: String, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + selected_proton: None, + first_run_completed: false, + data_path: default_data_path(), + steam_migration_shown: false, + cache_location: String::new(), + selected_steam_account: String::new(), + } + } +} + +impl AppConfig { + /// Config file path: ~/.config/nak/config.json + fn get_config_path() -> PathBuf { + PathBuf::from(format!("{}/.config/nak/config.json", get_home())) + } + + /// Legacy config path for migration: ~/NaK/config.json + fn get_legacy_path() -> PathBuf { + PathBuf::from(format!("{}/NaK/config.json", get_home())) + } + + pub fn load() -> Self { + let config_path = Self::get_config_path(); + let legacy_path = Self::get_legacy_path(); + + // Try new location first + if config_path.exists() { + if let Ok(content) = fs::read_to_string(&config_path) { + if let Ok(config) = serde_json::from_str(&content) { + return config; + } + } + } + + // Try legacy location and migrate if found + if legacy_path.exists() { + if let Ok(content) = fs::read_to_string(&legacy_path) { + if let Ok(mut config) = serde_json::from_str::<AppConfig>(&content) { + // Ensure data_path is set (old configs won't have it) + if config.data_path.is_empty() { + config.data_path = default_data_path(); + } + // Save to new location + config.save(); + // Remove old config + let _ = fs::remove_file(&legacy_path); + return config; + } + } + } + + Self::default() + } + + pub fn save(&self) { + let path = Self::get_config_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = fs::write(path, json); + } + } + + /// Get the NaK data directory path (legacy ~/NaK - used for migration detection) + pub fn get_data_path(&self) -> PathBuf { + PathBuf::from(&self.data_path) + } + + /// Get the NaK config directory (~/.config/nak/) + pub fn get_config_dir() -> PathBuf { + PathBuf::from(format!("{}/.config/nak", get_home())) + } + + /// Get the default cache directory (~/.cache/nak/) + pub fn get_default_cache_dir() -> PathBuf { + PathBuf::from(format!("{}/.cache/nak", get_home())) + } + + /// Get the cache directory (custom location or default ~/.cache/nak/) + pub fn get_cache_dir(&self) -> PathBuf { + if self.cache_location.is_empty() { + Self::get_default_cache_dir() + } else { + PathBuf::from(&self.cache_location) + } + } + + /// Get path to tmp directory (~/.cache/nak/tmp/) + pub fn get_tmp_path() -> PathBuf { + Self::get_default_cache_dir().join("tmp") + } + + /// Get path to Prefixes directory (legacy - for migration detection only) + pub fn get_prefixes_path(&self) -> PathBuf { + self.get_data_path().join("Prefixes") + } +} diff --git a/libs/nak/src/deps/mod.rs b/libs/nak/src/deps/mod.rs new file mode 100644 index 0000000..a055eb7 --- /dev/null +++ b/libs/nak/src/deps/mod.rs @@ -0,0 +1,177 @@ +//! Dependency management via winetricks +//! +//! Uses winetricks for all Windows dependency installation. + +pub mod tools; + +use std::error::Error; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::config::AppConfig; +use crate::logging::{log_error, log_install}; +use crate::runtime_wrap; +use crate::steam::SteamProton; + +// Re-export tools +pub use tools::{check_command_available, ensure_cabextract, ensure_winetricks, get_winetricks_path}; + +/// Standard winetricks verbs for MO2 prefix +pub const STANDARD_VERBS: &[&str] = &[ + "vcrun2022", // Visual C++ 2015-2022 Runtime + "dotnet6", // .NET 6.0 + "dotnet7", // .NET 7.0 + "dotnet8", // .NET 8.0 + "dotnetdesktop6", // .NET Desktop Runtime 6.0 + "d3dcompiler_47", // DirectX Compiler 47 + "d3dcompiler_43", // DirectX Compiler 43 + "d3dx9", // DirectX 9 (all versions) + "d3dx11_43", // DirectX 11 + "xact", // XACT Audio (32-bit) + "xact_x64", // XACT Audio (64-bit) +]; + + +/// Run winetricks to install dependencies +pub fn run_winetricks( + prefix_path: &Path, + proton: &SteamProton, + verbs: &[&str], + log_callback: impl Fn(String), +) -> Result<(), Box<dyn Error>> { + if verbs.is_empty() { + return Ok(()); + } + + let winetricks_path = ensure_winetricks()?; + ensure_cabextract()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found in Proton".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found in Proton".into()); + }; + + let cache_dir = AppConfig::get_default_cache_dir(); + std::fs::create_dir_all(&cache_dir)?; + + let verbs_str = verbs.join(" "); + log_callback(format!("Installing dependencies via winetricks: {}", verbs_str)); + log_install(&format!("Running winetricks with verbs: {}", verbs_str)); + + let nak_bin = tools::get_nak_bin_path(); + let current_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", nak_bin.display(), current_path); + + let envs: Vec<(&str, String)> = vec![ + ("PATH", new_path), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINETRICKS_CACHE", cache_dir.display().to_string()), + ]; + let status = runtime_wrap::build_command(&winetricks_path, &envs) + .arg("-q") + .args(verbs) + .status()?; + + if !status.success() { + let err_msg = format!("Winetricks failed with exit code: {:?}", status.code()); + log_error(&err_msg); + return Err(err_msg.into()); + } + + log_install("Winetricks completed successfully"); + Ok(()) +} + +/// Install all standard dependencies to a prefix +pub fn install_standard_deps( + prefix_path: &Path, + proton: &SteamProton, + log_callback: impl Fn(String), +) -> Result<(), Box<dyn Error>> { + run_winetricks(prefix_path, proton, STANDARD_VERBS, log_callback) +} + +/// Run winetricks with cancellation support. +pub fn run_winetricks_cancellable( + prefix_path: &Path, + proton: &SteamProton, + verbs: &[&str], + log_callback: impl Fn(String), + cancel_flag: &Arc<AtomicBool>, +) -> Result<(), Box<dyn Error>> { + if verbs.is_empty() { + return Ok(()); + } + + let winetricks_path = ensure_winetricks()?; + ensure_cabextract()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found in Proton".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found in Proton".into()); + }; + + let cache_dir = AppConfig::get_default_cache_dir(); + std::fs::create_dir_all(&cache_dir)?; + + let verbs_str = verbs.join(" "); + log_callback(format!("Installing dependencies via winetricks: {}", verbs_str)); + log_install(&format!("Running winetricks with verbs: {}", verbs_str)); + + let nak_bin = tools::get_nak_bin_path(); + let current_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", nak_bin.display(), current_path); + + let envs: Vec<(&str, String)> = vec![ + ("PATH", new_path), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINETRICKS_CACHE", cache_dir.display().to_string()), + ]; + let mut child = runtime_wrap::build_command(&winetricks_path, &envs) + .arg("-q") + .args(verbs) + .spawn()?; + + loop { + match child.try_wait()? { + Some(status) => { + if !status.success() { + let err_msg = format!("Winetricks failed with exit code: {:?}", status.code()); + log_error(&err_msg); + return Err(err_msg.into()); + } + log_install("Winetricks completed successfully"); + return Ok(()); + } + None => { + if cancel_flag.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err("Cancelled".into()); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } +} + +/// Install standard deps with cancellation support +pub fn install_standard_deps_cancellable( + prefix_path: &Path, + proton: &SteamProton, + log_callback: impl Fn(String), + cancel_flag: &Arc<AtomicBool>, +) -> Result<(), Box<dyn Error>> { + run_winetricks_cancellable(prefix_path, proton, STANDARD_VERBS, log_callback, cancel_flag) +} diff --git a/libs/nak/src/deps/tools.rs b/libs/nak/src/deps/tools.rs new file mode 100644 index 0000000..c97247c --- /dev/null +++ b/libs/nak/src/deps/tools.rs @@ -0,0 +1,174 @@ +//! Linux tool management (winetricks, cabextract) +//! +//! Handles downloading and managing Linux CLI tools. +//! Tools are stored in ~/.var/app/com.fluorine.manager/bin/ for Fluorine Manager. + +use std::error::Error; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; + +use crate::logging::{log_error, log_info, log_warning}; + +// ============================================================================ +// NaK Bin Directory (~/.var/app/com.fluorine.manager/bin/) +// ============================================================================ + +/// Get the tool bin directory path (~/.var/app/com.fluorine.manager/bin/) +/// This is accessible from both native and Flatpak environments. +pub fn get_nak_bin_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".var/app/com.fluorine.manager/bin") +} + +/// Check if a command exists (either in system PATH or tool bin) +pub fn check_command_available(cmd: &str) -> bool { + // Check system PATH first + if Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return true; + } + + // Check tool bin directory + let nak_bin = get_nak_bin_path().join(cmd); + nak_bin.exists() +} + +// ============================================================================ +// Winetricks +// ============================================================================ + +const WINETRICKS_URL: &str = + "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks"; + +/// Get the path to winetricks (without downloading) +pub fn get_winetricks_path() -> PathBuf { + get_nak_bin_path().join("winetricks") +} + +/// Ensures winetricks is downloaded and up-to-date. +pub fn ensure_winetricks() -> Result<PathBuf, Box<dyn Error>> { + let bin_dir = get_nak_bin_path(); + let winetricks_path = bin_dir.join("winetricks"); + + fs::create_dir_all(&bin_dir)?; + + log_info("Checking for winetricks updates..."); + + match ureq::get(WINETRICKS_URL).call() { + Ok(response) => { + let mut new_content = Vec::new(); + response.into_reader().read_to_end(&mut new_content)?; + + let should_update = if winetricks_path.exists() { + let existing = fs::read(&winetricks_path).unwrap_or_default(); + existing != new_content + } else { + true + }; + + if should_update { + fs::write(&winetricks_path, &new_content)?; + + let mut perms = fs::metadata(&winetricks_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&winetricks_path, perms)?; + + if winetricks_path.exists() { + log_info("Winetricks updated to latest version"); + } else { + log_info(&format!("Winetricks downloaded to {:?}", winetricks_path)); + } + } + } + Err(e) => { + if winetricks_path.exists() { + log_warning(&format!("Failed to check winetricks updates: {}", e)); + } else { + return Err(format!("Failed to download winetricks: {}", e).into()); + } + } + } + + Ok(winetricks_path) +} + +// ============================================================================ +// Cabextract (required by winetricks for DirectX cabs) +// ============================================================================ + +const CABEXTRACT_URL: &str = + "https://github.com/SulfurNitride/NaK/releases/download/Cabextract/cabextract-linux-x86_64.zip"; + +/// Ensures cabextract is available (either system or downloaded). +pub fn ensure_cabextract() -> Result<PathBuf, Box<dyn Error>> { + // First check if system has cabextract + if Command::new("which") + .arg("cabextract") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(PathBuf::from("cabextract")); + } + + // Check if we already downloaded it + let bin_dir = get_nak_bin_path(); + let cabextract_path = bin_dir.join("cabextract"); + + if cabextract_path.exists() { + return Ok(cabextract_path); + } + + // Download cabextract zip + log_warning("System cabextract not found, downloading..."); + fs::create_dir_all(&bin_dir)?; + + let response = ureq::get(CABEXTRACT_URL).call().map_err(|e| { + format!( + "Failed to download cabextract: {}. Please install cabextract manually.", + e + ) + })?; + + let zip_path = bin_dir.join("cabextract.zip"); + let mut zip_file = fs::File::create(&zip_path)?; + std::io::copy(&mut response.into_reader(), &mut zip_file)?; + + let status = Command::new("unzip") + .arg("-o") + .arg(&zip_path) + .arg("-d") + .arg(&bin_dir) + .status()?; + + if !status.success() { + let _ = Command::new("python3") + .arg("-c") + .arg(format!( + "import zipfile; zipfile.ZipFile('{}').extractall('{}')", + zip_path.display(), + bin_dir.display() + )) + .status(); + } + + let _ = fs::remove_file(&zip_path); + + if cabextract_path.exists() { + let mut perms = fs::metadata(&cabextract_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&cabextract_path, perms)?; + log_info(&format!("cabextract downloaded to {:?}", cabextract_path)); + Ok(cabextract_path) + } else { + log_error("Failed to extract cabextract from zip"); + Err("Failed to extract cabextract from zip".into()) + } +} diff --git a/libs/nak/src/dxvk.rs b/libs/nak/src/dxvk.rs new file mode 100644 index 0000000..97e4222 --- /dev/null +++ b/libs/nak/src/dxvk.rs @@ -0,0 +1,71 @@ +//! DXVK configuration management for Fluorine Manager. +//! +//! Downloads dxvk.conf from upstream, appends Fluorine-specific settings, +//! and stores at `~/.var/app/com.fluorine.manager/config/dxvk.conf`. + +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::logging::{log_info, log_warning}; + +const DXVK_CONF_URL: &str = + "https://raw.githubusercontent.com/doitsujin/dxvk/master/dxvk.conf"; + +const DXVK_CUSTOM_SETTINGS: &str = r#" +# Fluorine Custom Settings +# Disable Graphics Pipeline Library (can cause issues with modded games) +dxvk.enableGraphicsPipelineLibrary = False +"#; + +/// Get the path where the DXVK config will be stored. +pub fn get_dxvk_conf_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join(".var/app/com.fluorine.manager/config/dxvk.conf") +} + +/// Ensure the dxvk.conf file exists, downloading if necessary. +/// +/// Returns the path to the config file. +pub fn ensure_dxvk_conf() -> Result<PathBuf, Box<dyn Error>> { + let conf_path = get_dxvk_conf_path(); + + // If it already exists, return it + if conf_path.exists() { + return Ok(conf_path); + } + + download_and_create_dxvk_conf(&conf_path) +} + +/// Download the upstream dxvk.conf, append custom settings, and write to `dest`. +pub fn download_and_create_dxvk_conf(dest: &Path) -> Result<PathBuf, Box<dyn Error>> { + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + + log_info("Downloading dxvk.conf from upstream..."); + + let upstream_content = match ureq::get(DXVK_CONF_URL).call() { + Ok(response) => { + let mut body = String::new(); + response.into_reader().read_to_string(&mut body)?; + body + } + Err(e) => { + log_warning(&format!("Failed to download dxvk.conf: {}", e)); + // Create with just custom settings if download fails + String::new() + } + }; + + let full_content = format!("{}\n{}", upstream_content, DXVK_CUSTOM_SETTINGS); + fs::write(dest, &full_content)?; + + log_info(&format!("Created dxvk.conf at {:?}", dest)); + Ok(dest.to_path_buf()) +} + +use std::io::Read as _; diff --git a/libs/nak/src/game_finder/bottles.rs b/libs/nak/src/game_finder/bottles.rs new file mode 100644 index 0000000..7d974e8 --- /dev/null +++ b/libs/nak/src/game_finder/bottles.rs @@ -0,0 +1,147 @@ +//! Bottles prefix detection +//! +//! Detects games installed in Bottles prefixes by scanning registry files +//! for known game registry entries. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::known_games::KNOWN_GAMES; +use super::registry::{read_registry_value, wine_path_to_linux}; +use super::{Game, Launcher}; +use crate::logging::log_info; + +/// Possible Bottles data paths +const BOTTLES_PATHS: &[&str] = &[ + ".local/share/bottles/bottles", + ".var/app/com.usebottles.bottles/data/bottles/bottles", +]; + +/// Detect all games in Bottles prefixes +pub fn detect_bottles_games() -> Vec<Game> { + let mut games = Vec::new(); + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return games, + }; + + for relative_path in BOTTLES_PATHS { + let bottles_path = PathBuf::from(&home).join(relative_path); + if !bottles_path.exists() { + continue; + } + + log_info(&format!("Found Bottles installation: {}", bottles_path.display())); + + // Scan each bottle + let Ok(entries) = fs::read_dir(&bottles_path) else { + continue; + }; + + for entry in entries.flatten() { + let bottle_path = entry.path(); + if !bottle_path.is_dir() { + continue; + } + + // Each bottle might have games + let bottle_games = scan_bottle(&bottle_path); + games.extend(bottle_games); + } + } + + log_info(&format!("Bottles: Found {} installed games", games.len())); + games +} + +/// Scan a single Bottles bottle for known games +fn scan_bottle(bottle_path: &Path) -> Vec<Game> { + let mut games = Vec::new(); + + // The prefix is directly in the bottle folder (not in pfx subfolder like Steam) + // Check for drive_c to confirm it's a valid Wine prefix + let drive_c = bottle_path.join("drive_c"); + if !drive_c.exists() { + return games; + } + + let bottle_name = bottle_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + + // Check for each known game by registry entry + for known_game in KNOWN_GAMES { + if let Some(install_path_wine) = + read_registry_value(bottle_path, known_game.registry_path, known_game.registry_value) + { + // Convert Wine path to Linux path + let install_path = match wine_path_to_linux(&install_path_wine) { + Some(p) => p, + None => { + // Try as a relative path within drive_c + if install_path_wine.starts_with("C:") || install_path_wine.starts_with("c:") { + let relative = install_path_wine[2..].replace('\\', "/"); + drive_c.join(relative.trim_start_matches('/')) + } else { + continue; + } + } + }; + + if !install_path.exists() { + continue; + } + + log_info(&format!( + "Found {} in Bottles bottle '{}'", + known_game.name, bottle_name + )); + + games.push(Game { + name: known_game.name.to_string(), + app_id: format!("bottles-{}", known_game.steam_app_id), + install_path, + prefix_path: Some(bottle_path.to_path_buf()), + launcher: Launcher::Bottles, + my_games_folder: known_game.my_games_folder.map(String::from), + appdata_local_folder: known_game.appdata_local_folder.map(String::from), + appdata_roaming_folder: known_game.appdata_roaming_folder.map(String::from), + registry_path: Some(known_game.registry_path.to_string()), + registry_value: Some(known_game.registry_value.to_string()), + }); + } + } + + games +} + +/// Find all Bottles prefixes (for manual prefix selection) +pub fn find_bottles_prefixes() -> Vec<PathBuf> { + let mut prefixes = Vec::new(); + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return prefixes, + }; + + for relative_path in BOTTLES_PATHS { + let bottles_path = PathBuf::from(&home).join(relative_path); + if !bottles_path.exists() { + continue; + } + + let Ok(entries) = fs::read_dir(&bottles_path) else { + continue; + }; + + for entry in entries.flatten() { + let bottle_path = entry.path(); + // Verify it's a valid Wine prefix + if bottle_path.is_dir() && bottle_path.join("drive_c").exists() { + prefixes.push(bottle_path); + } + } + } + + prefixes +} diff --git a/libs/nak/src/game_finder/heroic.rs b/libs/nak/src/game_finder/heroic.rs new file mode 100644 index 0000000..7bdda09 --- /dev/null +++ b/libs/nak/src/game_finder/heroic.rs @@ -0,0 +1,277 @@ +//! Heroic Games Launcher detection +//! +//! Detects games installed via Heroic (GOG and Epic Games). +//! Parses installed.json and GamesConfig/*.json for game and prefix info. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::known_games::find_by_gog_id; +use super::{Game, HeroicStore, Launcher}; +use crate::logging::{log_info, log_warning}; + +/// Possible Heroic configuration paths +const HEROIC_PATHS: &[&str] = &[ + ".config/heroic", // Native + ".var/app/com.heroicgameslauncher.hgl/config/heroic", // Flatpak +]; + +/// Detect all Heroic games +pub fn detect_heroic_games() -> Vec<Game> { + let mut games = Vec::new(); + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return games, + }; + + for relative_path in HEROIC_PATHS { + let heroic_path = PathBuf::from(&home).join(relative_path); + if !heroic_path.exists() { + continue; + } + + log_info(&format!("Found Heroic installation: {}", heroic_path.display())); + + // Detect GOG games + let gog_games = detect_gog_games(&heroic_path); + games.extend(gog_games); + + // Detect Epic games + let epic_games = detect_epic_games(&heroic_path); + games.extend(epic_games); + } + + log_info(&format!("Heroic: Found {} installed games", games.len())); + games +} + +// ============================================================================ +// GOG Detection +// ============================================================================ + +/// GOG installed game entry from installed.json +#[derive(Debug, Deserialize)] +struct GogInstalledGame { + #[serde(rename = "appName")] + app_name: String, + title: Option<String>, + #[serde(rename = "install_path")] + install_path: Option<String>, + platform: Option<String>, +} + +/// Wrapper for Heroic's GOG installed.json format: {"installed": [...]} +#[derive(Debug, Deserialize)] +struct GogInstalledWrapper { + installed: Vec<GogInstalledGame>, +} + +/// Detect GOG games from Heroic +fn detect_gog_games(heroic_path: &Path) -> Vec<Game> { + let mut games = Vec::new(); + let installed_json = heroic_path.join("gog_store/installed.json"); + + let Ok(content) = fs::read_to_string(&installed_json) else { + return games; + }; + + // Heroic wraps GOG games in {"installed": [...]}, but also handle bare arrays + let installed: Vec<GogInstalledGame> = + if let Ok(wrapper) = serde_json::from_str::<GogInstalledWrapper>(&content) { + wrapper.installed + } else if let Ok(list) = serde_json::from_str::<Vec<GogInstalledGame>>(&content) { + list + } else { + log_warning("Failed to parse Heroic GOG installed.json"); + return games; + }; + + for gog_game in installed { + // Skip non-Windows games (we only care about Wine prefixes) + if gog_game.platform.as_deref() != Some("windows") { + continue; + } + + let Some(install_path_str) = gog_game.install_path else { + continue; + }; + + let install_path = PathBuf::from(&install_path_str); + if !install_path.exists() { + continue; + } + + // Get the game config for Wine prefix info + let prefix_path = get_heroic_game_prefix(heroic_path, &gog_game.app_name); + + // Look up known game info + let known_game = find_by_gog_id(&gog_game.app_name); + + let name = gog_game + .title + .unwrap_or_else(|| gog_game.app_name.clone()); + + games.push(Game { + name, + app_id: gog_game.app_name, + install_path, + prefix_path, + launcher: Launcher::Heroic { + store: HeroicStore::GOG, + }, + my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)), + appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)), + appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)), + registry_path: known_game.map(|g| g.registry_path.to_string()), + registry_value: known_game.map(|g| g.registry_value.to_string()), + }); + } + + games +} + +// ============================================================================ +// Epic Detection +// ============================================================================ + +/// Epic installed game entry from installed.json +#[derive(Debug, Deserialize)] +struct EpicInstalledGame { + #[serde(rename = "app_name")] + app_name: String, + title: Option<String>, + install_path: Option<String>, + platform: Option<String>, + is_installed: Option<bool>, +} + +/// Detect Epic games from Heroic +fn detect_epic_games(heroic_path: &Path) -> Vec<Game> { + let mut games = Vec::new(); + let installed_json = heroic_path.join("store_cache/legendary_library.json"); + + // Also try the older location + let installed_json = if installed_json.exists() { + installed_json + } else { + heroic_path.join("legendaryConfig/legendary/installed.json") + }; + + let Ok(content) = fs::read_to_string(&installed_json) else { + return games; + }; + + // The Epic library format can vary - try parsing as an object with game keys + if let Ok(library) = serde_json::from_str::<serde_json::Value>(&content) { + if let Some(obj) = library.as_object() { + for (app_name, game_data) in obj { + let Some(game_obj) = game_data.as_object() else { + continue; + }; + + // Check if installed + let is_installed = game_obj + .get("is_installed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_installed { + continue; + } + + // Get platform + let platform = game_obj + .get("platform") + .and_then(|v| v.as_str()); + if platform != Some("Windows") && platform != Some("windows") { + continue; + } + + // Get install path + let Some(install_path_str) = game_obj + .get("install_path") + .and_then(|v| v.as_str()) + else { + continue; + }; + + let install_path = PathBuf::from(install_path_str); + if !install_path.exists() { + continue; + } + + // Get title + let name = game_obj + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(app_name) + .to_string(); + + // Get Wine prefix + let prefix_path = get_heroic_game_prefix(heroic_path, app_name); + + games.push(Game { + name, + app_id: app_name.clone(), + install_path, + prefix_path, + launcher: Launcher::Heroic { + store: HeroicStore::Epic, + }, + my_games_folder: None, + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: None, + registry_value: None, + }); + } + } + } + + games +} + +// ============================================================================ +// Shared Utilities +// ============================================================================ + +/// Game config entry for Wine settings +#[derive(Debug, Deserialize)] +struct HeroicGameConfig { + #[serde(rename = "winePrefix")] + wine_prefix: Option<String>, + #[serde(rename = "wineVersion")] + wine_version: Option<WineVersion>, +} + +#[derive(Debug, Deserialize)] +struct WineVersion { + bin: Option<String>, + name: Option<String>, + #[serde(rename = "type")] + wine_type: Option<String>, +} + +/// Get the Wine prefix for a Heroic game from its config file +fn get_heroic_game_prefix(heroic_path: &Path, app_name: &str) -> Option<PathBuf> { + let config_path = heroic_path.join(format!("GamesConfig/{}.json", app_name)); + + let content = fs::read_to_string(&config_path).ok()?; + + // The config can be either a direct object or wrapped in the app_name key + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + + // Try to get winePrefix from the object or from a nested object + let wine_prefix = config + .get("winePrefix") + .or_else(|| config.get(app_name).and_then(|v| v.get("winePrefix"))) + .and_then(|v| v.as_str())?; + + let prefix_path = PathBuf::from(wine_prefix); + if prefix_path.exists() { + Some(prefix_path) + } else { + None + } +} diff --git a/libs/nak/src/game_finder/known_games.rs b/libs/nak/src/game_finder/known_games.rs new file mode 100644 index 0000000..8a30c18 --- /dev/null +++ b/libs/nak/src/game_finder/known_games.rs @@ -0,0 +1,245 @@ +//! Known games configuration +//! +//! Contains metadata for games that NaK supports, including: +//! - Steam App ID +//! - My Games folder name (Documents/My Games/*) +//! - AppData/Local folder name +//! - Registry path for game detection + +/// Configuration for a known game +#[derive(Debug, Clone)] +pub struct KnownGame { + /// Display name + pub name: &'static str, + /// Steam App ID + pub steam_app_id: &'static str, + /// GOG App ID (if available) + pub gog_app_id: Option<&'static str>, + /// Folder name in Documents/My Games (if applicable) + pub my_games_folder: Option<&'static str>, + /// Folder name in AppData/Local (if applicable) + pub appdata_local_folder: Option<&'static str>, + /// Folder name in AppData/Roaming (if applicable) + pub appdata_roaming_folder: Option<&'static str>, + /// Registry path under HKLM\Software\ (for game detection) + pub registry_path: &'static str, + /// Registry value name for install path + pub registry_value: &'static str, + /// Expected folder name in steamapps/common/ + pub steam_folder: &'static str, +} + +/// All known games that NaK supports +pub const KNOWN_GAMES: &[KnownGame] = &[ + // Bethesda Games + KnownGame { + name: "Enderal", + steam_app_id: "933480", + gog_app_id: None, + my_games_folder: Some("Enderal"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\SureAI\Enderal", + registry_value: "Install_Path", + steam_folder: "Enderal", + }, + KnownGame { + name: "Enderal Special Edition", + steam_app_id: "976620", + gog_app_id: None, + my_games_folder: Some("Enderal Special Edition"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\SureAI\Enderal SE", + registry_value: "installed path", + steam_folder: "Enderal Special Edition", + }, + KnownGame { + name: "Fallout 3", + steam_app_id: "22300", + gog_app_id: Some("1454315831"), // Fallout 3 GOTY + my_games_folder: Some("Fallout3"), + appdata_local_folder: Some("Fallout3"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Fallout3", + registry_value: "Installed Path", + steam_folder: "Fallout 3", + }, + KnownGame { + name: "Fallout 4", + steam_app_id: "377160", + gog_app_id: None, + my_games_folder: Some("Fallout4"), + appdata_local_folder: Some("Fallout4"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Fallout4", + registry_value: "Installed Path", + steam_folder: "Fallout 4", + }, + KnownGame { + name: "Fallout 4 VR", + steam_app_id: "611660", + gog_app_id: None, + my_games_folder: Some("Fallout4VR"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Fallout 4 VR", + registry_value: "Installed Path", + steam_folder: "Fallout 4 VR", + }, + KnownGame { + name: "Fallout New Vegas", + steam_app_id: "22380", + gog_app_id: Some("1454587428"), // Fallout NV Ultimate + my_games_folder: Some("FalloutNV"), + appdata_local_folder: Some("FalloutNV"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\FalloutNV", + registry_value: "Installed Path", + steam_folder: "Fallout New Vegas", + }, + KnownGame { + name: "Morrowind", + steam_app_id: "22320", + gog_app_id: Some("1440163901"), // Morrowind GOTY + my_games_folder: Some("Morrowind"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Morrowind", + registry_value: "Installed Path", + steam_folder: "Morrowind", + }, + KnownGame { + name: "Oblivion", + steam_app_id: "22330", + gog_app_id: Some("1458058109"), // Oblivion GOTY Deluxe + my_games_folder: Some("Oblivion"), + appdata_local_folder: Some("Oblivion"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Oblivion", + registry_value: "Installed Path", + steam_folder: "Oblivion", + }, + KnownGame { + name: "Skyrim", + steam_app_id: "72850", + gog_app_id: None, // Not on GOG + my_games_folder: Some("Skyrim"), + appdata_local_folder: Some("Skyrim"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Skyrim", + registry_value: "Installed Path", + steam_folder: "Skyrim", + }, + KnownGame { + name: "Skyrim Special Edition", + steam_app_id: "489830", + gog_app_id: Some("1711230643"), // Skyrim SE Anniversary Edition + my_games_folder: Some("Skyrim Special Edition"), + appdata_local_folder: Some("Skyrim Special Edition"), + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Skyrim Special Edition", + registry_value: "Installed Path", + steam_folder: "Skyrim Special Edition", + }, + KnownGame { + name: "Skyrim VR", + steam_app_id: "611670", + gog_app_id: None, + my_games_folder: Some("Skyrim VR"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Skyrim VR", + registry_value: "Installed Path", + steam_folder: "Skyrim VR", + }, + KnownGame { + name: "Starfield", + steam_app_id: "1716740", + gog_app_id: None, + my_games_folder: Some("Starfield"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\Bethesda Softworks\Starfield", + registry_value: "Installed Path", + steam_folder: "Starfield", + }, + // CD Projekt RED Games + KnownGame { + name: "The Witcher 3", + steam_app_id: "292030", + gog_app_id: Some("1495134320"), // Witcher 3 GOTY + my_games_folder: Some("The Witcher 3"), + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: r"Software\CD Projekt Red\The Witcher 3", + registry_value: "InstallFolder", + steam_folder: "The Witcher 3 Wild Hunt", + }, + KnownGame { + name: "Cyberpunk 2077", + steam_app_id: "1091500", + gog_app_id: Some("1423049311"), + my_games_folder: None, + appdata_local_folder: Some("CD Projekt Red/Cyberpunk 2077"), + appdata_roaming_folder: None, + registry_path: r"Software\CD Projekt Red\Cyberpunk 2077", + registry_value: "InstallFolder", + steam_folder: "Cyberpunk 2077", + }, + // Other popular moddable games + KnownGame { + name: "Baldur's Gate 3", + steam_app_id: "1086940", + gog_app_id: Some("1456460669"), + my_games_folder: None, + appdata_local_folder: Some("Larian Studios/Baldur's Gate 3"), + appdata_roaming_folder: None, + registry_path: r"Software\Larian Studios\Baldur's Gate 3", + registry_value: "InstallDir", + steam_folder: "Baldurs Gate 3", + }, +]; + +/// Find a known game by Steam App ID +pub fn find_by_steam_id(app_id: &str) -> Option<&'static KnownGame> { + let normalized_id = normalize_steam_id(app_id); + KNOWN_GAMES.iter().find(|g| g.steam_app_id == normalized_id) +} + +/// Find a known game by GOG App ID +pub fn find_by_gog_id(app_id: &str) -> Option<&'static KnownGame> { + KNOWN_GAMES + .iter() + .find(|g| g.gog_app_id == Some(app_id)) +} + +/// Find a known game by name (case-insensitive) +pub fn find_by_name(name: &str) -> Option<&'static KnownGame> { + let name_lower = name.to_lowercase(); + KNOWN_GAMES + .iter() + .find(|g| g.name.to_lowercase() == name_lower) +} + +/// Normalize Steam App IDs that have equivalent variants. +fn normalize_steam_id(app_id: &str) -> &str { + match app_id { + // Fallout 3 often appears as GOTY App ID 22370. + // We treat it as Fallout 3 for shared metadata/registry mapping. + "22370" => "22300", + _ => app_id, + } +} + +#[cfg(test)] +mod tests { + use super::find_by_steam_id; + + #[test] + fn fallout_3_goty_alias_maps_to_fallout_3() { + let game = find_by_steam_id("22370").expect("22370 should map to Fallout 3"); + assert_eq!(game.name, "Fallout 3"); + assert_eq!(game.steam_app_id, "22300"); + } +} diff --git a/libs/nak/src/game_finder/mod.rs b/libs/nak/src/game_finder/mod.rs new file mode 100644 index 0000000..c383c8a --- /dev/null +++ b/libs/nak/src/game_finder/mod.rs @@ -0,0 +1,191 @@ +//! Game detection module +//! +//! Provides unified game detection across multiple launchers: +//! - Steam (native, Flatpak, Snap) +//! - Heroic (GOG, Epic) +//! - Bottles + +// Allow unused items - this is a public API module +#![allow(dead_code)] +#![allow(unused_imports)] + +mod bottles; +mod heroic; +pub mod known_games; +mod registry; +mod steam; +mod vdf; + +use std::path::PathBuf; + +pub use bottles::detect_bottles_games; +pub use heroic::detect_heroic_games; +pub use known_games::{find_by_gog_id, find_by_name, find_by_steam_id, KnownGame, KNOWN_GAMES}; +pub use registry::{read_registry_value, wine_path_to_linux}; +pub use steam::{detect_steam_games, find_game_install_path, find_game_prefix_path, get_known_game}; + +// ============================================================================ +// Core Types +// ============================================================================ + +/// The launcher/store a game was installed from +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Launcher { + Steam { is_flatpak: bool, is_snap: bool }, + Heroic { store: HeroicStore }, + Bottles, +} + +impl Launcher { + pub fn display_name(&self) -> &'static str { + match self { + Launcher::Steam { is_flatpak: true, .. } => "Steam (Flatpak)", + Launcher::Steam { is_snap: true, .. } => "Steam (Snap)", + Launcher::Steam { .. } => "Steam", + Launcher::Heroic { store: HeroicStore::GOG } => "Heroic (GOG)", + Launcher::Heroic { store: HeroicStore::Epic } => "Heroic (Epic)", + Launcher::Bottles => "Bottles", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeroicStore { + GOG, + Epic, +} + +/// A detected game installation +#[derive(Debug, Clone)] +pub struct Game { + pub name: String, + pub app_id: String, + pub install_path: PathBuf, + pub prefix_path: Option<PathBuf>, + pub launcher: Launcher, + pub my_games_folder: Option<String>, + pub appdata_local_folder: Option<String>, + pub appdata_roaming_folder: Option<String>, + pub registry_path: Option<String>, + pub registry_value: Option<String>, +} + +impl Game { + pub fn has_prefix(&self) -> bool { + self.prefix_path.is_some() + } + + pub fn get_prefix_user_path(&self) -> Option<PathBuf> { + let prefix = self.prefix_path.as_ref()?; + let users_dir = prefix.join("drive_c/users"); + + if let Ok(entries) = std::fs::read_dir(&users_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name != "Public" && name != "root" { + return Some(users_dir.join(name)); + } + } + } + + Some(users_dir.join("steamuser")) + } + + pub fn get_prefix_documents_path(&self) -> Option<PathBuf> { + self.get_prefix_user_path().map(|p| p.join("Documents")) + } + + pub fn get_prefix_my_games_path(&self) -> Option<PathBuf> { + let docs = self.get_prefix_documents_path()?; + let folder = self.my_games_folder.as_ref()?; + Some(docs.join("My Games").join(folder)) + } + + pub fn get_prefix_appdata_local_path(&self) -> Option<PathBuf> { + let user = self.get_prefix_user_path()?; + let folder = self.appdata_local_folder.as_ref()?; + Some(user.join("AppData/Local").join(folder)) + } + + pub fn get_prefix_appdata_roaming_path(&self) -> Option<PathBuf> { + let user = self.get_prefix_user_path()?; + let folder = self.appdata_roaming_folder.as_ref()?; + Some(user.join("AppData/Roaming").join(folder)) + } +} + +// ============================================================================ +// Scan Results +// ============================================================================ + +#[derive(Debug, Default)] +pub struct GameScanResult { + pub games: Vec<Game>, + pub steam_count: usize, + pub heroic_count: usize, + pub bottles_count: usize, +} + +impl GameScanResult { + pub fn games_with_prefixes(&self) -> impl Iterator<Item = &Game> { + self.games.iter().filter(|g| g.has_prefix()) + } + + pub fn games_by_launcher(&self, launcher_type: &str) -> Vec<&Game> { + self.games + .iter() + .filter(|g| { + matches!( + (&g.launcher, launcher_type), + (Launcher::Steam { .. }, "steam") + | (Launcher::Heroic { .. }, "heroic") + | (Launcher::Bottles, "bottles") + ) + }) + .collect() + } + + pub fn find_by_name(&self, name: &str) -> Option<&Game> { + let name_lower = name.to_lowercase(); + self.games + .iter() + .find(|g| g.name.to_lowercase() == name_lower) + } + + pub fn find_by_app_id(&self, app_id: &str) -> Option<&Game> { + self.games.iter().find(|g| g.app_id == app_id) + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/// Detect all installed games from all supported launchers +pub fn detect_all_games() -> GameScanResult { + let mut result = GameScanResult::default(); + + let steam_games = detect_steam_games(); + result.steam_count = steam_games.len(); + result.games.extend(steam_games); + + let heroic_games = detect_heroic_games(); + result.heroic_count = heroic_games.len(); + result.games.extend(heroic_games); + + let bottles_games = detect_bottles_games(); + result.bottles_count = bottles_games.len(); + result.games.extend(bottles_games); + + result +} + +/// Detect only Steam games +pub fn detect_steam_only() -> GameScanResult { + let steam_games = detect_steam_games(); + GameScanResult { + steam_count: steam_games.len(), + games: steam_games, + ..Default::default() + } +} diff --git a/libs/nak/src/game_finder/registry.rs b/libs/nak/src/game_finder/registry.rs new file mode 100644 index 0000000..e843b6e --- /dev/null +++ b/libs/nak/src/game_finder/registry.rs @@ -0,0 +1,227 @@ +//! Wine registry parsing utilities +//! +//! Parses Wine registry files (system.reg, user.reg) to find game install paths. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::logging::log_warning; + +/// Read a registry value from a Wine prefix registry file +/// +/// # Arguments +/// * `prefix_path` - Path to the Wine prefix (containing system.reg, user.reg) +/// * `key_path` - Registry key path (e.g., "Software\\Bethesda Softworks\\Skyrim") +/// * `value_name` - Name of the value to read (e.g., "Installed Path") +/// +/// Returns the value as a string, or None if not found +pub fn read_registry_value( + prefix_path: &Path, + key_path: &str, + value_name: &str, +) -> Option<String> { + // Try system.reg first (HKEY_LOCAL_MACHINE) + let system_reg = prefix_path.join("system.reg"); + if let Some(value) = read_value_from_reg_file(&system_reg, key_path, value_name) { + return Some(value); + } + + // Try user.reg (HKEY_CURRENT_USER) + let user_reg = prefix_path.join("user.reg"); + if let Some(value) = read_value_from_reg_file(&user_reg, key_path, value_name) { + return Some(value); + } + + None +} + +/// Read a value from a specific .reg file +fn read_value_from_reg_file(reg_file: &Path, key_path: &str, value_name: &str) -> Option<String> { + let content = fs::read_to_string(reg_file).ok()?; + + // Convert the key path to Wine's format + // Wine uses lowercase keys with escaped backslashes + // e.g., [Software\\Bethesda Softworks\\Skyrim] becomes [software\\\\bethesda softworks\\\\skyrim] + let wine_key = format!( + "[{}]", + key_path.to_lowercase().replace('\\', "\\\\") + ); + + // Also try with the Wow6432Node variant for 32-bit apps on 64-bit Wine + let wine_key_wow64 = format!( + "[software\\\\wow6432node\\\\{}]", + key_path + .strip_prefix("Software\\") + .unwrap_or(key_path) + .to_lowercase() + .replace('\\', "\\\\") + ); + + // Find the key section and extract the value + for key_to_find in [&wine_key, &wine_key_wow64] { + if let Some(value) = find_value_in_content(&content, key_to_find, value_name) { + return Some(value); + } + } + + None +} + +/// Find a value within registry file content +fn find_value_in_content(content: &str, key: &str, value_name: &str) -> Option<String> { + let mut in_target_key = false; + let value_name_lower = value_name.to_lowercase(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Check for key header + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_target_key = trimmed.to_lowercase() == key.to_lowercase(); + continue; + } + + // If we're in the target key, look for the value + if in_target_key { + // Empty line or new section means we've left the key + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with('[') { + break; + } + + // Parse value line: "ValueName"="value" or @="default value" + if let Some((name, value)) = parse_reg_value_line(trimmed) { + if name.to_lowercase() == value_name_lower { + return Some(value); + } + } + } + } + + None +} + +/// Parse a registry value line like "ValueName"="value" +fn parse_reg_value_line(line: &str) -> Option<(String, String)> { + // Format: "name"="value" or "name"=dword:00000000 or @="default" + let line = line.trim(); + + // Find the = separator + let eq_pos = line.find('=')?; + let (name_part, value_part) = line.split_at(eq_pos); + let value_part = &value_part[1..]; // Skip the '=' + + // Extract name (remove quotes) + let name = if name_part == "@" { + "@".to_string() + } else { + name_part.trim().trim_matches('"').to_string() + }; + + // Extract value + let value = if value_part.starts_with('"') { + // String value - need to handle escapes + parse_quoted_reg_value(value_part)? + } else if value_part.starts_with("dword:") { + // DWORD value - convert to decimal string + let hex = value_part.strip_prefix("dword:")?; + let num = u32::from_str_radix(hex, 16).ok()?; + num.to_string() + } else { + // Other types - return as-is + value_part.to_string() + }; + + Some((name, value)) +} + +/// Parse a quoted registry value, handling Wine's escape sequences +fn parse_quoted_reg_value(s: &str) -> Option<String> { + let s = s.trim(); + if !s.starts_with('"') { + return None; + } + + let mut result = String::new(); + let chars = s[1..].chars(); + let mut prev_was_backslash = false; + + for c in chars { + if prev_was_backslash { + match c { + 'n' => result.push('\n'), + 'r' => result.push('\r'), + 't' => result.push('\t'), + '\\' => result.push('\\'), + '"' => result.push('"'), + _ => { + result.push('\\'); + result.push(c); + } + } + prev_was_backslash = false; + } else if c == '\\' { + prev_was_backslash = true; + } else if c == '"' { + break; // End of string + } else { + result.push(c); + } + } + + Some(result) +} + +/// Convert a Wine path (Z:\path\to\file) to a Linux path +pub fn wine_path_to_linux(wine_path: &str) -> Option<PathBuf> { + let path = wine_path.trim(); + + // Handle Z: drive (maps to /) + if path.starts_with("Z:") || path.starts_with("z:") { + let linux_path = path[2..].replace('\\', "/"); + return Some(PathBuf::from(linux_path)); + } + + // Handle C: drive (maps to prefix/drive_c) + // Note: This requires knowing the prefix path, so we can't convert it here + // For now, we'll just return None for C: paths + if path.starts_with("C:") || path.starts_with("c:") { + log_warning(&format!( + "Cannot convert C: drive path without prefix: {}", + path + )); + return None; + } + + None +} + +/// Check if a Wine prefix contains a specific game by registry key +pub fn has_game_registry( + prefix_path: &Path, + registry_path: &str, + registry_value: &str, +) -> bool { + read_registry_value(prefix_path, registry_path, registry_value).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_reg_value_line() { + let (name, value) = + parse_reg_value_line(r#""Installed Path"="Z:\\mnt\\games\\Skyrim""#).unwrap(); + assert_eq!(name, "Installed Path"); + assert_eq!(value, r"Z:\mnt\games\Skyrim"); + } + + #[test] + fn test_wine_path_to_linux() { + let linux = wine_path_to_linux(r"Z:\mnt\games\Skyrim").unwrap(); + assert_eq!(linux, PathBuf::from("/mnt/games/Skyrim")); + } +} diff --git a/libs/nak/src/game_finder/steam.rs b/libs/nak/src/game_finder/steam.rs new file mode 100644 index 0000000..b88a0b0 --- /dev/null +++ b/libs/nak/src/game_finder/steam.rs @@ -0,0 +1,251 @@ +//! Steam game detection +//! +//! Detects games installed via Steam by parsing appmanifest_*.acf files. +//! Supports native, Flatpak, and Snap Steam installations. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::known_games::{find_by_steam_id, KnownGame}; +use super::vdf::{parse_library_folders, AppManifest}; +use super::{Game, Launcher}; +use crate::logging::log_info; + +/// All possible Steam installation paths to check +const STEAM_PATHS: &[&str] = &[ + ".local/share/Steam", + ".steam/debian-installation", + ".steam/steam", + ".var/app/com.valvesoftware.Steam/data/Steam", + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + "snap/steam/common/.local/share/Steam", +]; + +/// Detect all Steam games across all installations +pub fn detect_steam_games() -> Vec<Game> { + let mut games = Vec::new(); + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return games, + }; + + // Find all Steam installations + for steam_info in find_steam_installations(&home) { + let libraries = get_library_folders(&steam_info.path); + + for library_path in libraries { + let steamapps = library_path.join("steamapps"); + if !steamapps.exists() { + continue; + } + + // Scan for appmanifest_*.acf files + let Ok(entries) = fs::read_dir(&steamapps) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if name.starts_with("appmanifest_") && name.ends_with(".acf") { + if let Some(game) = parse_appmanifest(&path, &steamapps, &steam_info) { + games.push(game); + } + } + } + } + } + + log_info(&format!("Steam: Found {} installed games", games.len())); + games +} + +/// Information about a Steam installation +struct SteamInstallation { + path: PathBuf, + is_flatpak: bool, + is_snap: bool, +} + +/// Find all Steam installations on the system +fn find_steam_installations(home: &str) -> Vec<SteamInstallation> { + let mut installations = Vec::new(); + + for relative_path in STEAM_PATHS { + let full_path = PathBuf::from(home).join(relative_path); + + // Check if this is a valid Steam installation + if full_path.join("steamapps").exists() || full_path.join("steam.pid").exists() { + let is_flatpak = relative_path.contains(".var/app/com.valvesoftware.Steam"); + let is_snap = relative_path.contains("snap/steam"); + + // Avoid duplicates (symlinks can cause the same installation to appear twice) + let canonical = full_path.canonicalize().unwrap_or(full_path.clone()); + if !installations.iter().any(|i: &SteamInstallation| { + i.path.canonicalize().unwrap_or(i.path.clone()) == canonical + }) { + log_info(&format!( + "Found Steam installation: {} (flatpak={}, snap={})", + full_path.display(), + is_flatpak, + is_snap + )); + installations.push(SteamInstallation { + path: full_path, + is_flatpak, + is_snap, + }); + } + } + } + + installations +} + +/// Get all library folders for a Steam installation +fn get_library_folders(steam_path: &Path) -> Vec<PathBuf> { + let mut folders = Vec::new(); + + // The Steam installation directory itself is always a library + folders.push(steam_path.to_path_buf()); + + // Parse libraryfolders.vdf for additional libraries + let vdf_path = steam_path.join("steamapps/libraryfolders.vdf"); + if let Ok(content) = fs::read_to_string(&vdf_path) { + for path_str in parse_library_folders(&content) { + let path = PathBuf::from(&path_str); + if path.exists() && !folders.contains(&path) { + folders.push(path); + } + } + } + + // Also check the older config/libraryfolders.vdf location + let old_vdf_path = steam_path.join("config/libraryfolders.vdf"); + if old_vdf_path != vdf_path { + if let Ok(content) = fs::read_to_string(&old_vdf_path) { + for path_str in parse_library_folders(&content) { + let path = PathBuf::from(&path_str); + if path.exists() && !folders.contains(&path) { + folders.push(path); + } + } + } + } + + folders +} + +/// Parse an appmanifest_*.acf file and create a Game struct +fn parse_appmanifest( + manifest_path: &Path, + steamapps_path: &Path, + steam_info: &SteamInstallation, +) -> Option<Game> { + let content = fs::read_to_string(manifest_path).ok()?; + let manifest = AppManifest::from_vdf(&content)?; + + // Only consider fully installed games + if !manifest.is_installed() { + return None; + } + + // Build the install path + let install_path = steamapps_path.join("common").join(&manifest.install_dir); + if !install_path.exists() { + return None; + } + + // Build the prefix path + let prefix_path = steamapps_path + .join("compatdata") + .join(&manifest.app_id) + .join("pfx"); + + let prefix_path = if prefix_path.exists() { + Some(prefix_path) + } else { + None + }; + + // Look up known game info + let known_game = find_by_steam_id(&manifest.app_id); + + Some(Game { + name: manifest.name, + app_id: manifest.app_id, + install_path, + prefix_path, + launcher: Launcher::Steam { + is_flatpak: steam_info.is_flatpak, + is_snap: steam_info.is_snap, + }, + my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)), + appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)), + appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)), + registry_path: known_game.map(|g| g.registry_path.to_string()), + registry_value: known_game.map(|g| g.registry_value.to_string()), + }) +} + +/// Find the installation path for a specific Steam game by App ID +pub fn find_game_install_path(app_id: &str) -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + + for steam_info in find_steam_installations(&home) { + let libraries = get_library_folders(&steam_info.path); + + for library_path in libraries { + let manifest_path = library_path + .join("steamapps") + .join(format!("appmanifest_{}.acf", app_id)); + + if manifest_path.exists() { + let content = fs::read_to_string(&manifest_path).ok()?; + let manifest = AppManifest::from_vdf(&content)?; + + if manifest.is_installed() { + let install_path = library_path + .join("steamapps/common") + .join(&manifest.install_dir); + + if install_path.exists() { + return Some(install_path); + } + } + } + } + } + + None +} + +/// Find the Wine prefix for a specific Steam game by App ID +pub fn find_game_prefix_path(app_id: &str) -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + + for steam_info in find_steam_installations(&home) { + let libraries = get_library_folders(&steam_info.path); + + for library_path in libraries { + let prefix_path = library_path + .join("steamapps/compatdata") + .join(app_id) + .join("pfx"); + + if prefix_path.exists() { + return Some(prefix_path); + } + } + } + + None +} + +/// Get the known game configuration for a Steam App ID +pub fn get_known_game(app_id: &str) -> Option<&'static KnownGame> { + find_by_steam_id(app_id) +} diff --git a/libs/nak/src/game_finder/vdf.rs b/libs/nak/src/game_finder/vdf.rs new file mode 100644 index 0000000..c296f27 --- /dev/null +++ b/libs/nak/src/game_finder/vdf.rs @@ -0,0 +1,254 @@ +//! Hand-rolled VDF (Valve Data Format) parser +//! +//! Parses VDF files like appmanifest_*.acf and libraryfolders.vdf +//! without external dependencies. + +use std::collections::HashMap; + +/// A VDF value - either a string or a nested object +#[derive(Debug, Clone)] +pub enum VdfValue { + String(String), + Object(HashMap<String, VdfValue>), +} + +impl VdfValue { + /// Get as string reference + pub fn as_str(&self) -> Option<&str> { + match self { + VdfValue::String(s) => Some(s), + VdfValue::Object(_) => None, + } + } + + /// Get as object reference + pub fn as_object(&self) -> Option<&HashMap<String, VdfValue>> { + match self { + VdfValue::String(_) => None, + VdfValue::Object(o) => Some(o), + } + } + + /// Get a nested value by key + pub fn get(&self, key: &str) -> Option<&VdfValue> { + self.as_object()?.get(key) + } + + /// Get a string value by key + pub fn get_str(&self, key: &str) -> Option<&str> { + self.get(key)?.as_str() + } +} + +/// Parse a VDF file content into a root object +pub fn parse_vdf(content: &str) -> Option<VdfValue> { + let mut chars = content.chars().peekable(); + parse_object(&mut chars) +} + +/// Parse an object (including the root level) +fn parse_object<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) -> Option<VdfValue> { + let mut map = HashMap::new(); + + loop { + skip_whitespace_and_comments(chars); + + match chars.peek() { + None => break, + Some('}') => { + chars.next(); + break; + } + Some('"') => { + // Parse key + let key = parse_quoted_string(chars)?; + skip_whitespace_and_comments(chars); + + // Check what follows - string value or object + match chars.peek() { + Some('"') => { + // String value + let value = parse_quoted_string(chars)?; + map.insert(key, VdfValue::String(value)); + } + Some('{') => { + // Object value + chars.next(); // consume '{' + let value = parse_object(chars)?; + map.insert(key, value); + } + _ => return None, // Unexpected token + } + } + _ => { + // Skip unexpected characters + chars.next(); + } + } + } + + Some(VdfValue::Object(map)) +} + +/// Parse a quoted string "..." +fn parse_quoted_string<I: Iterator<Item = char>>( + chars: &mut std::iter::Peekable<I>, +) -> Option<String> { + // Expect opening quote + if chars.next() != Some('"') { + return None; + } + + let mut result = String::new(); + + loop { + match chars.next() { + None => return None, // Unterminated string + Some('"') => break, + Some('\\') => { + // Handle escape sequences + match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('\\') => result.push('\\'), + Some('"') => result.push('"'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => return None, + } + } + Some(c) => result.push(c), + } + } + + Some(result) +} + +/// Skip whitespace and // comments +fn skip_whitespace_and_comments<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) { + loop { + // Skip whitespace + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + + // Check for // comment + // Need to peek ahead without cloning - consume first / and check second + if chars.peek() == Some(&'/') { + // Consume first / + chars.next(); + if chars.peek() == Some(&'/') { + // It's a comment - skip to end of line + chars.next(); + while chars.peek().is_some_and(|c| *c != '\n') { + chars.next(); + } + continue; + } + // Not a comment, but we consumed a '/' - this shouldn't happen in valid VDF + // Just continue processing + } + + break; + } +} + +/// Parse an appmanifest_*.acf file and extract app info +#[derive(Debug, Clone)] +pub struct AppManifest { + pub app_id: String, + pub name: String, + pub install_dir: String, + pub state_flags: u32, +} + +impl AppManifest { + /// Parse from VDF content + pub fn from_vdf(content: &str) -> Option<Self> { + let root = parse_vdf(content)?; + let app_state = root.get("AppState")?; + + Some(Self { + app_id: app_state.get_str("appid")?.to_string(), + name: app_state.get_str("name")?.to_string(), + install_dir: app_state.get_str("installdir")?.to_string(), + state_flags: app_state.get_str("StateFlags")?.parse().unwrap_or(0), + }) + } + + /// Check if the game is fully installed (StateFlags == 4) + pub fn is_installed(&self) -> bool { + self.state_flags == 4 + } +} + +/// Parse libraryfolders.vdf and extract library paths +pub fn parse_library_folders(content: &str) -> Vec<String> { + let mut paths = Vec::new(); + + let Some(root) = parse_vdf(content) else { + return paths; + }; + + let Some(library_folders) = root.get("libraryfolders").and_then(|v| v.as_object()) else { + return paths; + }; + + // Library folders are keyed by index: "0", "1", "2", etc. + for value in library_folders.values() { + if let Some(path) = value.get_str("path") { + paths.push(path.to_string()); + } + } + + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_appmanifest() { + let content = r#" +"AppState" +{ + "appid" "489830" + "Universe" "1" + "name" "Skyrim Special Edition" + "StateFlags" "4" + "installdir" "Skyrim Special Edition" +} +"#; + let manifest = AppManifest::from_vdf(content).unwrap(); + assert_eq!(manifest.app_id, "489830"); + assert_eq!(manifest.name, "Skyrim Special Edition"); + assert_eq!(manifest.install_dir, "Skyrim Special Edition"); + assert!(manifest.is_installed()); + } + + #[test] + fn test_parse_library_folders() { + let content = r#" +"libraryfolders" +{ + "0" + { + "path" "/home/user/.local/share/Steam" + "label" "" + } + "1" + { + "path" "/mnt/games/SteamLibrary" + "label" "Games" + } +} +"#; + let paths = parse_library_folders(content); + assert_eq!(paths.len(), 2); + assert!(paths.contains(&"/home/user/.local/share/Steam".to_string())); + assert!(paths.contains(&"/mnt/games/SteamLibrary".to_string())); + } +} diff --git a/libs/nak/src/installers/mod.rs b/libs/nak/src/installers/mod.rs new file mode 100644 index 0000000..cf3eb85 --- /dev/null +++ b/libs/nak/src/installers/mod.rs @@ -0,0 +1,334 @@ +//! Mod manager installation logic +//! +//! Stripped for Fluorine: no common.rs, mo2.rs, plugin.rs, compatdata_scanner.rs. + +pub mod symlinks; + +mod prefix_setup; + +pub use prefix_setup::{ + apply_dpi, apply_registry_for_game_path, auto_apply_game_registries, cleanup_prefix_drives, + install_all_dependencies, kill_wineserver, known_game_names, launch_dpi_test_app, DPI_PRESETS, +}; + +use std::error::Error; +use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::logging::log_install; +use crate::steam::SteamProton; + +// ============================================================================ +// Shared Types +// ============================================================================ + +/// Context for background installation tasks +#[derive(Clone)] +pub struct TaskContext { + pub status_callback: Arc<dyn Fn(String) + Send + Sync>, + pub log_callback: Arc<dyn Fn(String) + Send + Sync>, + pub progress_callback: Arc<dyn Fn(f32) + Send + Sync>, + pub cancel_flag: Arc<AtomicBool>, +} + +impl TaskContext { + pub fn new( + status: impl Fn(String) + Send + Sync + 'static, + log: impl Fn(String) + Send + Sync + 'static, + progress: impl Fn(f32) + Send + Sync + 'static, + cancel: Arc<AtomicBool>, + ) -> Self { + Self { + status_callback: Arc::new(status), + log_callback: Arc::new(log), + progress_callback: Arc::new(progress), + cancel_flag: cancel, + } + } + + pub fn set_status(&self, msg: String) { + (self.status_callback)(msg); + } + + pub fn log(&self, msg: String) { + (self.log_callback)(msg); + } + + pub fn set_progress(&self, p: f32) { + (self.progress_callback)(p); + } + + pub fn is_cancelled(&self) -> bool { + self.cancel_flag.load(std::sync::atomic::Ordering::Relaxed) + } + + /// Run a command that can be killed if the user cancels. + pub fn run_cancellable(&self, mut cmd: std::process::Command) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> { + let mut child = cmd.spawn()?; + + loop { + match child.try_wait()? { + Some(status) => return Ok(status), + None => { + if self.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return Err("Cancelled".into()); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } + } +} + +// ============================================================================ +// Shared Wine Registry Settings +// ============================================================================ + +/// Wine registry settings +pub const WINE_SETTINGS_REG: &str = r#"Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Wine\DllOverrides] +"dwrite.dll"="native,builtin" +"dwrite"="native,builtin" +"winmm.dll"="native,builtin" +"winmm"="native,builtin" +"version.dll"="native,builtin" +"version"="native,builtin" +"ArchiveXL.dll"="native,builtin" +"ArchiveXL"="native,builtin" +"Codeware.dll"="native,builtin" +"Codeware"="native,builtin" +"TweakXL.dll"="native,builtin" +"TweakXL"="native,builtin" +"input_loader.dll"="native,builtin" +"input_loader"="native,builtin" +"RED4ext.dll"="native,builtin" +"RED4ext"="native,builtin" +"mod_settings.dll"="native,builtin" +"mod_settings"="native,builtin" +"scc_lib.dll"="native,builtin" +"scc_lib"="native,builtin" +"dxgi.dll"="native,builtin" +"dxgi"="native,builtin" +"dbghelp.dll"="native,builtin" +"dbghelp"="native,builtin" +"d3d12.dll"="native,builtin" +"d3d12"="native,builtin" +"wininet.dll"="native,builtin" +"wininet"="native,builtin" +"winhttp.dll"="native,builtin" +"winhttp"="native,builtin" +"dinput.dll"="native,builtin" +"dinput8"="native,builtin" +"dinput8.dll"="native,builtin" + +[HKEY_CURRENT_USER\Software\Wine] +"ShowDotFiles"="Y" + +[HKEY_CURRENT_USER\Control Panel\Desktop] +"FontSmoothing"="2" +"FontSmoothingGamma"=dword:00000578 +"FontSmoothingOrientation"=dword:00000001 +"FontSmoothingType"=dword:00000002 + +[HKEY_CURRENT_USER\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers] +@="~ HIGHDPIAWARE" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\Pandora Behaviour Engine+.exe\X11 Driver] +"Decorated"="N" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\Vortex.exe\X11 Driver] +"Decorated"="N" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SSEEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SSEEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO4Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO4Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\TES4Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\TES4Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SF1Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FNVEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FNVEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xFOEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xFOEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xSFEEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xSFEEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xTESEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xTESEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO3Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO3Edit64.exe] +"Version"="winxp" + +; ============================================================================= +; Native file browser integration (opens folders in native file manager) +; ============================================================================= +[HKEY_CLASSES_ROOT\Folder\shell\explore\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Directory\shell\explore\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Folder\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Directory\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +; ============================================================================= +; Native text editor integration (opens text files in native editor) +; ============================================================================= +[HKEY_CLASSES_ROOT\txtfile\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe \"%1\"" + +[HKEY_CLASSES_ROOT\inifile\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe \"%1\"" + +[HKEY_CLASSES_ROOT\.txt] +@="txtfile" + +[HKEY_CLASSES_ROOT\.ini] +@="inifile" + +[HKEY_CLASSES_ROOT\.cfg] +@="txtfile" + +[HKEY_CLASSES_ROOT\.log] +@="txtfile" + +[HKEY_CLASSES_ROOT\.xml] +@="txtfile" + +[HKEY_CLASSES_ROOT\.json] +@="txtfile" + +[HKEY_CLASSES_ROOT\.yml] +@="txtfile" + +[HKEY_CLASSES_ROOT\.yaml] +@="txtfile" +"#; + +// ============================================================================ +// Shared Functions +// ============================================================================ + +/// Apply Wine registry settings to a prefix +pub fn apply_wine_registry_settings( + prefix_path: &std::path::Path, + proton: &SteamProton, + log_callback: &impl Fn(String), + _app_id: Option<u32>, +) -> Result<(), Box<dyn Error>> { + use std::io::Write; + use crate::config::AppConfig; + use crate::logging::{log_error, log_warning}; + use crate::runtime_wrap; + + let tmp_dir = AppConfig::get_tmp_path(); + fs::create_dir_all(&tmp_dir)?; + let reg_file = tmp_dir.join("wine_settings.reg"); + + let mut file = fs::File::create(®_file)?; + file.write_all(WINE_SETTINGS_REG.as_bytes())?; + + let wine_bin = proton.wine_binary().ok_or_else(|| { + let err_msg = format!( + "Wine binary not found for Proton '{}' (checked files/bin/wine and dist/bin/wine)", + proton.name + ); + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let wineserver_bin = proton.wineserver_binary().unwrap_or_else(|| { + wine_bin.with_file_name("wineserver") + }); + + let bin_dir = proton.bin_dir().ok_or_else(|| { + let err_msg = "Could not determine Proton bin directory"; + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let path_env = format!( + "{}:{}", + bin_dir.to_string_lossy(), + std::env::var("PATH").unwrap_or_default() + ); + + log_callback("Applying Wine registry settings...".to_string()); + log_install("Running wine regedit..."); + + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("PATH", path_env), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let regedit_status = runtime_wrap::build_command(&wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + match regedit_status { + Ok(status) => { + if status.success() { + log_callback("Registry settings applied successfully".to_string()); + log_install("Wine registry settings applied successfully"); + } else { + let msg = format!("regedit exited with code {:?}", status.code()); + log_callback(format!("Warning: {}", msg)); + log_warning(&msg); + } + } + Err(e) => { + let msg = format!("Failed to run regedit: {}", e); + log_callback(format!("Error: {}", msg)); + log_error(&msg); + return Err(msg.into()); + } + } + + let _ = fs::remove_file(®_file); + Ok(()) +} diff --git a/libs/nak/src/installers/prefix_setup.rs b/libs/nak/src/installers/prefix_setup.rs new file mode 100644 index 0000000..4f27606 --- /dev/null +++ b/libs/nak/src/installers/prefix_setup.rs @@ -0,0 +1,778 @@ +//! Unified prefix setup for MO2 +//! +//! This module handles all the dependency installation logic. +//! +//! Key approach (ORDER MATTERS): +//! 1. Install dependencies via winetricks (handles wineboot internally) +//! 2. Install custom dotnet runtimes (dotnet9sdk, dotnetdesktop10) +//! 3. Auto-detect installed games and apply registry entries +//! 4. Apply Wine registry settings (LAST - after prefix is fully set up) + +use std::error::Error; +use std::fs; +use std::path::Path; +use std::process::Child; + +use super::{apply_wine_registry_settings, TaskContext}; +use crate::config::AppConfig; +use crate::deps::{install_standard_deps_cancellable, STANDARD_VERBS}; +use crate::game_finder::{detect_all_games, known_games, Game, Launcher}; +use crate::logging::{log_install, log_warning}; +use crate::runtime_wrap; +use crate::steam::{detect_steam_path_checked, SteamProton}; + +// ============================================================================= +// Constants +// ============================================================================= + +/// .NET 9 SDK download URL +const DOTNET9_SDK_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"; + +/// .NET Desktop Runtime 10 download URL +const DOTNET_DESKTOP10_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"; + +/// Drive letters to keep in the prefix (c: is Windows root, z: maps to Linux /) +const ALLOWED_DRIVE_LETTERS: &[&str] = &["c:", "z:"]; + +/// Install all dependencies to a prefix. +/// +/// Order: proton init → winetricks → custom dotnet → game detection → registry → win11 → dotnet fixes +/// +/// # Arguments +/// * `app_id` - Steam AppID (used for registry operations) +pub fn install_all_dependencies( + prefix_root: &Path, + install_proton: &SteamProton, + ctx: &TaskContext, + start_progress: f32, + end_progress: f32, + app_id: u32, +) -> Result<(), Box<dyn Error>> { + fs::create_dir_all(AppConfig::get_tmp_path())?; + + // Progress distribution + let init_end = start_progress + (end_progress - start_progress) * 0.10; + let winetricks_end = start_progress + (end_progress - start_progress) * 0.50; + let dotnet_end = start_progress + (end_progress - start_progress) * 0.65; + let games_end = start_progress + (end_progress - start_progress) * 0.75; + + // ========================================================================= + // 0. Initialize prefix with Proton wrapper (creates proper prefix structure) + // ========================================================================= + ctx.set_status("Setting up Windows compatibility layer...".to_string()); + ctx.log("Initializing Wine prefix with Proton...".to_string()); + log_install("Running proton wineboot to initialize prefix"); + + if let Err(e) = initialize_prefix_with_proton(prefix_root, install_proton, app_id, ctx) { + ctx.log(format!("Warning: Proton prefix init failed: {}", e)); + log_warning(&format!("Proton prefix init failed: {}", e)); + // Continue anyway - winetricks might still work + } + + ctx.set_progress(init_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 0.5. Clean up unwanted drive letters (keep only C: and Z:) + // ========================================================================= + ctx.set_status("Optimizing prefix configuration...".to_string()); + ctx.log("Removing unwanted drive letters (keeping C: and Z:)...".to_string()); + log_install("Cleaning up Wine drive letters"); + + if let Err(e) = cleanup_wine_drives(prefix_root, install_proton) { + ctx.log(format!("Warning: Drive cleanup had issues: {}", e)); + log_warning(&format!("Drive cleanup failed: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 1. Standard Dependencies via Winetricks + // ========================================================================= + ctx.set_status("Installing required Windows components (this may take several minutes)...".to_string()); + ctx.log(format!( + "Installing {} dependencies via winetricks: {}", + STANDARD_VERBS.len(), + STANDARD_VERBS.join(", ") + )); + log_install(&format!("Running winetricks with {} verbs", STANDARD_VERBS.len())); + + let winetricks_log_cb = { + let ctx = ctx.clone(); + move |msg: String| { + ctx.log(msg.clone()); + ctx.set_status(msg); + } + }; + + if let Err(e) = install_standard_deps_cancellable(prefix_root, install_proton, winetricks_log_cb, &ctx.cancel_flag) { + let msg = format!("Winetricks installation had issues: {}", e); + ctx.log(format!("Warning: {}", msg)); + log_warning(&msg); + } + + ctx.set_progress(winetricks_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 2. Custom .NET Runtimes (not in winetricks yet) + // ========================================================================= + ctx.set_status("Installing .NET runtime (1 of 2)...".to_string()); + ctx.log("Installing .NET 9 SDK...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET9_SDK_URL, "dotnet-sdk-9", ctx) { + ctx.log(format!("Warning: .NET 9 SDK install failed: {}", e)); + log_warning(&format!(".NET 9 SDK install failed: {}", e)); + } + + ctx.set_status("Installing .NET runtime (2 of 2)...".to_string()); + ctx.log("Installing .NET Desktop Runtime 10...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET_DESKTOP10_URL, "dotnet-desktop-10", ctx) { + ctx.log(format!("Warning: .NET Desktop 10 install failed: {}", e)); + log_warning(&format!(".NET Desktop 10 install failed: {}", e)); + } + + ctx.set_progress(dotnet_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 3. Auto-detect and register installed games + // ========================================================================= + ctx.set_status("Detecting your installed games...".to_string()); + ctx.log("Auto-detecting installed Steam games...".to_string()); + log_install("Auto-detecting installed games for registry"); + + let game_log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + auto_apply_game_registries(prefix_root, install_proton, &game_log_cb, Some(app_id)); + + ctx.set_progress(games_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 4. Registry Settings (after prefix is fully initialized) + // ========================================================================= + ctx.set_status("Configuring Windows registry...".to_string()); + ctx.log("Applying Wine Registry Settings...".to_string()); + log_install("Applying Wine registry settings"); + + let log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + apply_wine_registry_settings(prefix_root, install_proton, &log_cb, Some(app_id))?; + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 5. Set Windows 11 Mode + // ========================================================================= + ctx.set_status("Finalizing compatibility settings...".to_string()); + ctx.log("Setting Windows 11 mode...".to_string()); + log_install("Setting Windows 11 mode via winetricks"); + + if let Err(e) = set_windows_11_mode(prefix_root, install_proton, ctx) { + ctx.log(format!("Warning: Failed to set Windows 11 mode: {}", e)); + log_warning(&format!("Failed to set Windows 11 mode: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + ctx.set_progress(end_progress); + ctx.set_status("Dependencies installed".to_string()); + Ok(()) +} + +/// Install a .NET runtime via direct exe download and wine execution +fn install_dotnet_runtime( + prefix_root: &Path, + proton: &SteamProton, + url: &str, + name: &str, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + let cache_dir = AppConfig::get_default_cache_dir(); + fs::create_dir_all(&cache_dir)?; + + let filename = url.split('/').next_back().unwrap_or("dotnet-installer.exe"); + let installer_path = cache_dir.join(filename); + + // Download if not cached + if !installer_path.exists() { + log_install(&format!("Downloading {}...", name)); + let response = ureq::get(url) + .set("User-Agent", "NaK-Rust") + .call() + .map_err(|e| format!("Failed to download {}: {}", name, e))?; + + let mut file = fs::File::create(&installer_path)?; + std::io::copy(&mut response.into_reader(), &mut file)?; + } + + // Run installer with wine + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + log_install(&format!("Running {} installer...", name)); + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&wine_bin, &envs); + cmd.arg(&installer_path) + .arg("/install") + .arg("/quiet") + .arg("/norestart"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("{} installer exited with code {:?}", name, status.code()).into()); + } + + log_install(&format!("{} installed successfully", name)); + Ok(()) +} + +/// Initialize prefix with Proton wrapper +/// +/// Runs `proton run wineboot -u` to properly initialize the prefix with all +/// the Steam/Proton environment variables. This creates a proper prefix +/// structure that Steam recognizes. +fn initialize_prefix_with_proton( + prefix_root: &Path, + proton: &SteamProton, + app_id: u32, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + // Find the proton wrapper script (not the wine binary) + let proton_script = proton.path.join("proton"); + if !proton_script.exists() { + return Err(format!("Proton wrapper script not found at {:?}", proton_script).into()); + } + + // Get Steam root path + let steam_root = detect_steam_path_checked() + .ok_or("Could not find Steam installation")?; + + // The compatdata path is the PARENT of the pfx directory + let compat_data_path = prefix_root.parent() + .ok_or("Could not determine compatdata path")?; + + log_install(&format!("STEAM_COMPAT_DATA_PATH={:?}", compat_data_path)); + + // Collect all env vars upfront so build_command can forward them + // via --env= flags in Flatpak mode. + let mut envs: Vec<(&str, String)> = vec![ + ("STEAM_COMPAT_CLIENT_INSTALL_PATH", steam_root.clone()), + ("STEAM_COMPAT_DATA_PATH", compat_data_path.display().to_string()), + ("SteamAppId", app_id.to_string()), + ("SteamGameId", app_id.to_string()), + ("DISPLAY", String::new()), // Suppress GUI + ("WAYLAND_DISPLAY", String::new()), // Suppress GUI + ("WINEDEBUG", "-all".to_string()), + ("WINEDLLOVERRIDES", "msdia80.dll=n;conhost.exe=d;cmd.exe=d".to_string()), + ]; + + let (exe, args): (std::path::PathBuf, Vec<&str>) = if runtime_wrap::use_umu_for_prefix() { + if let Some(umu_run) = runtime_wrap::resolve_umu_run() { + log_install(&format!("Initializing prefix with umu-run: {:?}", umu_run)); + envs.push(("PROTONPATH", proton.path.display().to_string())); + envs.push(("WINEPREFIX", prefix_root.display().to_string())); + envs.push(("GAMEID", app_id.to_string())); + (umu_run, vec!["wineboot", "-u"]) + } else { + log_warning( + "UMU prefix mode enabled but no umu-run was found; falling back to proton wrapper", + ); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + } + } else { + log_install(&format!("Initializing prefix with proton wrapper: {:?}", proton_script)); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + }; + + let mut cmd = runtime_wrap::build_command(&exe, &envs); + cmd.args(&args); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("proton wineboot failed with exit code: {:?}", status.code()).into()); + } + + // Give it a moment for files to land + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Verify prefix was created + if prefix_root.exists() { + log_install("Proton prefix initialized successfully"); + Ok(()) + } else { + Err("Prefix directory not created after wineboot".into()) + } +} + +/// Clean up unwanted Wine drive letters from prefix +/// +/// Removes both symbolic links in dosdevices/ and registry entries for +/// drive letters other than C: and Z:. This prevents Wine from mounting +/// other users' drives as E:, F:, G:, etc. +fn cleanup_wine_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<(), Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + log_install("dosdevices directory not found, skipping drive cleanup"); + return Ok(()); + } + + // ========================================================================= + // 1. Remove unwanted symlinks from dosdevices/ + // ========================================================================= + let mut removed_drives = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + + // Skip allowed drives and non-drive entries (like com1, lpt1, etc.) + if ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + continue; + } + + // Only process drive letters (single letter followed by colon) + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) { + let path = entry.path(); + if let Err(e) = fs::remove_file(&path) { + log_warning(&format!("Failed to remove drive symlink {}: {}", name, e)); + } else { + removed_drives.push(name.to_uppercase()); + } + } + } + } + + if !removed_drives.is_empty() { + log_install(&format!("Removed drive symlinks: {}", removed_drives.join(", "))); + } + + // ========================================================================= + // 2. Clean up registry entries for removed drives + // ========================================================================= + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping registry cleanup"); + return Ok(()); + }; + + // Create a .reg file to remove drive type entries + let tmp_dir = AppConfig::get_tmp_path(); + fs::create_dir_all(&tmp_dir)?; + + let mut reg_content = String::from("Windows Registry Editor Version 5.00\n\n"); + + // Remove entries from HKLM\Software\Wine\Drives + for drive in &removed_drives { + // Remove both uppercase and lowercase variants + reg_content.push_str(&format!( + "[HKEY_LOCAL_MACHINE\\Software\\Wine\\Drives]\n\"{drive}\"=-\n\n" + )); + } + + if !removed_drives.is_empty() { + let reg_file = tmp_dir.join("drive_cleanup.reg"); + fs::write(®_file, ®_content)?; + + let drive_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &drive_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install("Registry drive entries cleaned up"); + } + Ok(s) => { + log_warning(&format!("Registry cleanup may have failed (exit code: {:?})", s.code())); + } + Err(e) => { + log_warning(&format!("Failed to run registry cleanup: {}", e)); + } + } + } + + Ok(()) +} + +/// Public wrapper to clean up Wine drives on an existing prefix +/// +/// This can be called from the UI to fix drive letter issues on existing prefixes. +pub fn cleanup_prefix_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<Vec<String>, Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + return Err("dosdevices directory not found - is this a valid Wine prefix?".into()); + } + + // Collect drives before cleanup + let mut removed = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) + && !ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + removed.push(name.to_uppercase()); + } + } + } + + // Run the actual cleanup + cleanup_wine_drives(prefix_root, proton)?; + + Ok(removed) +} + +/// Set Windows 11 mode for the prefix using winetricks +/// +/// This should be called AFTER all components are installed. +/// Sets the Windows version to Windows 11 which is required for MO2 to work properly. +fn set_windows_11_mode( + prefix_root: &Path, + proton: &SteamProton, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + use crate::deps::ensure_winetricks; + + let winetricks_path = ensure_winetricks()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found".into()); + }; + + log_install("Running winetricks win11..."); + + let envs: Vec<(&str, String)> = vec![ + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&winetricks_path, &envs); + cmd.arg("-q").arg("win11"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("winetricks win11 failed with exit code: {:?}", status.code()).into()); + } + + log_install("Windows 11 mode set successfully"); + Ok(()) +} + +// ============================================================================= +// DPI Configuration +// ============================================================================= + +/// Common DPI presets with their percentage labels +pub const DPI_PRESETS: &[(u32, &str)] = &[ + (96, "100%"), + (120, "125%"), + (144, "150%"), + (192, "200%"), +]; + +/// Apply DPI setting to a Wine prefix via registry +pub fn apply_dpi( + prefix_root: &Path, + proton: &SteamProton, + dpi_value: u32, +) -> Result<(), Box<dyn Error>> { + log_install(&format!("Applying DPI {} to prefix", dpi_value)); + + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &envs) + .arg("reg") + .arg("add") + .arg(r"HKCU\Control Panel\Desktop") + .arg("/v") + .arg("LogPixels") + .arg("/t") + .arg("REG_DWORD") + .arg("/d") + .arg(dpi_value.to_string()) + .arg("/f") + .status()?; + + if !status.success() { + return Err(format!("Failed to apply DPI setting: exit code {:?}", status.code()).into()); + } + + log_install(&format!("DPI {} applied successfully", dpi_value)); + Ok(()) +} + +/// Launch a test application (winecfg, regedit, notepad, control) and return its PID +pub fn launch_dpi_test_app( + prefix_root: &Path, + proton: &SteamProton, + app_name: &str, +) -> Result<Child, Box<dyn Error>> { + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + log_install(&format!( + "Launching {} with wine={:?} prefix={:?}", + app_name, wine_bin, prefix_root + )); + + if !prefix_root.exists() { + return Err(format!("Prefix not found: {:?}", prefix_root).into()); + } + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let child = runtime_wrap::build_command(&wine_bin, &envs) + .arg(app_name) + .spawn()?; + + Ok(child) +} + +/// Kill the wineserver for a prefix (terminates all Wine processes in that prefix) +pub fn kill_wineserver(prefix_root: &Path, proton: &SteamProton) { + log_install("Killing wineserver for prefix"); + + let Some(wineserver_bin) = proton.wineserver_binary() else { + log_install("Wineserver binary not found, skipping kill"); + return; + }; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let _ = runtime_wrap::build_command(&wineserver_bin, &envs) + .arg("-k") + .status(); +} + +// ============================================================================ +// Game Registry Detection (uses game_finder module) +// ============================================================================ + +/// Auto-detect installed games and apply registry entries +/// +/// This uses the game_finder module to detect installed games across all +/// supported launchers (Steam, Heroic, Bottles) and automatically adds +/// the registry entries so mod managers can detect them. +pub fn auto_apply_game_registries( + prefix_path: &Path, + proton: &SteamProton, + log_callback: &impl Fn(String), + _app_id: Option<u32>, +) { + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping game registry auto-detection"); + return; + }; + + // Use the new game_finder module to detect all games + let scan_result = detect_all_games(); + let mut applied_count = 0; + + for game in &scan_result.games { + // Only process games that have registry info + let (Some(reg_path), Some(reg_value)) = (&game.registry_path, &game.registry_value) else { + continue; + }; + + // Apply registry for this game + if apply_game_registry( + prefix_path, + &wine_bin, + game, + reg_path, + reg_value, + log_callback, + ) { + applied_count += 1; + } + } + + if applied_count > 0 { + log_callback(format!("Auto-configured {} game(s) in registry", applied_count)); + log_install(&format!("Auto-applied registry for {} detected game(s)", applied_count)); + } +} + +/// Apply a game's registry entry with a custom install path. +/// +/// Looks up the game by name in KNOWN_GAMES, then writes the registry entry +/// pointing to `install_path`. Use this when the game is in a custom/stock +/// folder that auto-detection won't find. +pub fn apply_registry_for_game_path( + prefix_path: &Path, + proton: &SteamProton, + game_name: &str, + install_path: &Path, + log_callback: &impl Fn(String), +) -> Result<(), String> { + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".to_string()); + }; + + let known = known_games::find_by_name(game_name); + let (reg_path, reg_value) = if let Some(kg) = known { + (kg.registry_path, kg.registry_value) + } else { + return Err(format!("Unknown game: {game_name}")); + }; + + let fake_game = Game { + name: game_name.to_string(), + install_path: install_path.to_path_buf(), + app_id: known.map(|k| k.steam_app_id.to_string()).unwrap_or_default(), + prefix_path: None, + launcher: Launcher::Steam { is_flatpak: false, is_snap: false }, + my_games_folder: known.and_then(|k| k.my_games_folder.map(String::from)), + appdata_local_folder: known.and_then(|k| k.appdata_local_folder.map(String::from)), + appdata_roaming_folder: known.and_then(|k| k.appdata_roaming_folder.map(String::from)), + registry_path: Some(reg_path.to_string()), + registry_value: Some(reg_value.to_string()), + }; + + if apply_game_registry(prefix_path, &wine_bin, &fake_game, reg_path, reg_value, log_callback) { + Ok(()) + } else { + Err(format!("Failed to apply registry for {game_name}")) + } +} + +/// Return the list of known game names for UI display. +pub fn known_game_names() -> Vec<&'static str> { + known_games::KNOWN_GAMES.iter().map(|g| g.name).collect() +} + +/// Apply registry entry for a single game +fn apply_game_registry( + prefix_path: &Path, + wine_bin: &Path, + game: &Game, + reg_path: &str, + reg_value: &str, + log_callback: &impl Fn(String), +) -> bool { + log_callback(format!("Found {}, applying registry...", game.name)); + + // Convert Linux path to Wine Z: drive path with escaped backslashes for .reg file + let linux_path = game.install_path.to_string_lossy(); + let wine_path_reg = format!("Z:{}", linux_path.replace('/', "\\\\")); + + // Create .reg file content + let reg_content = format!( + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\{}] +"{}"="{}" + +[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\{}] +"{}"="{}" +"#, + reg_path, + reg_value, + wine_path_reg, + reg_path.strip_prefix("Software\\").unwrap_or(reg_path), + reg_value, + wine_path_reg, + ); + + // Write temp .reg file + let tmp_dir = AppConfig::get_tmp_path(); + let reg_file = tmp_dir.join(format!("game_reg_{}.reg", game.app_id)); + + if let Err(e) = fs::write(®_file, ®_content) { + log_warning(&format!("Failed to write registry file for {}: {}", game.name, e)); + return false; + } + + // Apply registry + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install(&format!("Applied registry for {} -> {:?}", game.name, game.install_path)); + true + } + Ok(s) => { + log_warning(&format!( + "Registry for {} may have failed (exit code: {:?})", + game.name, + s.code() + )); + false + } + Err(e) => { + log_warning(&format!("Failed to apply registry for {}: {}", game.name, e)); + false + } + } +} diff --git a/libs/nak/src/installers/symlinks.rs b/libs/nak/src/installers/symlinks.rs new file mode 100644 index 0000000..ef8cbb4 --- /dev/null +++ b/libs/nak/src/installers/symlinks.rs @@ -0,0 +1,359 @@ +//! Symlink management for NaK prefixes +//! +//! Creates symlinks FROM NaK prefix TO game prefixes. +//! Data stays in the game prefix, NaK just has links pointing to it. +//! +//! This inverted approach means: +//! - Game saves remain in their original location (Steam cloud sync works) +//! - NaK prefix provides unified access to all game data +//! - No data duplication or sync issues +//! +//! NaK Tools folder contains convenience symlinks pointing INTO the prefix +//! for easy access to Documents, AppData, etc. + +// Allow unused items - some functions are public API for future use +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +use crate::game_finder::{detect_all_games, Game, GameScanResult}; +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Create symlinks from NaK prefix to game prefixes for all detected games +/// +/// This creates symlinks in the NaK prefix pointing to the actual save/config +/// folders in each game's own prefix. The symlink direction is: +/// +/// NaK/Documents/My Games/Skyrim -> game_prefix/Documents/My Games/Skyrim +/// NaK/AppData/Local/Skyrim -> game_prefix/AppData/Local/Skyrim +/// +/// Data stays in the game prefix (preserving Steam Cloud sync), while NaK +/// provides unified access through symlinks. +pub fn create_game_symlinks(nak_prefix: &Path, games: &[Game]) { + let users_dir = nak_prefix.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let documents = user_dir.join("Documents"); + let my_games = documents.join("My Games"); + let appdata_local = user_dir.join("AppData/Local"); + let appdata_roaming = user_dir.join("AppData/Roaming"); + + // Ensure base directories exist (real folders in NaK prefix) + let _ = fs::create_dir_all(&my_games); + let _ = fs::create_dir_all(&appdata_local); + let _ = fs::create_dir_all(&appdata_roaming); + + let mut linked_count = 0; + + for game in games { + // Skip games without prefixes + let Some(game_prefix) = &game.prefix_path else { + continue; + }; + + // Discover the game prefix's user directory + let game_users_dir = game_prefix.join("drive_c/users"); + let game_username = find_prefix_username(&game_users_dir); + let game_user_dir = game_users_dir.join(&game_username); + + // Scan and symlink ALL folders in the game prefix's Documents/My Games/ + linked_count += scan_and_link_all( + &my_games, + &game_user_dir.join("Documents/My Games"), + "Documents/My Games", + &game.name, + game_prefix, + ); + + // Also link the Documents folder itself for non-My Games entries + // (some games put saves directly in Documents/<GameName>) + linked_count += scan_and_link_all( + &documents, + &game_user_dir.join("Documents"), + "Documents", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Local/ + linked_count += scan_and_link_all( + &appdata_local, + &game_user_dir.join("AppData/Local"), + "AppData/Local", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Roaming/ + linked_count += scan_and_link_all( + &appdata_roaming, + &game_user_dir.join("AppData/Roaming"), + "AppData/Roaming", + &game.name, + game_prefix, + ); + } + + if linked_count > 0 { + log_info(&format!( + "Created {} symlinks to game prefixes", + linked_count + )); + } + + // Create "My Documents" symlink for compatibility + let my_documents = user_dir.join("My Documents"); + if !my_documents.exists() && fs::symlink_metadata(&my_documents).is_err() { + if let Err(e) = std::os::unix::fs::symlink("Documents", &my_documents) { + log_warning(&format!("Failed to create My Documents symlink: {}", e)); + } + } +} + +/// Create NaK Tools convenience symlinks pointing INTO the prefix +/// +/// Creates symlinks in NaK Tools folder for easy access: +/// - NaK Tools/Prefix Documents -> prefix/drive_c/users/<user>/Documents +/// - NaK Tools/Prefix AppData Local -> prefix/drive_c/users/<user>/AppData/Local +/// - NaK Tools/Prefix AppData Roaming -> prefix/drive_c/users/<user>/AppData/Roaming +pub fn create_nak_tools_symlinks(tools_dir: &Path, prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + // Symlink: NaK Tools/Prefix Documents -> prefix Documents + let documents_link = tools_dir.join("Prefix Documents"); + let documents_target = user_dir.join("Documents"); + create_or_update_symlink(&documents_link, &documents_target, "Prefix Documents"); + + // Symlink: NaK Tools/Prefix AppData Local -> prefix AppData/Local + let appdata_local_link = tools_dir.join("Prefix AppData Local"); + let appdata_local_target = user_dir.join("AppData/Local"); + create_or_update_symlink(&appdata_local_link, &appdata_local_target, "Prefix AppData Local"); + + // Symlink: NaK Tools/Prefix AppData Roaming -> prefix AppData/Roaming + let appdata_roaming_link = tools_dir.join("Prefix AppData Roaming"); + let appdata_roaming_target = user_dir.join("AppData/Roaming"); + create_or_update_symlink(&appdata_roaming_link, &appdata_roaming_target, "Prefix AppData Roaming"); + + log_info("Created NaK Tools convenience symlinks to prefix folders"); +} + +/// Create or update a symlink +fn create_or_update_symlink(link_path: &Path, target: &Path, name: &str) { + // Remove existing symlink or file + if link_path.exists() || fs::symlink_metadata(link_path).is_ok() { + let _ = fs::remove_file(link_path); + let _ = fs::remove_dir_all(link_path); + } + + // Create symlink + if let Err(e) = std::os::unix::fs::symlink(target, link_path) { + log_warning(&format!("Failed to create {} symlink: {}", name, e)); + } +} + +/// Create symlinks for all detected games +/// +/// Convenience function that detects games and creates symlinks in one call. +pub fn create_game_symlinks_auto(nak_prefix: &Path) -> GameScanResult { + let result = detect_all_games(); + create_game_symlinks(nak_prefix, &result.games); + result +} + +/// Ensure only the Temp directory exists in AppData/Local +/// +/// MO2 and other tools require AppData/Local/Temp to exist. +/// We create only this essential directory, leaving other game-specific +/// folders to be symlinked from game prefixes. +pub fn ensure_temp_directory(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let temp_dir = user_dir.join("AppData/Local/Temp"); + if let Err(e) = fs::create_dir_all(&temp_dir) { + log_warning(&format!("Failed to create Temp directory: {}", e)); + } else { + log_info("Ensured AppData/Local/Temp directory exists"); + } +} + +// ============================================================================ +// Internal Functions +// ============================================================================ + +/// Directories to skip when scanning prefix folders for symlinking. +/// These are Wine/Proton internal or system dirs, not game data. +const SKIP_DIRS: &[&str] = &[ + "Temp", "Microsoft", "wine", "Public", "root", + "Application Data", "Cookies", "Local Settings", + "NetHood", "PrintHood", "Recent", "SendTo", + "Start Menu", "Templates", "My Documents", "My Music", + "My Pictures", "My Videos", "Desktop", "Downloads", + "Favorites", "Links", "Saved Games", "Searches", + "Contacts", "3D Objects", +]; + +/// Scan all subdirectories in a game prefix folder and create symlinks +/// for each one in the corresponding NaK prefix folder. +/// +/// Returns the number of symlinks created. +fn scan_and_link_all( + nak_base: &Path, + game_base: &Path, + label: &str, + game_name: &str, + _game_prefix: &Path, +) -> usize { + if !game_base.is_dir() { + return 0; + } + + let Ok(entries) = fs::read_dir(game_base) else { + return 0; + }; + + let mut count = 0; + for entry in entries.flatten() { + // Only symlink directories (game folders), not loose files + if !entry.path().is_dir() { + continue; + } + + let folder_name = entry.file_name().to_string_lossy().to_string(); + + // Skip Wine/system internal directories + if SKIP_DIRS.iter().any(|&s| s.eq_ignore_ascii_case(&folder_name)) { + continue; + } + + // Skip if it's "My Games" and we're scanning Documents (handled separately) + if label == "Documents" && folder_name == "My Games" { + continue; + } + + let nak_path = nak_base.join(&folder_name); + let source_path = entry.path(); + + if create_symlink_if_needed(&nak_path, &source_path, game_name, label, &folder_name) { + count += 1; + } + } + + count +} + +/// Create a symlink if the target doesn't already exist or is already correct. +/// +/// Returns true if a symlink was created (or already existed correctly). +fn create_symlink_if_needed( + nak_path: &Path, + source_path: &Path, + game_name: &str, + label: &str, + folder_name: &str, +) -> bool { + // Check if target already exists + if nak_path.exists() || fs::symlink_metadata(nak_path).is_ok() { + // Check if it's already a symlink to the correct location + if let Ok(target) = fs::read_link(nak_path) { + if target == source_path { + return true; // Already correctly linked + } + } + // Something else exists here, don't overwrite + return false; + } + + // Ensure parent directory exists + if let Some(parent) = nak_path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Create the symlink + match std::os::unix::fs::symlink(source_path, nak_path) { + Ok(()) => { + log_info(&format!( + "Linked {}/{} -> {} ({})", + label, + folder_name, + source_path.display(), + game_name, + )); + true + } + Err(e) => { + log_warning(&format!( + "Failed to create symlink for {} ({}/{}): {}", + game_name, label, folder_name, e + )); + false + } + } +} + +/// Find the username from a Wine prefix users directory +fn find_prefix_username(users_dir: &Path) -> String { + if let Ok(entries) = fs::read_dir(users_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name != "Public" && name != "root" { + return name; + } + } + } + "steamuser".to_string() +} + +// ============================================================================ +// Oblivion Lowercase INI Symlinks +// ============================================================================ + +/// Create lowercase INI symlinks for Oblivion (some tools expect lowercase) +pub fn create_oblivion_ini_symlinks(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let oblivion_dir = users_dir + .join(&username) + .join("Documents/My Games/Oblivion"); + + if !oblivion_dir.exists() { + return; + } + + create_lowercase_ini_symlink(&oblivion_dir, "Oblivion.ini", "oblivion.ini"); + create_lowercase_ini_symlink(&oblivion_dir, "OblivionPrefs.ini", "oblivionprefs.ini"); +} + +/// Create a lowercase symlink for an INI file +fn create_lowercase_ini_symlink(dir: &Path, original: &str, lowercase: &str) { + let original_path = dir.join(original); + let lowercase_path = dir.join(lowercase); + + // Only create if original exists and lowercase doesn't + if original_path.exists() + && !lowercase_path.exists() + && fs::symlink_metadata(&lowercase_path).is_err() + { + // Create relative symlink (just the filename) + if let Err(e) = std::os::unix::fs::symlink(original, &lowercase_path) { + log_warning(&format!( + "Failed to create lowercase symlink {} -> {}: {}", + lowercase, original, e + )); + } else { + log_info(&format!( + "Created lowercase INI symlink: {} -> {}", + lowercase, original + )); + } + } +} diff --git a/libs/nak/src/lib.rs b/libs/nak/src/lib.rs new file mode 100644 index 0000000..c93f07b --- /dev/null +++ b/libs/nak/src/lib.rs @@ -0,0 +1,15 @@ +//! NaK - Vendored library for Fluorine Manager +//! +//! Stripped version: no GUI, CLI, marketplace, updater, NXM handler, +//! Steam shortcuts, or managed prefix tracking. + +pub mod config; +pub mod dxvk; +pub mod game_finder; +pub mod logging; +pub mod runtime_wrap; +pub mod steam; +pub mod utils; + +pub mod deps; +pub mod installers; diff --git a/libs/nak/src/logging.rs b/libs/nak/src/logging.rs new file mode 100644 index 0000000..8c0f91a --- /dev/null +++ b/libs/nak/src/logging.rs @@ -0,0 +1,99 @@ +//! Logging for Fluorine Manager. +//! +//! All log messages are: +//! 1. Written to `~/.var/app/com.fluorine.manager/logs/nak.log` (always) +//! 2. Forwarded to an optional callback set via `set_log_callback()` (for MOBase::log) + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; + +/// Signature for the log callback: (level, message) +/// +/// Levels: "info", "warning", "error", "install", "action", "download" +type LogCallback = Box<dyn Fn(&str, &str) + Send + Sync>; + +static LOG_CALLBACK: Mutex<Option<LogCallback>> = Mutex::new(None); + +/// Set the global log callback. Call once at startup from FFI. +pub fn set_log_callback(cb: impl Fn(&str, &str) + Send + Sync + 'static) { + if let Ok(mut guard) = LOG_CALLBACK.lock() { + *guard = Some(Box::new(cb)); + } +} + +/// Get the log directory path. +fn log_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".var/app/com.fluorine.manager/logs") +} + +/// Get the log file path. +fn log_file_path() -> PathBuf { + log_dir().join("nak.log") +} + +fn emit(level: &str, message: &str) { + // Write to file (always, even before callback is set) + write_to_file(level, message); + + // Forward to callback if set + if let Ok(guard) = LOG_CALLBACK.lock() { + if let Some(ref cb) = *guard { + cb(level, message); + } + } +} + +fn write_to_file(level: &str, message: &str) { + let path = log_file_path(); + + // Ensure log directory exists (only try once per message, cheap no-op if exists) + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Rotate if over 2MB + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > 2 * 1024 * 1024 { + let old = path.with_extension("log.old"); + let _ = fs::rename(&path, &old); + } + } + + let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + else { + return; + }; + + let timestamp = chrono::Local::now().format("%H:%M:%S"); + let _ = writeln!(file, "[{}] [{}] {}", timestamp, level.to_uppercase(), message); +} + +pub fn log_info(message: &str) { + emit("info", message); +} + +pub fn log_warning(message: &str) { + emit("warning", message); +} + +pub fn log_error(message: &str) { + emit("error", message); +} + +pub fn log_install(message: &str) { + emit("install", message); +} + +pub fn log_action(message: &str) { + emit("action", message); +} + +pub fn log_download(message: &str) { + emit("download", message); +} diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs new file mode 100644 index 0000000..d33bd59 --- /dev/null +++ b/libs/nak/src/runtime_wrap.rs @@ -0,0 +1,121 @@ +use std::env; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn env_flag(name: &str) -> bool { + matches!( + env::var(name) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" | "on" + ) +} + +pub fn use_steam_run() -> bool { + env_flag("NAK_USE_STEAM_RUN") +} + +pub fn use_umu_for_prefix() -> bool { + env_flag("NAK_USE_UMU_FOR_PREFIX") +} + +pub fn prefer_system_umu() -> bool { + env_flag("NAK_PREFER_SYSTEM_UMU") +} + +fn find_in_path(binary: &str) -> Option<PathBuf> { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|entry| entry.join(binary)) + .find(|candidate| candidate.exists()) +} + +pub fn resolve_umu_run() -> Option<PathBuf> { + if is_flatpak() { + // In Flatpak, umu-run must run on the host via flatpak-spawn --host. + let host_copy = env::var("XDG_DATA_HOME") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".local/share") + }) + .join("fluorine/umu-run"); + if host_copy.exists() { + return Some(host_copy); + } + // Fall back to bare name for host PATH resolution. + return Some(PathBuf::from("umu-run")); + } + + let bundled = env::var("NAK_BUNDLED_UMU_RUN") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()); + let system = find_in_path("umu-run"); + + if prefer_system_umu() { + system.or(bundled) + } else { + bundled.or(system) + } +} + +pub fn is_flatpak() -> bool { + Path::new("/.flatpak-info").exists() +} + +/// Build a command to run `exe` with the given environment variables. +/// +/// In Flatpak mode, `flatpak-spawn --host` is used and env vars are passed as +/// `--env=KEY=VALUE` flags (the host process does NOT inherit the sandbox env). +/// In steam-run mode, the command is wrapped with `steam-run`. +/// Otherwise the command runs directly. +pub fn build_command<S: AsRef<OsStr>>( + exe: impl AsRef<OsStr>, + envs: &[(&str, S)], +) -> Command { + if use_steam_run() { + let mut cmd = Command::new("steam-run"); + cmd.arg(exe.as_ref()); + for (key, value) in envs { + cmd.env(key, value.as_ref()); + } + return cmd; + } + if is_flatpak() { + let mut cmd = Command::new("flatpak-spawn"); + cmd.arg("--host"); + for (key, value) in envs { + cmd.arg(format!( + "--env={}={}", + key, + value.as_ref().to_string_lossy() + )); + } + cmd.arg(exe.as_ref()); + return cmd; + } + let mut cmd = Command::new(exe); + for (key, value) in envs { + cmd.env(key, value.as_ref()); + } + cmd +} + +/// Build a command with no extra environment variables. +pub fn command_for(exe: impl AsRef<OsStr>) -> Command { + build_command::<&str>(exe, &[]) +} + +pub fn bundled_umu_path_from_appdir(appdir: &Path) -> Option<PathBuf> { + let path = appdir.join("umu-run"); + if path.exists() { + Some(path) + } else { + None + } +} diff --git a/libs/nak/src/steam/mod.rs b/libs/nak/src/steam/mod.rs new file mode 100644 index 0000000..c5964aa --- /dev/null +++ b/libs/nak/src/steam/mod.rs @@ -0,0 +1,135 @@ +//! Steam integration module +//! +//! Handles Proton detection, Steam path detection, and mount point discovery. +//! Shortcuts and config.vdf manipulation removed (handled by C++ side). + +mod paths; +mod proton; + +// Re-export path detection utilities +pub use paths::{ + detect_steam_path_checked, find_steam_path, find_userdata_path, + get_steam_accounts, +}; + +// Re-export Proton detection +pub use proton::{find_steam_protons, SteamProton}; + +use std::fs; + +/// Kill Steam process gracefully, then force if needed +pub fn kill_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::Command; + + // Try steam -shutdown first (graceful) + let _ = Command::new("steam") + .arg("-shutdown") + .status(); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Then force kill if still running + let _ = Command::new("pkill") + .arg("-9") + .arg("steam") + .status(); + + // Brief wait for Steam to fully exit + std::thread::sleep(std::time::Duration::from_secs(2)); + + Ok(()) +} + +/// Start Steam in background +pub fn start_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::{Command, Stdio}; + + Command::new("setsid") + .arg("steam") + .arg("-silent") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) +} + +/// Restart Steam (kill then start) +pub fn restart_steam() -> Result<(), Box<dyn std::error::Error>> { + kill_steam()?; + start_steam()?; + Ok(()) +} + +// ============================================================================ +// STEAM_COMPAT_MOUNTS Detection +// ============================================================================ + +/// Directories that pressure-vessel already exposes by default +const ALREADY_EXPOSED: &[&str] = &[ + "bin", "etc", "home", "lib", "lib32", "lib64", + "overrides", "run", "sbin", "tmp", "usr", "var", +]; + +/// System directories that shouldn't be mounted +const SYSTEM_DIRS: &[&str] = &[ + "proc", "sys", "dev", "boot", "root", "lost+found", "snap", +]; + +/// Detect directories at root that need to be added to STEAM_COMPAT_MOUNTS +pub fn detect_extra_mounts() -> Vec<String> { + let mut mounts = Vec::new(); + + let Ok(entries) = fs::read_dir("/") else { + return mounts; + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + if ALREADY_EXPOSED.contains(&name.as_str()) { + continue; + } + + if SYSTEM_DIRS.contains(&name.as_str()) { + continue; + } + + if name.starts_with('.') { + continue; + } + + if entry.path().is_dir() { + mounts.push(format!("/{}", name)); + } + } + + mounts.sort(); + mounts +} + +/// Generate launch options string with DXVK config file and STEAM_COMPAT_MOUNTS +pub fn generate_launch_options(dxvk_conf_path: Option<&std::path::Path>, is_electron_app: bool) -> String { + let mounts = detect_extra_mounts(); + + let dxvk_part = match dxvk_conf_path { + Some(path) => format!( + "DXVK_CONFIG_FILE=\"{}\"", + crate::config::normalize_path_for_steam(&path.to_string_lossy()) + ), + None => String::new(), + }; + + let electron_flags = if is_electron_app { + " --disable-gpu --no-sandbox" + } else { + "" + }; + + match (dxvk_part.is_empty(), mounts.is_empty()) { + (true, true) => format!("%command%{}", electron_flags), + (true, false) => format!("STEAM_COMPAT_MOUNTS={} %command%{}", mounts.join(":"), electron_flags), + (false, true) => format!("{} %command%{}", dxvk_part, electron_flags), + (false, false) => format!("{} STEAM_COMPAT_MOUNTS={} %command%{}", dxvk_part, mounts.join(":"), electron_flags), + } +} diff --git a/libs/nak/src/steam/paths.rs b/libs/nak/src/steam/paths.rs new file mode 100644 index 0000000..8ef5013 --- /dev/null +++ b/libs/nak/src/steam/paths.rs @@ -0,0 +1,259 @@ +//! Steam path detection utilities + +use std::fs; +use std::path::PathBuf; + +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Core Path Detection +// ============================================================================ + +/// Find the Steam installation path. +#[must_use] +pub fn find_steam_path() -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + + let steam_paths = [ + format!("{}/.steam/steam", home), + format!("{}/.local/share/Steam", home), + format!("{}/.var/app/com.valvesoftware.Steam/.steam/steam", home), + format!("{}/snap/steam/common/.steam/steam", home), + ]; + + steam_paths + .iter() + .map(PathBuf::from) + .find(|p| p.exists()) +} + +/// Find the Steam userdata directory. +#[must_use] +pub fn find_userdata_path() -> Option<PathBuf> { + let config = crate::config::AppConfig::load(); + if !config.selected_steam_account.is_empty() { + if let Some(path) = find_userdata_path_for_account(&config.selected_steam_account) { + return Some(path); + } + } + + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata"); + + if !userdata.exists() { + return None; + } + + let accounts = get_steam_accounts(); + + if let Some(most_recent) = accounts.iter().find(|a| a.most_recent) { + let path = userdata.join(&most_recent.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (MostRecent): {} ({})", + most_recent.persona_name, most_recent.account_id + )); + return Some(path); + } + } + + if let Some(first_account) = accounts.first() { + let path = userdata.join(&first_account.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (most recent timestamp): {} ({})", + first_account.persona_name, first_account.account_id + )); + return Some(path); + } + } + + log_warning("Could not determine active Steam account from loginusers.vdf, falling back to directory modification time"); + + let mut user_dirs: Vec<PathBuf> = Vec::new(); + + if let Ok(entries) = fs::read_dir(&userdata) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name() { + let name_str = name.to_string_lossy(); + if name_str != "0" && name_str.chars().all(|c| c.is_ascii_digit()) { + user_dirs.push(path); + } + } + } + } + } + + user_dirs.sort_by(|a, b| { + let a_time = fs::metadata(a).and_then(|m| m.modified()).ok(); + let b_time = fs::metadata(b).and_then(|m| m.modified()).ok(); + b_time.cmp(&a_time) + }); + + user_dirs.into_iter().next() +} + +// ============================================================================ +// Steam Account Detection (loginusers.vdf parsing) +// ============================================================================ + +/// Information about a Steam user account +#[derive(Debug, Clone)] +pub struct SteamAccount { + pub account_id: String, + pub persona_name: String, + pub most_recent: bool, + pub timestamp: u64, +} + +/// Get all Steam accounts from loginusers.vdf with their display names +#[must_use] +pub fn get_steam_accounts() -> Vec<SteamAccount> { + let Some(steam_path) = find_steam_path() else { + return Vec::new(); + }; + + let loginusers_path = steam_path.join("config/loginusers.vdf"); + let userdata_path = steam_path.join("userdata"); + + let Ok(content) = fs::read_to_string(&loginusers_path) else { + return Vec::new(); + }; + + let mut accounts = Vec::new(); + + let mut current_steam_id: Option<String> = None; + let mut current_account: Option<SteamAccountBuilder> = None; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('"') && trimmed.ends_with('"') { + let id = trimmed.trim_matches('"'); + if id.len() == 17 && id.starts_with("7656") && id.chars().all(|c| c.is_ascii_digit()) { + if let (Some(steam_id), Some(builder)) = (current_steam_id.take(), current_account.take()) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + current_steam_id = Some(id.to_string()); + current_account = Some(SteamAccountBuilder::default()); + } + } + + if let Some(ref mut builder) = current_account { + if let Some((key, value)) = parse_vdf_kv(trimmed) { + match key.to_lowercase().as_str() { + "accountname" => builder.account_name = Some(value), + "personaname" => builder.persona_name = Some(value), + "mostrecent" => builder.most_recent = value == "1", + "timestamp" => builder.timestamp = value.parse().unwrap_or(0), + _ => {} + } + } + } + } + + if let (Some(steam_id), Some(builder)) = (current_steam_id, current_account) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + + accounts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + accounts +} + +#[derive(Default)] +struct SteamAccountBuilder { + account_name: Option<String>, + persona_name: Option<String>, + most_recent: bool, + timestamp: u64, +} + +impl SteamAccountBuilder { + fn build(self, steam_id: &str, userdata_base: &std::path::Path) -> Option<SteamAccount> { + let account_name = self.account_name?; + let persona_name = self.persona_name.unwrap_or_else(|| account_name.clone()); + + let steam64: u64 = steam_id.parse().ok()?; + let account_id = (steam64 - 76561197960265728).to_string(); + + let userdata_path = userdata_base.join(&account_id); + + if !userdata_path.exists() { + return None; + } + + Some(SteamAccount { + account_id, + persona_name, + most_recent: self.most_recent, + timestamp: self.timestamp, + }) + } +} + +/// Parse a VDF key-value pair like: "Key" "Value" +fn parse_vdf_kv(line: &str) -> Option<(String, String)> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for c in line.chars() { + match c { + '"' => { + if in_quotes { + parts.push(current.clone()); + current.clear(); + } + in_quotes = !in_quotes; + } + _ if in_quotes => current.push(c), + _ => {} + } + } + + if parts.len() >= 2 { + Some((parts[0].clone(), parts[1].clone())) + } else { + None + } +} + +/// Find the userdata path for a specific Steam account +#[must_use] +pub fn find_userdata_path_for_account(account_id: &str) -> Option<PathBuf> { + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata").join(account_id); + + if userdata.exists() { + Some(userdata) + } else { + None + } +} + +// ============================================================================ +// Convenience Wrappers +// ============================================================================ + +/// Detect the Steam installation path with logging. +#[must_use] +pub fn detect_steam_path_checked() -> Option<String> { + match find_steam_path() { + Some(path) => { + let path_str = path.to_string_lossy().to_string(); + log_info(&format!("Steam detected at: {}", path_str)); + Some(path_str) + } + None => { + log_warning("Steam installation not detected! NaK requires Steam to be installed."); + None + } + } +} diff --git a/libs/nak/src/steam/proton.rs b/libs/nak/src/steam/proton.rs new file mode 100644 index 0000000..064fd96 --- /dev/null +++ b/libs/nak/src/steam/proton.rs @@ -0,0 +1,257 @@ +//! Steam Proton detection and management +//! +//! Finds Protons that Steam can see and use for non-Steam games. +//! This includes Steam's built-in Protons and custom Protons in compatibilitytools.d. + +use std::fs; +use std::path::PathBuf; + +use super::find_steam_path; + +/// Information about an installed Proton version +#[derive(Debug, Clone)] +pub struct SteamProton { + /// Display name (e.g., "GE-Proton9-20", "Proton Experimental") + pub name: String, + /// Internal name used in config.vdf (e.g., "proton_experimental", "GE-Proton9-20") + pub config_name: String, + /// Full path to the Proton installation + pub path: PathBuf, + /// Whether this is a Steam-provided Proton (vs custom) + pub is_steam_proton: bool, + /// Whether this is Proton Experimental + pub is_experimental: bool, +} + +impl SteamProton { + /// Get the path to the wine binary. + pub fn wine_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wine"), + self.path.join("dist/bin/wine"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the path to the wineserver binary. + pub fn wineserver_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wineserver"), + self.path.join("dist/bin/wineserver"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the bin directory containing wine executables. + pub fn bin_dir(&self) -> Option<PathBuf> { + self.wine_binary().and_then(|p| p.parent().map(|p| p.to_path_buf())) + } +} + +/// Find all Protons that Steam can use (Proton 10+ only) +pub fn find_steam_protons() -> Vec<SteamProton> { + let mut protons = Vec::new(); + + let Some(steam_path) = find_steam_path() else { + return protons; + }; + + let is_flatpak = steam_path.to_string_lossy().contains(".var/app/com.valvesoftware.Steam"); + + // 1. Steam's built-in Protons (steamapps/common/Proton*) + protons.extend(find_builtin_protons(&steam_path)); + + // 2. Custom Protons in user's compatibilitytools.d + protons.extend(find_custom_protons(&steam_path)); + + // 3. System-level Protons in /usr/share/steam/compatibilitytools.d/ + if is_flatpak { + crate::logging::log_info("Flatpak Steam detected - skipping system protons in /usr/share/steam/compatibilitytools.d/"); + } else { + protons.extend(find_system_protons()); + } + + // Filter to only include Proton 10+ (required for Steam-native integration) + protons.retain(is_proton_10_or_newer); + + // Filter to only include Protons with valid wine binaries + protons.retain(|p| { + let has_wine = p.wine_binary().is_some(); + if !has_wine { + crate::logging::log_warning(&format!( + "Skipping Proton '{}': wine binary not found at expected paths (files/bin/wine or dist/bin/wine)", + p.name + )); + } + has_wine + }); + + // Sort: Experimental first, then by name descending (newest first) + protons.sort_by(|a, b| { + if a.is_experimental != b.is_experimental { + return b.is_experimental.cmp(&a.is_experimental); + } + b.name.cmp(&a.name) + }); + + protons +} + +/// Check if a Proton version is 10 or newer +fn is_proton_10_or_newer(proton: &SteamProton) -> bool { + let name = &proton.name; + + if proton.is_experimental || name.contains("Experimental") { + return true; + } + + if name.contains("CachyOS") { + return true; + } + + if name == "LegacyRuntime" || name.contains("Runtime") { + return false; + } + + if name.starts_with("GE-Proton") { + if let Some(version_part) = name.strip_prefix("GE-Proton") { + let major: Option<u32> = version_part + .split('-') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("Proton ") { + if let Some(version_part) = name.strip_prefix("Proton ") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("EM-") { + if let Some(version_part) = name.strip_prefix("EM-") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + // Unknown format - allow it + true +} + +/// Find Steam's built-in Proton versions +fn find_builtin_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let common_dir = steam_path.join("steamapps/common"); + + let Ok(entries) = fs::read_dir(&common_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + if name.starts_with("Proton") && path.join("proton").exists() { + let is_experimental = name.contains("Experimental"); + + let config_name = if is_experimental { + "proton_experimental".to_string() + } else { + let version = name.replace("Proton ", ""); + let major = version.split('.').next().unwrap_or(&version); + format!("proton_{}", major) + }; + + found.push(SteamProton { + name: name.clone(), + config_name, + path, + is_steam_proton: true, + is_experimental, + }); + } + } + + found +} + +/// Find custom Protons in compatibilitytools.d +fn find_custom_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let compat_dir = steam_path.join("compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} + +/// Find system-level Protons in /usr/share/steam/compatibilitytools.d/ +fn find_system_protons() -> Vec<SteamProton> { + let mut found = Vec::new(); + let system_compat_dir = PathBuf::from("/usr/share/steam/compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&system_compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} diff --git a/libs/nak/src/utils.rs b/libs/nak/src/utils.rs new file mode 100644 index 0000000..7d7886c --- /dev/null +++ b/libs/nak/src/utils.rs @@ -0,0 +1,19 @@ +//! Shared utility functions used across the application + +use std::error::Error; +use std::fs; +use std::path::Path; + +/// Download a file from URL to the specified path +pub fn download_file(url: &str, path: &Path) -> Result<(), Box<dyn Error>> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let resp = ureq::get(url).call()?; + let mut reader = resp.into_reader(); + let mut file = fs::File::create(path)?; + std::io::copy(&mut reader, &mut file)?; + Ok(()) +} diff --git a/libs/nak_ffi/Cargo.lock b/libs/nak_ffi/Cargo.lock index 83d19cc..55ab8db 100644 --- a/libs/nak_ffi/Cargo.lock +++ b/libs/nak_ffi/Cargo.lock @@ -9,30 +9,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -42,219 +18,30 @@ dependencies = [ ] [[package]] -name = "annotate-snippets" -version = "0.12.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e4850548ff4a25a77ce3bda7241874e17fb702ea551f0cc62a2dbe052f1272" -dependencies = [ - "anstyle", - "unicode-width", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec", -] - -[[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "cfg_aliases", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] name = "cc" version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -265,69 +52,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] name = "chrono" version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", - "serde", + "wasm-bindgen", "windows-link", ] [[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "countme" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" - -[[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -337,66 +80,6 @@ dependencies = [ ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -408,100 +91,6 @@ dependencies = [ ] [[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -518,79 +107,6 @@ dependencies = [ ] [[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "log", - "slotmap", - "tinyvec", - "ttf-parser 0.25.1", -] - -[[package]] -name = "fontdue" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" -dependencies = [ - "hashbrown 0.15.5", - "rayon", - "ttf-parser 0.21.1", -] - -[[package]] -name = "fontique" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" -dependencies = [ - "bytemuck", - "hashbrown 0.16.1", - "icu_locale_core", - "linebender_resource_handle", - "memmap2", - "objc2", - "objc2-core-foundation", - "objc2-core-text", - "objc2-foundation", - "read-fonts", - "roxmltree", - "smallvec", - "windows", - "windows-core 0.58.0", - "yeslogic-fontconfig-sys", -] - -[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -611,113 +127,6 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", - "rayon", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "i-slint-common" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7df69e8ff23fd605ac12112e31844f92b83e5958235f3ec446efe79bc868e65" -dependencies = [ - "fontique", - "ttf-parser 0.25.1", -] - -[[package]] -name = "i-slint-compiler" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09f34ca3843cae3e92c1a8a4167680e4e4a2f72ea16bcbded9bc06271393427" -dependencies = [ - "annotate-snippets", - "by_address", - "derive_more", - "fontdue", - "i-slint-common", - "image", - "itertools", - "linked_hash_set", - "lyon_extra", - "lyon_path", - "num_enum", - "proc-macro2", - "quote", - "rayon", - "resvg", - "rowan", - "rspolib", - "smol_str", - "strum", - "typed-index-collections", - "url", -] - -[[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -729,7 +138,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -762,7 +171,6 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", "writeable", "zerovec", @@ -845,98 +253,12 @@ dependencies = [ ] [[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.0", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] name = "js-sys" version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -947,82 +269,12 @@ dependencies = [ ] [[package]] -name = "kurbo" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linked_hash_set" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" -dependencies = [ - "linked-hash-map", -] - -[[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1035,71 +287,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lyon_extra" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca94c7bf1e2557c2798989c43416822c12fc5dcc5e17cc3307ef0e71894a955" -dependencies = [ - "lyon_path", - "thiserror 1.0.69", -] - -[[package]] -name = "lyon_geom" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" -dependencies = [ - "lyon_geom", - "num-traits", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "memmap2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" -dependencies = [ - "libc", -] - -[[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1110,16 +303,6 @@ dependencies = [ ] [[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] name = "nak_ffi" version = "0.1.0" dependencies = [ @@ -1128,160 +311,22 @@ dependencies = [ [[package]] name = "nak_rust" -version = "4.4.1" -source = "git+https://github.com/SulfurNitride/NaK.git?branch=main#41915b5a095da6bc5b4960eac2c9e55771caa7c4" +version = "0.1.0" dependencies = [ "chrono", - "rand", "serde", "serde_json", - "slint-build", "ureq", "walkdir", ] [[package]] -name = "natord" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", -] - -[[package]] -name = "num_enum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.10.0", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.10.0", - "objc2", ] [[package]] @@ -1291,68 +336,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1362,24 +351,6 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", -] - -[[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1389,49 +360,6 @@ dependencies = [ ] [[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] name = "quote" version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1441,147 +369,6 @@ dependencies = [ ] [[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" -dependencies = [ - "bytemuck", - "font-types", -] - -[[package]] -name = "resvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" -dependencies = [ - "gif", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia", - "usvg", - "zune-jpeg 0.5.12", -] - -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" -dependencies = [ - "bytemuck", -] - -[[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1589,62 +376,13 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] -name = "rowan" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" -dependencies = [ - "countme", - "hashbrown 0.14.5", - "rustc-hash", - "text-size", -] - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "rspolib" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" -dependencies = [ - "lazy_static", - "natord", - "snafu", - "unicode-linebreak", - "unicode-width", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1686,24 +424,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - -[[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1713,12 +433,6 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1774,149 +488,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slint-build" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81baa6950be442989aedda43bc4318a174ebf902a50b1be4c1f8488cdfe1b023" -dependencies = [ - "derive_more", - "i-slint-compiler", - "spin_on", - "toml_edit 0.24.0+spec-1.1.0", -] - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "smol_str" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "spin_on" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" -dependencies = [ - "pin-utils", -] - -[[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] name = "syn" version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1939,270 +528,28 @@ dependencies = [ ] [[package]] -name = "text-size" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tiff" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.17.16", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", - "serde_core", "zerovec", ] [[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.24.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - -[[package]] -name = "typed-index-collections" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" -dependencies = [ - "bincode", - "serde", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - -[[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] name = "ureq" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2214,8 +561,6 @@ dependencies = [ "once_cell", "rustls", "rustls-pki-types", - "serde", - "serde_json", "url", "webpki-roots 0.26.11", ] @@ -2233,56 +578,12 @@ dependencies = [ ] [[package]] -name = "usvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" -dependencies = [ - "base64", - "data-url", - "flate2", - "fontdb", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree", - "rustybuzz", - "simplecss", - "siphasher", - "strict-num", - "svgtypes", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2299,15 +600,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] name = "wasm-bindgen" version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2371,12 +663,6 @@ dependencies = [ ] [[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2386,50 +672,16 @@ dependencies = [ ] [[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets", -] - -[[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-result", + "windows-strings", ] [[package]] @@ -2445,17 +697,6 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" @@ -2473,15 +714,6 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" @@ -2491,16 +723,6 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets", -] - -[[package]] -name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" @@ -2591,50 +813,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2658,26 +842,6 @@ dependencies = [ ] [[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2721,7 +885,6 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ - "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2743,42 +906,3 @@ name = "zmij" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core 0.5.1", -] diff --git a/libs/nak_ffi/Cargo.toml b/libs/nak_ffi/Cargo.toml index fc667e0..cdf98df 100644 --- a/libs/nak_ffi/Cargo.toml +++ b/libs/nak_ffi/Cargo.toml @@ -7,4 +7,4 @@ edition = "2021" crate-type = ["cdylib"]
[dependencies]
-nak_rust = { git = "https://github.com/SulfurNitride/NaK.git", branch = "main", default-features = false, features = ["installer"] }
+nak_rust = { path = "../nak" }
diff --git a/libs/nak_ffi/include/nak_ffi.h b/libs/nak_ffi/include/nak_ffi.h index df4d47b..8e24041 100644 --- a/libs/nak_ffi/include/nak_ffi.h +++ b/libs/nak_ffi/include/nak_ffi.h @@ -84,34 +84,7 @@ NakProtonList nak_find_steam_protons(void); void nak_proton_list_free(NakProtonList list);
/* ========================================================================
- * Tier 3: Steam Shortcuts
- * ======================================================================== */
-
-/** Result from adding a Steam shortcut */
-typedef struct {
- uint32_t app_id;
- char *prefix_path; /* NULL on error */
- char *error; /* NULL on success */
-} NakShortcutResult;
-
-/** Add a mod manager as a non-Steam game shortcut.
- * Check result.error: NULL = success. */
-NakShortcutResult nak_add_mod_manager_shortcut(
- const char *name,
- const char *exe_path,
- const char *start_dir,
- const char *proton_name
-);
-
-/** Remove a non-Steam game shortcut by AppID.
- * Returns NULL on success, or error message (free with nak_string_free). */
-char *nak_remove_steam_shortcut(uint32_t app_id);
-
-/** Free a NakShortcutResult */
-void nak_shortcut_result_free(NakShortcutResult result);
-
-/* ========================================================================
- * Tier 4: Steam Paths
+ * Tier 3: Steam Paths
* ======================================================================== */
/** Find the Steam installation path.
@@ -119,48 +92,7 @@ void nak_shortcut_result_free(NakShortcutResult result); char *nak_find_steam_path(void);
/* ========================================================================
- * Tier 5: Managed Prefixes
- * ======================================================================== */
-
-/** A managed Wine prefix */
-typedef struct {
- uint32_t app_id;
- char *name;
- char *prefix_path;
- char *install_path;
- char *manager_type;
- char *library_path;
- char *created; /* ISO 8601 timestamp */
- char *proton_config_name; /* NULL if not set */
-} NakManagedPrefix;
-
-/** List of managed prefixes */
-typedef struct {
- NakManagedPrefix *prefixes;
- size_t count;
-} NakManagedPrefixList;
-
-/** Load all managed prefixes */
-NakManagedPrefixList nak_managed_prefixes_load(void);
-
-/** Register a new managed prefix (proton_config_name may be NULL) */
-void nak_managed_prefixes_register(
- uint32_t app_id,
- const char *name,
- const char *prefix_path,
- const char *install_path,
- const char *library_path,
- const char *proton_config_name
-);
-
-/** Unregister a managed prefix by AppID */
-void nak_managed_prefixes_unregister(uint32_t app_id);
-
-/** Free a NakManagedPrefixList */
-void nak_managed_prefix_list_free(NakManagedPrefixList list);
-
-/* ========================================================================
- * Tier 6: Dependency Installation (callback-based)
+ * Tier 4: Dependency Installation (callback-based)
* ======================================================================== */
/** Callback for status/log messages */
@@ -207,7 +139,7 @@ char *nak_apply_registry_for_game_path( );
/* ========================================================================
- * Tier 7: Prefix Symlinks
+ * Tier 5: Prefix Symlinks
* ======================================================================== */
/** Ensure AppData/Local/Temp exists in the Wine prefix.
@@ -219,6 +151,30 @@ void nak_ensure_temp_directory(const char *prefix_path); void nak_create_game_symlinks_auto(const char *prefix_path);
/* ========================================================================
+ * Tier 6: Logging
+ * ======================================================================== */
+
+/** Callback for NaK log messages: (level, message).
+ * Levels: "info", "warning", "error", "install", "action", "download" */
+typedef void (*NakLogLevelCallback)(const char *level, const char *message);
+
+/** Initialize NaK logging with a callback.
+ * Call once at startup before any other nak_* functions. */
+void nak_init_logging(NakLogLevelCallback cb);
+
+/* ========================================================================
+ * Tier 7: DXVK Configuration
+ * ======================================================================== */
+
+/** Ensure the DXVK config file exists, downloading if necessary.
+ * Returns NULL on success, or error message (free with nak_string_free). */
+char *nak_ensure_dxvk_conf(void);
+
+/** Get the path to the DXVK config file.
+ * Returns newly allocated string (free with nak_string_free). */
+char *nak_get_dxvk_conf_path(void);
+
+/* ========================================================================
* General
* ======================================================================== */
diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs index 2e19af7..dbd4cdb 100644 --- a/libs/nak_ffi/src/lib.rs +++ b/libs/nak_ffi/src/lib.rs @@ -316,66 +316,7 @@ pub unsafe extern "C" fn nak_proton_list_free(list: NakProtonList) { }
// ============================================================================
-// Tier 3: Steam Shortcuts
-// ============================================================================
-
-/// Result from adding a Steam shortcut
-#[repr(C)]
-pub struct NakShortcutResult {
- pub app_id: u32,
- pub prefix_path: *mut c_char,
- pub error: *mut c_char, // null on success
-}
-
-/// Add a mod manager as a non-Steam game shortcut
-///
-/// Returns a NakShortcutResult. Check `error` field - null means success.
-#[no_mangle]
-pub unsafe extern "C" fn nak_add_mod_manager_shortcut(
- name: *const c_char,
- exe_path: *const c_char,
- start_dir: *const c_char,
- proton_name: *const c_char,
-) -> NakShortcutResult {
- let name = unsafe { from_cstr(name) };
- let exe = unsafe { from_cstr(exe_path) };
- let dir = unsafe { from_cstr(start_dir) };
- let proton = unsafe { from_cstr(proton_name) };
-
- match nak_rust::steam::add_mod_manager_shortcut(name, exe, dir, proton, None, false) {
- Ok(result) => NakShortcutResult {
- app_id: result.app_id,
- prefix_path: to_cstring(&result.prefix_path.to_string_lossy()),
- error: ptr::null_mut(),
- },
- Err(e) => NakShortcutResult {
- app_id: 0,
- prefix_path: ptr::null_mut(),
- error: error_to_cstring(e),
- },
- }
-}
-
-/// Remove a non-Steam game shortcut by AppID
-///
-/// Returns null on success, or an error message string (caller must free with nak_string_free).
-#[no_mangle]
-pub unsafe extern "C" fn nak_remove_steam_shortcut(app_id: u32) -> *mut c_char {
- match nak_rust::steam::remove_steam_shortcut(app_id) {
- Ok(()) => ptr::null_mut(),
- Err(e) => error_to_cstring(e),
- }
-}
-
-/// Free a NakShortcutResult
-#[no_mangle]
-pub unsafe extern "C" fn nak_shortcut_result_free(result: NakShortcutResult) {
- free_if_nonnull(result.prefix_path);
- free_if_nonnull(result.error);
-}
-
-// ============================================================================
-// Tier 4: Steam Paths
+// Tier 3: Steam Paths
// ============================================================================
/// Find the Steam installation path
@@ -391,119 +332,7 @@ pub extern "C" fn nak_find_steam_path() -> *mut c_char { }
// ============================================================================
-// Tier 5: Managed Prefixes
-// ============================================================================
-
-/// A managed Wine prefix (C-compatible)
-#[repr(C)]
-pub struct NakManagedPrefix {
- pub app_id: u32,
- pub name: *mut c_char,
- pub prefix_path: *mut c_char,
- pub install_path: *mut c_char,
- pub manager_type: *mut c_char,
- pub library_path: *mut c_char,
- pub created: *mut c_char,
- pub proton_config_name: *mut c_char, // null if not set
-}
-
-/// List of managed prefixes
-#[repr(C)]
-pub struct NakManagedPrefixList {
- pub prefixes: *mut NakManagedPrefix,
- pub count: usize,
-}
-
-/// Load all managed prefixes
-#[no_mangle]
-pub extern "C" fn nak_managed_prefixes_load() -> NakManagedPrefixList {
- let managed = nak_rust::config::ManagedPrefixes::load();
-
- let mut ffi_prefixes: Vec<NakManagedPrefix> = managed
- .prefixes
- .iter()
- .map(|p| NakManagedPrefix {
- app_id: p.app_id,
- name: to_cstring(&p.name),
- prefix_path: to_cstring(&p.prefix_path),
- install_path: to_cstring(&p.install_path),
- manager_type: to_cstring(&p.manager_type.to_string()),
- library_path: to_cstring(&p.library_path),
- created: to_cstring(&p.created.to_rfc3339()),
- proton_config_name: to_cstring_opt(p.proton_config_name.as_deref()),
- })
- .collect();
-
- let list = NakManagedPrefixList {
- prefixes: ffi_prefixes.as_mut_ptr(),
- count: ffi_prefixes.len(),
- };
- std::mem::forget(ffi_prefixes);
- list
-}
-
-/// Register a new managed prefix
-#[no_mangle]
-pub unsafe extern "C" fn nak_managed_prefixes_register(
- app_id: u32,
- name: *const c_char,
- prefix_path: *const c_char,
- install_path: *const c_char,
- library_path: *const c_char,
- proton_config_name: *const c_char,
-) {
- let name = unsafe { from_cstr(name) };
- let prefix = unsafe { from_cstr(prefix_path) };
- let install = unsafe { from_cstr(install_path) };
- let library = unsafe { from_cstr(library_path) };
- let proton = if proton_config_name.is_null() {
- None
- } else {
- let s = unsafe { from_cstr(proton_config_name) };
- if s.is_empty() {
- None
- } else {
- Some(s)
- }
- };
-
- nak_rust::config::ManagedPrefixes::register(
- app_id,
- name,
- prefix,
- install,
- nak_rust::config::ManagerType::MO2,
- library,
- proton,
- );
-}
-
-/// Unregister a managed prefix by AppID
-#[no_mangle]
-pub extern "C" fn nak_managed_prefixes_unregister(app_id: u32) {
- nak_rust::config::ManagedPrefixes::unregister(app_id);
-}
-
-/// Free a NakManagedPrefixList
-#[no_mangle]
-pub unsafe extern "C" fn nak_managed_prefix_list_free(list: NakManagedPrefixList) {
- if list.prefixes.is_null() {
- return;
- }
- let prefixes = unsafe { Vec::from_raw_parts(list.prefixes, list.count, list.count) };
- for p in prefixes {
- free_if_nonnull(p.name);
- free_if_nonnull(p.prefix_path);
- free_if_nonnull(p.install_path);
- free_if_nonnull(p.manager_type);
- free_if_nonnull(p.library_path);
- free_if_nonnull(p.created);
- free_if_nonnull(p.proton_config_name);
- }
-}
-
-// ============================================================================
-// Tier 6: Dependency Installation (callback-based)
+// Tier 4: Dependency Installation (callback-based)
// ============================================================================
/// Callback for status messages: fn(message: *const c_char)
@@ -712,7 +541,7 @@ pub unsafe extern "C" fn nak_apply_registry_for_game_path( }
// ============================================================================
-// Tier 7: Prefix Symlinks
+// Tier 5: Prefix Symlinks
// ============================================================================
/// Ensure the Temp directory exists in the Wine prefix's AppData/Local.
@@ -734,6 +563,54 @@ pub unsafe extern "C" fn nak_create_game_symlinks_auto(prefix_path: *const c_cha }
// ============================================================================
+// Tier 6: Logging
+// ============================================================================
+
+/// Callback for NaK log messages: fn(level: *const c_char, message: *const c_char)
+///
+/// Levels: "info", "warning", "error", "install", "action", "download"
+pub type NakLogLevelCallback = Option<unsafe extern "C" fn(*const c_char, *const c_char)>;
+
+/// Initialize NaK logging with a callback.
+///
+/// The callback receives (level, message) for all NaK internal log messages.
+/// Call once at startup before any other nak_* functions.
+#[no_mangle]
+pub unsafe extern "C" fn nak_init_logging(cb: NakLogLevelCallback) {
+ if let Some(callback) = cb {
+ nak_rust::logging::set_log_callback(move |level: &str, message: &str| {
+ let c_level = CString::new(level).unwrap_or_default();
+ let c_msg = CString::new(message).unwrap_or_default();
+ unsafe { callback(c_level.as_ptr(), c_msg.as_ptr()) };
+ });
+ }
+}
+
+// ============================================================================
+// Tier 7: DXVK Configuration
+// ============================================================================
+
+/// Ensure the DXVK config file exists, downloading if necessary.
+///
+/// Returns null on success, or an error message (caller must free with nak_string_free).
+#[no_mangle]
+pub extern "C" fn nak_ensure_dxvk_conf() -> *mut c_char {
+ match nak_rust::dxvk::ensure_dxvk_conf() {
+ Ok(_) => ptr::null_mut(),
+ Err(e) => error_to_cstring(e),
+ }
+}
+
+/// Get the path to the DXVK config file.
+///
+/// Returns a newly allocated string (caller must free with nak_string_free).
+#[no_mangle]
+pub extern "C" fn nak_get_dxvk_conf_path() -> *mut c_char {
+ let path = nak_rust::dxvk::get_dxvk_conf_path();
+ to_cstring(&path.to_string_lossy())
+}
+
+// ============================================================================
// General: String free
// ============================================================================
diff --git a/libs/plugin_python/src/proxy/proxypython.cpp b/libs/plugin_python/src/proxy/proxypython.cpp index af897db..196bf23 100644 --- a/libs/plugin_python/src/proxy/proxypython.cpp +++ b/libs/plugin_python/src/proxy/proxypython.cpp @@ -175,6 +175,16 @@ bool ProxyPython::init(IOrganizer* moInfo) libpath, std::filesystem::path{IOrganizer::getPluginDataPath().toStdWString()}}; + // pluginDataPath() returns a writable directory (plugin_data/) separate + // from the possibly-read-only plugins/data/ that ships bundled assets + // like the DDS module. Add the bundled path too so imports still work. + { + auto bundledData = pluginDataRoot / "data"; + if (fs::is_directory(bundledData)) { + paths.emplace_back(bundledData); + } + } + // Allow portable/AppImage builds to ship Python packages next to the app. // MO2_PYTHON_DIR (set by AppRun) points to the writable python/ dir // next to the AppImage; fall back to <exe_dir>/python. diff --git a/libs/winreg.py b/libs/winreg.py index 2ecbb1f..d0012e7 100644 --- a/libs/winreg.py +++ b/libs/winreg.py @@ -10,5 +10,16 @@ def OpenKey(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") +OpenKeyEx = OpenKey + + def QueryValueEx(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") + + +def QueryInfoKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") + + +def EnumKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") diff --git a/src/plugins/rootbuilder.py b/src/plugins/rootbuilder.py index 7237400..fbe082c 100644 --- a/src/plugins/rootbuilder.py +++ b/src/plugins/rootbuilder.py @@ -15,6 +15,7 @@ import shutil import subprocess import mobase +from PyQt6.QtCore import qInfo, qWarning from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( QCheckBox, @@ -26,8 +27,16 @@ from PyQt6.QtWidgets import ( QVBoxLayout, ) -MANIFEST_NAME = ".rootbuilder_manifest.json" -BACKUP_DIR_NAME = ".rootbuilder_backup" +# Storage lives under <instance_dir>/rootbuilder/ — NOT the game directory, +# which Steam/Wine may make read-only during gameplay. +_STORAGE_SUBDIR = "rootbuilder" +_MANIFEST_NAME = "manifest.json" +_SETTINGS_NAME = "settings.json" +_BACKUP_SUBDIR = "backup" + +# Legacy names (stored in game dir by older versions) +_LEGACY_MANIFEST = ".rootbuilder_manifest.json" +_LEGACY_BACKUP = ".rootbuilder_backup" def _find_root_dir(mod_path: str) -> str | None: @@ -48,51 +57,116 @@ def _walk_files(root_dir: str): yield os.path.join(dirpath, name) +def _host_cp(src: str, dst: str) -> bool: + """Copy a file via the host OS (bypasses Flatpak sandbox restrictions).""" + try: + result = subprocess.run( + ["flatpak-spawn", "--host", "cp", "-f", "--reflink=auto", "--", src, dst], + capture_output=True, timeout=30, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, OSError): + return False + + def _reflink_copy(src: str, dst: str): """Copy with reflink (CoW) if supported, fallback to regular copy.""" try: subprocess.run( - ["cp", "--reflink=auto", "--", src, dst], + ["cp", "--reflink=auto", "-f", "--", src, dst], check=True, capture_output=True, ) + return except (subprocess.CalledProcessError, FileNotFoundError): + pass + try: shutil.copy2(src, dst) + return + except OSError: + pass + if _IN_FLATPAK and _host_cp(src, dst): + return + raise OSError(f"Root Builder: failed to copy {src} -> {dst}") -def _manifest_path(game_dir: str) -> str: - return os.path.join(game_dir, MANIFEST_NAME) +def _ensure_writable(path: str): + """Make a file or directory writable by the owner.""" + try: + st = os.stat(path) + os.chmod(path, st.st_mode | 0o200) + except OSError: + pass -def _load_manifest(game_dir: str) -> dict | None: - path = _manifest_path(game_dir) - if not os.path.isfile(path): - return None +_IN_FLATPAK = os.path.exists("/.flatpak-info") + + +def _host_rm(path: str) -> bool: + """Remove a file via the host OS (bypasses Flatpak sandbox restrictions).""" try: - with open(path, "r") as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return None + result = subprocess.run( + ["flatpak-spawn", "--host", "rm", "-f", "--", path], + capture_output=True, timeout=10, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, OSError): + return False -def _save_manifest(game_dir: str, manifest: dict): - path = _manifest_path(game_dir) - with open(path, "w") as f: - json.dump(manifest, f, indent=2) +def _host_mv(src: str, dst: str) -> bool: + """Move a file via the host OS (bypasses Flatpak sandbox restrictions).""" + try: + result = subprocess.run( + ["flatpak-spawn", "--host", "mv", "-f", "--", src, dst], + capture_output=True, timeout=10, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, OSError): + return False -def _remove_manifest(game_dir: str): - path = _manifest_path(game_dir) - if os.path.isfile(path): +def _force_remove(path: str) -> bool: + """Remove a file, fixing permissions if needed. Returns True on success.""" + try: + os.remove(path) + return True + except PermissionError: + pass + try: + _ensure_writable(os.path.dirname(path)) + _ensure_writable(path) os.remove(path) + return True + except OSError: + pass + if _IN_FLATPAK and _host_rm(path): + return True + qWarning(f"Root Builder: could not remove {path}") + return False -def _cleanup_empty_dirs(game_dir: str, deployed: list[str]): +def _rmtree_onerror(_func, path, _exc_info): + """onerror handler for shutil.rmtree that fixes permissions and retries.""" + try: + _ensure_writable(os.path.dirname(path)) + _ensure_writable(path) + os.remove(path) + except OSError: + pass + + +def _cleanup_empty_dirs(base_dir: str, paths: list[str]): """Remove empty directories left behind after clearing deployed files.""" dirs_to_check = set() - for path in deployed: + for path in paths: parent = os.path.dirname(path) - while parent and parent != game_dir and not os.path.samefile(parent, game_dir): + while parent and parent != base_dir: + try: + if os.path.samefile(parent, base_dir): + break + except OSError: + break dirs_to_check.add(parent) parent = os.path.dirname(parent) @@ -104,15 +178,44 @@ def _cleanup_empty_dirs(game_dir: str, deployed: list[str]): pass +# --- Manifest helpers (operate on storage_dir, NOT game dir) --- + +def _load_manifest(storage_dir: str) -> dict | None: + path = os.path.join(storage_dir, _MANIFEST_NAME) + if not os.path.isfile(path): + return None + try: + with open(path, "r") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None + + +def _save_manifest(storage_dir: str, manifest: dict): + os.makedirs(storage_dir, exist_ok=True) + path = os.path.join(storage_dir, _MANIFEST_NAME) + with open(path, "w") as f: + json.dump(manifest, f, indent=2) + + +def _remove_manifest(storage_dir: str): + path = os.path.join(storage_dir, _MANIFEST_NAME) + if os.path.isfile(path): + try: + os.remove(path) + except OSError: + pass + + class RootBuilderDialog(QDialog): """Small settings/control dialog shown from the Tools menu.""" - def __init__(self, organizer: mobase.IOrganizer, build_fn, clear_fn, parent=None): + def __init__(self, settings: dict, save_fn, build_fn, clear_fn, parent=None): super().__init__(parent) - self._organizer = organizer + self._settings = settings + self._save_fn = save_fn self._build_fn = build_fn self._clear_fn = clear_fn - self._plugin_name = "Root Builder" self.setWindowTitle("Root Builder") self.resize(350, 220) @@ -125,9 +228,7 @@ class RootBuilderDialog(QDialog): # Enable checkbox self._enableCheck = QCheckBox("Auto-deploy on game launch") - self._enableCheck.setChecked( - bool(organizer.pluginSetting(self._plugin_name, "enabled")) - ) + self._enableCheck.setChecked(settings.get("enabled", False) is True) layout.addWidget(self._enableCheck) # Mode selector @@ -135,8 +236,7 @@ class RootBuilderDialog(QDialog): mode_layout.addWidget(QLabel("Deploy mode:")) self._modeCombo = QComboBox() self._modeCombo.addItems(["copy", "link"]) - current = organizer.pluginSetting(self._plugin_name, "mode") - self._modeCombo.setCurrentText(current if current else "copy") + self._modeCombo.setCurrentText(settings.get("mode", "copy")) mode_layout.addWidget(self._modeCombo) layout.addLayout(mode_layout) @@ -159,28 +259,23 @@ class RootBuilderDialog(QDialog): close_btn.clicked.connect(self.accept) layout.addWidget(close_btn) - def _save_settings(self): - self._organizer.setPluginSetting( - self._plugin_name, "enabled", self._enableCheck.isChecked() - ) - self._organizer.setPluginSetting( - self._plugin_name, "mode", self._modeCombo.currentText() - ) + # Save settings whenever the user changes them + self._enableCheck.stateChanged.connect(lambda _: self._do_save()) + self._modeCombo.currentTextChanged.connect(lambda _: self._do_save()) + + def _do_save(self): + self._settings["enabled"] = self._enableCheck.isChecked() + self._settings["mode"] = self._modeCombo.currentText() + self._save_fn(self._settings) def _on_build(self): - self._save_settings() count = self._build_fn() self._status.setText(f"Deployed {count} file(s).") def _on_clear(self): - self._save_settings() count = self._clear_fn() self._status.setText(f"Cleared {count} file(s).") - def accept(self): - self._save_settings() - super().accept() - class RootBuilder(mobase.IPluginTool): _organizer: mobase.IOrganizer @@ -193,24 +288,77 @@ class RootBuilder(mobase.IPluginTool): def init(self, organizer: mobase.IOrganizer) -> bool: self._organizer = organizer + self._migrate_legacy() self._check_third_party_rootbuilder() organizer.onAboutToRun(self._on_about_to_run) organizer.onFinishedRun(self._on_finished_run) return True + # --- Storage paths (instance dir, always writable) --- + + def _storage_dir(self) -> str: + d = os.path.join(self._organizer.basePath(), _STORAGE_SUBDIR) + os.makedirs(d, exist_ok=True) + return d + + # --- Settings (our own JSON, not pluginSetting) --- + + def _load_settings(self) -> dict: + path = os.path.join(self._storage_dir(), _SETTINGS_NAME) + try: + with open(path, "r") as f: + return json.load(f) + except (OSError, json.JSONDecodeError, ValueError): + return {"enabled": False, "mode": "copy"} + + def _save_settings(self, settings: dict): + path = os.path.join(self._storage_dir(), _SETTINGS_NAME) + with open(path, "w") as f: + json.dump(settings, f, indent=2) + + def _is_enabled(self) -> bool: + return self._load_settings().get("enabled", False) is True + + # --- Legacy migration --- + + def _migrate_legacy(self): + """Move legacy manifest/backup from game dir to our storage dir.""" + game = self._organizer.managedGame() + if game is None: + return + game_dir = game.gameDirectory().absolutePath() + storage = self._storage_dir() + + old_manifest = os.path.join(game_dir, _LEGACY_MANIFEST) + if os.path.isfile(old_manifest): + try: + new_path = os.path.join(storage, _MANIFEST_NAME) + if not os.path.isfile(new_path): + shutil.copy2(old_manifest, new_path) + _force_remove(old_manifest) + except OSError: + pass + + old_backup = os.path.join(game_dir, _LEGACY_BACKUP) + if os.path.isdir(old_backup): + try: + new_backup = os.path.join(storage, _BACKUP_SUBDIR) + if not os.path.isdir(new_backup): + shutil.copytree(old_backup, new_backup) + shutil.rmtree(old_backup, onerror=_rmtree_onerror) + except OSError: + pass + def _check_third_party_rootbuilder(self): """Move any third-party Root Builder plugins into DisabledPlugins/.""" plugins_dir = os.path.dirname(os.path.abspath(__file__)) disabled_dir = os.path.join(os.path.dirname(plugins_dir), "DisabledPlugins") my_file = os.path.basename(__file__) - # Collect conflicts first, then move (don't modify dir during iteration) conflicts = [] for entry in os.scandir(plugins_dir): - # Kezyma's standard install: plugins/rootbuilder/ if entry.is_dir() and entry.name.lower() == "rootbuilder": conflicts.append((entry.name, entry.path)) - # Other rootbuilder*.py files that aren't us elif ( entry.is_file() and entry.name.lower().startswith("rootbuilder") @@ -224,17 +372,15 @@ class RootBuilder(mobase.IPluginTool): try: os.makedirs(disabled_dir, exist_ok=True) shutil.move(path, dst) - mobase.log( - mobase.LogLevel.INFO, + qInfo( f"Root Builder: moved incompatible third-party plugin " f"'{name}' to DisabledPlugins/. " - f"It uses Windows-only USVFS and cannot work on Linux.", + f"It uses Windows-only USVFS and cannot work on Linux." ) except OSError as e: - mobase.log( - mobase.LogLevel.WARNING, + qWarning( f"Root Builder: failed to move third-party plugin " - f"'{name}' to DisabledPlugins/: {e}", + f"'{name}' to DisabledPlugins/: {e}" ) def name(self) -> str: @@ -256,15 +402,10 @@ class RootBuilder(mobase.IPluginTool): return mobase.VersionInfo(1, 0, 0) def enabledByDefault(self) -> bool: - return False + return True def settings(self) -> list[mobase.PluginSetting]: - return [ - mobase.PluginSetting("mode", "Deploy mode: copy or link", "copy"), - mobase.PluginSetting( - "enabled", "Auto-deploy root files on launch", True - ), - ] + return [] # --- IPluginTool --- @@ -281,20 +422,22 @@ class RootBuilder(mobase.IPluginTool): self.__parentWidget = widget def display(self): + settings = self._load_settings() dialog = RootBuilderDialog( - self._organizer, self._build, self._clear, self.__parentWidget + settings, self._save_settings, self._build, self._clear, + self.__parentWidget, ) dialog.exec() # --- Hooks --- def _on_about_to_run(self, executable: str) -> bool: - if self._organizer.pluginSetting(self.name(), "enabled"): + if self._is_enabled(): self._build() return True def _on_finished_run(self, executable: str, exit_code: int): - if self._organizer.pluginSetting(self.name(), "enabled"): + if self._is_enabled(): self._clear() # --- Build / Clear --- @@ -302,16 +445,17 @@ class RootBuilder(mobase.IPluginTool): def _build(self) -> int: """Deploy root files from all active mods. Returns number of files deployed.""" game_dir = self._organizer.managedGame().gameDirectory().absolutePath() + storage = self._storage_dir() mod_list = self._organizer.modList() mods = mod_list.allModsByProfilePriority() - mode = self._organizer.pluginSetting(self.name(), "mode") or "copy" + mode = self._load_settings().get("mode", "copy") # Clear any previous deployment first - if _load_manifest(game_dir) is not None: + if _load_manifest(storage) is not None: self._clear() manifest = {"deployed": [], "backups": {}} - backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME) + backup_dir = os.path.join(storage, _BACKUP_SUBDIR) deployed_set = set() for mod_name in mods: @@ -333,62 +477,96 @@ class RootBuilder(mobase.IPluginTool): rel = os.path.relpath(src_file, root_dir) dst = os.path.join(game_dir, rel) - # Backup existing file if not already deployed by us - if os.path.exists(dst) and dst not in deployed_set: - bak = os.path.join(backup_dir, rel) - os.makedirs(os.path.dirname(bak), exist_ok=True) - shutil.move(dst, bak) - manifest["backups"][dst] = bak + try: + # Backup existing file if not already deployed by us + if os.path.exists(dst) and dst not in deployed_set: + bak = os.path.join(backup_dir, rel) + os.makedirs(os.path.dirname(bak), exist_ok=True) + try: + shutil.copy2(dst, bak) + except PermissionError: + _ensure_writable(dst) + shutil.copy2(dst, bak) + manifest["backups"][dst] = bak - os.makedirs(os.path.dirname(dst), exist_ok=True) + os.makedirs(os.path.dirname(dst), exist_ok=True) - if os.path.lexists(dst): - os.remove(dst) + if os.path.lexists(dst): + _force_remove(dst) - # In link mode, .exe and .dll must be copied — Wine/Proton - # resolves a symlinked exe's path to the target, so the - # process can't find sibling files in the game directory. - ext = os.path.splitext(src_file)[1].lower() - if mode == "link" and ext not in (".exe", ".dll"): - os.symlink(src_file, dst) - else: - _reflink_copy(src_file, dst) + # In link mode, .exe and .dll must be copied — Wine/Proton + # resolves a symlinked exe's path to the target, so the + # process can't find sibling files in the game directory. + ext = os.path.splitext(src_file)[1].lower() + if mode == "link" and ext not in (".exe", ".dll"): + os.symlink(src_file, dst) + else: + _reflink_copy(src_file, dst) - if dst not in deployed_set: - manifest["deployed"].append(dst) - deployed_set.add(dst) + if dst not in deployed_set: + manifest["deployed"].append(dst) + deployed_set.add(dst) + except OSError as e: + qWarning(f"Root Builder: could not deploy {rel}: {e}") - _save_manifest(game_dir, manifest) + _save_manifest(storage, manifest) return len(manifest["deployed"]) def _clear(self) -> int: """Remove deployed files and restore backups. Returns count of removed files.""" game_dir = self._organizer.managedGame().gameDirectory().absolutePath() - manifest = _load_manifest(game_dir) + storage = self._storage_dir() + manifest = _load_manifest(storage) if manifest is None: return 0 count = 0 + failed = [] # Remove deployed files for path in manifest["deployed"]: if os.path.lexists(path): - os.remove(path) - count += 1 + if _force_remove(path): + count += 1 + else: + failed.append(path) # Restore backups for dst, bak in manifest["backups"].items(): if os.path.exists(bak): - os.makedirs(os.path.dirname(dst), exist_ok=True) - shutil.move(bak, dst) + try: + parent = os.path.dirname(dst) + os.makedirs(parent, exist_ok=True) + _ensure_writable(parent) + if os.path.lexists(dst): + _force_remove(dst) + shutil.move(bak, dst) + except OSError: + if not (_IN_FLATPAK and _host_mv(bak, dst)): + qWarning( + f"Root Builder: could not restore backup " + f"{bak} -> {dst}" + ) # Clean up backup dir - backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME) + backup_dir = os.path.join(storage, _BACKUP_SUBDIR) if os.path.isdir(backup_dir): shutil.rmtree(backup_dir, ignore_errors=True) - _remove_manifest(game_dir) - _cleanup_empty_dirs(game_dir, manifest["deployed"]) + if failed: + # Update manifest to only contain files we couldn't remove, + # so the next clear attempt can retry them. + manifest["deployed"] = failed + manifest["backups"] = {} + _save_manifest(storage, manifest) + qWarning( + f"Root Builder: {len(failed)} file(s) could not be removed. " + f"They will be retried on next clear." + ) + else: + _remove_manifest(storage) + + _cleanup_empty_dirs(game_dir, [p for p in manifest["deployed"] if p not in failed]) return count diff --git a/src/src/createinstancedialog.cpp b/src/src/createinstancedialog.cpp index d3dcdb2..10bcce5 100644 --- a/src/src/createinstancedialog.cpp +++ b/src/src/createinstancedialog.cpp @@ -390,6 +390,15 @@ void CreateInstanceDialog::finish() logCreation(tr("Done."));
+ // register non-default portable instances so they appear in the sidebar
+ if (ci.type == Portable) {
+ const auto defaultPortable =
+ QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ if (QDir(ci.dataPath).absolutePath() != defaultPortable) {
+ InstanceManager::singleton().registerPortableInstance(ci.dataPath);
+ }
+ }
+
// launch the new instance
if (ui->launch->isChecked()) {
if (ci.type == Portable) {
diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp index 7c4f167..3138a17 100644 --- a/src/src/createinstancedialogpages.cpp +++ b/src/src/createinstancedialogpages.cpp @@ -6,6 +6,7 @@ #include "settingsdialognexus.h"
#include "shared/appconfig.h"
#include "ui_createinstancedialog.h"
+#include <QFileDialog>
#include <iplugingame.h>
#include <report.h>
#include <utility.h>
@@ -13,6 +14,19 @@ namespace cid
{
+// On Flatpak the native file dialog returns XDG portal FUSE paths that may
+// not properly expose directory contents. Returns options that force the
+// Qt built-in dialog when running inside Flatpak.
+static QFileDialog::Options flatpakSafeOptions()
+{
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
+ return opts;
+}
+
using namespace MOBase;
using MOBase::TaskDialog;
@@ -314,7 +328,8 @@ void GamePage::select(IPluginGame* game, const QString& dir) const auto path = QFileDialog::getExistingDirectory(
&m_dlg,
- QObject::tr("Find game installation for %1").arg(game->displayGameName()));
+ QObject::tr("Find game installation for %1").arg(game->displayGameName()),
+ {}, flatpakSafeOptions());
if (path.isEmpty()) {
// cancelled
@@ -361,8 +376,8 @@ void GamePage::select(IPluginGame* game, const QString& dir) void GamePage::selectCustom()
{
- const auto path =
- QFileDialog::getExistingDirectory(&m_dlg, QObject::tr("Find game installation"));
+ const auto path = QFileDialog::getExistingDirectory(
+ &m_dlg, QObject::tr("Find game installation"), {}, flatpakSafeOptions());
if (path.isEmpty()) {
// reselect the previous button
@@ -1045,6 +1060,20 @@ void PathsPage::doActivated(bool firstTime) // regenerated
const bool changed = (m_lastInstanceName != name) || (m_lastType != type);
+ const bool isGlobal = (type == CreateInstanceDialog::Global);
+
+ // Global instances have a fixed location derived from the instance name;
+ // the user should not be able to browse to an arbitrary directory.
+ ui->location->setReadOnly(isGlobal);
+ ui->browseLocation->setVisible(!isGlobal);
+ ui->advancedPathOptions->setVisible(!isGlobal);
+
+ // If switching from portable back to global, reset to simple view
+ if (isGlobal && ui->advancedPathOptions->isChecked()) {
+ ui->advancedPathOptions->setChecked(false);
+ ui->pathPages->setCurrentIndex(0);
+ }
+
// generating and paths
setPaths(name, changed);
checkPaths();
@@ -1085,7 +1114,8 @@ void PathsPage::onChanged() void PathsPage::browse(QLineEdit* e)
{
- const auto s = QFileDialog::getExistingDirectory(&m_dlg, {}, e->text());
+ const auto s =
+ QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), flatpakSafeOptions());
if (s.isNull() || s.isEmpty()) {
return;
}
@@ -1148,6 +1178,8 @@ void PathsPage::setPaths(const QString& name, bool force) } else {
const auto root = InstanceManager::singleton().globalInstancesRootPath();
basePath = root + "/" + name;
+ // Global instances always use the auto-derived path
+ force = true;
}
basePath = QDir::toNativeSeparators(QDir::cleanPath(basePath));
diff --git a/src/src/filedialogmemory.cpp b/src/src/filedialogmemory.cpp index 390843f..3c4ea11 100644 --- a/src/src/filedialogmemory.cpp +++ b/src/src/filedialogmemory.cpp @@ -72,6 +72,15 @@ QString FileDialogMemory::getExistingDirectory(const QString& dirID, QWidget* pa }
}
+#ifndef _WIN32
+ // On Flatpak, the native file dialog goes through the XDG Desktop Portal,
+ // which returns FUSE paths (/run/user/.../doc/...) that may not properly
+ // expose directory contents. Use the Qt built-in dialog to get real paths.
+ if (qEnvironmentVariableIsSet("FLATPAK_ID")) {
+ options |= QFileDialog::DontUseNativeDialog;
+ }
+#endif
+
QString result =
QFileDialog::getExistingDirectory(parent, caption, currentDir, options);
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index 2e3a7ba..a276074 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -252,8 +252,11 @@ bool FuseConnector::mount( m_dataDirName = data_dir_name.toStdString(); m_lastMods = mods; - // Compute the actual data directory path and mount directly on it - m_dataDirPath = (fs::path(m_gameDir) / m_dataDirName).string(); + // Use the caller-supplied data directory path directly. Re-computing it + // as gameDir/dataDirName breaks games where the data directory IS the game + // directory (e.g. BG3 with GameDataPath=""), because dirName() returns the + // last path component and appending it produces a non-existent double path. + m_dataDirPath = mount_point.toStdString(); m_mountPoint = m_dataDirPath; if (!fs::exists(m_dataDirPath)) { @@ -275,10 +278,19 @@ bool FuseConnector::mount( fs::create_directories(m_stagingDir, ec); fs::create_directories(m_overwriteDir, ec); - // Scan + cache base game files BEFORE mounting (after mount they're hidden) - m_baseFileCache = scanDataDir(m_dataDirPath); - log::debug("Cached {} base game entries from {}", m_baseFileCache.size(), - QString::fromStdString(m_dataDirPath)); + // Scan + cache base game files BEFORE mounting (after mount they're hidden). + // Reuse the cache across mount/unmount cycles since base game files don't + // change between runs — this avoids a full recursive directory walk on + // every launch. + if (m_baseFileCache.empty() || m_dataDirPath != m_cachedDataDirPath) { + m_baseFileCache = scanDataDir(m_dataDirPath); + m_cachedDataDirPath = m_dataDirPath; + log::debug("Scanned {} base game entries from {}", m_baseFileCache.size(), + QString::fromStdString(m_dataDirPath)); + } else { + log::debug("Reusing cached {} base game entries for {}", + m_baseFileCache.size(), QString::fromStdString(m_dataDirPath)); + } // Open fd to data dir BEFORE mounting so we can access original files m_backingFd = open(m_dataDirPath.c_str(), O_RDONLY | O_DIRECTORY); @@ -292,6 +304,9 @@ bool FuseConnector::mount( auto tree = std::make_shared<VfsTree>( buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir)); + // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt) + injectExtraFiles(*tree, m_extraVfsFiles); + m_context = std::make_shared<Mo2FsContext>(); m_context->tree = tree; m_context->inodes = std::make_unique<InodeTable>(); @@ -362,6 +377,7 @@ void FuseConnector::unmount() m_helperProcess = nullptr; m_mounted = false; setFuseMountPointForCrashCleanup(nullptr); + cleanupExternalMappings(); log::debug("VFS helper stopped, FUSE unmounted from {}", QString::fromStdString(m_mountPoint)); return; @@ -392,6 +408,9 @@ void FuseConnector::unmount() m_mounted = false; setFuseMountPointForCrashCleanup(nullptr); + // Clean up symlinks created for non-data-dir mappings. + cleanupExternalMappings(); + log::debug("FUSE unmounted from {}", QString::fromStdString(m_mountPoint)); } @@ -431,6 +450,9 @@ void FuseConnector::rebuild( auto newTree = std::make_shared<VfsTree>( buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir)); + // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt) + injectExtraFiles(*newTree, m_extraVfsFiles); + std::unique_lock lock(m_context->tree_mutex); m_context->tree.swap(newTree); } @@ -449,14 +471,138 @@ void FuseConnector::updateMapping(const MappingType& mapping) auto mods = buildModsFromMapping(mapping, dataDirPath, overwriteDir); + // Deploy non-data-dir mappings as real symlinks and collect file-level + // data-dir mappings for VFS tree injection. + deployExternalMappings(mapping, dataDirPath); + if (!m_mounted) { - // mount_point param is ignored — mount() computes it from gameDir + dataDirName mount(dataDirPath, overwriteDir, gameDir, dataDirName, mods); } else { rebuild(mods, overwriteDir, dataDirName); } } +void FuseConnector::deployExternalMappings(const MappingType& mapping, + const QString& dataDir) +{ + cleanupExternalMappings(); + m_extraVfsFiles.clear(); + + const QString cleanDataDir = QDir::cleanPath(dataDir); + const QString dataPrefix = cleanDataDir + QStringLiteral("/"); + + for (const auto& map : mapping) { + const QString src = + QDir::cleanPath(QDir::fromNativeSeparators(map.source)); + const QString dst = + QDir::cleanPath(QDir::fromNativeSeparators(map.destination)); + + const bool targetsDataDir = + (dst == cleanDataDir || dst.startsWith(dataPrefix)); + + if (targetsDataDir) { + if (!map.isDirectory) { + // File-level mapping INTO the data directory (e.g. plugins.txt). + // FUSE sits on top, so we cannot create a physical symlink there. + // Record it for injection into the VFS tree instead. + const QString relPath = dst.startsWith(dataPrefix) + ? dst.mid(dataPrefix.length()) + : QFileInfo(src).fileName(); + m_extraVfsFiles.emplace_back(relPath.toStdString(), src.toStdString()); + } + // Directory-level data-dir mappings are handled by the FUSE VFS. + continue; + } + + // Non-data-dir mapping — deploy via real symlinks so the game + // (running through Proton) can see the files. + std::error_code ec; + + if (map.isDirectory) { + const fs::path srcPath(src.toStdString()); + if (!fs::exists(srcPath, ec)) { + continue; + } + + for (auto it = fs::recursive_directory_iterator( + srcPath, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + const fs::path rel = fs::relative(entry.path(), srcPath, ec); + if (ec || rel.empty()) { + continue; + } + + const fs::path destPath = fs::path(dst.toStdString()) / rel; + if (entry.is_directory(ec)) { + fs::create_directories(destPath, ec); + } else if (entry.is_regular_file(ec) || entry.is_symlink(ec)) { + fs::create_directories(destPath.parent_path(), ec); + if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) { + // Never overwrite real game files — only replace our own symlinks. + continue; + } + if (fs::is_symlink(destPath, ec)) { + fs::remove(destPath, ec); + } + fs::create_symlink(entry.path(), destPath, ec); + if (!ec) { + m_externalSymlinks.push_back(destPath.string()); + } else { + log::warn("Failed to symlink {} -> {}: {}", + QString::fromStdString(destPath.string()), + QString::fromStdString(entry.path().string()), + QString::fromStdString(ec.message())); + } + } + } + } else { + // Single file symlink. + const fs::path destPath(dst.toStdString()); + fs::create_directories(destPath.parent_path(), ec); + if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) { + continue; + } + if (fs::is_symlink(destPath, ec)) { + fs::remove(destPath, ec); + } + fs::create_symlink(fs::path(src.toStdString()), destPath, ec); + if (!ec) { + m_externalSymlinks.push_back(destPath.string()); + } else { + log::warn("Failed to symlink {} -> {}: {}", dst, src, + QString::fromStdString(ec.message())); + } + } + } + + if (!m_externalSymlinks.empty()) { + log::debug("Deployed {} external symlinks for non-data-dir mappings", + m_externalSymlinks.size()); + } + if (!m_extraVfsFiles.empty()) { + log::debug("Collected {} extra file mappings for VFS injection", + m_extraVfsFiles.size()); + } +} + +void FuseConnector::cleanupExternalMappings() +{ + if (m_externalSymlinks.empty()) { + return; + } + + std::error_code ec; + for (const auto& path : m_externalSymlinks) { + if (fs::is_symlink(path, ec)) { + fs::remove(path, ec); + } + } + + log::debug("Cleaned up {} external symlinks", m_externalSymlinks.size()); + m_externalSymlinks.clear(); +} + void FuseConnector::updateParams(MOBase::log::Levels /*logLevel*/, env::CoreDumpTypes /*coreDumpType*/, const QString& /*crashDumpsPath*/, @@ -682,4 +828,9 @@ void FuseConnector::writeVfsConfig( out << "mod=" << QString::fromStdString(name) << "|" << QString::fromStdString(path) << "\n"; } + + for (const auto& [relPath, realPath] : m_extraVfsFiles) { + out << "extra_file=" << QString::fromStdString(relPath) << "|" + << QString::fromStdString(realPath) << "\n"; + } } diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index b33c52c..fd5cb01 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -61,6 +61,8 @@ public: private: void flushStaging(); + void deployExternalMappings(const MappingType& mapping, const QString& dataDir); + void cleanupExternalMappings(); std::string m_mountPoint; std::string m_stagingDir; @@ -70,9 +72,16 @@ private: std::string m_dataDirPath; int m_backingFd = -1; std::vector<CachedBaseFile> m_baseFileCache; + std::string m_cachedDataDirPath; std::vector<std::pair<std::string, std::string>> m_lastMods; + // Symlinks created for non-data-dir mappings (e.g. Paks, OBSE, UE4SS). + std::vector<std::string> m_externalSymlinks; + // File-level mappings targeting the data directory (e.g. plugins.txt). + // Injected into the VFS tree after building. (relPath, absRealPath) + std::vector<std::pair<std::string, std::string>> m_extraVfsFiles; + std::shared_ptr<Mo2FsContext> m_context; struct fuse_session* m_session = nullptr; diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp index 0c6ca63..1b9eff1 100644 --- a/src/src/instancemanager.cpp +++ b/src/src/instancemanager.cpp @@ -84,10 +84,15 @@ Instance::Instance(QString dir, bool portable, QString profileName) QString Instance::displayName() const
{
- if (isPortable())
- return QObject::tr("Portable");
- else
+ if (isPortable()) {
+ const auto defaultPortable =
+ QDir(InstanceManager::singleton().portablePath()).absolutePath();
+ if (QDir(m_dir).absolutePath() == defaultPortable) {
+ return QObject::tr("Portable");
+ }
return QDir(m_dir).dirName();
+ }
+ return QDir(m_dir).dirName();
}
QString Instance::gameName() const
@@ -135,9 +140,9 @@ bool Instance::isActive() const auto& m = InstanceManager::singleton();
if (auto i = m.currentInstance()) {
- if (m_portable) {
- return i->isPortable();
- } else {
+ if (m_portable && i->isPortable()) {
+ return QDir(m_dir).absolutePath() == QDir(i->directory()).absolutePath();
+ } else if (!m_portable && !i->isPortable()) {
return (i->displayName() == displayName());
}
}
@@ -762,6 +767,21 @@ bool InstanceManager::instanceExists(const QString& instanceName) const return root.exists(instanceName);
}
+QStringList InstanceManager::registeredPortablePaths() const
+{
+ return GlobalSettings::portableInstances();
+}
+
+void InstanceManager::registerPortableInstance(const QString& path)
+{
+ GlobalSettings::addPortableInstance(path);
+}
+
+void InstanceManager::unregisterPortableInstance(const QString& path)
+{
+ GlobalSettings::removePortableInstance(path);
+}
+
std::unique_ptr<Instance> selectInstance()
{
auto& m = InstanceManager::singleton();
diff --git a/src/src/instancemanager.h b/src/src/instancemanager.h index 794728a..e4bf7a3 100644 --- a/src/src/instancemanager.h +++ b/src/src/instancemanager.h @@ -317,6 +317,12 @@ public: //
QString iniPath(const QString& instanceDir) const;
+ // persistent registry of portable instance paths (stored in GlobalSettings)
+ //
+ QStringList registeredPortablePaths() const;
+ void registerPortableInstance(const QString& path);
+ void unregisterPortableInstance(const QString& path);
+
private:
InstanceManager();
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp index 605f80e..24ce3bd 100644 --- a/src/src/instancemanagerdialog.cpp +++ b/src/src/instancemanagerdialog.cpp @@ -258,6 +258,25 @@ void InstanceManagerDialog::updateInstances() return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0);
});
+ // add registered portable instances (non-default paths)
+ const QString defaultPortable = QDir(m.portablePath()).absolutePath();
+ for (const auto& path : m.registeredPortablePaths()) {
+ // skip the default portable path (handled separately below)
+ if (QDir(path).absolutePath() == defaultPortable) {
+ continue;
+ }
+ // skip paths where ModOrganizer.ini no longer exists
+ if (!QFileInfo::exists(QDir(path).filePath("ModOrganizer.ini"))) {
+ continue;
+ }
+ m_instances.push_back(std::make_unique<Instance>(path, true));
+ }
+
+ // re-sort to interleave registered portables alphabetically
+ std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) {
+ return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0);
+ });
+
if (m.portableInstanceExists()) {
m_instances.insert(m_instances.begin(),
std::make_unique<Instance>(m.portablePath(), true));
@@ -342,8 +361,9 @@ void InstanceManagerDialog::selectActiveInstance() const auto active = InstanceManager::singleton().currentInstance();
if (active) {
+ const QString activeDir = QDir(active->directory()).absolutePath();
for (std::size_t i = 0; i < m_instances.size(); ++i) {
- if (m_instances[i]->displayName() == active->displayName()) {
+ if (QDir(m_instances[i]->directory()).absolutePath() == activeDir) {
select(i);
ui->list->scrollTo(m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0)));
@@ -588,6 +608,11 @@ void InstanceManagerDialog::deleteInstance() return;
}
+ // unregister portable instance from the persistent list
+ if (i->isPortable()) {
+ InstanceManager::singleton().unregisterPortableInstance(i->directory());
+ }
+
// updating ui
updateInstances();
updateList();
@@ -750,9 +775,19 @@ void InstanceManagerDialog::setButtonsEnabled(bool b) void InstanceManagerDialog::openExistingPortable()
{
+ // On Flatpak, the native file dialog goes through the XDG Desktop Portal,
+ // which returns FUSE paths (/run/user/.../doc/...) that may not properly
+ // expose directory contents. Use the Qt built-in dialog to get real paths.
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID")) {
+ opts |= QFileDialog::DontUseNativeDialog;
+ }
+#endif
+
const QString dir = QFileDialog::getExistingDirectory(
this, tr("Select portable instance folder"),
- QStandardPaths::writableLocation(QStandardPaths::HomeLocation));
+ QStandardPaths::writableLocation(QStandardPaths::HomeLocation), opts);
if (dir.isEmpty()) {
return;
@@ -766,12 +801,20 @@ void InstanceManagerDialog::openExistingPortable() return;
}
- // Switch directly to this portable instance
- InstanceManager::singleton().setCurrentInstance(dir);
+ // Register the portable instance so it persists in the sidebar
+ auto& m = InstanceManager::singleton();
+ m.registerPortableInstance(dir);
- if (m_restartOnSelect) {
- ExitModOrganizer(Exit::Restart);
- }
+ // Refresh the instance list and select the newly added entry
+ updateInstances();
+ updateList();
- accept();
+ // Find and select the new instance by directory
+ const QString canonical = QDir(dir).absolutePath();
+ for (std::size_t i = 0; i < m_instances.size(); ++i) {
+ if (QDir(m_instances[i]->directory()).absolutePath() == canonical) {
+ select(i);
+ break;
+ }
+ }
}
diff --git a/src/src/loglist.cpp b/src/src/loglist.cpp index 34df015..55ace68 100644 --- a/src/src/loglist.cpp +++ b/src/src/loglist.cpp @@ -18,16 +18,25 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. */
#include "loglist.h"
-#include "copyeventfilter.h" -#include "env.h" -#include "organizercore.h" -#include <cstdlib> +#include "copyeventfilter.h"
+#include "env.h"
+#include "organizercore.h"
+#include <cstdlib>
using namespace MOBase;
static LogModel* g_instance = nullptr;
const std::size_t MaxLines = 1000;
+static QString fluorineLogDir()
+{
+#ifndef _WIN32
+ return QDir::homePath() + "/.var/app/com.fluorine.manager/logs";
+#else
+ return {};
+#endif
+}
+
static std::unique_ptr<env::Console> m_console;
static bool m_stdout = false;
static std::mutex m_stdoutMutex;
@@ -239,8 +248,12 @@ void LogList::clear() void LogList::openLogsFolder()
{
- QString logsPath = qApp->property("dataPath").toString() + "/" +
- QString::fromStdWString(AppConfig::logPath());
+#ifndef _WIN32
+ const QString logsPath = fluorineLogDir();
+#else
+ const QString logsPath = qApp->property("dataPath").toString() + "/" +
+ QString::fromStdWString(AppConfig::logPath());
+#endif
shell::Explore(logsPath);
}
@@ -369,15 +382,15 @@ void initLogging() LogModel::instance().add(e);
});
- const char* username = std::getenv("USERNAME"); - if (username == nullptr || username[0] == '\0') { - username = std::getenv("USER"); - } - - if (username != nullptr && username[0] != '\0') { - log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME"); - log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME"); - } + const char* username = std::getenv("USERNAME");
+ if (username == nullptr || username[0] == '\0') {
+ username = std::getenv("USER");
+ }
+
+ if (username != nullptr && username[0] != '\0') {
+ log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME");
+ log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME");
+ }
qInstallMessageHandler(qtLogCallback);
}
@@ -400,12 +413,20 @@ bool createAndMakeWritable(const std::wstring& subPath) bool setLogDirectory(const QString& dir)
{
+#ifndef _WIN32
+ // On Linux, all logs go to ~/.var/app/com.fluorine.manager/logs/
+ const QString logDir = fluorineLogDir();
+ QDir().mkpath(logDir);
+ const auto logFile = logDir + "/" +
+ QString::fromStdWString(AppConfig::logFileName());
+#else
const auto logFile = dir + "/" + QString::fromStdWString(AppConfig::logPath()) + "/" +
QString::fromStdWString(AppConfig::logFileName());
if (!createAndMakeWritable(AppConfig::logPath())) {
return false;
}
+#endif
log::getDefault().setFile(MOBase::log::File::single(logFile.toStdWString()));
diff --git a/src/src/main.cpp b/src/src/main.cpp index b4c4b6f..b82b1b8 100644 --- a/src/src/main.cpp +++ b/src/src/main.cpp @@ -9,6 +9,7 @@ #include "shared/util.h"
#include "thread_utils.h"
#include <log.h>
+#include <nak_ffi.h>
#include <report.h>
#include <QString>
@@ -17,6 +18,7 @@ #else
#include <csignal>
#include <cstdlib>
+#include <cstring>
#include <execinfo.h>
#include <sys/wait.h>
#include <unistd.h>
@@ -82,6 +84,23 @@ int run(int argc, char* argv[]) initLogging();
+ // Route NaK (Rust) log messages through MOBase::log
+ nak_init_logging([](const char* level, const char* message) {
+ if (!message || !*message) return;
+ if (!level || !*level) {
+ log::info("[nak] {}", message);
+ return;
+ }
+ // Map NaK levels to MOBase log levels
+ if (std::strcmp(level, "error") == 0) {
+ log::error("[nak] {}", message);
+ } else if (std::strcmp(level, "warning") == 0) {
+ log::warn("[nak] {}", message);
+ } else {
+ log::info("[nak] {}", message);
+ }
+ });
+
// must be after logging
TimeThis tt("main() multiprocess");
@@ -175,12 +194,13 @@ int run(int argc, char* argv[]) QObject::connect(&nxmHandler, &NxmHandlerLinux::nxmReceived, &app.core(),
[&](const NxmLink& link) {
app.core().downloadRequestedNXM(
- QString("nxm://%1/mods/%2/files/%3?key=%4&expires=%5")
+ QString("nxm://%1/mods/%2/files/%3?key=%4&expires=%5&user_id=%6")
.arg(link.game_domain)
.arg(link.mod_id)
.arg(link.file_id)
.arg(link.key)
- .arg(link.expires));
+ .arg(link.expires)
+ .arg(link.user_id));
});
}
#endif
diff --git a/src/src/modinfo.cpp b/src/src/modinfo.cpp index e89b3a0..f12ceda 100644 --- a/src/src/modinfo.cpp +++ b/src/src/modinfo.cpp @@ -241,11 +241,22 @@ void ModInfo::updateFromDisc(const QString& modsDirectory, OrganizerCore& core, s_Overwrite = nullptr;
{ // list all directories in the mod directory and make a mod out of each
- QDir mods(QDir::fromNativeSeparators(modsDirectory));
+ const QString cleanModsDir = QDir::fromNativeSeparators(modsDirectory);
+ QDir mods(cleanModsDir);
+ if (!mods.exists()) {
+ log::error("mods directory does not exist: '{}'", cleanModsDir);
+ }
mods.setFilter(QDir::Dirs | QDir::NoDotAndDotDot);
QDirIterator modIter(mods);
+ std::size_t managedCount = 0;
while (modIter.hasNext()) {
createFrom(QDir(modIter.next()), core);
+ ++managedCount;
+ }
+ log::info("found {} managed mod directories in '{}'", managedCount, cleanModsDir);
+ if (managedCount == 0 && mods.exists()) {
+ log::warn("mods directory exists but contains no subdirectories; "
+ "check path and permissions");
}
}
diff --git a/src/src/nxmhandler_linux.cpp b/src/src/nxmhandler_linux.cpp index eb9f1df..a93826d 100644 --- a/src/src/nxmhandler_linux.cpp +++ b/src/src/nxmhandler_linux.cpp @@ -138,7 +138,9 @@ std::optional<NxmLink> NxmLink::parse(const QString& url) return {}; } - return NxmLink{gameDomain, modId, fileId, key, expires}; + const int userId = query.queryItemValue("user_id").toInt(); + + return NxmLink{gameDomain, modId, fileId, key, expires, userId}; } QString NxmLink::lookupKey() const diff --git a/src/src/nxmhandler_linux.h b/src/src/nxmhandler_linux.h index 0d2fd4d..b6bd675 100644 --- a/src/src/nxmhandler_linux.h +++ b/src/src/nxmhandler_linux.h @@ -14,6 +14,7 @@ struct NxmLink uint64_t file_id = 0; QString key; uint64_t expires = 0; + int user_id = 0; static std::optional<NxmLink> parse(const QString& url); QString lookupKey() const; diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 9fba748..5a3d21d 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -309,7 +309,10 @@ void OrganizerCore::updateExecutablesList() void OrganizerCore::updateModInfoFromDisc()
{
- ModInfo::updateFromDisc(m_Settings.paths().mods(), *this,
+ const QString modsPath = m_Settings.paths().mods();
+ log::debug("updateModInfoFromDisc: base='{}', mods='{}'",
+ m_Settings.paths().base(), modsPath);
+ ModInfo::updateFromDisc(modsPath, *this,
m_Settings.interface().displayForeign(),
m_Settings.refreshThreadCount());
}
@@ -465,8 +468,11 @@ void OrganizerCore::downloadRequested(QNetworkReply* reply, QString gameName, in void OrganizerCore::removeOrigin(const QString& name)
{
- FilesOrigin& origin = m_DirectoryStructure->getOriginByName(ToWString(name));
- origin.enable(false);
+ const auto wname = ToWString(name);
+ if (m_DirectoryStructure->originExists(wname)) {
+ FilesOrigin& origin = m_DirectoryStructure->getOriginByName(wname);
+ origin.enable(false);
+ }
refreshLists();
}
@@ -846,8 +852,15 @@ void OrganizerCore::setPersistent(const QString& pluginName, const QString& key, QString OrganizerCore::pluginDataPath()
{
+#ifndef _WIN32
+ // On Linux, the plugins/ directory may contain symlinks into a read-only
+ // bundled directory (e.g. /app/ in Flatpak). Place plugin data in a
+ // separate writable directory so mkdir() never hits a read-only FS.
+ return AppConfig::basePath() + "/plugin_data";
+#else
return AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()) +
"/data";
+#endif
}
MOBase::IModInterface* OrganizerCore::installMod(const QString& archivePath,
@@ -1782,10 +1795,11 @@ void OrganizerCore::modPrioritiesChanged(const QModelIndexList& indices) int priority = currentProfile()->getModPriority(i);
if (currentProfile()->modEnabled(i)) {
ModInfo::Ptr modInfo = ModInfo::getByIndex(i);
+ const auto name = MOBase::ToWString(modInfo->internalName());
// priorities in the directory structure are one higher because data is 0
- directoryStructure()
- ->getOriginByName(MOBase::ToWString(modInfo->internalName()))
- .setPriority(priority + 1);
+ if (directoryStructure()->originExists(name)) {
+ directoryStructure()->getOriginByName(name).setPriority(priority + 1);
+ }
}
}
refreshBSAList();
@@ -2221,11 +2235,12 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) QFile::remove(m_CurrentProfile->getLoadOrderFileName());
}
- refreshDirectoryStructure();
-
#ifndef _WIN32
- // Flush staged VFS writes to overwrite and rebuild tree
- m_USVFS.flushStagingLive();
+ // Unmount the FUSE VFS now that the application has exited. unmount()
+ // flushes the staging directory (moves new/changed files to overwrite)
+ // and tears down the FUSE session. This mirrors Windows behaviour where
+ // USVFS is only active while a hooked process is running.
+ m_USVFS.unmount();
if (m_CurrentProfile != nullptr) {
const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame());
@@ -2272,6 +2287,11 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) }
#endif
+ // Refresh directory structure after VFS is unmounted so the refresher
+ // reads the real (vanilla) data directory plus individual mod directories,
+ // matching Windows USVFS behaviour.
+ refreshDirectoryStructure();
+
refreshESPList(true);
savePluginList();
cycleDiagnostics();
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index c07aa2a..a6a37ca 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -8,24 +8,24 @@ #include <log.h>
#include <report.h>
#include <uibase/utility.h>
-#ifndef _WIN32 -#include <QCoreApplication> -#include <QEventLoop> -#include <QFile> -#include <QFileInfo> -#include <QMetaObject> -#include <QPointer> -#include <QProcess> -#include <QThread> -#include <cerrno> -#include <deque> -#include <dirent.h> -#include <fstream> -#include <signal.h> -#include <unordered_map> -#include <unordered_set> -#include <sys/wait.h> -#endif +#ifndef _WIN32
+#include <QCoreApplication>
+#include <QEventLoop>
+#include <QFile>
+#include <QFileInfo>
+#include <QMetaObject>
+#include <QPointer>
+#include <QProcess>
+#include <QThread>
+#include <cerrno>
+#include <deque>
+#include <dirent.h>
+#include <fstream>
+#include <signal.h>
+#include <unordered_map>
+#include <unordered_set>
+#include <sys/wait.h>
+#endif
using namespace MOBase;
@@ -63,6 +63,8 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, binPath += adjustedBin;
}
+#ifdef _WIN32
+ // On Windows, launch through MO2 helper to set up USVFS.
QString cmdline = QString("launch \"%1\" \"%2\" %3")
.arg(QDir::toNativeSeparators(cwdPath),
QDir::toNativeSeparators(binPath), sp.arguments);
@@ -70,6 +72,27 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, sp.binary = QFileInfo(QCoreApplication::applicationFilePath());
sp.arguments = cmdline;
sp.currentDirectory.setPath(QCoreApplication::applicationDirPath());
+#else
+ // On Linux, FUSE is already mounted — resolve paths directly without
+ // launching through MO2-core (which would fail in the Proton prefix).
+ //
+ // Root Builder deploys Root/ contents to the game directory root,
+ // stripping the "Root/" prefix. Fix paths that were remapped to
+ // <dataDir>/Root/... so they point to <gameDir>/... instead.
+ const QString gameDir = game->gameDirectory().absolutePath();
+ const QString dataDir = game->dataDirectory().absolutePath();
+ const QString rootTag = dataDir + QStringLiteral("/Root/");
+
+ if (binPath.startsWith(rootTag, Qt::CaseInsensitive)) {
+ binPath = gameDir + QStringLiteral("/") + binPath.mid(rootTag.length());
+ }
+ if (cwdPath.startsWith(rootTag, Qt::CaseInsensitive)) {
+ cwdPath = gameDir + QStringLiteral("/") + cwdPath.mid(rootTag.length());
+ }
+
+ sp.binary = QFileInfo(binPath);
+ sp.currentDirectory.setPath(cwdPath);
+#endif
}
}
@@ -475,153 +498,153 @@ ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, #else // !_WIN32
-pid_t handleToPid(HANDLE h) -{ - return static_cast<pid_t>(reinterpret_cast<intptr_t>(h)); -} - -QString readProcComm(pid_t pid) -{ - QFile f(QString("/proc/%1/comm").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return {}; - } - - return QString::fromUtf8(f.readAll()).trimmed(); -} - -std::unordered_map<pid_t, std::vector<pid_t>> buildProcChildrenMap() -{ - std::unordered_map<pid_t, std::vector<pid_t>> children; - DIR* proc = opendir("/proc"); - if (!proc) { - return children; - } - - struct dirent* entry = nullptr; - while ((entry = readdir(proc)) != nullptr) { - if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { - continue; - } - - const char* name = entry->d_name; - if (*name == '\0' || !std::isdigit(static_cast<unsigned char>(*name))) { - continue; - } - - const pid_t pid = static_cast<pid_t>(std::strtol(name, nullptr, 10)); - std::ifstream status(QString("/proc/%1/status").arg(pid).toStdString()); - if (!status.is_open()) { - continue; - } - - std::string line; - pid_t ppid = 0; - while (std::getline(status, line)) { - if (line.rfind("PPid:", 0) == 0) { - ppid = static_cast<pid_t>(std::strtol(line.c_str() + 5, nullptr, 10)); - break; - } - } - - if (ppid > 0) { - children[ppid].push_back(pid); - } - } - - closedir(proc); - return children; -} - -std::unordered_set<pid_t> -collectDescendants(pid_t root, const std::unordered_map<pid_t, std::vector<pid_t>>& children) -{ - std::unordered_set<pid_t> out; - std::deque<pid_t> q; - q.push_back(root); - - while (!q.empty()) { - const pid_t cur = q.front(); - q.pop_front(); - - const auto it = children.find(cur); - if (it == children.end()) { - continue; - } - - for (pid_t child : it->second) { - if (out.insert(child).second) { - q.push_back(child); - } - } - } - - return out; -} - -QStringList buildExpectedExecutables(const QFileInfo& binary, const QString& arguments) -{ - QStringList expected; - auto addName = [&](QString name) { - name = name.trimmed().toLower(); - if (!name.isEmpty() && !expected.contains(name)) { - expected.push_back(name); - } - }; - - addName(binary.fileName()); - - const auto args = QProcess::splitCommand(arguments); - for (const QString& arg : args) { - const QFileInfo fi(arg); - const QString base = fi.fileName(); - if (base.endsWith(".exe", Qt::CaseInsensitive)) { - addName(base); - } - } - - return expected; -} - -pid_t findTrackedProcess(pid_t rootPid, const QStringList& expected, - QString* trackedNameOut) -{ - if (expected.isEmpty()) { - return 0; - } - - const auto children = buildProcChildrenMap(); - const auto descendants = collectDescendants(rootPid, children); - if (descendants.empty()) { - return 0; - } - - pid_t best = 0; - QString bestName; - for (pid_t pid : descendants) { - const QString comm = readProcComm(pid); - if (comm.isEmpty()) { - continue; - } - const QString lower = comm.toLower(); - if (expected.contains(lower)) { - best = pid; - bestName = comm; - break; - } - } - - if (best > 0 && trackedNameOut) { - *trackedNameOut = bestName; - } - return best; -} - -DWORD exitCodeFromWaitStatus(int status) -{ - if (WIFEXITED(status)) { - return static_cast<DWORD>(WEXITSTATUS(status)); - } +pid_t handleToPid(HANDLE h)
+{
+ return static_cast<pid_t>(reinterpret_cast<intptr_t>(h));
+}
+
+QString readProcComm(pid_t pid)
+{
+ QFile f(QString("/proc/%1/comm").arg(pid));
+ if (!f.open(QIODevice::ReadOnly)) {
+ return {};
+ }
+
+ return QString::fromUtf8(f.readAll()).trimmed();
+}
+
+std::unordered_map<pid_t, std::vector<pid_t>> buildProcChildrenMap()
+{
+ std::unordered_map<pid_t, std::vector<pid_t>> children;
+ DIR* proc = opendir("/proc");
+ if (!proc) {
+ return children;
+ }
+
+ struct dirent* entry = nullptr;
+ while ((entry = readdir(proc)) != nullptr) {
+ if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) {
+ continue;
+ }
+
+ const char* name = entry->d_name;
+ if (*name == '\0' || !std::isdigit(static_cast<unsigned char>(*name))) {
+ continue;
+ }
+
+ const pid_t pid = static_cast<pid_t>(std::strtol(name, nullptr, 10));
+ std::ifstream status(QString("/proc/%1/status").arg(pid).toStdString());
+ if (!status.is_open()) {
+ continue;
+ }
+
+ std::string line;
+ pid_t ppid = 0;
+ while (std::getline(status, line)) {
+ if (line.rfind("PPid:", 0) == 0) {
+ ppid = static_cast<pid_t>(std::strtol(line.c_str() + 5, nullptr, 10));
+ break;
+ }
+ }
+
+ if (ppid > 0) {
+ children[ppid].push_back(pid);
+ }
+ }
+
+ closedir(proc);
+ return children;
+}
+
+std::unordered_set<pid_t>
+collectDescendants(pid_t root, const std::unordered_map<pid_t, std::vector<pid_t>>& children)
+{
+ std::unordered_set<pid_t> out;
+ std::deque<pid_t> q;
+ q.push_back(root);
+
+ while (!q.empty()) {
+ const pid_t cur = q.front();
+ q.pop_front();
+
+ const auto it = children.find(cur);
+ if (it == children.end()) {
+ continue;
+ }
+
+ for (pid_t child : it->second) {
+ if (out.insert(child).second) {
+ q.push_back(child);
+ }
+ }
+ }
+
+ return out;
+}
+
+QStringList buildExpectedExecutables(const QFileInfo& binary, const QString& arguments)
+{
+ QStringList expected;
+ auto addName = [&](QString name) {
+ name = name.trimmed().toLower();
+ if (!name.isEmpty() && !expected.contains(name)) {
+ expected.push_back(name);
+ }
+ };
+
+ addName(binary.fileName());
+
+ const auto args = QProcess::splitCommand(arguments);
+ for (const QString& arg : args) {
+ const QFileInfo fi(arg);
+ const QString base = fi.fileName();
+ if (base.endsWith(".exe", Qt::CaseInsensitive)) {
+ addName(base);
+ }
+ }
+
+ return expected;
+}
+
+pid_t findTrackedProcess(pid_t rootPid, const QStringList& expected,
+ QString* trackedNameOut)
+{
+ if (expected.isEmpty()) {
+ return 0;
+ }
+
+ const auto children = buildProcChildrenMap();
+ const auto descendants = collectDescendants(rootPid, children);
+ if (descendants.empty()) {
+ return 0;
+ }
+
+ pid_t best = 0;
+ QString bestName;
+ for (pid_t pid : descendants) {
+ const QString comm = readProcComm(pid);
+ if (comm.isEmpty()) {
+ continue;
+ }
+ const QString lower = comm.toLower();
+ if (expected.contains(lower)) {
+ best = pid;
+ bestName = comm;
+ break;
+ }
+ }
+
+ if (best > 0 && trackedNameOut) {
+ *trackedNameOut = bestName;
+ }
+ return best;
+}
+
+DWORD exitCodeFromWaitStatus(int status)
+{
+ if (WIFEXITED(status)) {
+ return static_cast<DWORD>(WEXITSTATUS(status));
+ }
if (WIFSIGNALED(status)) {
return static_cast<DWORD>(128 + WTERMSIG(status));
@@ -630,12 +653,12 @@ DWORD exitCodeFromWaitStatus(int status) return 0;
}
-ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls, - const QStringList& expected) -{ - if (pid <= 0) { - return ProcessRunner::Error; - } +ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls,
+ const QStringList& expected)
+{
+ if (pid <= 0) {
+ return ProcessRunner::Error;
+ }
// startDetached() creates a non-child process, so waitpid() will fail with
// ECHILD. Detect this on the first call and switch to kill(pid, 0) polling
@@ -657,34 +680,34 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session useKillPoll = true;
log::debug("process {} is detached, using kill(0) polling", pid);
}
- } - - bool seenTrackedProcess = false; - - while (true) { - QString trackedName; - pid_t displayPid = pid; - QString displayName = readProcComm(pid); - const pid_t tracked = findTrackedProcess(pid, expected, &trackedName); - if (tracked > 0) { - seenTrackedProcess = true; - displayPid = tracked; - displayName = trackedName; - } else if (seenTrackedProcess) { - if (exitCode != nullptr) { - *exitCode = 0; - } - log::debug("tracked child process for root {} exited", pid); - return ProcessRunner::Completed; - } - - if (ls != nullptr) { - ls->setInfo(static_cast<DWORD>(std::max<pid_t>(0, displayPid)), displayName); - } - - if (useKillPoll) { - // Poll for process existence via kill(pid, 0) - if (::kill(pid, 0) != 0) { + }
+
+ bool seenTrackedProcess = false;
+
+ while (true) {
+ QString trackedName;
+ pid_t displayPid = pid;
+ QString displayName = readProcComm(pid);
+ const pid_t tracked = findTrackedProcess(pid, expected, &trackedName);
+ if (tracked > 0) {
+ seenTrackedProcess = true;
+ displayPid = tracked;
+ displayName = trackedName;
+ } else if (seenTrackedProcess) {
+ if (exitCode != nullptr) {
+ *exitCode = 0;
+ }
+ log::debug("tracked child process for root {} exited", pid);
+ return ProcessRunner::Completed;
+ }
+
+ if (ls != nullptr) {
+ ls->setInfo(static_cast<DWORD>(std::max<pid_t>(0, displayPid)), displayName);
+ }
+
+ if (useKillPoll) {
+ // Poll for process existence via kill(pid, 0)
+ if (::kill(pid, 0) != 0) {
if (errno == ESRCH) {
if (exitCode != nullptr) {
*exitCode = 0;
@@ -724,21 +747,21 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session }
}
- if (ls != nullptr) { - switch (ls->result()) { - case UILocker::StillLocked: - break; + if (ls != nullptr) {
+ switch (ls->result()) {
+ case UILocker::StillLocked:
+ break;
case UILocker::ForceUnlocked:
log::debug("waiting for {} force unlocked by user", pid);
return ProcessRunner::ForceUnlocked;
- case UILocker::Cancelled: - log::debug("waiting for {} cancelled by user, terminating", displayPid); - if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) { - log::warn("failed to terminate {}, errno={}", displayPid, errno); - } - return ProcessRunner::Cancelled; + case UILocker::Cancelled:
+ log::debug("waiting for {} cancelled by user, terminating", displayPid);
+ if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) {
+ log::warn("failed to terminate {}, errno={}", displayPid, errno);
+ }
+ return ProcessRunner::Cancelled;
case UILocker::NoResult:
default:
@@ -752,27 +775,27 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session }
}
-ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, - UILocker::Session* ls, - const QStringList& expected) -{ - return waitForPid(handleToPid(initialProcess), exitCode, ls, expected); -} - -ProcessRunner::Results waitForProcesses(const std::vector<HANDLE>& initialProcesses, - UILocker::Session* ls, - const QStringList& expected) -{ - if (initialProcesses.empty()) { - return ProcessRunner::Completed; - } - - for (HANDLE h : initialProcesses) { - DWORD ignored = 0; - const auto r = waitForPid(handleToPid(h), &ignored, ls, expected); - if (r != ProcessRunner::Completed) { - return r; - } +ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode,
+ UILocker::Session* ls,
+ const QStringList& expected)
+{
+ return waitForPid(handleToPid(initialProcess), exitCode, ls, expected);
+}
+
+ProcessRunner::Results waitForProcesses(const std::vector<HANDLE>& initialProcesses,
+ UILocker::Session* ls,
+ const QStringList& expected)
+{
+ if (initialProcesses.empty()) {
+ return ProcessRunner::Completed;
+ }
+
+ for (HANDLE h : initialProcesses) {
+ DWORD ignored = 0;
+ const auto r = waitForPid(handleToPid(h), &ignored, ls, expected);
+ if (r != ProcessRunner::Completed) {
+ return r;
+ }
}
return ProcessRunner::Completed;
@@ -1122,22 +1145,22 @@ std::optional<ProcessRunner::Results> ProcessRunner::runBinary() // parent widget used for any dialog popped up while checking for things
QWidget* parent = (m_ui ? m_ui->mainWindow() : nullptr);
- const auto* game = m_core.managedGame(); - auto& settings = m_core.settings(); - - if (m_sp.steamAppID.trimmed().isEmpty()) { - const QString gameSteamId = game->steamAPPId().trimmed(); - if (!gameSteamId.isEmpty()) { - m_sp.steamAppID = gameSteamId; - log::debug("process runner: using game steam app id '{}' for launch", - m_sp.steamAppID); - } - } - - // start steam if needed - if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) { - return Error; - } + const auto* game = m_core.managedGame();
+ auto& settings = m_core.settings();
+
+ if (m_sp.steamAppID.trimmed().isEmpty()) {
+ const QString gameSteamId = game->steamAPPId().trimmed();
+ if (!gameSteamId.isEmpty()) {
+ m_sp.steamAppID = gameSteamId;
+ log::debug("process runner: using game steam app id '{}' for launch",
+ m_sp.steamAppID);
+ }
+ }
+
+ // start steam if needed
+ if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) {
+ return Error;
+ }
// warn if the executable is on the blacklist
if (!checkBlacklist(parent, m_sp, settings)) {
@@ -1185,12 +1208,12 @@ bool ProcessRunner::shouldRefresh(Results r) const return true;
}
- case ForceUnlocked: { - // The process may still be running when the user force-unlocks. - // Refreshing in that state can race with file updates. - log::debug("process runner: not refreshing because the ui was force unlocked"); - return false; - } + case ForceUnlocked: {
+ // The process may still be running when the user force-unlocks.
+ // Refreshing in that state can race with file updates.
+ log::debug("process runner: not refreshing because the ui was force unlocked");
+ return false;
+ }
case Error: // fall-through
case Cancelled:
@@ -1218,9 +1241,9 @@ ProcessRunner::Results ProcessRunner::postRun() m_lockReason = UILocker::LockUI;
}
- const bool lockEnabled = m_core.settings().interface().lockGUI(); - const QStringList expectedExecutables = - buildExpectedExecutables(m_sp.binary, m_sp.arguments); + const bool lockEnabled = m_core.settings().interface().lockGUI();
+ const QStringList expectedExecutables =
+ buildExpectedExecutables(m_sp.binary, m_sp.arguments);
if (mustWait) {
if (!lockEnabled) {
@@ -1228,56 +1251,56 @@ ProcessRunner::Results ProcessRunner::postRun() log::debug("locking is disabled, but the output of the application is required; "
"overriding this setting and locking the ui");
}
- } else { - // no force wait - - if (m_lockReason == UILocker::NoReason) { - // no locking requested -#ifndef _WIN32 - // Main window launches typically use TriggerRefresh without waiting/locking. - // In that mode we still need post-run refresh/sync once the process exits. - if (m_waitFlags.testFlag(TriggerRefresh)) { - const pid_t pid = static_cast<pid_t>(reinterpret_cast<intptr_t>(m_handle.get())); - const QFileInfo binary = m_sp.binary; - QPointer<OrganizerCore> core = &m_core; - - std::thread([core, binary, pid]() { - int status = 0; - pid_t waited = -1; - do { - waited = ::waitpid(pid, &status, 0); - } while (waited == -1 && errno == EINTR); - - DWORD exitCode = 0; - if (waited == pid) { - if (WIFEXITED(status)) { - exitCode = static_cast<DWORD>(WEXITSTATUS(status)); - } else if (WIFSIGNALED(status)) { - exitCode = static_cast<DWORD>(128 + WTERMSIG(status)); - } - } else { - MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid, - errno); - } - - if (!core) { - return; - } - - QMetaObject::invokeMethod( - core, [core, binary, exitCode]() { - if (core) { - core->afterRun(binary, exitCode); - } - }, - Qt::QueuedConnection); - }).detach(); - - log::debug("process runner: scheduled async post-run refresh for pid {}", pid); - } -#endif - return Running; - } + } else {
+ // no force wait
+
+ if (m_lockReason == UILocker::NoReason) {
+ // no locking requested
+#ifndef _WIN32
+ // Main window launches typically use TriggerRefresh without waiting/locking.
+ // In that mode we still need post-run refresh/sync once the process exits.
+ if (m_waitFlags.testFlag(TriggerRefresh)) {
+ const pid_t pid = static_cast<pid_t>(reinterpret_cast<intptr_t>(m_handle.get()));
+ const QFileInfo binary = m_sp.binary;
+ QPointer<OrganizerCore> core = &m_core;
+
+ std::thread([core, binary, pid]() {
+ int status = 0;
+ pid_t waited = -1;
+ do {
+ waited = ::waitpid(pid, &status, 0);
+ } while (waited == -1 && errno == EINTR);
+
+ DWORD exitCode = 0;
+ if (waited == pid) {
+ if (WIFEXITED(status)) {
+ exitCode = static_cast<DWORD>(WEXITSTATUS(status));
+ } else if (WIFSIGNALED(status)) {
+ exitCode = static_cast<DWORD>(128 + WTERMSIG(status));
+ }
+ } else {
+ MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid,
+ errno);
+ }
+
+ if (!core) {
+ return;
+ }
+
+ QMetaObject::invokeMethod(
+ core, [core, binary, exitCode]() {
+ if (core) {
+ core->afterRun(binary, exitCode);
+ }
+ },
+ Qt::QueuedConnection);
+ }).detach();
+
+ log::debug("process runner: scheduled async post-run refresh for pid {}", pid);
+ }
+#endif
+ return Running;
+ }
if (!lockEnabled) {
// disabling locking is like clicking on unlock immediately
@@ -1290,7 +1313,7 @@ ProcessRunner::Results ProcessRunner::postRun() auto r = Error;
- if (mustWait && m_lockReason == UILocker::PreventExit && !lockEnabled) { + if (mustWait && m_lockReason == UILocker::PreventExit && !lockEnabled) {
// this happens when running shortcuts and locking is disabled
//
// MO must stay alive until all processes are dead or child processes
@@ -1301,12 +1324,12 @@ ProcessRunner::Results ProcessRunner::postRun() //
// MO will be running in the background with no visual feedback, but that's
// how it is
- r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables); - } else { - withLock([&](auto& ls) { - r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables); - }); - } + r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables);
+ } else {
+ withLock([&](auto& ls) {
+ r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables);
+ });
+ }
if (shouldRefresh(r)) {
QEventLoop loop;
diff --git a/src/src/profile.cpp b/src/src/profile.cpp index fdc3852..405ba4e 100644 --- a/src/src/profile.cpp +++ b/src/src/profile.cpp @@ -444,10 +444,12 @@ void Profile::refreshModStatus() bool modStatusModified = false;
m_ModStatus.clear();
m_ModStatus.resize(ModInfo::getNumMods());
+ log::debug("refreshModStatus: ModInfo has {} entries", ModInfo::getNumMods());
std::set<QString> namesRead;
bool warnAboutOverwrite = false;
+ unsigned int modsNotFound = 0;
// load mods from file and update enabled state and priority for them
int index = 0;
@@ -488,7 +490,10 @@ void Profile::refreshModStatus() unsigned int modIndex = ModInfo::getIndex(modName);
if (modIndex == UINT_MAX) {
- log::debug("mod not found: \"{}\" (profile \"{}\")", modName, m_Directory.path());
+ if (modsNotFound < 5) {
+ log::warn("mod not found: \"{}\" (profile \"{}\")", modName, m_Directory.path());
+ }
+ ++modsNotFound;
// need to rewrite the modlist to fix this
modStatusModified = true;
continue;
@@ -515,6 +520,12 @@ void Profile::refreshModStatus() file.close();
+ if (modsNotFound > 0) {
+ log::error("refreshModStatus: {} mods from modlist.txt were not found in "
+ "ModInfo (total ModInfo entries: {})",
+ modsNotFound, ModInfo::getNumMods());
+ }
+
const int numKnownMods = index;
int topInsert = 0;
@@ -919,7 +930,11 @@ bool Profile::localSettingsEnabled() const QStringList missingFiles;
for (QString file : m_GamePlugin->iniFiles()) {
QString fileName = QFileInfo(file).fileName();
- if (!QFile::exists(m_Directory.filePath(fileName))) {
+ // Use case-insensitive lookup on Linux — the file may exist with
+ // different casing (e.g. "skyrimprefs.ini" vs "SkyrimPrefs.ini").
+ QString resolved = MOBase::resolveFileCaseInsensitive(
+ m_Directory.filePath(fileName));
+ if (!QFile::exists(resolved)) {
log::warn("missing {} in {}", fileName, m_Directory.path());
missingFiles << fileName;
}
@@ -959,7 +974,9 @@ bool Profile::enableLocalSettings(bool enable) if (res == QMessageBox::Yes) {
QStringList filesToDelete;
for (QString file : m_GamePlugin->iniFiles()) {
- filesToDelete << m_Directory.absoluteFilePath(QFileInfo(file).fileName());
+ QString resolved = MOBase::resolveFileCaseInsensitive(
+ m_Directory.absoluteFilePath(QFileInfo(file).fileName()));
+ filesToDelete << resolved;
}
shellDelete(filesToDelete, true);
} else if (res == QMessageBox::No) {
@@ -1003,7 +1020,8 @@ QString Profile::getIniFileName() const if (iniFiles.isEmpty())
return "";
else
- return m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName());
+ return MOBase::resolveFileCaseInsensitive(
+ m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName()));
}
QString Profile::absoluteIniFilePath(QString iniFile) const
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 61e11ff..5c3ef54 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -336,6 +336,15 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const env.insert(it.key(), it.value()); } + // Set DXVK config if available + if (char* dxvkPath = nak_get_dxvk_conf_path(); dxvkPath != nullptr) { + const QString dxvkConf = QString::fromUtf8(dxvkPath); + nak_string_free(dxvkPath); + if (QFileInfo::exists(dxvkConf)) { + env.insert("DXVK_CONFIG_FILE", dxvkConf); + } + } + maybeWrapForFlatpak(program, arguments, env); MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary); @@ -446,6 +455,15 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const env.insert(it.key(), it.value()); } + // Set DXVK config if available + if (char* dxvkPath = nak_get_dxvk_conf_path(); dxvkPath != nullptr) { + const QString dxvkConf = QString::fromUtf8(dxvkPath); + nak_string_free(dxvkPath); + if (QFileInfo::exists(dxvkConf)) { + env.insert("DXVK_CONFIG_FILE", dxvkConf); + } + } + maybeWrapForFlatpak(program, arguments, env); MOBase::log::info("UMU launch: '{}' '{}' (game id: {}, steam: '{}')", umuRun, diff --git a/src/src/settings.cpp b/src/src/settings.cpp index a2d10e4..5578daf 100644 --- a/src/src/settings.cpp +++ b/src/src/settings.cpp @@ -21,51 +21,51 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "env.h"
#include "envmetrics.h"
#include "executableslist.h"
-#include "instancemanager.h" -#include "modelutils.h" -#include "nxmhandler_linux.h" -#include "serverinfo.h" -#include "settingsutilities.h" -#include "shared/appconfig.h" +#include "instancemanager.h"
+#include "modelutils.h"
+#include "nxmhandler_linux.h"
+#include "serverinfo.h"
+#include "settingsutilities.h"
+#include "shared/appconfig.h"
#include <expanderwidget.h>
#include <iplugingame.h>
#include <utility.h>
-using namespace MOBase; -using namespace MOShared; - -namespace -{ -bool usesBaseDirVariable(const QString& path) -{ - return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive); -} - -QString loadStoredPath(const QString& path) -{ - QString value = QDir::fromNativeSeparators(path); - if (!usesBaseDirVariable(value)) { - value = MOBase::normalizePathForHost(value); - } - return value; -} - -QString storePathForIni(const QString& path) -{ - if (path.isEmpty()) { - return path; - } - - if (usesBaseDirVariable(path)) { - return QDir::fromNativeSeparators(path); - } - - return MOBase::normalizePathForWine(path); -} -} // namespace - -EndorsementState endorsementStateFromString(const QString& s) -{ +using namespace MOBase;
+using namespace MOShared;
+
+namespace
+{
+bool usesBaseDirVariable(const QString& path)
+{
+ return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive);
+}
+
+QString loadStoredPath(const QString& path)
+{
+ QString value = QDir::fromNativeSeparators(path);
+ if (!usesBaseDirVariable(value)) {
+ value = MOBase::normalizePathForHost(value);
+ }
+ return value;
+}
+
+QString storePathForIni(const QString& path)
+{
+ if (path.isEmpty()) {
+ return path;
+ }
+
+ if (usesBaseDirVariable(path)) {
+ return QDir::fromNativeSeparators(path);
+ }
+
+ return MOBase::normalizePathForWine(path);
+}
+} // namespace
+
+EndorsementState endorsementStateFromString(const QString& s)
+{
if (s == "Endorsed") {
return EndorsementState::Accepted;
} else if (s == "Abstained") {
@@ -395,27 +395,27 @@ void Settings::setArchiveParsing(bool b) set(m_Settings, "Settings", "archive_parsing_experimental", b);
}
-std::vector<std::map<QString, QVariant>> Settings::executables() const -{ - ScopedReadArray sra(m_Settings, "customExecutables"); - std::vector<std::map<QString, QVariant>> v; +std::vector<std::map<QString, QVariant>> Settings::executables() const
+{
+ ScopedReadArray sra(m_Settings, "customExecutables");
+ std::vector<std::map<QString, QVariant>> v;
sra.for_each([&] {
std::map<QString, QVariant> map;
- for (auto&& key : sra.keys()) { - map[key] = sra.get<QVariant>(key); - } - if (map.contains("binary")) { - map["binary"] = loadStoredPath(map["binary"].toString()); - } - if (map.contains("workingDirectory")) { - map["workingDirectory"] = - loadStoredPath(map["workingDirectory"].toString()); - } - - v.push_back(map); - }); + for (auto&& key : sra.keys()) {
+ map[key] = sra.get<QVariant>(key);
+ }
+ if (map.contains("binary")) {
+ map["binary"] = loadStoredPath(map["binary"].toString());
+ }
+ if (map.contains("workingDirectory")) {
+ map["workingDirectory"] =
+ loadStoredPath(map["workingDirectory"].toString());
+ }
+
+ v.push_back(map);
+ });
return v;
}
@@ -436,18 +436,18 @@ void Settings::setExecutables(const std::vector<std::map<QString, QVariant>>& v) ScopedWriteArray swa(m_Settings, "customExecutables", v.size());
- for (const auto& map : v) { - swa.next(); - - for (auto&& p : map) { - if ((p.first == "binary") || (p.first == "workingDirectory")) { - swa.set(p.first, storePathForIni(p.second.toString())); - } else { - swa.set(p.first, p.second); - } - } - } -} + for (const auto& map : v) {
+ swa.next();
+
+ for (auto&& p : map) {
+ if ((p.first == "binary") || (p.first == "workingDirectory")) {
+ swa.set(p.first, storePathForIni(p.second.toString()));
+ } else {
+ swa.set(p.first, p.second);
+ }
+ }
+ }
+}
bool Settings::keepBackupOnInstall() const
{
@@ -660,19 +660,19 @@ void GameSettings::setForceEnableCoreFiles(bool b) set(m_Settings, "Settings", "force_enable_core_files", b);
}
-std::optional<QString> GameSettings::directory() const -{ - if (auto v = getOptional<QByteArray>(m_Settings, "General", "gamePath")) { - return loadStoredPath(QString::fromUtf8(*v)); - } - - return {}; -} - -void GameSettings::setDirectory(const QString& path) -{ - set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8()); -} +std::optional<QString> GameSettings::directory() const
+{
+ if (auto v = getOptional<QByteArray>(m_Settings, "General", "gamePath")) {
+ return loadStoredPath(QString::fromUtf8(*v));
+ }
+
+ return {};
+}
+
+void GameSettings::setDirectory(const QString& path)
+{
+ set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8());
+}
std::optional<QString> GameSettings::name() const
{
@@ -1629,20 +1629,20 @@ const QString PathSettings::BaseDirVariable = "%BASE_DIR%"; PathSettings::PathSettings(QSettings& settings) : m_Settings(settings) {}
-std::map<QString, QString> PathSettings::recent() const -{ - std::map<QString, QString> map; +std::map<QString, QString> PathSettings::recent() const
+{
+ std::map<QString, QString> map;
ScopedReadArray sra(m_Settings, "recentDirectories");
sra.for_each([&] {
- const QVariant name = sra.get<QVariant>("name"); - const QVariant dir = sra.get<QVariant>("directory"); - - if (name.isValid() && dir.isValid()) { - map.emplace(name.toString(), loadStoredPath(dir.toString())); - } - }); + const QVariant name = sra.get<QVariant>("name");
+ const QVariant dir = sra.get<QVariant>("directory");
+
+ if (name.isValid() && dir.isValid()) {
+ map.emplace(name.toString(), loadStoredPath(dir.toString()));
+ }
+ });
return map;
}
@@ -1658,35 +1658,35 @@ void PathSettings::setRecent(const std::map<QString, QString>& map) ScopedWriteArray swa(m_Settings, "recentDirectories", map.size());
- for (auto&& p : map) { - swa.next(); - - swa.set("name", p.first); - swa.set("directory", storePathForIni(p.second)); - } -} + for (auto&& p : map) {
+ swa.next();
+
+ swa.set("name", p.first);
+ swa.set("directory", storePathForIni(p.second));
+ }
+}
+
+QString PathSettings::getConfigurablePath(const QString& key, const QString& def,
+ bool resolve) const
+{
+ QString result =
+ loadStoredPath(get<QString>(m_Settings, "Settings", key, makeDefaultPath(def)));
-QString PathSettings::getConfigurablePath(const QString& key, const QString& def, - bool resolve) const -{ - QString result = - loadStoredPath(get<QString>(m_Settings, "Settings", key, makeDefaultPath(def))); - - if (resolve) { - result = PathSettings::resolve(result, base()); - } + if (resolve) {
+ result = PathSettings::resolve(result, base());
+ }
return result;
}
-void PathSettings::setConfigurablePath(const QString& key, const QString& path) -{ - if (path.isEmpty()) { - remove(m_Settings, "Settings", key); - } else { - set(m_Settings, "Settings", key, storePathForIni(path)); - } -} +void PathSettings::setConfigurablePath(const QString& key, const QString& path)
+{
+ if (path.isEmpty()) {
+ remove(m_Settings, "Settings", key);
+ } else {
+ set(m_Settings, "Settings", key, storePathForIni(path));
+ }
+}
QString PathSettings::resolve(const QString& path, const QString& baseDir)
{
@@ -1700,13 +1700,13 @@ QString PathSettings::makeDefaultPath(const QString dirName) return BaseDirVariable + "/" + dirName;
}
-QString PathSettings::base() const -{ - const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path(); - - return loadStoredPath(get<QString>(m_Settings, "Settings", "base_directory", - dataPath)); -} +QString PathSettings::base() const
+{
+ const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path();
+
+ return loadStoredPath(get<QString>(m_Settings, "Settings", "base_directory",
+ dataPath));
+}
QString PathSettings::downloads(bool resolve) const
{
@@ -1738,14 +1738,14 @@ QString PathSettings::overwrite(bool resolve) const ToQString(AppConfig::overwritePath()), resolve);
}
-void PathSettings::setBase(const QString& path) -{ - if (path.isEmpty()) { - remove(m_Settings, "Settings", "base_directory"); - } else { - set(m_Settings, "Settings", "base_directory", storePathForIni(path)); - } -} +void PathSettings::setBase(const QString& path)
+{
+ if (path.isEmpty()) {
+ remove(m_Settings, "Settings", "base_directory");
+ } else {
+ set(m_Settings, "Settings", "base_directory", storePathForIni(path));
+ }
+}
void PathSettings::setDownloads(const QString& path)
{
@@ -2026,15 +2026,15 @@ void NexusSettings::setCategoryMappings(bool b) const set(m_Settings, "Settings", "category_mappings", b);
}
-void NexusSettings::registerAsNXMHandler(bool force) -{ -#ifndef _WIN32 - Q_UNUSED(force); - NxmHandlerLinux handler; - handler.registerHandler(); -#else - const auto nxmPath = QCoreApplication::applicationDirPath() + "/" + - QString::fromStdWString(AppConfig::nxmHandlerExe()); +void NexusSettings::registerAsNXMHandler(bool force)
+{
+#ifndef _WIN32
+ Q_UNUSED(force);
+ NxmHandlerLinux handler;
+ handler.registerHandler();
+#else
+ const auto nxmPath = QCoreApplication::applicationDirPath() + "/" +
+ QString::fromStdWString(AppConfig::nxmHandlerExe());
const auto executable = QCoreApplication::applicationFilePath();
@@ -2047,13 +2047,13 @@ void NexusSettings::registerAsNXMHandler(bool force) const auto r = shell::Execute(nxmPath, parameters);
- if (!r.success()) { - QMessageBox::critical( - nullptr, QObject::tr("Failed"), - QObject::tr("Failed to start the helper application: %1").arg(r.toString())); - } -#endif -} + if (!r.success()) {
+ QMessageBox::critical(
+ nullptr, QObject::tr("Failed"),
+ QObject::tr("Failed to start the helper application: %1").arg(r.toString()));
+ }
+#endif
+}
std::vector<std::chrono::seconds> NexusSettings::validationTimeouts() const
{
@@ -2582,3 +2582,27 @@ void GlobalSettings::resetDialogs() setHideCreateInstanceIntro(false);
setHideTutorialQuestion(false);
}
+
+QStringList GlobalSettings::portableInstances()
+{
+ return settings().value("PortableInstances").toStringList();
+}
+
+void GlobalSettings::addPortableInstance(const QString& path)
+{
+ const QString canonical = QDir(path).absolutePath();
+ QStringList list = portableInstances();
+ if (!list.contains(canonical)) {
+ list.append(canonical);
+ settings().setValue("PortableInstances", list);
+ }
+}
+
+void GlobalSettings::removePortableInstance(const QString& path)
+{
+ const QString canonical = QDir(path).absolutePath();
+ QStringList list = portableInstances();
+ if (list.removeAll(canonical) > 0) {
+ settings().setValue("PortableInstances", list);
+ }
+}
diff --git a/src/src/settings.h b/src/src/settings.h index 7b2516f..f1ea342 100644 --- a/src/src/settings.h +++ b/src/src/settings.h @@ -966,6 +966,11 @@ public: // resets anything that the user can disable
static void resetDialogs();
+ // persistent registry of portable instance paths
+ static QStringList portableInstances();
+ static void addPortableInstance(const QString& path);
+ static void removePortableInstance(const QString& path);
+
private:
static QSettings settings();
};
diff --git a/src/src/settingsdialogpaths.cpp b/src/src/settingsdialogpaths.cpp index 1e48523..dee5362 100644 --- a/src/src/settingsdialogpaths.cpp +++ b/src/src/settingsdialogpaths.cpp @@ -1,8 +1,21 @@ #include "settingsdialogpaths.h"
#include "shared/appconfig.h"
#include "ui_settingsdialog.h"
+#include <QFileDialog>
#include <iplugingame.h>
+namespace {
+QFileDialog::Options flatpakSafeOptions()
+{
+ QFileDialog::Options opts;
+#ifndef _WIN32
+ if (qEnvironmentVariableIsSet("FLATPAK_ID"))
+ opts |= QFileDialog::DontUseNativeDialog;
+#endif
+ return opts;
+}
+} // namespace
+
PathsSettingsTab::PathsSettingsTab(Settings& s, SettingsDialog& d)
: SettingsTab(s, d), m_gameDir(settings().game().plugin()->gameDirectory())
{
@@ -129,7 +142,8 @@ void PathsSettingsTab::update() void PathsSettingsTab::on_browseBaseDirBtn_clicked()
{
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text());
+ &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text(),
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->baseDirEdit->setText(temp);
}
@@ -141,7 +155,8 @@ void PathsSettingsTab::on_browseDownloadDirBtn_clicked() searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select download directory"), searchPath);
+ &dialog(), QObject::tr("Select download directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->downloadDirEdit->setText(temp);
}
@@ -153,7 +168,8 @@ void PathsSettingsTab::on_browseModDirBtn_clicked() searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select mod directory"), searchPath);
+ &dialog(), QObject::tr("Select mod directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->modDirEdit->setText(temp);
}
@@ -165,7 +181,8 @@ void PathsSettingsTab::on_browseCacheDirBtn_clicked() searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select cache directory"), searchPath);
+ &dialog(), QObject::tr("Select cache directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->cacheDirEdit->setText(temp);
}
@@ -177,7 +194,8 @@ void PathsSettingsTab::on_browseProfilesDirBtn_clicked() searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select profiles directory"), searchPath);
+ &dialog(), QObject::tr("Select profiles directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->profilesDirEdit->setText(temp);
}
@@ -189,7 +207,8 @@ void PathsSettingsTab::on_browseOverwriteDirBtn_clicked() searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text());
QString temp = QFileDialog::getExistingDirectory(
- &dialog(), QObject::tr("Select overwrite directory"), searchPath);
+ &dialog(), QObject::tr("Select overwrite directory"), searchPath,
+ flatpakSafeOptions());
if (!temp.isEmpty()) {
ui->overwriteDirEdit->setText(temp);
}
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp index 8c9c225..af1b344 100644 --- a/src/src/settingsdialogproton.cpp +++ b/src/src/settingsdialogproton.cpp @@ -4,6 +4,7 @@ #include "ui_settingsdialog.h" #include <QtConcurrent/QtConcurrentRun> +#include <log.h> #include <nak_ffi.h> #include <atomic> #include <QComboBox> @@ -272,8 +273,14 @@ void ProtonSettingsTab::onOpenPrefixFolder() void ProtonSettingsTab::onBrowsePrefixLocation() { + QFileDialog::Options opts; +#ifndef _WIN32 + if (qEnvironmentVariableIsSet("FLATPAK_ID")) + opts |= QFileDialog::DontUseNativeDialog; +#endif const QString dir = QFileDialog::getExistingDirectory( - parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text()); + parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text(), + opts); if (!dir.isEmpty()) { ui->prefixLocationEdit->setText(dir); } @@ -281,7 +288,7 @@ void ProtonSettingsTab::onBrowsePrefixLocation() QString ProtonSettingsTab::ensureWinetricks() { - const QString nakWinetricks = QDir::homePath() + "/.config/nak/bin/winetricks"; + const QString nakWinetricks = QDir::homePath() + "/.var/app/com.fluorine.manager/bin/winetricks"; if (QFileInfo::exists(nakWinetricks)) { return nakWinetricks; } @@ -291,7 +298,7 @@ QString ProtonSettingsTab::ensureWinetricks() return systemWinetricks; } - const QString nakBinDir = QDir::homePath() + "/.config/nak/bin"; + const QString nakBinDir = QDir::homePath() + "/.var/app/com.fluorine.manager/bin"; QDir().mkpath(nakBinDir); QString downloadTool; @@ -469,9 +476,14 @@ void ProtonSettingsTab::showGameRegistryDialog() layout->addLayout(pathLayout); QObject::connect(browseBtn, &QPushButton::clicked, &dialog, [&dialog, pathEdit]() { + QFileDialog::Options opts; +#ifndef _WIN32 + if (qEnvironmentVariableIsSet("FLATPAK_ID")) + opts |= QFileDialog::DontUseNativeDialog; +#endif const QString dir = QFileDialog::getExistingDirectory( &dialog, QObject::tr("Select Game Installation Folder"), - pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text()); + pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text(), opts); if (!dir.isEmpty()) { pathEdit->setText(dir); } @@ -688,7 +700,9 @@ void ProtonSettingsTab::statusCallback(const char* message) void ProtonSettingsTab::logCallback(const char* message) { - Q_UNUSED(message); + if (message && *message) { + MOBase::log::info("{}", message); + } } void ProtonSettingsTab::progressCallback(float progress) @@ -718,6 +732,12 @@ void ProtonSettingsTab::onInstallFinished() nak_create_game_symlinks_auto(prefixPathUtf8.constData()); } + // Ensure DXVK config exists for game launches + if (char* dxvkErr = nak_ensure_dxvk_conf(); dxvkErr != nullptr) { + MOBase::log::warn("Failed to create dxvk.conf: {}", dxvkErr); + nak_string_free(dxvkErr); + } + FluorineConfig cfg; cfg.app_id = m_pendingAppId; cfg.prefix_path = m_pendingPrefixPath; diff --git a/src/src/vfs/vfs_helper_main.cpp b/src/src/vfs/vfs_helper_main.cpp index f0b4889..9139d4c 100644 --- a/src/src/vfs/vfs_helper_main.cpp +++ b/src/src/vfs/vfs_helper_main.cpp @@ -32,6 +32,7 @@ struct HelperConfig std::string data_dir_name; std::string overwrite_dir; std::vector<std::pair<std::string, std::string>> mods; + std::vector<std::pair<std::string, std::string>> extra_files; }; static HelperConfig readConfig(const std::string& path) @@ -66,6 +67,11 @@ static HelperConfig readConfig(const std::string& path) if (pipe != std::string::npos) { cfg.mods.emplace_back(val.substr(0, pipe), val.substr(pipe + 1)); } + } else if (key == "extra_file") { + const auto pipe = val.find('|'); + if (pipe != std::string::npos) { + cfg.extra_files.emplace_back(val.substr(0, pipe), val.substr(pipe + 1)); + } } } @@ -206,6 +212,7 @@ int main(int argc, char* argv[]) auto tree = std::make_shared<VfsTree>( buildDataDirVfs(baseFileCache, dataDirPath, config.mods, config.overwrite_dir)); + injectExtraFiles(*tree, config.extra_files); auto context = std::make_shared<Mo2FsContext>(); context->tree = tree; @@ -272,6 +279,7 @@ int main(int argc, char* argv[]) auto newConfig = readConfig(configPath); auto newTree = std::make_shared<VfsTree>(buildDataDirVfs( baseFileCache, dataDirPath, newConfig.mods, newConfig.overwrite_dir)); + injectExtraFiles(*newTree, newConfig.extra_files); { std::unique_lock lock(context->tree_mutex); @@ -286,6 +294,7 @@ int main(int argc, char* argv[]) auto newTree = std::make_shared<VfsTree>(buildDataDirVfs( baseFileCache, dataDirPath, config.mods, config.overwrite_dir)); + injectExtraFiles(*newTree, config.extra_files); { std::unique_lock lock(context->tree_mutex); diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp index 5070635..7ab8f70 100644 --- a/src/src/vfs/vfstree.cpp +++ b/src/src/vfs/vfstree.cpp @@ -378,3 +378,22 @@ VfsTree buildDataDirVfs(const std::vector<CachedBaseFile>& cached_files, return tree; } + +void injectExtraFiles( + VfsTree& tree, + const std::vector<std::pair<std::string, std::string>>& extra_files) +{ + for (const auto& [relPath, realPath] : extra_files) { + auto components = splitPath(relPath); + if (components.empty()) { + continue; + } + + std::error_code ec; + const auto size = fs::file_size(realPath, ec); + tree.root.insertFile(components, realPath, ec ? 0ULL : size, + std::chrono::system_clock::now(), "_profile", + /*is_backing=*/false); + ++tree.file_count; + } +} diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h index 5d6de43..3fb257e 100644 --- a/src/src/vfs/vfstree.h +++ b/src/src/vfs/vfstree.h @@ -77,4 +77,11 @@ VfsTree buildDataDirVfs(const std::vector<CachedBaseFile>& cached_files, const std::vector<std::pair<std::string, std::string>>& mods, const std::string& overwrite_dir); +// Inject individual file mappings into an already-built VFS tree. +// Each entry is (relative_vfs_path, absolute_real_path). Inserted with +// highest priority (overwrites any existing entry at the same path). +void injectExtraFiles( + VfsTree& tree, + const std::vector<std::pair<std::string, std::string>>& extra_files); + #endif |
