aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt1
-rw-r--r--flatpak/com.fluorine.manager.yml10
-rwxr-xr-xflatpak/flatpak-install.sh4
-rw-r--r--libs/basic_games/basic_game.py78
-rw-r--r--libs/basic_games/games/baldursgate3/bg3_file_mapper.py33
-rw-r--r--libs/basic_games/games/baldursgate3/lslib_retriever.py77
-rw-r--r--libs/basic_games/games/baldursgate3/pak_parser.py97
-rw-r--r--libs/basic_games/games/game_baldursgate3.py1
-rw-r--r--libs/basic_games/games/game_oblivion_remaster.py4
-rw-r--r--libs/basic_games/games/oblivion_remaster/mod_data_checker.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/model.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/widget.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/script_extender.py2
-rw-r--r--libs/basic_games/winreg.py11
-rw-r--r--libs/game_bethesda/src/gamebryo/gamegamebryo.cpp6
-rw-r--r--libs/installer_fomod_plus/.clang-format4
-rw-r--r--libs/installer_fomod_plus/.clangd16
-rw-r--r--libs/installer_fomod_plus/.editorconfig16
-rw-r--r--libs/installer_fomod_plus/CMakeLists.txt36
-rw-r--r--libs/installer_fomod_plus/cmake/patch_pugixml.cmake20
-rw-r--r--libs/installer_fomod_plus/installer/CMakeLists.txt53
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp840
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.h214
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp512
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.h115
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_de.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_en.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts79
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomodplus.json4
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp50
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.h21
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.cpp176
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.h42
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.cpp122
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.h20
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.cpp274
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.h94
-rw-r--r--libs/installer_fomod_plus/installer/lib/FlagMap.h113
-rw-r--r--libs/installer_fomod_plus/installer/lib/Logger.h118
-rw-r--r--libs/installer_fomod_plus/installer/lib/ViewModels.h120
-rw-r--r--libs/installer_fomod_plus/installer/resources.qrc14
-rw-r--r--libs/installer_fomod_plus/installer/resources/delete.pngbin0 -> 2125 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/image_icon.pngbin0 -> 727 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/left-chevron.pngbin0 -> 1732 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/right-chevron.pngbin0 -> 1808 bytes
-rw-r--r--libs/installer_fomod_plus/installer/ui/ClickableWidget.h24
-rw-r--r--libs/installer_fomod_plus/installer/ui/Colors.h155
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp284
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.h92
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp746
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.h154
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp120
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.h45
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.cpp95
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.h80
-rw-r--r--libs/installer_fomod_plus/patchwizard/CMakeLists.txt45
-rw-r--r--libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp181
-rw-r--r--libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h54
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts96
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts19
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json8
-rw-r--r--libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp86
-rw-r--r--libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h49
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources.qrc6
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources/infoscroll.pngbin0 -> 3682 bytes
-rw-r--r--libs/installer_fomod_plus/scanner/CMakeLists.txt54
-rw-r--r--libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp128
-rw-r--r--libs/installer_fomod_plus/scanner/FomodPlusScanner.h64
-rw-r--r--libs/installer_fomod_plus/scanner/archiveparser.h98
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomodplusscanner.json8
-rw-r--r--libs/installer_fomod_plus/scanner/resources.qrc6
-rw-r--r--libs/installer_fomod_plus/scanner/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/scanner/resources/wizard.gifbin0 -> 5263 bytes
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h160
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDB.h146
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h129
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodRescan.h277
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/PluginReader.h119
-rw-r--r--libs/installer_fomod_plus/share/stringutil.h150
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp44
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.h25
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp352
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.h305
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlHelper.h17
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlParseException.h10
-rw-r--r--libs/nak/Cargo.lock901
-rw-r--r--libs/nak/Cargo.toml15
-rw-r--r--libs/nak/src/config.rs154
-rw-r--r--libs/nak/src/deps/mod.rs177
-rw-r--r--libs/nak/src/deps/tools.rs174
-rw-r--r--libs/nak/src/dxvk.rs71
-rw-r--r--libs/nak/src/game_finder/bottles.rs147
-rw-r--r--libs/nak/src/game_finder/heroic.rs277
-rw-r--r--libs/nak/src/game_finder/known_games.rs245
-rw-r--r--libs/nak/src/game_finder/mod.rs191
-rw-r--r--libs/nak/src/game_finder/registry.rs227
-rw-r--r--libs/nak/src/game_finder/steam.rs251
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
-rw-r--r--libs/nak/src/installers/mod.rs334
-rw-r--r--libs/nak/src/installers/prefix_setup.rs778
-rw-r--r--libs/nak/src/installers/symlinks.rs359
-rw-r--r--libs/nak/src/lib.rs15
-rw-r--r--libs/nak/src/logging.rs99
-rw-r--r--libs/nak/src/runtime_wrap.rs121
-rw-r--r--libs/nak/src/steam/mod.rs135
-rw-r--r--libs/nak/src/steam/paths.rs259
-rw-r--r--libs/nak/src/steam/proton.rs257
-rw-r--r--libs/nak/src/utils.rs19
-rw-r--r--libs/nak_ffi/Cargo.lock1894
-rw-r--r--libs/nak_ffi/Cargo.toml2
-rw-r--r--libs/nak_ffi/include/nak_ffi.h98
-rw-r--r--libs/nak_ffi/src/lib.rs225
-rw-r--r--libs/plugin_python/src/proxy/proxypython.cpp10
-rw-r--r--libs/winreg.py11
-rw-r--r--src/plugins/rootbuilder.py368
-rw-r--r--src/src/createinstancedialog.cpp9
-rw-r--r--src/src/createinstancedialogpages.cpp40
-rw-r--r--src/src/filedialogmemory.cpp9
-rw-r--r--src/src/fuseconnector.cpp165
-rw-r--r--src/src/fuseconnector.h9
-rw-r--r--src/src/instancemanager.cpp32
-rw-r--r--src/src/instancemanager.h6
-rw-r--r--src/src/instancemanagerdialog.cpp59
-rw-r--r--src/src/loglist.cpp51
-rw-r--r--src/src/main.cpp24
-rw-r--r--src/src/modinfo.cpp13
-rw-r--r--src/src/nxmhandler_linux.cpp4
-rw-r--r--src/src/nxmhandler_linux.h1
-rw-r--r--src/src/organizercore.cpp40
-rw-r--r--src/src/processrunner.cpp647
-rw-r--r--src/src/profile.cpp26
-rw-r--r--src/src/protonlauncher.cpp18
-rw-r--r--src/src/settings.cpp320
-rw-r--r--src/src/settings.h5
-rw-r--r--src/src/settingsdialogpaths.cpp31
-rw-r--r--src/src/settingsdialogproton.cpp30
-rw-r--r--src/src/vfs/vfs_helper_main.cpp9
-rw-r--r--src/src/vfs/vfstree.cpp19
-rw-r--r--src/src/vfs/vfstree.h7
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&auml;hlen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="359"/>
+ <source>Reset Choices</source>
+ <translation>Optionen Zur&uuml;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&uuml;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
new file mode 100644
index 0000000..f6502b1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/delete.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/fomod_icon.png b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/image_icon.png b/libs/installer_fomod_plus/installer/resources/image_icon.png
new file mode 100644
index 0000000..b300751
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/image_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/left-chevron.png b/libs/installer_fomod_plus/installer/resources/left-chevron.png
new file mode 100644
index 0000000..28222a1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/left-chevron.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/right-chevron.png b/libs/installer_fomod_plus/installer/resources/right-chevron.png
new file mode 100644
index 0000000..1a0af49
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/right-chevron.png
Binary files differ
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&ouml;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
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png
new file mode 100644
index 0000000..0c31777
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png
Binary files differ
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
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/scanner/resources/wizard.gif b/libs/installer_fomod_plus/scanner/resources/wizard.gif
new file mode 100644
index 0000000..c1f75d3
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/resources/wizard.gif
Binary files differ
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("&#13;&#10;"), "<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(&reg_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, &reg_envs)
+ .arg("regedit")
+ .arg(&reg_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(&reg_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(&reg_file, &reg_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(&reg_file)
+ .status();
+
+ let _ = fs::remove_file(&reg_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(&reg_file, &reg_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, &reg_envs)
+ .arg("regedit")
+ .arg(&reg_file)
+ .status();
+
+ let _ = fs::remove_file(&reg_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