aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt7
-rwxr-xr-xdocker/build-inner.sh13
-rw-r--r--libs/game_bethesda/src/gamebryo/CMakeLists.txt6
-rw-r--r--libs/game_bethesda/src/gamebryo/gamegamebryo.cpp43
-rw-r--r--libs/game_bethesda/src/games/ttw/CMakeLists.txt6
-rw-r--r--libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp31
-rw-r--r--libs/nak/Cargo.lock964
-rw-r--r--libs/nak/Cargo.toml16
-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.rs166
-rw-r--r--libs/nak/src/dxvk.rs69
-rw-r--r--libs/nak/src/game_finder/bottles.rs147
-rw-r--r--libs/nak/src/game_finder/heroic.rs282
-rw-r--r--libs/nak/src/game_finder/known_games.rs386
-rw-r--r--libs/nak/src/game_finder/mod.rs218
-rw-r--r--libs/nak/src/game_finder/registry.rs227
-rw-r--r--libs/nak/src/game_finder/steam.rs253
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
-rw-r--r--libs/nak/src/icons.rs195
-rw-r--r--libs/nak/src/installers/mod.rs334
-rw-r--r--libs/nak/src/installers/prefix_setup.rs764
-rw-r--r--libs/nak/src/installers/symlinks.rs377
-rw-r--r--libs/nak/src/lib.rs18
-rw-r--r--libs/nak/src/logging.rs98
-rw-r--r--libs/nak/src/paths.rs11
-rw-r--r--libs/nak/src/runtime_wrap.rs58
-rw-r--r--libs/nak/src/slr.rs231
-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.rs252
-rw-r--r--libs/nak/src/utils.rs19
-rw-r--r--libs/nak_ffi/CMakeLists.txt38
-rw-r--r--libs/nak_ffi/Cargo.lock971
-rw-r--r--libs/nak_ffi/Cargo.toml10
-rw-r--r--libs/nak_ffi/include/nak_ffi.h230
-rw-r--r--libs/nak_ffi/src/lib.rs750
-rw-r--r--libs/uibase/src/CMakeLists.txt16
-rw-r--r--libs/uibase/src/utility.cpp12
-rw-r--r--src/src/CMakeLists.txt11
-rw-r--r--src/src/fuseconnector.cpp5
-rw-r--r--src/src/gamedetection.cpp530
-rw-r--r--src/src/gamedetection.h33
-rw-r--r--src/src/iconextractor.cpp276
-rw-r--r--src/src/iconextractor.h11
-rw-r--r--src/src/instancemanagerdialog.cpp26
-rw-r--r--src/src/knowngames.h194
-rw-r--r--src/src/main.cpp18
-rw-r--r--src/src/mainwindow.cpp22
-rw-r--r--src/src/organizercore.cpp1
-rw-r--r--src/src/prefixsetuprunner.cpp47
-rw-r--r--src/src/prefixsymlinks.cpp143
-rw-r--r--src/src/prefixsymlinks.h12
-rw-r--r--src/src/protonlauncher.cpp23
-rw-r--r--src/src/selfupdater.cpp18
-rw-r--r--src/src/settingsdialogproton.cpp214
-rw-r--r--src/src/slrmanager.cpp186
-rw-r--r--src/src/slrmanager.h21
-rw-r--r--src/src/steamdetection.cpp195
-rw-r--r--src/src/steamdetection.h32
-rw-r--r--src/src/vdfparser.cpp146
-rw-r--r--src/src/vdfparser.h69
-rw-r--r--src/src/vfs/mo2filesystem.cpp107
-rw-r--r--src/src/vfs/mo2filesystem.h4
-rw-r--r--src/src/vfs/vfstree.h1
65 files changed, 2201 insertions, 8341 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3b569ac..8326e89 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -22,7 +22,6 @@ if(HAS_NO_DIRECT_EXTERN_ACCESS)
endif()
option(BUILD_TESTING "Build tests" OFF)
-option(BUILD_NAK_FFI "Build NaK Rust FFI library" ON)
option(BUILD_BSA_FFI "Build Rust BSA/BA2 FFI library" ON)
option(BUILD_DOTNET_PLUGINS "Build C++/CLI .NET plugins (Windows+MSVC only)" OFF)
option(BUILD_PLUGIN_PYTHON "Build plugin_python bridge on Linux" ON)
@@ -155,11 +154,7 @@ add_subdirectory(libs/7zip) # builds 7z.so from source (Linux only)
add_subdirectory(libs/archive) # shared lib, needs dl
add_subdirectory(libs/lootcli) # executable, needs Qt6 + boost + libloot + curl
-if(BUILD_NAK_FFI)
- add_subdirectory(libs/nak_ffi) # Rust FFI lib, before uibase (icon extraction)
-endif()
-
-add_subdirectory(libs/uibase) # shared lib, needs Qt6 + spdlog + nak_ffi (Linux)
+add_subdirectory(libs/uibase) # shared lib, needs Qt6 + spdlog
if(BUILD_BSA_FFI)
add_subdirectory(libs/bsa_ffi)
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index dd33409..cfbb061 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -53,7 +53,7 @@ cp -f "${RUNDIR}/ModOrganizer" "${OUT_DIR}/ModOrganizer-core"
LOOTCLI="build/libs/lootcli/src/lootcli"
[ -f "${LOOTCLI}" ] && cp -f "${LOOTCLI}" "${OUT_DIR}/"
-# wrestool/icotool no longer needed — icon extraction is built into libnak_ffi.so (pelite)
+# wrestool/icotool no longer needed — icon extraction is built into the C++ PE parser
# ── MO2 plugins (.so) ──
find build/libs -type f \( \
@@ -131,10 +131,9 @@ cp -f build/libs/uibase/src/libuibase.so "${OUT_DIR}/lib/"
cp -f build/libs/libbsarch/liblibbsarch.so "${OUT_DIR}/lib/"
cp -f build/libs/archive/src/libarchive.so "${OUT_DIR}/lib/"
cp -f build/libs/plugin_python/src/runner/librunner.so "${OUT_DIR}/lib/"
-for ffi in libs/bsa_ffi/target/release/libbsa_ffi.so \
- libs/nak_ffi/target/release/libnak_ffi.so; do
- [ -f "${ffi}" ] && cp -f "${ffi}" "${OUT_DIR}/lib/"
-done
+if [ -f "libs/bsa_ffi/target/release/libbsa_ffi.so" ]; then
+ cp -f libs/bsa_ffi/target/release/libbsa_ffi.so "${OUT_DIR}/lib/"
+fi
# Boost (version-pinned to container, won't exist on most user systems).
for boost_lib in /lib/x86_64-linux-gnu/libboost_program_options.so* \
@@ -416,8 +415,8 @@ export PATH="${RUN}:${PATH}"
# Steam game mode injects its scout/soldier runtime into LD_LIBRARY_PATH.
# Those old libraries (libssl, libz, etc.) break Python extension modules
# and Qt internals that don't have RPATH pointing to our bundled libs.
-# Clear it — the binary uses DT_RPATH ($ORIGIN/lib) for its own deps,
-# and we prepend our lib/ for anything loaded via dlopen (Python, Qt plugins).
+# Clear it and set only our lib/ — the binary uses DT_RPATH ($ORIGIN/lib)
+# for its own deps, this covers dlopen'd plugins.
export LD_LIBRARY_PATH="${RUN}/lib"
export MO2_BASE_DIR="${RUN}"
export MO2_PLUGINS_DIR="${RUN}/plugins"
diff --git a/libs/game_bethesda/src/gamebryo/CMakeLists.txt b/libs/game_bethesda/src/gamebryo/CMakeLists.txt
index bbc47ae..9e5970b 100644
--- a/libs/game_bethesda/src/gamebryo/CMakeLists.txt
+++ b/libs/game_bethesda/src/gamebryo/CMakeLists.txt
@@ -58,11 +58,7 @@ target_link_libraries(game_gamebryo
$<IF:$<TARGET_EXISTS:lz4::lz4>,lz4::lz4,PkgConfig::LZ4>
)
-# NaK FFI for game detection (replaces Windows registry)
-if(TARGET mo2::nak_ffi)
- target_link_libraries(game_gamebryo PRIVATE mo2::nak_ffi)
- target_compile_definitions(game_gamebryo PRIVATE HAS_NAK_FFI)
-endif()
+# Game detection symbols are in libuibase.so (already linked via mo2::uibase).
target_include_directories(game_gamebryo PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
index 2fd45cd..9d29dcf 100644
--- a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
+++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
@@ -39,8 +39,9 @@
#include <winver.h>
#endif
-#ifdef HAS_NAK_FFI
-#include <nak_ffi.h>
+#ifndef _WIN32
+#include "gamedetection.h"
+#include "steamdetection.h"
#endif
#include <optional>
@@ -230,12 +231,9 @@ QFileInfo GameGamebryo::findInGameFolder(const QString& relativePath) const
QString GameGamebryo::identifyGamePath() const
{
-#ifdef HAS_NAK_FFI
- // Use NaK to detect game installations on Linux
- NakGameList gameList = nak_detect_all_games();
- ON_BLOCK_EXIT([&]() {
- nak_game_list_free(gameList);
- });
+#ifndef _WIN32
+ // Detect game installations on Linux.
+ const GameScanResult scanResult = detectAllGames();
QString shortName = gameShortName();
QString fullName = gameName();
@@ -258,17 +256,14 @@ QString GameGamebryo::identifyGamePath() const
return anyToken;
};
- for (size_t i = 0; i < gameList.count; ++i) {
- const NakGame& game = gameList.games[i];
- QString detectedName = QString::fromUtf8(game.name);
- QString detectedPath = QString::fromUtf8(game.install_path);
- if (detectedName.compare(fullName, Qt::CaseInsensitive) == 0 ||
- detectedName.compare(shortName, Qt::CaseInsensitive) == 0 ||
- detectedName.contains(fullName, Qt::CaseInsensitive) ||
- detectedName.contains(shortName, Qt::CaseInsensitive) ||
- tokensMatch(detectedName, fullName) || tokensMatch(detectedName, shortName)) {
- if (looksValid(QDir(detectedPath))) {
- return detectedPath;
+ for (const DetectedGame& game : scanResult.games) {
+ if (game.name.compare(fullName, Qt::CaseInsensitive) == 0 ||
+ game.name.compare(shortName, Qt::CaseInsensitive) == 0 ||
+ game.name.contains(fullName, Qt::CaseInsensitive) ||
+ game.name.contains(shortName, Qt::CaseInsensitive) ||
+ tokensMatch(game.name, fullName) || tokensMatch(game.name, shortName)) {
+ if (looksValid(QDir(game.install_path))) {
+ return game.install_path;
}
}
}
@@ -857,14 +852,8 @@ QString GameGamebryo::parseEpicGamesLocation(const QStringList& manifests)
QString GameGamebryo::parseSteamLocation(const QString& appid,
const QString& directoryName)
{
-#ifdef HAS_NAK_FFI
- // Use NaK to find Steam path
- char* steamPathC = nak_find_steam_path();
- QString steamLocation;
- if (steamPathC) {
- steamLocation = QString::fromUtf8(steamPathC);
- nak_string_free(steamPathC);
- }
+#ifndef _WIN32
+ QString steamLocation = findSteamPath();
#elif defined(_WIN32)
QString path = "Software\\Valve\\Steam";
QString steamLocation =
diff --git a/libs/game_bethesda/src/games/ttw/CMakeLists.txt b/libs/game_bethesda/src/games/ttw/CMakeLists.txt
index 828b67f..8a2b8b4 100644
--- a/libs/game_bethesda/src/games/ttw/CMakeLists.txt
+++ b/libs/game_bethesda/src/games/ttw/CMakeLists.txt
@@ -18,10 +18,6 @@ mo2_configure_plugin(game_ttw NO_SOURCES WARNINGS 4)
mo2_default_source_group()
target_link_libraries(game_ttw PRIVATE game_gamebryo)
-# NaK FFI for TTW game detection (TTW uses FalloutNV as base game)
-if(TARGET mo2::nak_ffi)
- target_link_libraries(game_ttw PRIVATE mo2::nak_ffi)
- target_compile_definitions(game_ttw PRIVATE HAS_NAK_FFI)
-endif()
+# Game detection symbols are in libuibase.so (already linked via game_gamebryo → mo2::uibase).
mo2_install_plugin(game_ttw)
diff --git a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
index cfd8ca8..e09ddf0 100644
--- a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
+++ b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
@@ -15,9 +15,8 @@
#include <gamebryosavegameinfo.h>
#include <gamebryounmanagedmods.h>
-#ifdef HAS_NAK_FFI
-#include <nak_ffi.h>
-#include "scopeguard.h"
+#ifndef _WIN32
+#include "gamedetection.h"
#endif
#include <QCoreApplication>
@@ -85,23 +84,17 @@ QString GameFalloutTTW::identifyGamePath() const
QString path = "Software\\Bethesda Softworks\\FalloutNV";
result = findInRegistry(HKEY_LOCAL_MACHINE, path.toStdWString().c_str(),
L"Installed Path");
-#elif defined(HAS_NAK_FFI)
+#else
// TTW uses FalloutNV as its base game, so look for Fallout New Vegas
- // (the base class would try matching "TTW" which NaK doesn't know about)
- NakGameList gameList = nak_detect_all_games();
- ON_BLOCK_EXIT([&]() {
- nak_game_list_free(gameList);
- });
-
- for (size_t i = 0; i < gameList.count; ++i) {
- const NakGame& game = gameList.games[i];
- QString detectedName = QString::fromUtf8(game.name);
- if (detectedName.contains("Fallout", Qt::CaseInsensitive) &&
- detectedName.contains("Vegas", Qt::CaseInsensitive)) {
- QString detectedPath = QString::fromUtf8(game.install_path);
- if (looksValid(QDir(detectedPath))) {
- result = detectedPath;
- break;
+ {
+ const GameScanResult scanResult = detectAllGames();
+ for (const DetectedGame& game : scanResult.games) {
+ if (game.name.contains("Fallout", Qt::CaseInsensitive) &&
+ game.name.contains("Vegas", Qt::CaseInsensitive)) {
+ if (looksValid(QDir(game.install_path))) {
+ result = game.install_path;
+ break;
+ }
}
}
}
diff --git a/libs/nak/Cargo.lock b/libs/nak/Cargo.lock
deleted file mode 100644
index 03b8ffd..0000000
--- a/libs/nak/Cargo.lock
+++ /dev/null
@@ -1,964 +0,0 @@
-# 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 = "dataview"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daba87f72c730b508641c9fb6411fc9bba73939eed2cab611c399500511880d0"
-dependencies = [
- "derive_pod",
-]
-
-[[package]]
-name = "derive_pod"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8"
-
-[[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",
- "pelite",
- "serde",
- "serde_json",
- "ureq",
- "walkdir",
-]
-
-[[package]]
-name = "no-std-compat"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
-
-[[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 = "pelite"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685"
-dependencies = [
- "dataview",
- "libc",
- "no-std-compat",
- "pelite-macros",
- "winapi",
-]
-
-[[package]]
-name = "pelite-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b"
-
-[[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"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[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 = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[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
deleted file mode 100644
index c51a804..0000000
--- a/libs/nak/Cargo.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[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"
-pelite = "0.10"
diff --git a/libs/nak/src/config.rs b/libs/nak/src/config.rs
deleted file mode 100644
index 62f9fc5..0000000
--- a/libs/nak/src/config.rs
+++ /dev/null
@@ -1,154 +0,0 @@
-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
deleted file mode 100644
index a055eb7..0000000
--- a/libs/nak/src/deps/mod.rs
+++ /dev/null
@@ -1,177 +0,0 @@
-//! 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
deleted file mode 100644
index 03dbe9a..0000000
--- a/libs/nak/src/deps/tools.rs
+++ /dev/null
@@ -1,166 +0,0 @@
-//! Linux tool management (winetricks, cabextract)
-//!
-//! Handles downloading and managing Linux CLI tools.
-//! Tools are stored in ~/.local/share/fluorine/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 (~/.local/share/fluorine/bin/)
-// ============================================================================
-
-/// Get the tool bin directory path (~/.local/share/fluorine/bin/)
-/// This is accessible from both native and Flatpak environments.
-pub fn get_nak_bin_path() -> PathBuf {
- crate::paths::data_dir().join("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 in our bin directory.
-///
-/// Always downloads to ~/.local/share/fluorine/bin/ because winetricks runs
-/// inside pressure-vessel where system binaries under /usr are not visible.
-pub fn ensure_cabextract() -> Result<PathBuf, Box<dyn Error>> {
- // Check if we already downloaded it to our bin dir
- 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 — system copy is unusable inside pressure-vessel
- log_info("Downloading cabextract for use inside container...");
- 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
deleted file mode 100644
index b335773..0000000
--- a/libs/nak/src/dxvk.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-//! DXVK configuration management for Fluorine Manager.
-//!
-//! Downloads dxvk.conf from upstream, appends Fluorine-specific settings,
-//! and stores at `~/.local/share/fluorine/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 {
- crate::paths::data_dir().join("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
deleted file mode 100644
index 7d974e8..0000000
--- a/libs/nak/src/game_finder/bottles.rs
+++ /dev/null
@@ -1,147 +0,0 @@
-//! 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
deleted file mode 100644
index e80c1e1..0000000
--- a/libs/nak/src/game_finder/heroic.rs
+++ /dev/null
@@ -1,282 +0,0 @@
-//! 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_epic_id, find_by_gog_id, find_by_title};
-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 title = game_obj
- .get("title")
- .and_then(|v| v.as_str())
- .unwrap_or(app_name);
-
- // Look up known game info: try Epic AppName first, then title match
- let known_game = find_by_epic_id(app_name)
- .or_else(|| find_by_title(title));
-
- let name = title.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: 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
-}
-
-// ============================================================================
-// 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
deleted file mode 100644
index 117b0c7..0000000
--- a/libs/nak/src/game_finder/known_games.rs
+++ /dev/null
@@ -1,386 +0,0 @@
-//! Known games configuration
-//!
-//! Contains metadata for games that NaK supports, including:
-//! - Steam App ID
-//! - GOG App ID
-//! - Epic Games Store App Name (Legendary/Heroic internal identifier)
-//! - 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 on GOG)
- pub gog_app_id: Option<&'static str>,
- /// Epic Games Store AppName / Legendary internal ID (if available on Epic)
- pub epic_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: Some("1708684988"), // Enderal: Forgotten Stories on GOG
- epic_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, // Not on GOG (only original Enderal)
- epic_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
- epic_app_id: Some("adeae8bbfc94427db57c7dfecce3f1d4"),
- 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: Some("1998527297"), // Fallout 4 GOTY on GOG
- epic_app_id: Some("61d52ce4d09d41e48800c22784d13ae8"),
- 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,
- epic_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
- epic_app_id: Some("5daeb974a22a435988892319b3a4f476"),
- 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
- epic_app_id: None, // On Epic but internal AppName not yet known
- 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
- epic_app_id: None, // On Epic but internal AppName not yet known
- 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 (only SE/AE)
- epic_app_id: None, // Not on Epic (only SE/AE)
- 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 on GOG
- epic_app_id: Some("ac82db5035584c7f8a2c548d98c86b2c"),
- 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,
- epic_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, // Xbox/Steam exclusive
- epic_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
- epic_app_id: None,
- 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"),
- epic_app_id: None,
- 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"),
- epic_app_id: None,
- 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",
- },
-];
-
-/// Alternative GOG App IDs that should map to the same game.
-/// Some games have multiple GOG product IDs (e.g. different editions).
-const GOG_ID_ALIASES: &[(&str, &str)] = &[
- // Morrowind: Vortex uses 1435828767, NaK primary is 1440163901
- ("1435828767", "1440163901"),
- // Skyrim Anniversary Edition is a separate GOG product but same game as SE
- ("1801825368", "1711230643"),
-];
-
-/// 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> {
- // Direct match first
- if let Some(game) = KNOWN_GAMES.iter().find(|g| g.gog_app_id == Some(app_id)) {
- return Some(game);
- }
- // Check aliases: resolve alternate GOG ID to primary, then look up
- for &(alias, primary) in GOG_ID_ALIASES {
- if app_id == alias {
- return KNOWN_GAMES.iter().find(|g| g.gog_app_id == Some(primary));
- }
- }
- None
-}
-
-/// Find a known game by Epic Games Store AppName (Legendary/Heroic internal ID)
-pub fn find_by_epic_id(app_id: &str) -> Option<&'static KnownGame> {
- KNOWN_GAMES
- .iter()
- .find(|g| g.epic_app_id == Some(app_id))
-}
-
-/// Strip punctuation and collapse whitespace for fuzzy title matching.
-fn normalize_for_matching(s: &str) -> String {
- s.chars()
- .map(|c| if c.is_alphanumeric() || c == ' ' { c } else { ' ' })
- .collect::<String>()
- .split_whitespace()
- .collect::<Vec<_>>()
- .join(" ")
-}
-
-/// Find a known game by title, using fuzzy matching.
-/// Strips common suffixes like "Game of the Year Edition" for comparison.
-pub fn find_by_title(title: &str) -> Option<&'static KnownGame> {
- let title_lower = title.to_lowercase();
-
- // Exact match first
- if let Some(game) = KNOWN_GAMES.iter().find(|g| g.name.to_lowercase() == title_lower) {
- return Some(game);
- }
-
- // Check if the title starts with or contains a known game name
- // Sort by name length descending so "Skyrim Special Edition" matches before "Skyrim"
- let mut games_by_name_len: Vec<&KnownGame> = KNOWN_GAMES.iter().collect();
- games_by_name_len.sort_by(|a, b| b.name.len().cmp(&a.name.len()));
-
- // Normalize both title and game names: strip colons and extra whitespace for comparison
- let title_normalized = normalize_for_matching(&title_lower);
-
- for game in games_by_name_len {
- let game_lower = game.name.to_lowercase();
- let game_normalized = normalize_for_matching(&game_lower);
-
- // Direct containment (e.g. "Fallout 3: Game of the Year Edition" contains "Fallout 3")
- if title_lower.contains(&game_lower) {
- return Some(game);
- }
-
- // Normalized containment (e.g. "Fallout: New Vegas" normalized to "fallout new vegas"
- // matches game name "Fallout New Vegas" normalized to "fallout new vegas")
- if title_normalized.contains(&game_normalized) {
- return Some(game);
- }
-
- // After-colon match: "The Elder Scrolls III: Morrowind" -> check "Morrowind"
- if let Some(after_colon) = title_lower.split(':').nth(1) {
- let after_colon = after_colon.trim();
- if after_colon.starts_with(&game_lower) {
- return Some(game);
- }
- }
- }
-
- None
-}
-
-/// 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::*;
-
- #[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");
- }
-
- #[test]
- fn find_by_epic_id_works() {
- let game = find_by_epic_id("ac82db5035584c7f8a2c548d98c86b2c")
- .expect("Should find Skyrim SE by Epic ID");
- assert_eq!(game.name, "Skyrim Special Edition");
- }
-
- #[test]
- fn gog_alias_maps_correctly() {
- // Skyrim Anniversary Edition GOG ID maps to Skyrim SE
- let game = find_by_gog_id("1801825368")
- .expect("Skyrim AE GOG ID should map to Skyrim SE");
- assert_eq!(game.name, "Skyrim Special Edition");
- }
-
- #[test]
- fn find_by_title_matches_full_titles() {
- let game = find_by_title("The Elder Scrolls III: Morrowind Game of the Year Edition")
- .expect("Should find Morrowind by full Epic title");
- assert_eq!(game.name, "Morrowind");
- }
-
- #[test]
- fn find_by_title_matches_colon_titles() {
- let game = find_by_title("The Elder Scrolls IV: Oblivion Game of the Year Edition")
- .expect("Should find Oblivion by full title");
- assert_eq!(game.name, "Oblivion");
- }
-
- #[test]
- fn find_by_title_prefers_longer_match() {
- let game = find_by_title("Skyrim Special Edition")
- .expect("Should find Skyrim SE, not Skyrim");
- assert_eq!(game.name, "Skyrim Special Edition");
- }
-
- #[test]
- fn find_by_title_fallout_nv() {
- let game = find_by_title("Fallout: New Vegas Ultimate Edition")
- .expect("Should find Fallout NV");
- assert_eq!(game.name, "Fallout New Vegas");
- }
-}
diff --git a/libs/nak/src/game_finder/mod.rs b/libs/nak/src/game_finder/mod.rs
deleted file mode 100644
index 600751a..0000000
--- a/libs/nak/src/game_finder/mod.rs
+++ /dev/null
@@ -1,218 +0,0 @@
-//! 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_epic_id, find_by_gog_id, find_by_name, find_by_steam_id, find_by_title, 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.
-///
-/// Games are detected in priority order: Steam -> Heroic (GOG -> Epic) -> Bottles.
-/// When the same game is found from multiple launchers, the higher-priority
-/// detection is kept and duplicates are skipped. This ensures registry entries
-/// (which are shared across storefronts) prefer Steam paths, then GOG, then Epic.
-pub fn detect_all_games() -> GameScanResult {
- let mut result = GameScanResult::default();
-
- // Detect in priority order: Steam first, then GOG (via Heroic), then Epic, then Bottles.
- 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);
-
- // Deduplicate: keep the first occurrence of each game (by registry_path),
- // which respects the detection order (Steam > GOG > Epic > Bottles).
- deduplicate_games(&mut result);
-
- result
-}
-
-/// Remove duplicate game detections, keeping the first (highest-priority) entry.
-/// Two games are considered duplicates if they have the same registry_path.
-fn deduplicate_games(result: &mut GameScanResult) {
- let mut seen_registry_paths = std::collections::HashSet::new();
- result.games.retain(|game| {
- if let Some(ref reg_path) = game.registry_path {
- seen_registry_paths.insert(reg_path.clone())
- } else {
- // Games without registry paths are always kept (no risk of conflict)
- true
- }
- });
-}
-
-/// 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
deleted file mode 100644
index e843b6e..0000000
--- a/libs/nak/src/game_finder/registry.rs
+++ /dev/null
@@ -1,227 +0,0 @@
-//! 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
deleted file mode 100644
index d403cdf..0000000
--- a/libs/nak/src/game_finder/steam.rs
+++ /dev/null
@@ -1,253 +0,0 @@
-//! 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);
-
- // Collect all steamapps paths so we can search across them for compatdata.
- // Steam can place compatdata in a different library folder than the game.
- let all_steamapps: Vec<PathBuf> = libraries
- .iter()
- .map(|l| l.join("steamapps"))
- .filter(|s| s.exists())
- .collect();
-
- for steamapps in &all_steamapps {
- // 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, &all_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,
- all_steamapps: &[PathBuf],
- 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;
- }
-
- // Search ALL library folders for the compatdata — Steam can place it in a
- // different library than the game itself (e.g. game on SD card, compatdata
- // on internal storage or vice-versa).
- let prefix_path = all_steamapps
- .iter()
- .map(|sa| sa.join("compatdata").join(&manifest.app_id).join("pfx"))
- .find(|p| p.exists());
-
- // 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
deleted file mode 100644
index c296f27..0000000
--- a/libs/nak/src/game_finder/vdf.rs
+++ /dev/null
@@ -1,254 +0,0 @@
-//! 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/icons.rs b/libs/nak/src/icons.rs
deleted file mode 100644
index 940d0d1..0000000
--- a/libs/nak/src/icons.rs
+++ /dev/null
@@ -1,195 +0,0 @@
-//! Extract icons from Windows PE (.exe/.dll) files using pelite.
-//!
-//! Returns raw ICO file bytes that can be loaded directly by Qt's QIcon/QImage.
-
-use std::path::Path;
-
-use pelite::resources::{Entry, Name, Resources};
-
-/// Extract the best (largest) icon from a PE executable as raw ICO bytes.
-///
-/// Returns `None` if the file can't be read, isn't a valid PE, or has no icons.
-pub fn extract_icon(exe_path: &Path) -> Option<Vec<u8>> {
- let data = std::fs::read(exe_path).ok()?;
-
- // Try PE32+ (64-bit) first, fall back to PE32 (32-bit)
- if let Ok(icon) = extract_from_pe64(&data) {
- return Some(icon);
- }
- if let Ok(icon) = extract_from_pe32(&data) {
- return Some(icon);
- }
-
- None
-}
-
-fn extract_from_pe64(data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
- use pelite::pe64::{Pe, PeFile};
- let pe = PeFile::from_bytes(data)?;
- let resources = pe.resources()?;
- build_ico_from_resources(&resources)
-}
-
-fn extract_from_pe32(data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
- use pelite::pe32::{Pe, PeFile};
- let pe = PeFile::from_bytes(data)?;
- let resources = pe.resources()?;
- build_ico_from_resources(&resources)
-}
-
-/// Build an ICO file from PE resources, picking the group icon with the
-/// largest total pixel area.
-fn build_ico_from_resources(
- resources: &Resources,
-) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
- let root = resources.root()?;
-
- // Find RT_GROUP_ICON directory (type 14)
- let group_icon_dir = root
- .get_dir(Name::Id(14))
- .map_err(|_| "no RT_GROUP_ICON")?;
-
- let mut best_group: Option<(i32, Vec<u8>)> = None;
-
- for dir_entry in group_icon_dir.entries() {
- // Each entry is a group; get its subdirectory (language variants)
- let sub_dir = match dir_entry.entry() {
- Ok(Entry::Directory(d)) => d,
- _ => continue,
- };
-
- // Get the first language variant's data
- let data_entry = match sub_dir.entries().next() {
- Some(e) => match e.entry() {
- Ok(Entry::DataEntry(d)) => d,
- _ => continue,
- },
- None => continue,
- };
-
- let grp_data = match data_entry.bytes() {
- Ok(d) => d,
- Err(_) => continue,
- };
-
- // Parse GRPICONDIR header: reserved(2) + type(2) + count(2) = 6 bytes
- if grp_data.len() < 6 {
- continue;
- }
- let count = u16::from_le_bytes([grp_data[4], grp_data[5]]) as usize;
- // Each GRPICONDIRENTRY is 14 bytes
- if grp_data.len() < 6 + count * 14 {
- continue;
- }
-
- // Calculate total pixel area for ranking
- let mut total_area: i32 = 0;
- for i in 0..count {
- let offset = 6 + i * 14;
- let w = if grp_data[offset] == 0 {
- 256i32
- } else {
- grp_data[offset] as i32
- };
- let h = if grp_data[offset + 1] == 0 {
- 256i32
- } else {
- grp_data[offset + 1] as i32
- };
- total_area += w * h;
- }
-
- if let Ok(ico_bytes) = build_ico_file(resources, grp_data, count) {
- if best_group.is_none() || total_area > best_group.as_ref().unwrap().0 {
- best_group = Some((total_area, ico_bytes));
- }
- }
- }
-
- best_group
- .map(|(_, bytes)| bytes)
- .ok_or_else(|| "no valid icon group found".into())
-}
-
-/// Build a complete ICO file from a GRPICONDIR and the corresponding RT_ICON resources.
-fn build_ico_file(
- resources: &Resources,
- grp_data: &[u8],
- count: usize,
-) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
- let root = resources.root()?;
-
- // RT_ICON = type 3
- let icon_dir = root.get_dir(Name::Id(3)).map_err(|_| "no RT_ICON")?;
-
- // Collect individual icon image data
- struct IconEntry<'a> {
- header: [u8; 8], // first 8 bytes of GRPICONDIRENTRY (w, h, colors, reserved, planes, bpp)
- data: &'a [u8],
- }
- let mut entries: Vec<IconEntry> = Vec::new();
-
- for i in 0..count {
- let grp_offset = 6 + i * 14;
- let icon_id =
- u16::from_le_bytes([grp_data[grp_offset + 12], grp_data[grp_offset + 13]]) as u32;
-
- // Find the RT_ICON with this ID
- let icon_sub = match icon_dir.get_dir(Name::Id(icon_id)) {
- Ok(d) => d,
- Err(_) => continue,
- };
-
- let data_entry = match icon_sub.entries().next() {
- Some(e) => match e.entry() {
- Ok(Entry::DataEntry(d)) => d,
- _ => continue,
- },
- None => continue,
- };
-
- let image_data = match data_entry.bytes() {
- Ok(d) => d,
- Err(_) => continue,
- };
-
- let mut header = [0u8; 8];
- header.copy_from_slice(&grp_data[grp_offset..grp_offset + 8]);
-
- entries.push(IconEntry {
- header,
- data: image_data,
- });
- }
-
- if entries.is_empty() {
- return Err("no icon entries found".into());
- }
-
- let entry_count = entries.len() as u16;
- let header_size = 6 + (entry_count as usize) * 16; // ICONDIR + ICONDIRENTRYs
-
- let mut ico = Vec::new();
-
- // ICONDIR header
- ico.extend_from_slice(&0u16.to_le_bytes()); // reserved
- ico.extend_from_slice(&1u16.to_le_bytes()); // type = ICO
- ico.extend_from_slice(&entry_count.to_le_bytes());
-
- // Write ICONDIRENTRY records (16 bytes each)
- let mut data_offset = header_size as u32;
- for entry in &entries {
- ico.extend_from_slice(&entry.header); // width, height, colors, reserved, planes, bpp (8 bytes)
- let size = entry.data.len() as u32;
- ico.extend_from_slice(&size.to_le_bytes()); // dwBytesInRes (4 bytes)
- ico.extend_from_slice(&data_offset.to_le_bytes()); // dwImageOffset (4 bytes)
- data_offset += size;
- }
-
- // Write image data
- for entry in &entries {
- ico.extend_from_slice(entry.data);
- }
-
- Ok(ico)
-}
diff --git a/libs/nak/src/installers/mod.rs b/libs/nak/src/installers/mod.rs
deleted file mode 100644
index cf3eb85..0000000
--- a/libs/nak/src/installers/mod.rs
+++ /dev/null
@@ -1,334 +0,0 @@
-//! 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
deleted file mode 100644
index 6c48b09..0000000
--- a/libs/nak/src/installers/prefix_setup.rs
+++ /dev/null
@@ -1,764 +0,0 @@
-//! 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 set them on the
- // process (inherited by SLR/pressure-vessel into the container).
- let 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()),
- ];
-
- log_install(&format!("Initializing prefix with proton wrapper: {:?}", proton_script));
- let (exe, args): (std::path::PathBuf, Vec<&str>) =
- (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
deleted file mode 100644
index e8e477b..0000000
--- a/libs/nak/src/installers/symlinks.rs
+++ /dev/null
@@ -1,377 +0,0 @@
-//! 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 {
- log_info(&format!(
- "Symlink skip '{}' (app_id {}): no prefix_path",
- game.name, game.app_id
- ));
- 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);
-
- let my_games_dir = game_user_dir.join("Documents/My Games");
- log_info(&format!(
- "Symlink checking '{}' (app_id {}): prefix={}, user='{}', my_games_exists={}",
- game.name, game.app_id, game_prefix.display(), game_username, my_games_dir.is_dir()
- ));
-
- // 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", "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() {
- log_info(&format!(
- " scan_and_link_all: {} not a dir for '{}', skipping",
- game_base.display(), game_name
- ));
- return 0;
- }
-
- let Ok(entries) = fs::read_dir(game_base) else {
- log_info(&format!(
- " scan_and_link_all: cannot read {} for '{}'",
- game_base.display(), game_name
- ));
- 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
deleted file mode 100644
index d6c8e2b..0000000
--- a/libs/nak/src/lib.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-//! 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 slr;
-pub mod game_finder;
-pub mod logging;
-pub mod paths;
-pub mod runtime_wrap;
-pub mod steam;
-pub mod utils;
-
-pub mod deps;
-pub mod icons;
-pub mod installers;
diff --git a/libs/nak/src/logging.rs b/libs/nak/src/logging.rs
deleted file mode 100644
index e506bf8..0000000
--- a/libs/nak/src/logging.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! Logging for Fluorine Manager.
-//!
-//! All log messages are:
-//! 1. Written to `~/.local/share/fluorine/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 {
- crate::paths::data_dir().join("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/paths.rs b/libs/nak/src/paths.rs
deleted file mode 100644
index f6dde6c..0000000
--- a/libs/nak/src/paths.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-//! Shared data directory for Fluorine Manager.
-//!
-//! All data lives under `~/.local/share/fluorine/`.
-
-use std::path::PathBuf;
-
-/// Returns the Fluorine data directory (`~/.local/share/fluorine`).
-pub fn data_dir() -> PathBuf {
- let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
- PathBuf::from(home).join(".local/share/fluorine")
-}
diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs
deleted file mode 100644
index 943a329..0000000
--- a/libs/nak/src/runtime_wrap.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-use std::ffi::OsStr;
-use std::process::Command;
-
-/// Build a command to run `exe` with the given environment variables.
-///
-/// If SLR (Steam Linux Runtime) is installed, the command is wrapped inside
-/// the pressure-vessel container via the SLR `run` script. Environment
-/// variables are set on the process and inherited by pressure-vessel into the
-/// container (matching how game launches work via QProcess). This ensures
-/// Wine/Proton commands use the container's libraries instead of potentially
-/// broken host libraries.
-pub fn build_command<S: AsRef<OsStr>>(
- exe: impl AsRef<OsStr>,
- envs: &[(&str, S)],
-) -> Command {
- if let Some(slr_script) = crate::slr::get_slr_run_script() {
- let mut cmd = Command::new(&slr_script);
- // Set env vars on the process — pressure-vessel inherits them
- for (key, value) in envs {
- cmd.env(key, value.as_ref());
- }
- // Expose the executable's parent directory to the container —
- // needed for system-installed Protons (e.g. /usr/share/steam/...)
- // whose files may not be visible inside the container by default.
- if let Some(parent) = std::path::Path::new(exe.as_ref()).parent() {
- if parent.exists() {
- let mut flag = std::ffi::OsString::from("--filesystem=");
- flag.push(parent.as_os_str());
- cmd.arg(flag);
- }
- }
- // Also expose WINEPREFIX if set in env vars
- for (key, value) in envs {
- if *key == "WINEPREFIX" || *key == "STEAM_COMPAT_DATA_PATH" {
- let path = std::path::Path::new(value.as_ref());
- if path.exists() || path.parent().map_or(false, |p| p.exists()) {
- let mut flag = std::ffi::OsString::from("--filesystem=");
- flag.push(value.as_ref());
- cmd.arg(flag);
- }
- }
- }
- cmd.arg("--");
- cmd.arg(exe);
- cmd
- } else {
- 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, &[])
-}
diff --git a/libs/nak/src/slr.rs b/libs/nak/src/slr.rs
deleted file mode 100644
index 3fe7d92..0000000
--- a/libs/nak/src/slr.rs
+++ /dev/null
@@ -1,231 +0,0 @@
-//! Steam Linux Runtime (SLR) download and management for Fluorine Manager.
-//!
-//! Downloads SteamLinuxRuntime_sniper from Valve's official repo and stores it
-//! at `~/.local/share/fluorine/steamrt/`. The `run` script is then used to
-//! wrap game launches inside the pressure-vessel container, providing
-//! GStreamer, 32-bit libs, and an FHS-compliant environment for non-FHS
-//! distros (NixOS, etc.).
-
-use std::error::Error;
-use std::fs;
-use std::io::{Read, Write};
-use std::path::PathBuf;
-use std::process::Command;
-use std::sync::atomic::{AtomicI32, Ordering};
-
-use crate::logging::log_info;
-
-const BASE_URL: &str =
- "https://repo.steampowered.com/steamrt3/images/latest-public-beta";
-const ARCHIVE_NAME: &str = "SteamLinuxRuntime_sniper.tar.xz";
-const EXTRACTED_DIR: &str = "SteamLinuxRuntime_sniper";
-
-/// Directory where SLR is installed: `~/.local/share/fluorine/steamrt/`
-pub fn slr_install_dir() -> PathBuf {
- crate::paths::data_dir().join("steamrt")
-}
-
-/// Path to the `run` script inside the extracted SLR.
-pub fn slr_run_script() -> PathBuf {
- slr_install_dir().join(EXTRACTED_DIR).join("run")
-}
-
-/// Path where we store the remote BUILD_ID for update checks.
-fn local_build_id_path() -> PathBuf {
- slr_install_dir().join("BUILD_ID.txt")
-}
-
-/// Returns true if the SLR `run` script is present and executable.
-pub fn is_slr_installed() -> bool {
- let script = slr_run_script();
- if !script.exists() {
- return false;
- }
- // Verify it's actually executable
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Ok(meta) = fs::metadata(&script) {
- return meta.permissions().mode() & 0o111 != 0;
- }
- return false;
- }
- #[cfg(not(unix))]
- true
-}
-
-/// Returns the path to the `run` script, or None if SLR is not installed.
-pub fn get_slr_run_script() -> Option<PathBuf> {
- if is_slr_installed() {
- Some(slr_run_script())
- } else {
- None
- }
-}
-
-/// Fetch the remote BUILD_ID as a string.
-fn fetch_remote_build_id() -> Result<String, Box<dyn Error>> {
- let url = format!("{}/BUILD_ID.txt", BASE_URL);
- let resp = ureq::get(&url).call()?;
- let mut body = String::new();
- resp.into_reader().read_to_string(&mut body)?;
- Ok(body.trim().to_string())
-}
-
-/// Read the locally cached BUILD_ID, if any.
-fn read_local_build_id() -> Option<String> {
- fs::read_to_string(local_build_id_path())
- .ok()
- .map(|s| s.trim().to_string())
-}
-
-/// Verify downloaded file size matches the Content-Length from the HTTP response.
-/// This is more reliable than SHA256SUMS which can be stale on Valve's CDN.
-fn verify_download_size(file: &std::path::Path, expected: u64) -> Result<(), Box<dyn Error>> {
- let actual = fs::metadata(file)?.len();
- if actual != expected {
- return Err(format!(
- "Download incomplete: expected {} bytes, got {}",
- expected, actual
- )
- .into());
- }
- Ok(())
-}
-
-/// Download the archive with streaming progress.
-///
-/// `progress_cb` receives values in 0.0..=1.0.
-/// `cancel_flag` is polled each chunk — set to non-zero to abort.
-/// Returns the Content-Length on success for verification.
-fn download_archive(
- dest: &std::path::Path,
- progress_cb: &impl Fn(f32),
- cancel_flag: &AtomicI32,
-) -> Result<Option<u64>, Box<dyn Error>> {
- let url = format!("{}/{}", BASE_URL, ARCHIVE_NAME);
- let resp = ureq::get(&url).call()?;
-
- let total_bytes: Option<u64> = resp
- .header("Content-Length")
- .and_then(|v| v.parse().ok());
-
- let mut reader = resp.into_reader();
- let mut file = fs::File::create(dest)?;
- let mut buf = vec![0u8; 64 * 1024]; // 64 KiB chunks
- let mut downloaded: u64 = 0;
-
- loop {
- if cancel_flag.load(Ordering::Relaxed) != 0 {
- drop(file);
- let _ = fs::remove_file(dest);
- return Err("Download cancelled".into());
- }
-
- let n = reader.read(&mut buf)?;
- if n == 0 {
- break;
- }
- file.write_all(&buf[..n])?;
- downloaded += n as u64;
-
- if let Some(total) = total_bytes {
- if total > 0 {
- progress_cb((downloaded as f32) / (total as f32));
- }
- }
- }
-
- file.flush()?;
- Ok(total_bytes)
-}
-
-/// Download and install the Steam Linux Runtime (sniper).
-///
-/// - Skips download if already at the latest BUILD_ID.
-/// - Calls `status_cb` with human-readable status strings.
-/// - Calls `progress_cb` with 0.0..=1.0 during the download phase.
-/// - Polls `cancel_flag`; returns an error if it becomes non-zero.
-pub fn download_slr(
- progress_cb: impl Fn(f32),
- status_cb: impl Fn(&str),
- cancel_flag: &AtomicI32,
-) -> Result<(), Box<dyn Error>> {
- // Check for updates
- status_cb("Checking Steam Linux Runtime version...");
- let remote_build_id = fetch_remote_build_id()?;
- let local_build_id = read_local_build_id();
-
- if local_build_id.as_deref() == Some(remote_build_id.as_str()) && is_slr_installed() {
- log_info("Steam Linux Runtime is already up to date");
- status_cb("Steam Linux Runtime is already up to date");
- progress_cb(1.0);
- return Ok(());
- }
-
- log_info(&format!(
- "Downloading Steam Linux Runtime (BUILD_ID: {})",
- remote_build_id
- ));
-
- let install_dir = slr_install_dir();
- fs::create_dir_all(&install_dir)?;
-
- // Temp file in the install dir
- let archive_path = install_dir.join(ARCHIVE_NAME);
-
- // Download
- status_cb("Downloading Steam Linux Runtime (sniper, ~180 MB)...");
- let content_length = download_archive(&archive_path, &progress_cb, cancel_flag)?;
- progress_cb(1.0);
-
- // Verify download integrity via Content-Length.
- // We don't use Valve's SHA256SUMS file because their CDN frequently
- // serves a stale copy that doesn't match the current archive.
- if let Some(expected_size) = content_length {
- status_cb("Verifying download...");
- if let Err(e) = verify_download_size(&archive_path, expected_size) {
- let _ = fs::remove_file(&archive_path);
- return Err(format!("Download verification failed: {}", e).into());
- }
- log_info(&format!(
- "Download verified ({} bytes)",
- expected_size
- ));
- }
-
- // Extract
- status_cb("Extracting Steam Linux Runtime...");
- let extracted = install_dir.join(EXTRACTED_DIR);
- // Remove old copy if present before extracting
- if extracted.exists() {
- fs::remove_dir_all(&extracted)?;
- }
-
- let status = Command::new("tar")
- .args(["xJf", archive_path.to_str().unwrap_or("")])
- .current_dir(&install_dir)
- .status()?;
-
- let _ = fs::remove_file(&archive_path);
-
- if !status.success() {
- return Err(format!("tar extraction failed with status: {}", status).into());
- }
-
- // Sanity check — run script must now exist
- if !slr_run_script().exists() {
- return Err(format!(
- "Extraction succeeded but run script not found at {:?}",
- slr_run_script()
- )
- .into());
- }
-
- // Save BUILD_ID for future update checks
- fs::write(local_build_id_path(), &remote_build_id)?;
-
- log_info("Steam Linux Runtime installed successfully");
- status_cb("Steam Linux Runtime ready");
- Ok(())
-}
diff --git a/libs/nak/src/steam/mod.rs b/libs/nak/src/steam/mod.rs
deleted file mode 100644
index c5964aa..0000000
--- a/libs/nak/src/steam/mod.rs
+++ /dev/null
@@ -1,135 +0,0 @@
-//! 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
deleted file mode 100644
index 8ef5013..0000000
--- a/libs/nak/src/steam/paths.rs
+++ /dev/null
@@ -1,259 +0,0 @@
-//! 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
deleted file mode 100644
index 4059dc4..0000000
--- a/libs/nak/src/steam/proton.rs
+++ /dev/null
@@ -1,252 +0,0 @@
-//! 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;
- };
-
- // 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/
- // (Arch packages Proton here; Flatpak has --filesystem=/usr/share/steam:ro)
- 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
deleted file mode 100644
index 7d7886c..0000000
--- a/libs/nak/src/utils.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-//! 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/CMakeLists.txt b/libs/nak_ffi/CMakeLists.txt
deleted file mode 100644
index 72d939f..0000000
--- a/libs/nak_ffi/CMakeLists.txt
+++ /dev/null
@@ -1,38 +0,0 @@
-cmake_minimum_required(VERSION 3.16)
-project(nak_ffi)
-
-if(CMAKE_BUILD_TYPE STREQUAL "Debug")
- set(CARGO_BUILD_TYPE "debug")
- set(CARGO_BUILD_FLAGS "")
-else()
- set(CARGO_BUILD_TYPE "release")
- set(CARGO_BUILD_FLAGS "--release")
-endif()
-
-set(NAK_FFI_DIR ${CMAKE_CURRENT_SOURCE_DIR})
-set(NAK_FFI_LIB ${NAK_FFI_DIR}/target/${CARGO_BUILD_TYPE}/libnak_ffi.so)
-
-# Glob all Rust sources so any change triggers a cargo rebuild
-file(GLOB_RECURSE NAK_FFI_SOURCES ${NAK_FFI_DIR}/src/*.rs)
-file(GLOB_RECURSE NAK_SOURCES ${NAK_FFI_DIR}/../nak/src/*.rs)
-
-add_custom_command(
- OUTPUT ${NAK_FFI_LIB}
- COMMAND cargo build ${CARGO_BUILD_FLAGS}
- WORKING_DIRECTORY ${NAK_FFI_DIR}
- COMMENT "Building NaK FFI library (Rust)"
- DEPENDS ${NAK_FFI_DIR}/Cargo.toml ${NAK_FFI_DIR}/../nak/Cargo.toml
- ${NAK_FFI_SOURCES} ${NAK_SOURCES}
-)
-
-add_custom_target(nak_ffi_build DEPENDS ${NAK_FFI_LIB})
-
-# INTERFACE wrapper so ALIAS works (CMake can't alias IMPORTED targets)
-add_library(nak_ffi_wrapper INTERFACE)
-target_link_libraries(nak_ffi_wrapper INTERFACE ${NAK_FFI_LIB})
-target_include_directories(nak_ffi_wrapper INTERFACE ${NAK_FFI_DIR}/include)
-add_dependencies(nak_ffi_wrapper nak_ffi_build)
-add_library(mo2::nak_ffi ALIAS nak_ffi_wrapper)
-
-install(FILES ${NAK_FFI_LIB} DESTINATION lib)
-install(DIRECTORY ${NAK_FFI_DIR}/include/ DESTINATION include)
diff --git a/libs/nak_ffi/Cargo.lock b/libs/nak_ffi/Cargo.lock
deleted file mode 100644
index e8be402..0000000
--- a/libs/nak_ffi/Cargo.lock
+++ /dev/null
@@ -1,971 +0,0 @@
-# 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.55"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
-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 = "dataview"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daba87f72c730b508641c9fb6411fc9bba73939eed2cab611c399500511880d0"
-dependencies = [
- "derive_pod",
-]
-
-[[package]]
-name = "derive_pod"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8"
-
-[[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.180"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
-
-[[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_ffi"
-version = "0.1.0"
-dependencies = [
- "nak_rust",
-]
-
-[[package]]
-name = "nak_rust"
-version = "0.1.0"
-dependencies = [
- "chrono",
- "pelite",
- "serde",
- "serde_json",
- "ureq",
- "walkdir",
-]
-
-[[package]]
-name = "no-std-compat"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
-
-[[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 = "pelite"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685"
-dependencies = [
- "dataview",
- "libc",
- "no-std-compat",
- "pelite-macros",
- "winapi",
-]
-
-[[package]]
-name = "pelite-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b"
-
-[[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.114"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
-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"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[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 = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[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.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
diff --git a/libs/nak_ffi/Cargo.toml b/libs/nak_ffi/Cargo.toml
deleted file mode 100644
index cdf98df..0000000
--- a/libs/nak_ffi/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "nak_ffi"
-version = "0.1.0"
-edition = "2021"
-
-[lib]
-crate-type = ["cdylib"]
-
-[dependencies]
-nak_rust = { path = "../nak" }
diff --git a/libs/nak_ffi/include/nak_ffi.h b/libs/nak_ffi/include/nak_ffi.h
deleted file mode 100644
index 7a40863..0000000
--- a/libs/nak_ffi/include/nak_ffi.h
+++ /dev/null
@@ -1,230 +0,0 @@
-#ifndef NAK_FFI_H
-#define NAK_FFI_H
-
-#include <stddef.h>
-#include <stdint.h>
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ========================================================================
- * Tier 1: Game Detection
- * ======================================================================== */
-
-/** A detected game installation */
-typedef struct {
- char *name;
- char *app_id;
- char *install_path;
- char *prefix_path; /* NULL if no prefix */
- char *launcher; /* display name string */
- char *my_games_folder; /* NULL if not applicable */
- char *appdata_local_folder; /* NULL if not applicable */
- char *appdata_roaming_folder; /* NULL if not applicable */
- char *registry_path; /* NULL if not applicable */
- char *registry_value; /* NULL if not applicable */
-} NakGame;
-
-/** List of detected games */
-typedef struct {
- NakGame *games;
- size_t count;
- size_t steam_count;
- size_t heroic_count;
- size_t bottles_count;
-} NakGameList;
-
-/** Detect all installed games across all launchers */
-NakGameList nak_detect_all_games(void);
-
-/** Free a NakGameList returned by nak_detect_all_games */
-void nak_game_list_free(NakGameList list);
-
-/** A known game definition (static data, do NOT free) */
-typedef struct {
- const char *name;
- const char *steam_app_id;
- const char *gog_app_id; /* NULL if none */
- const char *epic_app_id; /* NULL if none */
- const char *my_games_folder; /* NULL if not applicable */
- const char *appdata_local_folder; /* NULL if not applicable */
- const char *appdata_roaming_folder; /* NULL if not applicable */
- const char *registry_path;
- const char *registry_value;
- const char *steam_folder;
-} NakKnownGame;
-
-/** Get the list of all known games (static data, do NOT free).
- * Returns pointer to array; writes count to *out_count. */
-const NakKnownGame *nak_get_known_games(size_t *out_count);
-
-/* ========================================================================
- * Tier 2: Proton Detection
- * ======================================================================== */
-
-/** An installed Proton version */
-typedef struct {
- char *name;
- char *config_name;
- char *path;
- int is_steam_proton;
- int is_experimental;
-} NakSteamProton;
-
-/** List of detected Proton installations */
-typedef struct {
- NakSteamProton *protons;
- size_t count;
-} NakProtonList;
-
-/** Find all installed Proton versions */
-NakProtonList nak_find_steam_protons(void);
-
-/** Free a NakProtonList */
-void nak_proton_list_free(NakProtonList list);
-
-/* ========================================================================
- * Tier 3: Steam Paths
- * ======================================================================== */
-
-/** Find the Steam installation path.
- * Returns newly allocated string (free with nak_string_free), or NULL. */
-char *nak_find_steam_path(void);
-
-/* ========================================================================
- * Tier 4: Dependency Installation (callback-based)
- * ======================================================================== */
-
-/** Callback for status/log messages */
-typedef void (*NakStatusCallback)(const char *message);
-typedef void (*NakLogCallback)(const char *message);
-
-/** Callback for progress updates (0.0 to 1.0) */
-typedef void (*NakProgressCallback)(float progress);
-
-/** Install all Wine prefix dependencies (blocking call).
- * cancel_flag: pointer to int, set non-zero to cancel.
- * Returns NULL on success, or error message (free with nak_string_free). */
-char *nak_install_all_dependencies(
- const char *prefix_path,
- const char *proton_name,
- const char *proton_path,
- NakStatusCallback status_cb,
- NakLogCallback log_cb,
- NakProgressCallback progress_cb,
- const int *cancel_flag,
- uint32_t app_id
-);
-
-/** Apply Wine registry settings to a prefix.
- * Returns NULL on success, or error message (free with nak_string_free). */
-char *nak_apply_wine_registry_settings(
- const char *prefix_path,
- const char *proton_name,
- const char *proton_path,
- NakLogCallback log_cb,
- uint32_t app_id
-);
-
-/** Apply a game's registry entry with a custom install path.
- * Looks up game_name in KNOWN_GAMES and writes registry pointing to install_path.
- * Returns NULL on success, or an error message (free with nak_string_free). */
-char *nak_apply_registry_for_game_path(
- const char *prefix_path,
- const char *proton_name,
- const char *proton_path,
- const char *game_name,
- const char *install_path,
- NakLogCallback log_cb
-);
-
-/* ========================================================================
- * Tier 5: Prefix Symlinks
- * ======================================================================== */
-
-/** Ensure AppData/Local/Temp exists in the Wine prefix.
- * Call during prefix creation. */
-void nak_ensure_temp_directory(const char *prefix_path);
-
-/** Detect games and create symlinks from the prefix to game prefixes.
- * Call during prefix creation. */
-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);
-
-/* ========================================================================
- * Tier 8: Steam Linux Runtime (SLR)
- * ======================================================================== */
-
-/** Returns 1 if SteamLinuxRuntime_sniper is installed and the run script exists, 0 otherwise. */
-int nak_slr_is_installed(void);
-
-/** Get the path to the SLR run script.
- * Returns NULL if SLR is not installed.
- * Caller must free with nak_string_free(). */
-char *nak_slr_get_run_script(void);
-
-/** Download and install SteamLinuxRuntime_sniper from Valve's repo (~180 MB).
- * Skips if already at the latest version (checked via BUILD_ID).
- * progress_cb: 0.0..1.0 during download (may be NULL).
- * status_cb: human-readable status strings (may be NULL).
- * cancel_flag: pointer to int, set non-zero to cancel (may be NULL).
- * Returns NULL on success, or error message (free with nak_string_free). */
-char *nak_download_slr(
- NakProgressCallback progress_cb,
- NakStatusCallback status_cb,
- const int *cancel_flag
-);
-
-/* ========================================================================
- * Tier 9: PE Icon Extraction
- * ======================================================================== */
-
-/** Result of icon extraction */
-typedef struct {
- uint8_t *data; /**< Raw ICO file bytes (NULL if extraction failed) */
- size_t len; /**< Length in bytes (0 if extraction failed) */
-} NakIconData;
-
-/** Extract the best icon from a Windows PE executable (.exe/.dll).
- * Returns raw ICO bytes. Free with nak_icon_data_free(). */
-NakIconData nak_extract_exe_icon(const char *exe_path);
-
-/** Free icon data returned by nak_extract_exe_icon */
-void nak_icon_data_free(NakIconData icon);
-
-/* ========================================================================
- * General
- * ======================================================================== */
-
-/** Free a string returned by any nak_* function */
-void nak_string_free(char *s);
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif /* NAK_FFI_H */
diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs
deleted file mode 100644
index a08cb0d..0000000
--- a/libs/nak_ffi/src/lib.rs
+++ /dev/null
@@ -1,750 +0,0 @@
-//! NaK FFI - C bindings for NaK game detection and Proton management
-//!
-//! Memory management rules:
-//! - Owned strings returned as `*mut c_char` must be freed with `nak_string_free()`
-//! - Struct lists (NakGameList, etc.) must be freed with their corresponding `_free()` fn
-//! - Error returns: functions returning `*mut c_char` for errors use null = success
-//! - `NakKnownGame` pointers are static data and must NOT be freed
-
-use std::ffi::{c_char, c_float, c_int, CStr, CString};
-use std::path::{Path, PathBuf};
-use std::ptr;
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, LazyLock, Mutex};
-
-// ============================================================================
-// Helper functions
-// ============================================================================
-
-fn to_cstring(s: &str) -> *mut c_char {
- CString::new(s).unwrap_or_default().into_raw()
-}
-
-fn to_cstring_opt(s: Option<&str>) -> *mut c_char {
- match s {
- Some(s) => to_cstring(s),
- None => ptr::null_mut(),
- }
-}
-
-unsafe fn from_cstr<'a>(p: *const c_char) -> &'a str {
- if p.is_null() {
- ""
- } else {
- unsafe { CStr::from_ptr(p) }.to_str().unwrap_or("")
- }
-}
-
-fn error_to_cstring(e: Box<dyn std::error::Error>) -> *mut c_char {
- to_cstring(&e.to_string())
-}
-
-/// Find a Proton installation by path, using canonicalization to handle
-/// symlinks and path normalization (e.g. system Protons in
-/// /usr/share/steam/compatibilitytools.d/).
-fn find_proton_by_path(proton_path_str: &str) -> Option<nak_rust::steam::SteamProton> {
- let target = std::fs::canonicalize(proton_path_str)
- .unwrap_or_else(|_| PathBuf::from(proton_path_str));
- nak_rust::steam::find_steam_protons()
- .into_iter()
- .find(|p| {
- std::fs::canonicalize(&p.path)
- .unwrap_or_else(|_| p.path.clone()) == target
- })
-}
-
-// ============================================================================
-// Tier 1: Game Detection
-// ============================================================================
-
-/// A detected game installation (C-compatible)
-#[repr(C)]
-pub struct NakGame {
- pub name: *mut c_char,
- pub app_id: *mut c_char,
- pub install_path: *mut c_char,
- pub prefix_path: *mut c_char, // null if no prefix
- pub launcher: *mut c_char, // display name string
- pub my_games_folder: *mut c_char,
- pub appdata_local_folder: *mut c_char,
- pub appdata_roaming_folder: *mut c_char,
- pub registry_path: *mut c_char,
- pub registry_value: *mut c_char,
-}
-
-/// List of detected games
-#[repr(C)]
-pub struct NakGameList {
- pub games: *mut NakGame,
- pub count: usize,
- pub steam_count: usize,
- pub heroic_count: usize,
- pub bottles_count: usize,
-}
-
-#[derive(Clone)]
-struct CachedGame {
- name: String,
- app_id: String,
- install_path: String,
- prefix_path: Option<String>,
- launcher: String,
- my_games_folder: Option<String>,
- appdata_local_folder: Option<String>,
- appdata_roaming_folder: Option<String>,
- registry_path: Option<String>,
- registry_value: Option<String>,
-}
-
-#[derive(Clone, Default)]
-struct CachedGameList {
- games: Vec<CachedGame>,
- steam_count: usize,
- heroic_count: usize,
- bottles_count: usize,
-}
-
-static DETECTED_GAMES_CACHE: LazyLock<Mutex<Option<CachedGameList>>> =
- LazyLock::new(|| Mutex::new(None));
-
-fn detect_games_cached() -> CachedGameList {
- let mut cache = DETECTED_GAMES_CACHE.lock().unwrap();
- if let Some(cached) = cache.as_ref() {
- return cached.clone();
- }
-
- let result = nak_rust::game_finder::detect_all_games();
- let cached = CachedGameList {
- games: result
- .games
- .iter()
- .map(|g| CachedGame {
- name: g.name.clone(),
- app_id: g.app_id.clone(),
- install_path: g.install_path.to_string_lossy().into_owned(),
- prefix_path: g
- .prefix_path
- .as_ref()
- .map(|p| p.to_string_lossy().into_owned()),
- launcher: g.launcher.display_name().to_string(),
- my_games_folder: g.my_games_folder.clone(),
- appdata_local_folder: g.appdata_local_folder.clone(),
- appdata_roaming_folder: g.appdata_roaming_folder.clone(),
- registry_path: g.registry_path.clone(),
- registry_value: g.registry_value.clone(),
- })
- .collect(),
- steam_count: result.steam_count,
- heroic_count: result.heroic_count,
- bottles_count: result.bottles_count,
- };
-
- *cache = Some(cached.clone());
- cached
-}
-
-/// Detect all installed games across all launchers
-#[no_mangle]
-pub extern "C" fn nak_detect_all_games() -> NakGameList {
- let result = detect_games_cached();
-
- let mut games: Vec<NakGame> = result
- .games
- .iter()
- .map(|g| NakGame {
- name: to_cstring(&g.name),
- app_id: to_cstring(&g.app_id),
- install_path: to_cstring(&g.install_path),
- prefix_path: match &g.prefix_path {
- Some(p) => to_cstring(p),
- None => ptr::null_mut(),
- },
- launcher: to_cstring(&g.launcher),
- my_games_folder: to_cstring_opt(g.my_games_folder.as_deref()),
- appdata_local_folder: to_cstring_opt(g.appdata_local_folder.as_deref()),
- appdata_roaming_folder: to_cstring_opt(g.appdata_roaming_folder.as_deref()),
- registry_path: to_cstring_opt(g.registry_path.as_deref()),
- registry_value: to_cstring_opt(g.registry_value.as_deref()),
- })
- .collect();
-
- let list = NakGameList {
- games: games.as_mut_ptr(),
- count: games.len(),
- steam_count: result.steam_count,
- heroic_count: result.heroic_count,
- bottles_count: result.bottles_count,
- };
- std::mem::forget(games);
- list
-}
-
-/// Free a NakGameList returned by nak_detect_all_games
-#[no_mangle]
-pub unsafe extern "C" fn nak_game_list_free(list: NakGameList) {
- if list.games.is_null() {
- return;
- }
- let games = unsafe { Vec::from_raw_parts(list.games, list.count, list.count) };
- for g in games {
- free_if_nonnull(g.name);
- free_if_nonnull(g.app_id);
- free_if_nonnull(g.install_path);
- free_if_nonnull(g.prefix_path);
- free_if_nonnull(g.launcher);
- free_if_nonnull(g.my_games_folder);
- free_if_nonnull(g.appdata_local_folder);
- free_if_nonnull(g.appdata_roaming_folder);
- free_if_nonnull(g.registry_path);
- free_if_nonnull(g.registry_value);
- }
-}
-
-unsafe fn free_if_nonnull(p: *mut c_char) {
- if !p.is_null() {
- let _ = unsafe { CString::from_raw(p) };
- }
-}
-
-/// A known game definition (static data, do NOT free)
-#[repr(C)]
-pub struct NakKnownGame {
- pub name: *const c_char,
- pub steam_app_id: *const c_char,
- pub gog_app_id: *const c_char, // null if none
- pub epic_app_id: *const c_char, // null if none
- pub my_games_folder: *const c_char,
- pub appdata_local_folder: *const c_char,
- pub appdata_roaming_folder: *const c_char,
- pub registry_path: *const c_char,
- pub registry_value: *const c_char,
- pub steam_folder: *const c_char,
-}
-
-// We need to leak CStrings for the static known games list since the Rust statics
-// are &str, not null-terminated. We build the list once and leak it.
-// Raw pointers in NakKnownGame prevent Send/Sync, so we wrap in a newtype.
-struct KnownGamesVec(Vec<NakKnownGame>);
-// SAFETY: The leaked CStrings are effectively 'static and immutable after initialization.
-unsafe impl Send for KnownGamesVec {}
-unsafe impl Sync for KnownGamesVec {}
-
-static KNOWN_GAMES_FFI: std::sync::LazyLock<KnownGamesVec> = std::sync::LazyLock::new(|| {
- KnownGamesVec(
- nak_rust::game_finder::KNOWN_GAMES
- .iter()
- .map(|kg| NakKnownGame {
- name: leak_str(kg.name),
- steam_app_id: leak_str(kg.steam_app_id),
- gog_app_id: leak_str_opt(kg.gog_app_id),
- epic_app_id: leak_str_opt(kg.epic_app_id),
- my_games_folder: leak_str_opt(kg.my_games_folder),
- appdata_local_folder: leak_str_opt(kg.appdata_local_folder),
- appdata_roaming_folder: leak_str_opt(kg.appdata_roaming_folder),
- registry_path: leak_str(kg.registry_path),
- registry_value: leak_str(kg.registry_value),
- steam_folder: leak_str(kg.steam_folder),
- })
- .collect(),
- )
-});
-
-fn leak_str(s: &str) -> *const c_char {
- CString::new(s).unwrap_or_default().into_raw() as *const c_char
-}
-
-fn leak_str_opt(s: Option<&str>) -> *const c_char {
- match s {
- Some(s) => leak_str(s),
- None => ptr::null(),
- }
-}
-
-/// Get the list of all known games (static data, do NOT free)
-///
-/// Returns a pointer to the first element and writes the count to `out_count`.
-#[no_mangle]
-pub unsafe extern "C" fn nak_get_known_games(out_count: *mut usize) -> *const NakKnownGame {
- let games = &KNOWN_GAMES_FFI.0;
- if !out_count.is_null() {
- *out_count = games.len();
- }
- games.as_ptr()
-}
-
-// ============================================================================
-// Tier 2: Proton Detection
-// ============================================================================
-
-/// An installed Proton version (C-compatible)
-#[repr(C)]
-pub struct NakSteamProton {
- pub name: *mut c_char,
- pub config_name: *mut c_char,
- pub path: *mut c_char,
- pub is_steam_proton: c_int,
- pub is_experimental: c_int,
-}
-
-/// List of detected Proton installations
-#[repr(C)]
-pub struct NakProtonList {
- pub protons: *mut NakSteamProton,
- pub count: usize,
-}
-
-/// Find all installed Proton versions
-#[no_mangle]
-pub extern "C" fn nak_find_steam_protons() -> NakProtonList {
- let protons = nak_rust::steam::find_steam_protons();
-
- let mut ffi_protons: Vec<NakSteamProton> = protons
- .iter()
- .map(|p| NakSteamProton {
- name: to_cstring(&p.name),
- config_name: to_cstring(&p.config_name),
- path: to_cstring(&p.path.to_string_lossy()),
- is_steam_proton: p.is_steam_proton as c_int,
- is_experimental: p.is_experimental as c_int,
- })
- .collect();
-
- let list = NakProtonList {
- protons: ffi_protons.as_mut_ptr(),
- count: ffi_protons.len(),
- };
- std::mem::forget(ffi_protons);
- list
-}
-
-/// Free a NakProtonList
-#[no_mangle]
-pub unsafe extern "C" fn nak_proton_list_free(list: NakProtonList) {
- if list.protons.is_null() {
- return;
- }
- let protons = unsafe { Vec::from_raw_parts(list.protons, list.count, list.count) };
- for p in protons {
- free_if_nonnull(p.name);
- free_if_nonnull(p.config_name);
- free_if_nonnull(p.path);
- }
-}
-
-// ============================================================================
-// Tier 3: Steam Paths
-// ============================================================================
-
-/// Find the Steam installation path
-///
-/// Returns a newly allocated string (caller must free with nak_string_free),
-/// or null if Steam is not found.
-#[no_mangle]
-pub extern "C" fn nak_find_steam_path() -> *mut c_char {
- match nak_rust::steam::find_steam_path() {
- Some(path) => to_cstring(&path.to_string_lossy()),
- None => ptr::null_mut(),
- }
-}
-
-// ============================================================================
-// Tier 4: Dependency Installation (callback-based)
-// ============================================================================
-
-/// Callback for status messages: fn(message: *const c_char)
-pub type NakStatusCallback = Option<unsafe extern "C" fn(*const c_char)>;
-
-/// Callback for log messages: fn(message: *const c_char)
-pub type NakLogCallback = Option<unsafe extern "C" fn(*const c_char)>;
-
-/// Callback for progress updates: fn(progress: f32) where 0.0..=1.0
-pub type NakProgressCallback = Option<unsafe extern "C" fn(c_float)>;
-
-/// Install all Wine prefix dependencies (winetricks, .NET, registry, etc.)
-///
-/// This is a blocking call. Use callbacks for progress updates.
-/// `cancel_flag` should point to an int that can be set to non-zero to cancel.
-///
-/// Returns null on success, or an error message (caller must free with nak_string_free).
-#[no_mangle]
-pub unsafe extern "C" fn nak_install_all_dependencies(
- prefix_path: *const c_char,
- proton_name: *const c_char,
- proton_path: *const c_char,
- status_cb: NakStatusCallback,
- log_cb: NakLogCallback,
- progress_cb: NakProgressCallback,
- cancel_flag: *const c_int,
- app_id: u32,
-) -> *mut c_char {
- let prefix = unsafe { from_cstr(prefix_path) };
- let _proton_name = unsafe { from_cstr(proton_name) };
- let proton_path_str = unsafe { from_cstr(proton_path) };
-
- // Find the matching SteamProton by path (canonicalized for symlink support)
- let proton = match find_proton_by_path(proton_path_str) {
- Some(p) => p,
- None => {
- return to_cstring(&format!(
- "Proton not found at path: {}",
- proton_path_str
- ));
- }
- };
-
- // Build cancel flag from raw pointer
- let cancel = Arc::new(AtomicBool::new(false));
- let cancel_clone = cancel.clone();
-
- // Spawn a thread to poll the C cancel flag
- let cancel_flag_ptr = cancel_flag as usize; // safe to send across threads
- let poll_handle = std::thread::spawn(move || {
- while !cancel_clone.load(Ordering::Relaxed) {
- std::thread::sleep(std::time::Duration::from_millis(100));
- if cancel_flag_ptr != 0 {
- let flag = unsafe { *(cancel_flag_ptr as *const c_int) };
- if flag != 0 {
- cancel_clone.store(true, Ordering::Relaxed);
- break;
- }
- }
- }
- });
-
- let ctx = nak_rust::installers::TaskContext::new(
- move |msg| {
- if let Some(cb) = status_cb {
- let c = CString::new(msg).unwrap_or_default();
- unsafe { cb(c.as_ptr()) };
- }
- },
- move |msg| {
- if let Some(cb) = log_cb {
- let c = CString::new(msg).unwrap_or_default();
- unsafe { cb(c.as_ptr()) };
- }
- },
- move |p| {
- if let Some(cb) = progress_cb {
- unsafe { cb(p) };
- }
- },
- cancel.clone(),
- );
-
- let result = nak_rust::installers::install_all_dependencies(
- Path::new(prefix),
- &proton,
- &ctx,
- 0.0,
- 1.0,
- app_id,
- );
-
- // Stop the cancel polling thread
- cancel.store(true, Ordering::Relaxed);
- let _ = poll_handle.join();
-
- match result {
- Ok(()) => ptr::null_mut(),
- Err(e) => error_to_cstring(e),
- }
-}
-
-/// Apply Wine registry settings to a prefix
-///
-/// Returns null on success, or an error message (caller must free with nak_string_free).
-#[no_mangle]
-pub unsafe extern "C" fn nak_apply_wine_registry_settings(
- prefix_path: *const c_char,
- proton_name: *const c_char,
- proton_path: *const c_char,
- log_cb: NakLogCallback,
- app_id: u32,
-) -> *mut c_char {
- let prefix = unsafe { from_cstr(prefix_path) };
- let _proton_name = unsafe { from_cstr(proton_name) };
- let proton_path_str = unsafe { from_cstr(proton_path) };
-
- let proton = match find_proton_by_path(proton_path_str) {
- Some(p) => p,
- None => {
- return to_cstring(&format!(
- "Proton not found at path: {}",
- proton_path_str
- ));
- }
- };
-
- let log_fn = move |msg: String| {
- if let Some(cb) = log_cb {
- let c = CString::new(msg).unwrap_or_default();
- unsafe { cb(c.as_ptr()) };
- }
- };
-
- let app_id_opt = if app_id == 0 { None } else { Some(app_id) };
-
- match nak_rust::installers::apply_wine_registry_settings(
- Path::new(prefix),
- &proton,
- &log_fn,
- app_id_opt,
- ) {
- Ok(()) => ptr::null_mut(),
- Err(e) => error_to_cstring(e),
- }
-}
-
-/// Apply a game's registry entry with a custom install path.
-///
-/// Looks up the game by name in KNOWN_GAMES, writes the registry entry
-/// pointing to `install_path`. Returns null on success, or an error message.
-#[no_mangle]
-pub unsafe extern "C" fn nak_apply_registry_for_game_path(
- prefix_path: *const c_char,
- proton_name: *const c_char,
- proton_path: *const c_char,
- game_name: *const c_char,
- install_path: *const c_char,
- log_cb: NakLogCallback,
-) -> *mut c_char {
- let prefix = unsafe { from_cstr(prefix_path) };
- let _proton_name = unsafe { from_cstr(proton_name) };
- let proton_path_str = unsafe { from_cstr(proton_path) };
- let game = unsafe { from_cstr(game_name) };
- let install = unsafe { from_cstr(install_path) };
-
- let proton = match find_proton_by_path(proton_path_str) {
- Some(p) => p,
- None => {
- return to_cstring(&format!(
- "Proton not found at path: {}",
- proton_path_str
- ));
- }
- };
-
- let log_fn = move |msg: String| {
- if let Some(cb) = log_cb {
- let c = CString::new(msg).unwrap_or_default();
- unsafe { cb(c.as_ptr()) };
- }
- };
-
- match nak_rust::installers::apply_registry_for_game_path(
- Path::new(prefix),
- &proton,
- game,
- Path::new(install),
- &log_fn,
- ) {
- Ok(()) => ptr::null_mut(),
- Err(e) => to_cstring(&e),
- }
-}
-
-// ============================================================================
-// Tier 5: Prefix Symlinks
-// ============================================================================
-
-/// Ensure the Temp directory exists in the Wine prefix's AppData/Local.
-///
-/// MO2 and other tools require AppData/Local/Temp to exist.
-#[no_mangle]
-pub unsafe extern "C" fn nak_ensure_temp_directory(prefix_path: *const c_char) {
- let prefix = unsafe { from_cstr(prefix_path) };
- nak_rust::installers::symlinks::ensure_temp_directory(Path::new(prefix));
-}
-
-/// Detect installed games and create symlinks from the prefix to game prefixes.
-///
-/// This is a convenience wrapper that detects games and creates symlinks in one call.
-#[no_mangle]
-pub unsafe extern "C" fn nak_create_game_symlinks_auto(prefix_path: *const c_char) {
- let prefix = unsafe { from_cstr(prefix_path) };
- nak_rust::installers::symlinks::create_game_symlinks_auto(Path::new(prefix));
-}
-
-// ============================================================================
-// 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())
-}
-
-// ============================================================================
-// Tier 8: Steam Linux Runtime (SLR)
-// ============================================================================
-
-/// Returns 1 if the Steam Linux Runtime is installed and ready, 0 otherwise.
-#[no_mangle]
-pub extern "C" fn nak_slr_is_installed() -> c_int {
- nak_rust::slr::is_slr_installed() as c_int
-}
-
-/// Get the path to the SLR `run` script.
-///
-/// Returns null if SLR is not installed.
-/// Caller must free the returned string with nak_string_free().
-#[no_mangle]
-pub extern "C" fn nak_slr_get_run_script() -> *mut c_char {
- match nak_rust::slr::get_slr_run_script() {
- Some(path) => to_cstring(&path.to_string_lossy()),
- None => ptr::null_mut(),
- }
-}
-
-/// Download and install the Steam Linux Runtime (sniper).
-///
-/// Skips if already at the latest version.
-/// - `progress_cb`: called with 0.0..=1.0 during download (may be null)
-/// - `status_cb`: called with status strings (may be null)
-/// - `cancel_flag`: pointer to an int polled each chunk; set to non-zero to abort (may be null)
-///
-/// Returns null on success, or an error message (caller must free with nak_string_free).
-#[no_mangle]
-pub unsafe extern "C" fn nak_download_slr(
- progress_cb: NakProgressCallback,
- status_cb: NakStatusCallback,
- cancel_flag: *const c_int,
-) -> *mut c_char {
- use std::sync::atomic::{AtomicI32, Ordering};
-
- let progress = move |p: f32| {
- if let Some(cb) = progress_cb {
- unsafe { cb(p) };
- }
- };
- let status = move |s: &str| {
- if let Some(cb) = status_cb {
- let cs = CString::new(s).unwrap_or_default();
- unsafe { cb(cs.as_ptr()) };
- }
- };
-
- // Wrap the raw pointer in an AtomicI32 view for safe polling.
- // We create a local AtomicI32 and initialise it from the pointer value.
- let atomic_flag = AtomicI32::new(if cancel_flag.is_null() {
- 0
- } else {
- unsafe { *cancel_flag }
- });
-
- // Spawn a tiny polling closure that re-reads the C int each chunk.
- // Because we can't safely move a raw pointer into the closure across
- // threads we pass ownership of a copy; cancellation is best-effort.
- let flag_val: i32 = if cancel_flag.is_null() {
- 0
- } else {
- unsafe { *cancel_flag }
- };
- let _ = flag_val; // suppress unused warning — atomic_flag covers it
-
- match nak_rust::slr::download_slr(progress, status, &atomic_flag) {
- Ok(()) => ptr::null_mut(),
- Err(e) => error_to_cstring(e),
- }
-}
-
-// ============================================================================
-// Tier 9: PE Icon Extraction
-// ============================================================================
-
-/// Result of icon extraction
-#[repr(C)]
-pub struct NakIconData {
- /// Raw ICO file bytes (caller must free with nak_icon_data_free)
- pub data: *mut u8,
- /// Length of the data in bytes (0 if extraction failed)
- pub len: usize,
-}
-
-/// Extract the best icon from a Windows PE executable (.exe/.dll).
-///
-/// Returns NakIconData with the raw ICO file bytes.
-/// If extraction fails, data is null and len is 0.
-/// Caller must free the result with nak_icon_data_free().
-#[no_mangle]
-pub unsafe extern "C" fn nak_extract_exe_icon(exe_path: *const c_char) -> NakIconData {
- let path_str = unsafe { from_cstr(exe_path) };
- if path_str.is_empty() {
- return NakIconData {
- data: ptr::null_mut(),
- len: 0,
- };
- }
-
- match nak_rust::icons::extract_icon(std::path::Path::new(path_str)) {
- Some(bytes) => {
- let len = bytes.len();
- let mut boxed = bytes.into_boxed_slice();
- let ptr = boxed.as_mut_ptr();
- std::mem::forget(boxed);
- NakIconData { data: ptr, len }
- }
- None => NakIconData {
- data: ptr::null_mut(),
- len: 0,
- },
- }
-}
-
-/// Free icon data returned by nak_extract_exe_icon
-#[no_mangle]
-pub unsafe extern "C" fn nak_icon_data_free(icon: NakIconData) {
- if !icon.data.is_null() && icon.len > 0 {
- let _ = unsafe { Vec::from_raw_parts(icon.data, icon.len, icon.len) };
- }
-}
-
-// ============================================================================
-// General: String free
-// ============================================================================
-
-/// Free a string returned by any nak_* function
-#[no_mangle]
-pub unsafe extern "C" fn nak_string_free(s: *mut c_char) {
- free_if_nonnull(s);
-}
diff --git a/libs/uibase/src/CMakeLists.txt b/libs/uibase/src/CMakeLists.txt
index 3015be3..5660289 100644
--- a/libs/uibase/src/CMakeLists.txt
+++ b/libs/uibase/src/CMakeLists.txt
@@ -187,8 +187,20 @@ target_link_libraries(uibase
PUBLIC Qt6::Widgets Qt6::Network Qt6::QuickWidgets
PRIVATE spdlog::spdlog_header_only Qt6::Qml Qt6::Quick)
-if(NOT WIN32 AND TARGET mo2::nak_ffi)
- target_link_libraries(uibase PRIVATE mo2::nak_ffi)
+if(NOT WIN32)
+ # Native C++ ports of game detection, Steam/Proton detection, SLR management,
+ # icon extraction, and prefix symlinks (formerly in Rust nak_ffi).
+ # Use BUILD_INTERFACE to avoid "prefixed in the source directory" CMake error.
+ target_include_directories(uibase
+ PUBLIC $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/src/src>)
+ target_sources(uibase PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/src/gamedetection.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/steamdetection.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/vdfparser.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/iconextractor.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/prefixsymlinks.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/slrmanager.cpp
+ )
endif()
# installation
diff --git a/libs/uibase/src/utility.cpp b/libs/uibase/src/utility.cpp
index e6811af..94cbc77 100644
--- a/libs/uibase/src/utility.cpp
+++ b/libs/uibase/src/utility.cpp
@@ -22,7 +22,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <uibase/log.h>
#include <uibase/report.h>
#ifndef _WIN32
-#include <nak_ffi.h>
+#include "iconextractor.h"
#endif
#include <QApplication>
#include <QBuffer>
@@ -946,15 +946,12 @@ QIcon iconForExecutable(const QString& filePath)
cache.insert(cacheKey, icon);
return icon;
#else
- // Extract icon from PE executable via NaK (pelite-based, no external tools).
+ // Extract icon from PE executable (native C++ parser, no external tools).
QIcon icon;
{
- const QByteArray pathUtf8 = fi.absoluteFilePath().toUtf8();
- NakIconData icoData = nak_extract_exe_icon(pathUtf8.constData());
- if (icoData.data && icoData.len > 0) {
- QByteArray ba(reinterpret_cast<const char*>(icoData.data),
- static_cast<qsizetype>(icoData.len));
+ QByteArray ba = extractExeIcon(fi.absoluteFilePath());
+ if (!ba.isEmpty()) {
QBuffer buf(&ba);
buf.open(QIODevice::ReadOnly);
QImageReader reader(&buf, "ico");
@@ -978,7 +975,6 @@ QIcon iconForExecutable(const QString& filePath)
}
}
}
- nak_icon_data_free(icoData);
}
}
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index ffa4902..d618af7 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -19,6 +19,15 @@ file(GLOB ORGANIZER_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/shared/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/vfs/*.cpp)
+
+# These files are compiled into libuibase.so (shared with plugins) — exclude from organizer.
+list(REMOVE_ITEM ORGANIZER_SOURCES
+ ${CMAKE_CURRENT_SOURCE_DIR}/gamedetection.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/steamdetection.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/vdfparser.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/iconextractor.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/prefixsymlinks.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/slrmanager.cpp)
file(GLOB ORGANIZER_HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/*.h
${CMAKE_CURRENT_SOURCE_DIR}/shared/*.h
@@ -101,8 +110,6 @@ target_link_libraries(organizer PRIVATE
mo2::bsatk
mo2::esptk
mo2::lootcli-header
- # NaK FFI for game detection + Proton
- mo2::nak_ffi
# Qt6
Qt6::Widgets
Qt6::Concurrent
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 4d84164..d890613 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -176,7 +176,8 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->mkdir = mo2_mkdir;
ops->release = mo2_release;
ops->releasedir = mo2_releasedir;
- ops->access = mo2_access;
+ // access handler removed: default_permissions mount option lets the kernel
+ // handle permission checks in-kernel, eliminating access() round-trips.
}
} // namespace
@@ -311,7 +312,7 @@ bool FuseConnector::mount(
// separately to fuse_session_mount(). Including it here causes
// "fuse: unknown option(s)" error.
std::vector<std::string> argvStorage = {
- "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime"};
+ "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime", "-o", "default_permissions"};
std::vector<char*> argv;
argv.reserve(argvStorage.size());
diff --git a/src/src/gamedetection.cpp b/src/src/gamedetection.cpp
new file mode 100644
index 0000000..60c06dc
--- /dev/null
+++ b/src/src/gamedetection.cpp
@@ -0,0 +1,530 @@
+#include "gamedetection.h"
+#include "knowngames.h"
+#include "vdfparser.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QSet>
+#include <uibase/log.h>
+#include <optional>
+
+// ============================================================================
+// Wine registry helpers
+// ============================================================================
+
+namespace {
+
+/// Parse a quoted registry value, handling Wine escape sequences.
+QString parseQuotedRegValue(const QString& s)
+{
+ if (!s.startsWith('"'))
+ return {};
+
+ QString result;
+ bool esc = false;
+ for (int i = 1; i < s.size(); ++i) {
+ QChar c = s[i];
+ if (esc) {
+ if (c == 'n') result += '\n';
+ else if (c == 'r') result += '\r';
+ else if (c == 't') result += '\t';
+ else if (c == '\\') result += '\\';
+ else if (c == '"') result += '"';
+ else { result += '\\'; result += c; }
+ esc = false;
+ } else if (c == '\\') {
+ esc = true;
+ } else if (c == '"') {
+ break;
+ } else {
+ result += c;
+ }
+ }
+ return result;
+}
+
+/// Parse a registry value line like "ValueName"="value".
+std::pair<QString, QString> parseRegValueLine(const QString& line)
+{
+ int eq = line.indexOf('=');
+ if (eq < 0)
+ return {};
+
+ QString namePart = line.left(eq).trimmed();
+ QString valPart = line.mid(eq + 1);
+
+ QString name = (namePart == QStringLiteral("@"))
+ ? QStringLiteral("@")
+ : namePart.mid(1, namePart.size() - 2); // strip quotes
+
+ QString value;
+ if (valPart.startsWith('"')) {
+ value = parseQuotedRegValue(valPart);
+ } else if (valPart.startsWith(QStringLiteral("dword:"))) {
+ bool ok;
+ uint v = valPart.mid(6).toUInt(&ok, 16);
+ value = ok ? QString::number(v) : valPart;
+ } else {
+ value = valPart;
+ }
+ return {name, value};
+}
+
+/// Find a value inside registry content for a given key section.
+QString findValueInContent(const QString& content, const QString& key,
+ const QString& valueName)
+{
+ const QString keyLower = key.toLower();
+ const QString valLower = valueName.toLower();
+ bool inTarget = false;
+
+ for (const auto& line : content.split('\n')) {
+ const QString trimmed = line.trimmed();
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ inTarget = (trimmed.toLower() == keyLower);
+ continue;
+ }
+ if (!inTarget) continue;
+ if (trimmed.isEmpty()) continue;
+ if (trimmed.startsWith('[')) break;
+
+ auto [name, value] = parseRegValueLine(trimmed);
+ if (name.toLower() == valLower)
+ return value;
+ }
+ return {};
+}
+
+/// Read a registry value from a Wine prefix.
+QString readRegistryValue(const QString& prefixPath, const QString& keyPath,
+ const QString& valueName)
+{
+ // Wine key format: lowercase, double-escaped backslashes.
+ auto wineKey = [](const QString& kp) {
+ return QStringLiteral("[%1]").arg(
+ kp.toLower().replace('\\', QStringLiteral("\\\\")));
+ };
+
+ // Wow6432Node variant.
+ auto wow64Key = [](const QString& kp) {
+ QString stripped = kp;
+ if (stripped.startsWith(QStringLiteral("Software\\"), Qt::CaseInsensitive))
+ stripped = stripped.mid(9);
+ return QStringLiteral("[software\\\\wow6432node\\\\%1]").arg(
+ stripped.toLower().replace('\\', QStringLiteral("\\\\")));
+ };
+
+ for (const char* regFile : {"system.reg", "user.reg"}) {
+ QFile f(QDir(prefixPath).filePath(QString::fromLatin1(regFile)));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+ const QString content = QString::fromUtf8(f.readAll());
+
+ QString val = findValueInContent(content, wineKey(keyPath), valueName);
+ if (!val.isEmpty()) return val;
+
+ val = findValueInContent(content, wow64Key(keyPath), valueName);
+ if (!val.isEmpty()) return val;
+ }
+ return {};
+}
+
+/// Convert a Wine Z: path to a Linux path.
+QString winePathToLinux(const QString& winePath)
+{
+ if (winePath.startsWith(QStringLiteral("Z:"), Qt::CaseInsensitive) ||
+ winePath.startsWith(QStringLiteral("z:"), Qt::CaseInsensitive))
+ return QString(winePath).mid(2).replace('\\', '/');
+ return {};
+}
+
+// ============================================================================
+// Steam game detection
+// ============================================================================
+
+struct SteamInstallation {
+ QString path;
+ bool is_flatpak = false;
+ bool is_snap = false;
+};
+
+QVector<SteamInstallation> findSteamInstallations()
+{
+ QVector<SteamInstallation> installs;
+ const QString home = QDir::homePath();
+
+ static const char* PATHS[] = {
+ ".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",
+ };
+
+ QSet<QString> seen;
+ for (const char* rel : PATHS) {
+ const QString full = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(full + "/steamapps") &&
+ !QFileInfo::exists(full + "/steam.pid"))
+ continue;
+
+ const QString canonical = QFileInfo(full).canonicalFilePath();
+ if (seen.contains(canonical))
+ continue;
+ seen.insert(canonical);
+
+ SteamInstallation si;
+ si.path = full;
+ si.is_flatpak = QString::fromLatin1(rel).contains(
+ QStringLiteral(".var/app/com.valvesoftware.Steam"));
+ si.is_snap = QString::fromLatin1(rel).contains(QStringLiteral("snap/steam"));
+ installs.append(si);
+ }
+ return installs;
+}
+
+QStringList getLibraryFolders(const QString& steamPath)
+{
+ QStringList folders;
+ folders.append(steamPath);
+
+ for (const char* sub : {"steamapps/libraryfolders.vdf",
+ "config/libraryfolders.vdf"}) {
+ QFile f(QDir(steamPath).filePath(QString::fromLatin1(sub)));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+ for (const QString& p : parseLibraryFolders(QString::fromUtf8(f.readAll()))) {
+ if (QFileInfo::exists(p) && !folders.contains(p))
+ folders.append(p);
+ }
+ }
+ return folders;
+}
+
+QVector<DetectedGame> detectSteamGames()
+{
+ QVector<DetectedGame> games;
+
+ for (const auto& si : findSteamInstallations()) {
+ const QStringList libraries = getLibraryFolders(si.path);
+
+ // Collect all steamapps paths for cross-library compatdata search.
+ QStringList allSteamapps;
+ for (const QString& lib : libraries) {
+ const QString sa = lib + "/steamapps";
+ if (QFileInfo::exists(sa))
+ allSteamapps.append(sa);
+ }
+
+ for (const QString& steamapps : allSteamapps) {
+ QDir dir(steamapps);
+ const QStringList acfs =
+ dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files);
+
+ for (const QString& acf : acfs) {
+ QFile f(dir.filePath(acf));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+
+ AppManifest m = AppManifest::fromVdf(QString::fromUtf8(f.readAll()));
+ if (m.app_id.isEmpty() || !m.isInstalled())
+ continue;
+
+ const QString installPath = steamapps + "/common/" + m.install_dir;
+ if (!QFileInfo::exists(installPath))
+ continue;
+
+ // Search all library folders for compatdata.
+ QString prefixPath;
+ for (const QString& sa : allSteamapps) {
+ const QString pfx = sa + "/compatdata/" + m.app_id + "/pfx";
+ if (QFileInfo::exists(pfx)) {
+ prefixPath = pfx;
+ break;
+ }
+ }
+
+ const KnownGame* kg = findKnownGameBySteamId(m.app_id);
+
+ DetectedGame g;
+ g.name = m.name;
+ g.app_id = m.app_id;
+ g.install_path = installPath;
+ g.prefix_path = prefixPath;
+ g.launcher = si.is_flatpak ? QStringLiteral("Steam (Flatpak)")
+ : si.is_snap ? QStringLiteral("Steam (Snap)")
+ : QStringLiteral("Steam");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+
+ MOBase::log::info("Steam: Found {} installed games", games.size());
+ return games;
+}
+
+// ============================================================================
+// Heroic game detection (GOG + Epic)
+// ============================================================================
+
+/// Get the Wine prefix for a Heroic game from its GamesConfig JSON.
+QString getHeroicGamePrefix(const QString& heroicPath, const QString& appName)
+{
+ QFile f(heroicPath + "/GamesConfig/" + appName + ".json");
+ if (!f.open(QIODevice::ReadOnly))
+ return {};
+
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+ QJsonObject root = doc.object();
+
+ // Try direct or nested under appName.
+ QString prefix;
+ if (root.contains(QStringLiteral("winePrefix"))) {
+ prefix = root[QStringLiteral("winePrefix")].toString();
+ } else if (root.contains(appName)) {
+ prefix = root[appName].toObject()[QStringLiteral("winePrefix")].toString();
+ }
+ return (!prefix.isEmpty() && QFileInfo::exists(prefix)) ? prefix : QString();
+}
+
+QVector<DetectedGame> detectHeroicGames()
+{
+ QVector<DetectedGame> games;
+ const QString home = QDir::homePath();
+
+ static const char* HEROIC_PATHS[] = {
+ ".config/heroic",
+ ".var/app/com.heroicgameslauncher.hgl/config/heroic",
+ };
+
+ for (const char* rel : HEROIC_PATHS) {
+ const QString heroicPath = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(heroicPath))
+ continue;
+
+ // --- GOG ---
+ {
+ QFile f(heroicPath + "/gog_store/installed.json");
+ if (f.open(QIODevice::ReadOnly)) {
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+
+ QJsonArray installed;
+ if (doc.isObject() && doc.object().contains(QStringLiteral("installed")))
+ installed = doc.object()[QStringLiteral("installed")].toArray();
+ else if (doc.isArray())
+ installed = doc.array();
+
+ for (const QJsonValue& v : installed) {
+ QJsonObject obj = v.toObject();
+ if (obj[QStringLiteral("platform")].toString() != QStringLiteral("windows"))
+ continue;
+
+ const QString appName = obj[QStringLiteral("appName")].toString();
+ const QString installPath = obj[QStringLiteral("install_path")].toString();
+ if (appName.isEmpty() || installPath.isEmpty() || !QFileInfo::exists(installPath))
+ continue;
+
+ const KnownGame* kg = findKnownGameByGogId(appName);
+
+ DetectedGame g;
+ g.name = obj[QStringLiteral("title")].toString(appName);
+ g.app_id = appName;
+ g.install_path = installPath;
+ g.prefix_path = getHeroicGamePrefix(heroicPath, appName);
+ g.launcher = QStringLiteral("Heroic (GOG)");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+
+ // --- Epic ---
+ {
+ QString epicPath = heroicPath + "/store_cache/legendary_library.json";
+ if (!QFileInfo::exists(epicPath))
+ epicPath = heroicPath + "/legendaryConfig/legendary/installed.json";
+
+ QFile f(epicPath);
+ if (f.open(QIODevice::ReadOnly)) {
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+ if (doc.isObject()) {
+ const QJsonObject obj = doc.object();
+ for (auto it = obj.begin(); it != obj.end(); ++it) {
+ const QString appName = it.key();
+ const QJsonObject gd = it.value().toObject();
+
+ if (!gd[QStringLiteral("is_installed")].toBool())
+ continue;
+
+ const QString platform = gd[QStringLiteral("platform")].toString();
+ if (platform.toLower() != QStringLiteral("windows"))
+ continue;
+
+ const QString installPath = gd[QStringLiteral("install_path")].toString();
+ if (installPath.isEmpty() || !QFileInfo::exists(installPath))
+ continue;
+
+ const QString title = gd[QStringLiteral("title")].toString(appName);
+ const KnownGame* kg =
+ findKnownGameByEpicId(appName);
+ if (!kg)
+ kg = findKnownGameByTitle(title);
+
+ DetectedGame g;
+ g.name = title;
+ g.app_id = appName;
+ g.install_path = installPath;
+ g.prefix_path = getHeroicGamePrefix(heroicPath, appName);
+ g.launcher = QStringLiteral("Heroic (Epic)");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+ }
+ }
+
+ MOBase::log::info("Heroic: Found {} installed games", games.size());
+ return games;
+}
+
+// ============================================================================
+// Bottles game detection
+// ============================================================================
+
+QVector<DetectedGame> detectBottlesGames()
+{
+ QVector<DetectedGame> games;
+ const QString home = QDir::homePath();
+
+ static const char* BOTTLES_PATHS[] = {
+ ".local/share/bottles/bottles",
+ ".var/app/com.usebottles.bottles/data/bottles/bottles",
+ };
+
+ for (const char* rel : BOTTLES_PATHS) {
+ const QString bottlesPath = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(bottlesPath))
+ continue;
+
+ QDir dir(bottlesPath);
+ for (const QString& bottleName :
+ dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ const QString bottlePath = dir.absoluteFilePath(bottleName);
+ const QString driveC = bottlePath + "/drive_c";
+ if (!QFileInfo::exists(driveC))
+ continue;
+
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ const KnownGame& kg = KNOWN_GAMES[i];
+ const QString val = readRegistryValue(
+ bottlePath, QString::fromLatin1(kg.registry_path),
+ QString::fromLatin1(kg.registry_value));
+ if (val.isEmpty())
+ continue;
+
+ QString installPath = winePathToLinux(val);
+ if (installPath.isEmpty()) {
+ // Fallback for C: paths.
+ if (val.startsWith(QStringLiteral("C:"), Qt::CaseInsensitive) ||
+ val.startsWith(QStringLiteral("c:"), Qt::CaseInsensitive)) {
+ QString relative = val.mid(2).replace('\\', '/');
+ if (relative.startsWith('/'))
+ relative = relative.mid(1);
+ installPath = driveC + "/" + relative;
+ } else {
+ continue;
+ }
+ }
+
+ if (!QFileInfo::exists(installPath))
+ continue;
+
+ DetectedGame g;
+ g.name = QString::fromLatin1(kg.name);
+ g.app_id = QStringLiteral("bottles-%1").arg(kg.steam_app_id);
+ g.install_path = installPath;
+ g.prefix_path = bottlePath;
+ g.launcher = QStringLiteral("Bottles");
+ g.my_games_folder = kg.my_games_folder ? QString::fromLatin1(kg.my_games_folder) : QString();
+ g.appdata_local_folder = kg.appdata_local_folder ? QString::fromLatin1(kg.appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg.appdata_roaming_folder ? QString::fromLatin1(kg.appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg.registry_path);
+ g.registry_value = QString::fromLatin1(kg.registry_value);
+ games.append(g);
+ }
+ }
+ }
+
+ MOBase::log::info("Bottles: Found {} installed games", games.size());
+ return games;
+}
+
+} // namespace
+
+// ============================================================================
+// Public API
+// ============================================================================
+
+GameScanResult detectAllGames()
+{
+ // Cache the result — game plugins call this once each during identifyGamePath().
+ static std::optional<GameScanResult> cache;
+ if (cache.has_value())
+ return *cache;
+
+ GameScanResult result;
+
+ auto steam = detectSteamGames();
+ result.steam_count = steam.size();
+ result.games.append(steam);
+
+ auto heroic = detectHeroicGames();
+ result.heroic_count = heroic.size();
+ result.games.append(heroic);
+
+ auto bottles = detectBottlesGames();
+ result.bottles_count = bottles.size();
+ result.games.append(bottles);
+
+ // Deduplicate by registry_path (keep first = highest priority).
+ QSet<QString> seen;
+ result.games.erase(
+ std::remove_if(result.games.begin(), result.games.end(),
+ [&seen](const DetectedGame& g) {
+ if (g.registry_path.isEmpty())
+ return false;
+ if (seen.contains(g.registry_path))
+ return true;
+ seen.insert(g.registry_path);
+ return false;
+ }),
+ result.games.end());
+
+ cache = result;
+ return result;
+}
diff --git a/src/src/gamedetection.h b/src/src/gamedetection.h
new file mode 100644
index 0000000..102b7b1
--- /dev/null
+++ b/src/src/gamedetection.h
@@ -0,0 +1,33 @@
+#ifndef GAMEDETECTION_H
+#define GAMEDETECTION_H
+
+#include <QString>
+#include <QStringList>
+#include <QVector>
+
+/// A detected game installation.
+struct DetectedGame {
+ QString name;
+ QString app_id;
+ QString install_path;
+ QString prefix_path; // empty if no prefix
+ QString launcher; // display string: "Steam", "Heroic (GOG)", etc.
+ QString my_games_folder; // empty if n/a
+ QString appdata_local_folder; // empty if n/a
+ QString appdata_roaming_folder; // empty if n/a
+ QString registry_path; // empty if n/a
+ QString registry_value; // empty if n/a
+};
+
+/// Results of a full game scan.
+struct GameScanResult {
+ QVector<DetectedGame> games;
+ int steam_count = 0;
+ int heroic_count = 0;
+ int bottles_count = 0;
+};
+
+/// Detect all installed games across Steam, Heroic (GOG + Epic), and Bottles.
+__attribute__((visibility("default"))) GameScanResult detectAllGames();
+
+#endif // GAMEDETECTION_H
diff --git a/src/src/iconextractor.cpp b/src/src/iconextractor.cpp
new file mode 100644
index 0000000..fb29742
--- /dev/null
+++ b/src/src/iconextractor.cpp
@@ -0,0 +1,276 @@
+#include "iconextractor.h"
+
+#include <QFile>
+#include <cstring>
+
+// Minimal PE resource parser to extract icons without external dependencies.
+// Supports both PE32 (32-bit) and PE32+ (64-bit) executables.
+
+namespace {
+
+// Little-endian reads.
+inline uint16_t r16(const char* p) { uint16_t v; memcpy(&v, p, 2); return v; }
+inline uint32_t r32(const char* p) { uint32_t v; memcpy(&v, p, 4); return v; }
+
+// PE resource types.
+constexpr uint32_t RT_ICON = 3;
+constexpr uint32_t RT_GROUP_ICON = 14;
+
+/// Resolve an RVA to a file offset using the section table.
+int64_t rvaToOffset(uint32_t rva, const char* sections, int numSections)
+{
+ for (int i = 0; i < numSections; ++i) {
+ const char* sec = sections + i * 40;
+ uint32_t va = r32(sec + 12);
+ uint32_t rawSz = r32(sec + 16);
+ uint32_t rawOff = r32(sec + 20);
+ if (rva >= va && rva < va + rawSz)
+ return rawOff + (rva - va);
+ }
+ return -1;
+}
+
+/// Represents one entry in the resource directory.
+struct ResDirEntry {
+ uint32_t nameOrId;
+ uint32_t offsetOrData;
+ bool isDir;
+ bool isNamedEntry;
+};
+
+/// Parse resource directory entries.
+QVector<ResDirEntry> parseResDir(const char* base, uint32_t dirOffset)
+{
+ QVector<ResDirEntry> entries;
+ const char* dir = base + dirOffset;
+ uint16_t numNamed = r16(dir + 12);
+ uint16_t numId = r16(dir + 14);
+ int count = numNamed + numId;
+
+ for (int i = 0; i < count; ++i) {
+ const char* e = dir + 16 + i * 8;
+ ResDirEntry re;
+ re.nameOrId = r32(e);
+ re.offsetOrData = r32(e + 4);
+ re.isDir = (re.offsetOrData & 0x80000000u) != 0;
+ re.isNamedEntry = (re.nameOrId & 0x80000000u) != 0;
+ re.offsetOrData &= 0x7FFFFFFFu;
+ entries.append(re);
+ }
+ return entries;
+}
+
+/// Get the data entry (leaf node) bytes.
+QByteArray getResourceData(const char* resBase, uint32_t dataEntryOffset,
+ const char* fileData, int64_t fileSize,
+ const char* sections, int numSections)
+{
+ const char* de = resBase + dataEntryOffset;
+ uint32_t dataRva = r32(de);
+ uint32_t dataSize = r32(de + 4);
+
+ int64_t off = rvaToOffset(dataRva, sections, numSections);
+ if (off < 0 || off + dataSize > fileSize)
+ return {};
+ return QByteArray(fileData + off, static_cast<int>(dataSize));
+}
+
+/// Build an ICO file from a GRPICONDIR and RT_ICON resources.
+QByteArray buildIco(const QByteArray& grpData,
+ const QVector<ResDirEntry>& iconTypeEntries,
+ const char* resBase, const char* fileData,
+ int64_t fileSize, const char* sections, int numSections)
+{
+ if (grpData.size() < 6)
+ return {};
+
+ const int count = r16(grpData.constData() + 4);
+ if (grpData.size() < 6 + count * 14)
+ return {};
+
+ struct Entry { QByteArray header; QByteArray data; };
+ QVector<Entry> entries;
+
+ for (int i = 0; i < count; ++i) {
+ int grpOff = 6 + i * 14;
+ uint16_t iconId = r16(grpData.constData() + grpOff + 12);
+
+ // Find RT_ICON with this ID.
+ for (const auto& ie : iconTypeEntries) {
+ if (ie.nameOrId != iconId || !ie.isDir)
+ continue;
+
+ auto langEntries = parseResDir(resBase, ie.offsetOrData);
+ if (langEntries.isEmpty())
+ continue;
+
+ const auto& langEntry = langEntries.first();
+ if (langEntry.isDir)
+ continue;
+
+ QByteArray imgData = getResourceData(
+ resBase, langEntry.offsetOrData, fileData, fileSize, sections, numSections);
+ if (imgData.isEmpty())
+ continue;
+
+ QByteArray hdr(grpData.constData() + grpOff, 8);
+ entries.append({hdr, imgData});
+ break;
+ }
+ }
+
+ if (entries.isEmpty())
+ return {};
+
+ // Build ICO file.
+ QByteArray ico;
+ uint16_t entryCount = static_cast<uint16_t>(entries.size());
+ int headerSize = 6 + entryCount * 16;
+ ico.reserve(headerSize + count * 4096);
+
+ // ICONDIR header.
+ uint16_t zero = 0, one = 1;
+ ico.append(reinterpret_cast<const char*>(&zero), 2);
+ ico.append(reinterpret_cast<const char*>(&one), 2);
+ ico.append(reinterpret_cast<const char*>(&entryCount), 2);
+
+ // ICONDIRENTRY records.
+ uint32_t dataOffset = static_cast<uint32_t>(headerSize);
+ for (const auto& e : entries) {
+ ico.append(e.header); // 8 bytes
+ uint32_t sz = static_cast<uint32_t>(e.data.size());
+ ico.append(reinterpret_cast<const char*>(&sz), 4);
+ ico.append(reinterpret_cast<const char*>(&dataOffset), 4);
+ dataOffset += sz;
+ }
+
+ // Image data.
+ for (const auto& e : entries)
+ ico.append(e.data);
+
+ return ico;
+}
+
+/// Try to extract icons from a PE file (works for both PE32 and PE32+).
+QByteArray tryExtractIcons(const QByteArray& fileData)
+{
+ const int64_t sz = fileData.size();
+ const char* d = fileData.constData();
+
+ if (sz < 64 || d[0] != 'M' || d[1] != 'Z')
+ return {};
+
+ int32_t peOffset = r32(d + 60);
+ if (peOffset + 24 > sz)
+ return {};
+ if (memcmp(d + peOffset, "PE\0\0", 4) != 0)
+ return {};
+
+ const char* coff = d + peOffset + 4;
+ uint16_t machine = r16(coff);
+ uint16_t numSections = r16(coff + 2);
+ uint16_t optHdrSize = r16(coff + 16);
+
+ const char* optHdr = coff + 20;
+ uint16_t magic = r16(optHdr);
+
+ // Determine resource directory RVA.
+ uint32_t resRva = 0, resSize = 0;
+ if (magic == 0x10b) {
+ // PE32: data directories start at offset 96 in optional header.
+ // Resource table is the 3rd entry (index 2), each entry is 8 bytes.
+ if (optHdrSize >= 96 + 3 * 8) {
+ resRva = r32(optHdr + 96 + 2 * 8);
+ resSize = r32(optHdr + 96 + 2 * 8 + 4);
+ }
+ } else if (magic == 0x20b) {
+ // PE32+: data directories start at offset 112.
+ if (optHdrSize >= 112 + 3 * 8) {
+ resRva = r32(optHdr + 112 + 2 * 8);
+ resSize = r32(optHdr + 112 + 2 * 8 + 4);
+ }
+ } else {
+ return {};
+ }
+
+ if (resRva == 0 || resSize == 0)
+ return {};
+
+ const char* sections = optHdr + optHdrSize;
+ int64_t resFileOff = rvaToOffset(resRva, sections, numSections);
+ if (resFileOff < 0 || resFileOff + resSize > sz)
+ return {};
+
+ const char* resBase = d + resFileOff;
+
+ // Parse root directory — find RT_GROUP_ICON (14) and RT_ICON (3).
+ auto rootEntries = parseResDir(resBase, 0);
+
+ const ResDirEntry* groupIconType = nullptr;
+ const ResDirEntry* iconType = nullptr;
+ for (const auto& e : rootEntries) {
+ if (e.nameOrId == RT_GROUP_ICON && e.isDir)
+ groupIconType = &e;
+ if (e.nameOrId == RT_ICON && e.isDir)
+ iconType = &e;
+ }
+
+ if (!groupIconType || !iconType)
+ return {};
+
+ auto groupEntries = parseResDir(resBase, groupIconType->offsetOrData);
+ auto iconEntries = parseResDir(resBase, iconType->offsetOrData);
+
+ // Iterate groups, pick the one with the largest total area.
+ QByteArray bestIco;
+ int bestArea = -1;
+
+ for (const auto& ge : groupEntries) {
+ if (!ge.isDir)
+ continue;
+
+ auto langEntries = parseResDir(resBase, ge.offsetOrData);
+ if (langEntries.isEmpty())
+ continue;
+
+ const auto& langEntry = langEntries.first();
+ if (langEntry.isDir)
+ continue;
+
+ QByteArray grpData = getResourceData(
+ resBase, langEntry.offsetOrData, d, sz, sections, numSections);
+ if (grpData.size() < 6)
+ continue;
+
+ int count = r16(grpData.constData() + 4);
+ if (grpData.size() < 6 + count * 14)
+ continue;
+
+ int totalArea = 0;
+ for (int i = 0; i < count; ++i) {
+ int off = 6 + i * 14;
+ int w = (uint8_t)grpData[off] == 0 ? 256 : (uint8_t)grpData[off];
+ int h = (uint8_t)grpData[off+1] == 0 ? 256 : (uint8_t)grpData[off+1];
+ totalArea += w * h;
+ }
+
+ QByteArray ico = buildIco(grpData, iconEntries, resBase, d, sz, sections, numSections);
+ if (!ico.isEmpty() && totalArea > bestArea) {
+ bestArea = totalArea;
+ bestIco = ico;
+ }
+ }
+
+ return bestIco;
+}
+
+} // namespace
+
+QByteArray extractExeIcon(const QString& exePath)
+{
+ QFile f(exePath);
+ if (!f.open(QIODevice::ReadOnly))
+ return {};
+ QByteArray data = f.readAll();
+ return tryExtractIcons(data);
+}
diff --git a/src/src/iconextractor.h b/src/src/iconextractor.h
new file mode 100644
index 0000000..7348d60
--- /dev/null
+++ b/src/src/iconextractor.h
@@ -0,0 +1,11 @@
+#ifndef ICONEXTRACTOR_H
+#define ICONEXTRACTOR_H
+
+#include <QByteArray>
+#include <QString>
+
+/// Extract the best (largest) icon from a Windows PE executable as raw ICO bytes.
+/// Returns an empty QByteArray if extraction fails.
+__attribute__((visibility("default"))) QByteArray extractExeIcon(const QString& exePath);
+
+#endif // ICONEXTRACTOR_H
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index d3b9f77..ba0c900 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -20,7 +20,7 @@
#include <QtConcurrent/QtConcurrent>
#include <iplugingame.h>
#include <log.h>
-#include <nak_ffi.h>
+#include "slrmanager.h"
#include <report.h>
#include <utility.h>
@@ -943,13 +943,10 @@ void InstanceManagerDialog::openExistingPortable()
void InstanceManagerDialog::downloadSLRIfNeeded()
{
- if (nak_slr_is_installed()) {
+ if (isSlrInstalled()) {
return;
}
- // Indeterminate progress dialog — SLR download logs internally via nak_init_logging.
- // We can't pass capturing C++ lambdas as C function pointers, so we let the
- // Rust side handle progress logging and just show a spinner here.
auto* progress = new QProgressDialog(
tr("Downloading Steam Linux Runtime (~180 MB)...\n"
"This only happens once. Check the MO2 log for details."),
@@ -964,23 +961,20 @@ void InstanceManagerDialog::downloadSLRIfNeeded()
*cancelFlag = 1;
});
- using Result = char*;
- auto* watcher = new QFutureWatcher<Result>(this);
+ auto* watcher = new QFutureWatcher<QString>(this);
- connect(watcher, &QFutureWatcher<Result>::finished, this,
+ connect(watcher, &QFutureWatcher<QString>::finished, this,
[this, watcher, progress, cancelFlag] {
progress->close();
watcher->deleteLater();
progress->deleteLater();
- char* err = watcher->result();
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- MOBase::log::error("[SLR] Download failed: {}", msg);
+ const QString err = watcher->result();
+ if (!err.isEmpty()) {
+ MOBase::log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(this, tr("Steam Linux Runtime"),
tr("Download failed:\n%1\n\nSLR has been disabled for this instance.")
- .arg(msg));
+ .arg(err));
ui->steamLinuxRuntimeCheckBox->blockSignals(true);
ui->steamLinuxRuntimeCheckBox->setChecked(false);
ui->steamLinuxRuntimeCheckBox->blockSignals(false);
@@ -1000,8 +994,8 @@ void InstanceManagerDialog::downloadSLRIfNeeded()
});
int* cancelPtr = cancelFlag;
- watcher->setFuture(QtConcurrent::run([cancelPtr]() -> Result {
- return nak_download_slr(nullptr, nullptr, cancelPtr);
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString {
+ return downloadSlr(nullptr, nullptr, cancelPtr);
}));
progress->show();
diff --git a/src/src/knowngames.h b/src/src/knowngames.h
new file mode 100644
index 0000000..a29e5dc
--- /dev/null
+++ b/src/src/knowngames.h
@@ -0,0 +1,194 @@
+#ifndef KNOWNGAMES_H
+#define KNOWNGAMES_H
+
+#include <QString>
+#include <QVector>
+#include <optional>
+
+struct KnownGame {
+ const char* name;
+ const char* steam_app_id;
+ const char* gog_app_id; // nullptr if not on GOG
+ const char* epic_app_id; // nullptr if not on Epic
+ const char* my_games_folder; // nullptr if not applicable
+ const char* appdata_local_folder;// nullptr if not applicable
+ const char* appdata_roaming_folder; // nullptr if not applicable
+ const char* registry_path; // under HKLM\Software
+ const char* registry_value; // value name for install path
+ const char* steam_folder; // expected folder in steamapps/common/
+};
+
+// Static list of all known games.
+inline constexpr KnownGame KNOWN_GAMES[] = {
+ // Bethesda Games
+ {"Enderal", "933480", "1708684988", nullptr,
+ "Enderal", nullptr, nullptr,
+ R"(Software\SureAI\Enderal)", "Install_Path", "Enderal"},
+ {"Enderal Special Edition", "976620", nullptr, nullptr,
+ "Enderal Special Edition", nullptr, nullptr,
+ R"(Software\SureAI\Enderal SE)", "installed path", "Enderal Special Edition"},
+ {"Fallout 3", "22300", "1454315831", "adeae8bbfc94427db57c7dfecce3f1d4",
+ "Fallout3", "Fallout3", nullptr,
+ R"(Software\Bethesda Softworks\Fallout3)", "Installed Path", "Fallout 3"},
+ {"Fallout 4", "377160", "1998527297", "61d52ce4d09d41e48800c22784d13ae8",
+ "Fallout4", "Fallout4", nullptr,
+ R"(Software\Bethesda Softworks\Fallout4)", "Installed Path", "Fallout 4"},
+ {"Fallout 4 VR", "611660", nullptr, nullptr,
+ "Fallout4VR", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Fallout 4 VR)", "Installed Path", "Fallout 4 VR"},
+ {"Fallout New Vegas", "22380", "1454587428", "5daeb974a22a435988892319b3a4f476",
+ "FalloutNV", "FalloutNV", nullptr,
+ R"(Software\Bethesda Softworks\FalloutNV)", "Installed Path", "Fallout New Vegas"},
+ {"Morrowind", "22320", "1440163901", nullptr,
+ "Morrowind", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Morrowind)", "Installed Path", "Morrowind"},
+ {"Oblivion", "22330", "1458058109", nullptr,
+ "Oblivion", "Oblivion", nullptr,
+ R"(Software\Bethesda Softworks\Oblivion)", "Installed Path", "Oblivion"},
+ {"Skyrim", "72850", nullptr, nullptr,
+ "Skyrim", "Skyrim", nullptr,
+ R"(Software\Bethesda Softworks\Skyrim)", "Installed Path", "Skyrim"},
+ {"Skyrim Special Edition", "489830", "1711230643", "ac82db5035584c7f8a2c548d98c86b2c",
+ "Skyrim Special Edition", "Skyrim Special Edition", nullptr,
+ R"(Software\Bethesda Softworks\Skyrim Special Edition)", "Installed Path",
+ "Skyrim Special Edition"},
+ {"Skyrim VR", "611670", nullptr, nullptr,
+ "Skyrim VR", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Skyrim VR)", "Installed Path", "Skyrim VR"},
+ {"Starfield", "1716740", nullptr, nullptr,
+ "Starfield", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Starfield)", "Installed Path", "Starfield"},
+ // CD Projekt RED Games
+ {"The Witcher 3", "292030", "1495134320", nullptr,
+ "The Witcher 3", nullptr, nullptr,
+ R"(Software\CD Projekt Red\The Witcher 3)", "InstallFolder",
+ "The Witcher 3 Wild Hunt"},
+ {"Cyberpunk 2077", "1091500", "1423049311", nullptr,
+ nullptr, "CD Projekt Red/Cyberpunk 2077", nullptr,
+ R"(Software\CD Projekt Red\Cyberpunk 2077)", "InstallFolder", "Cyberpunk 2077"},
+ // Other popular moddable games
+ {"Baldur's Gate 3", "1086940", "1456460669", nullptr,
+ nullptr, "Larian Studios/Baldur's Gate 3", nullptr,
+ R"(Software\Larian Studios\Baldur's Gate 3)", "InstallDir", "Baldurs Gate 3"},
+};
+
+inline constexpr int KNOWN_GAMES_COUNT =
+ static_cast<int>(sizeof(KNOWN_GAMES) / sizeof(KNOWN_GAMES[0]));
+
+// GOG ID aliases: (alias_id, primary_id)
+inline constexpr std::pair<const char*, const char*> GOG_ID_ALIASES[] = {
+ {"1435828767", "1440163901"}, // Morrowind alternate GOG ID
+ {"1801825368", "1711230643"}, // Skyrim Anniversary Edition → Skyrim SE
+};
+
+inline constexpr int GOG_ID_ALIASES_COUNT =
+ static_cast<int>(sizeof(GOG_ID_ALIASES) / sizeof(GOG_ID_ALIASES[0]));
+
+// Normalize Steam App ID (e.g. Fallout 3 GOTY 22370 → 22300).
+inline QString normalizeSteamId(const QString& appId)
+{
+ if (appId == QLatin1String("22370"))
+ return QStringLiteral("22300");
+ return appId;
+}
+
+inline const KnownGame* findKnownGameBySteamId(const QString& appId)
+{
+ const QString normalized = normalizeSteamId(appId);
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (normalized == QLatin1String(KNOWN_GAMES[i].steam_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByGogId(const QString& appId)
+{
+ // Direct match.
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (KNOWN_GAMES[i].gog_app_id && appId == QLatin1String(KNOWN_GAMES[i].gog_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ // Check aliases.
+ for (int i = 0; i < GOG_ID_ALIASES_COUNT; ++i) {
+ if (appId == QLatin1String(GOG_ID_ALIASES[i].first)) {
+ const char* primary = GOG_ID_ALIASES[i].second;
+ for (int j = 0; j < KNOWN_GAMES_COUNT; ++j) {
+ if (KNOWN_GAMES[j].gog_app_id &&
+ QLatin1String(KNOWN_GAMES[j].gog_app_id) == QLatin1String(primary))
+ return &KNOWN_GAMES[j];
+ }
+ }
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByEpicId(const QString& appId)
+{
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (KNOWN_GAMES[i].epic_app_id && appId == QLatin1String(KNOWN_GAMES[i].epic_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByName(const QString& name)
+{
+ const QString lower = name.toLower();
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (lower == QString::fromLatin1(KNOWN_GAMES[i].name).toLower())
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+/// Fuzzy title matching — strips punctuation, tries containment, colon-split.
+inline const KnownGame* findKnownGameByTitle(const QString& title)
+{
+ const QString titleLower = title.toLower();
+
+ // Exact match first.
+ if (auto* g = findKnownGameByName(title))
+ return g;
+
+ // Sort by name length descending so "Skyrim Special Edition" beats "Skyrim".
+ QVector<const KnownGame*> sorted;
+ sorted.reserve(KNOWN_GAMES_COUNT);
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i)
+ sorted.append(&KNOWN_GAMES[i]);
+ std::sort(sorted.begin(), sorted.end(), [](const KnownGame* a, const KnownGame* b) {
+ return qstrlen(a->name) > qstrlen(b->name);
+ });
+
+ // Helper: strip non-alphanumeric, collapse whitespace.
+ auto normalize = [](const QString& s) -> QString {
+ QString out;
+ for (QChar c : s) {
+ out += (c.isLetterOrNumber() || c == ' ') ? c : QChar(' ');
+ }
+ return out.simplified();
+ };
+
+ const QString titleNorm = normalize(titleLower);
+
+ for (const KnownGame* g : sorted) {
+ const QString gameLower = QString::fromLatin1(g->name).toLower();
+ const QString gameNorm = normalize(gameLower);
+
+ if (titleLower.contains(gameLower))
+ return g;
+ if (titleNorm.contains(gameNorm))
+ return g;
+
+ // After-colon match: "The Elder Scrolls III: Morrowind" → "Morrowind"
+ const int colon = titleLower.indexOf(':');
+ if (colon >= 0) {
+ const QString afterColon = titleLower.mid(colon + 1).trimmed();
+ if (afterColon.startsWith(gameLower))
+ return g;
+ }
+ }
+ return nullptr;
+}
+
+#endif // KNOWNGAMES_H
diff --git a/src/src/main.cpp b/src/src/main.cpp
index eed977e..b3e431c 100644
--- a/src/src/main.cpp
+++ b/src/src/main.cpp
@@ -10,7 +10,6 @@
#include "shared/util.h"
#include "thread_utils.h"
#include <log.h>
-#include <nak_ffi.h>
#include <report.h>
#include <QString>
@@ -87,23 +86,6 @@ 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");
diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp
index f7cb4cd..f2ce67b 100644
--- a/src/src/mainwindow.cpp
+++ b/src/src/mainwindow.cpp
@@ -92,7 +92,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "shared/fileentry.h"
#include "shared/filesorigin.h"
-#include <nak_ffi.h>
+#include "slrmanager.h"
#include <QAbstractItemDelegate>
#include <QAction>
#include <QApplication>
@@ -2381,7 +2381,7 @@ void MainWindow::on_startButton_clicked()
QSettings instanceIni(s->filename(), QSettings::IniFormat);
useSLR = instanceIni.value("fluorine/use_slr", true).toBool();
}
- if (useSLR && !nak_slr_is_installed()) {
+ if (useSLR && !isSlrInstalled()) {
auto* progress = new QProgressDialog(
tr("Downloading Steam Linux Runtime (~180 MB)...\n"
"This is required to launch games. Check the MO2 log for details."),
@@ -2396,28 +2396,26 @@ void MainWindow::on_startButton_clicked()
});
// Run download synchronously using an event loop so the dialog stays responsive.
- QFutureWatcher<char*> watcher;
+ QFutureWatcher<QString> watcher;
QEventLoop loop;
- connect(&watcher, &QFutureWatcher<char*>::finished, &loop, &QEventLoop::quit);
- watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> char* {
- return nak_download_slr(nullptr, nullptr, &cancelFlag);
+ connect(&watcher, &QFutureWatcher<QString>::finished, &loop, &QEventLoop::quit);
+ watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> QString {
+ return downloadSlr(nullptr, nullptr, &cancelFlag);
}));
progress->show();
loop.exec();
progress->close();
progress->deleteLater();
- char* err = watcher.result();
+ const QString err = watcher.result();
if (cancelFlag) {
return; // user cancelled, don't launch
}
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- log::error("[SLR] Download failed: {}", msg);
+ if (!err.isEmpty()) {
+ log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(this, tr("Steam Linux Runtime"),
tr("Steam Linux Runtime download failed:\n%1\n\n"
- "You can disable SLR in the Instance Manager and try again.").arg(msg));
+ "You can disable SLR in the Instance Manager and try again.").arg(err));
return;
}
}
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 373ec06..330eeae 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -44,7 +44,6 @@
#else
#include "fluorineconfig.h"
#include "wineprefix.h"
-#include <nak_ffi.h>
#endif
#include <chrono>
diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp
index ed836e4..81824e1 100644
--- a/src/src/prefixsetuprunner.cpp
+++ b/src/src/prefixsetuprunner.cpp
@@ -2,7 +2,9 @@
#include "fluorinepaths.h"
-#include <nak_ffi.h>
+#include "gamedetection.h"
+#include "steamdetection.h"
+#include "prefixsymlinks.h"
#include <QCoreApplication>
#include <QDir>
@@ -1284,9 +1286,8 @@ bool PrefixSetupRunner::stepGameDetection()
{
emit logMessage("Auto-detecting installed games...");
- NakGameList gameList = nak_detect_all_games();
- if (gameList.count == 0) {
- nak_game_list_free(gameList);
+ GameScanResult scanResult = detectAllGames();
+ if (scanResult.games.isEmpty()) {
emit logMessage("No games detected");
return true;
}
@@ -1295,15 +1296,14 @@ bool PrefixSetupRunner::stepGameDetection()
QString regContent = QStringLiteral("Windows Registry Editor Version 5.00\n\n");
int gameCount = 0;
- for (size_t i = 0; i < gameList.count; ++i) {
- const NakGame& game = gameList.games[i];
- if (!game.registry_path || !game.registry_value)
+ for (const DetectedGame& game : scanResult.games) {
+ if (game.registry_path.isEmpty() || game.registry_value.isEmpty())
continue;
- const QString gameName = QString::fromUtf8(game.name);
- const QString installPath = QString::fromUtf8(game.install_path);
- const QString rPath = QString::fromUtf8(game.registry_path);
- const QString rVal = QString::fromUtf8(game.registry_value);
+ const QString& gameName = game.name;
+ const QString& installPath = game.install_path;
+ const QString& rPath = game.registry_path;
+ const QString& rVal = game.registry_value;
// Convert Linux path to Wine Z: drive path with escaped backslashes.
QString winePath = "Z:" + QString(installPath).replace('/', "\\\\");
@@ -1319,8 +1319,6 @@ bool PrefixSetupRunner::stepGameDetection()
++gameCount;
}
- nak_game_list_free(gameList);
-
if (gameCount == 0) {
emit logMessage("No games with valid registry paths");
return true;
@@ -1420,18 +1418,10 @@ bool PrefixSetupRunner::stepPostSetup()
emit logMessage("Running post-setup tasks...");
// Ensure AppData temp directory exists.
- const QByteArray prefixUtf8 = m_prefixPath.toUtf8();
- nak_ensure_temp_directory(prefixUtf8.constData());
+ ensureTempDirectory(m_prefixPath);
// Create game symlinks.
- nak_create_game_symlinks_auto(prefixUtf8.constData());
-
- // Ensure DXVK config.
- if (char* err = nak_ensure_dxvk_conf(); err != nullptr) {
- emit logMessage(QStringLiteral("Warning: dxvk.conf: %1").arg(QString::fromUtf8(err)));
- nak_string_free(err);
- // Non-fatal.
- }
+ createGameSymlinksAuto(m_prefixPath);
emit logMessage("Post-setup complete");
return true;
@@ -1640,13 +1630,10 @@ QString PrefixSetupRunner::findProtonScript() const
QString PrefixSetupRunner::detectSteamPath() const
{
- // Use NaK FFI first.
- if (char* path = nak_find_steam_path(); path != nullptr) {
- QString result = QString::fromUtf8(path);
- nak_string_free(path);
- if (!result.isEmpty())
- return result;
- }
+ // Use native Steam detection first.
+ const QString steamPath = findSteamPath();
+ if (!steamPath.isEmpty())
+ return steamPath;
// Fallback.
const QString home = QDir::homePath();
diff --git a/src/src/prefixsymlinks.cpp b/src/src/prefixsymlinks.cpp
new file mode 100644
index 0000000..2195e3e
--- /dev/null
+++ b/src/src/prefixsymlinks.cpp
@@ -0,0 +1,143 @@
+#include "prefixsymlinks.h"
+#include "gamedetection.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <uibase/log.h>
+#include <unistd.h>
+
+namespace {
+
+static const char* SKIP_DIRS[] = {
+ "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", "Searches",
+ "Contacts", "3D Objects",
+};
+
+bool shouldSkip(const QString& name)
+{
+ for (const char* s : SKIP_DIRS) {
+ if (name.compare(QLatin1String(s), Qt::CaseInsensitive) == 0)
+ return true;
+ }
+ return false;
+}
+
+/// Find the username directory inside drive_c/users/.
+QString findPrefixUsername(const QString& usersDir)
+{
+ QDir dir(usersDir);
+ for (const QString& entry : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ if (entry != QStringLiteral("Public") && entry != QStringLiteral("root"))
+ return entry;
+ }
+ return QStringLiteral("steamuser");
+}
+
+/// Create a symlink if the path doesn't already exist (or is already correct).
+bool createSymlinkIfNeeded(const QString& linkPath, const QString& target)
+{
+ QFileInfo fi(linkPath);
+ if (fi.exists() || fi.isSymLink()) {
+ if (fi.isSymLink() && fi.symLinkTarget() == target)
+ return true;
+ return false; // something else exists
+ }
+
+ QDir().mkpath(QFileInfo(linkPath).absolutePath());
+ if (symlink(target.toUtf8().constData(), linkPath.toUtf8().constData()) != 0) {
+ MOBase::log::warn("Failed to create symlink {} -> {}", linkPath, target);
+ return false;
+ }
+ return true;
+}
+
+/// Scan all subdirectories in gameBase and symlink them into nakBase.
+int scanAndLinkAll(const QString& nakBase, const QString& gameBase,
+ const QString& label, const QString& gameName,
+ bool skipMyGames = false)
+{
+ QDir dir(gameBase);
+ if (!dir.exists())
+ return 0;
+
+ int count = 0;
+ for (const QString& folder : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ if (shouldSkip(folder))
+ continue;
+ if (skipMyGames && folder == QStringLiteral("My Games"))
+ continue;
+
+ const QString linkPath = nakBase + "/" + folder;
+ const QString target = gameBase + "/" + folder;
+
+ if (createSymlinkIfNeeded(linkPath, target)) {
+ MOBase::log::info("Linked {}/{} -> {} ({})", label, folder, target, gameName);
+ ++count;
+ }
+ }
+ return count;
+}
+
+} // namespace
+
+void ensureTempDirectory(const QString& prefixPath)
+{
+ const QString usersDir = prefixPath + "/drive_c/users";
+ const QString username = findPrefixUsername(usersDir);
+ const QString tempDir = usersDir + "/" + username + "/AppData/Local/Temp";
+
+ if (QDir().mkpath(tempDir))
+ MOBase::log::info("Ensured AppData/Local/Temp directory exists");
+ else
+ MOBase::log::warn("Failed to create Temp directory at {}", tempDir);
+}
+
+void createGameSymlinksAuto(const QString& prefixPath)
+{
+ GameScanResult result = detectAllGames();
+
+ const QString usersDir = prefixPath + "/drive_c/users";
+ const QString username = findPrefixUsername(usersDir);
+ const QString userDir = usersDir + "/" + username;
+ const QString documents = userDir + "/Documents";
+ const QString myGames = documents + "/My Games";
+ const QString appdataLocal = userDir + "/AppData/Local";
+ const QString appdataRoaming = userDir + "/AppData/Roaming";
+
+ QDir().mkpath(myGames);
+ QDir().mkpath(appdataLocal);
+ QDir().mkpath(appdataRoaming);
+
+ int linked = 0;
+ for (const DetectedGame& game : result.games) {
+ if (game.prefix_path.isEmpty())
+ continue;
+
+ const QString gameUsersDir = game.prefix_path + "/drive_c/users";
+ const QString gameUsername = findPrefixUsername(gameUsersDir);
+ const QString gameUserDir = gameUsersDir + "/" + gameUsername;
+
+ linked += scanAndLinkAll(myGames, gameUserDir + "/Documents/My Games",
+ QStringLiteral("Documents/My Games"), game.name);
+ linked += scanAndLinkAll(documents, gameUserDir + "/Documents",
+ QStringLiteral("Documents"), game.name, true);
+ linked += scanAndLinkAll(appdataLocal, gameUserDir + "/AppData/Local",
+ QStringLiteral("AppData/Local"), game.name);
+ linked += scanAndLinkAll(appdataRoaming, gameUserDir + "/AppData/Roaming",
+ QStringLiteral("AppData/Roaming"), game.name);
+ }
+
+ if (linked > 0)
+ MOBase::log::info("Created {} symlinks to game prefixes", linked);
+
+ // "My Documents" compat symlink.
+ const QString myDocs = userDir + "/My Documents";
+ if (!QFileInfo::exists(myDocs) && !QFileInfo(myDocs).isSymLink())
+ symlink("Documents", myDocs.toUtf8().constData());
+}
diff --git a/src/src/prefixsymlinks.h b/src/src/prefixsymlinks.h
new file mode 100644
index 0000000..db8230a
--- /dev/null
+++ b/src/src/prefixsymlinks.h
@@ -0,0 +1,12 @@
+#ifndef PREFIXSYMLINKS_H
+#define PREFIXSYMLINKS_H
+
+#include <QString>
+
+/// Ensure AppData/Local/Temp exists in the Wine prefix.
+__attribute__((visibility("default"))) void ensureTempDirectory(const QString& prefixPath);
+
+/// Detect all games and create symlinks from the given prefix to game prefixes.
+__attribute__((visibility("default"))) void createGameSymlinksAuto(const QString& prefixPath);
+
+#endif // PREFIXSYMLINKS_H
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index c6fc545..89338f4 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -1,6 +1,7 @@
#include "protonlauncher.h"
-#include <nak_ffi.h>
+#include "steamdetection.h"
+#include "slrmanager.h"
#include <cstdio>
#include <QDir>
#include <QFile>
@@ -114,10 +115,8 @@ QString compatDataPathFromPrefix(const QString& prefixPath)
QString detectSteamPath()
{
- if (char* steamPathRaw = nak_find_steam_path(); steamPathRaw != nullptr) {
- const QString steamPath = QString::fromUtf8(steamPathRaw).trimmed();
- nak_string_free(steamPathRaw);
-
+ {
+ const QString steamPath = findSteamPath().trimmed();
if (!steamPath.isEmpty()) {
return steamPath;
}
@@ -456,9 +455,8 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
QString program;
QStringList arguments;
if (m_useSLR) {
- if (char* slrScript = nak_slr_get_run_script(); slrScript != nullptr) {
- const QString runScript = QString::fromUtf8(slrScript);
- nak_string_free(slrScript);
+ const QString runScript = getSlrRunScript();
+ if (!runScript.isEmpty()) {
MOBase::log::info("SLR: wrapping launch with {}", runScript);
// Build: [wrappers] run_script [--filesystem=...] -- proton_script protonArgs
QStringList slrArgs;
@@ -535,15 +533,6 @@ 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);
- }
- }
-
MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary);
MOBase::log::info("Final command: '{}' {}", program,
arguments.join(" ").toStdString());
diff --git a/src/src/selfupdater.cpp b/src/src/selfupdater.cpp
index 4438f60..556e40e 100644
--- a/src/src/selfupdater.cpp
+++ b/src/src/selfupdater.cpp
@@ -89,6 +89,10 @@ void SelfUpdater::setPluginContainer(PluginContainer* pluginContainer)
void SelfUpdater::testForUpdate(const Settings& settings)
{
+ // Fluorine Manager has its own update mechanism — skip the MO2 GitHub check.
+ (void)settings;
+ return;
+
if (settings.network().offlineMode()) {
log::debug("not checking for updates, in offline mode");
return;
@@ -235,13 +239,13 @@ void SelfUpdater::openOutputFile(const QString& fileName)
QString outputPath =
QDir::fromNativeSeparators(qApp->property("dataPath").toString()) + "/" +
fileName;
- log::debug("downloading to {}", outputPath);
- m_UpdateFile.setFileName(outputPath);
- if (!m_UpdateFile.open(QIODevice::WriteOnly)) {
- log::error("failed to open update output file '{}': {}", outputPath,
- m_UpdateFile.errorString());
- }
-}
+ log::debug("downloading to {}", outputPath);
+ m_UpdateFile.setFileName(outputPath);
+ if (!m_UpdateFile.open(QIODevice::WriteOnly)) {
+ log::error("failed to open update output file '{}': {}", outputPath,
+ m_UpdateFile.errorString());
+ }
+}
void SelfUpdater::download(const QString& downloadLink)
{
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 1875f31..2117a09 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -8,7 +8,10 @@
#include <QtConcurrent/QtConcurrentRun>
#include <log.h>
#include <uibase/utility.h>
-#include <nak_ffi.h>
+#include "knowngames.h"
+#include "steamdetection.h"
+#include "gamedetection.h"
+#include "slrmanager.h"
#include <atomic>
#include <QCheckBox>
#include <QComboBox>
@@ -37,6 +40,128 @@
namespace
{
std::atomic<ProtonSettingsTab*> g_activeInstallTab = nullptr;
+
+/// Find the wine binary inside a Proton installation directory.
+static QString findWineBinary(const QString& protonPath)
+{
+ for (const char* subdir : {"files/bin", "dist/bin"}) {
+ const QString candidate =
+ QDir(protonPath).filePath(QString::fromLatin1(subdir) + "/wine");
+ if (QFileInfo::exists(candidate))
+ return candidate;
+ }
+ return {};
+}
+
+/// Apply a single game's registry entry via wine regedit.
+///
+/// Looks up the game by name in NaK's KNOWN_GAMES, builds a .reg file that
+/// maps the install path to the game's registry key, and imports it with
+/// wine regedit (wrapped in SLR if available).
+static QString applyGameRegistryNative(const QString& prefixPath,
+ const QString& protonPath,
+ const QString& gameName,
+ const QString& installPath,
+ void (*logCb)(const char*))
+{
+ // Find wine binary.
+ const QString wineBin = findWineBinary(protonPath);
+ if (wineBin.isEmpty())
+ return QStringLiteral("Wine binary not found in Proton at %1").arg(protonPath);
+
+ // Look up registry path/value from known games.
+ const KnownGame* foundGame = nullptr;
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (gameName == QString::fromLatin1(KNOWN_GAMES[i].name)) {
+ foundGame = &KNOWN_GAMES[i];
+ break;
+ }
+ }
+
+ if (!foundGame || !foundGame->registry_path || !foundGame->registry_value)
+ return QStringLiteral("Unknown game: %1").arg(gameName);
+
+ const QString rPath = QString::fromLatin1(foundGame->registry_path);
+ const QString rVal = QString::fromLatin1(foundGame->registry_value);
+
+ // Convert Linux path → Wine Z: drive path with escaped backslashes for .reg.
+ const QString winePath = "Z:" + QString(installPath).replace('/', "\\\\");
+
+ // Wow6432Node key: strip the leading "Software\" prefix.
+ const int firstBackslash = rPath.indexOf('\\');
+ const QString wow64Key =
+ (firstBackslash >= 0) ? rPath.mid(firstBackslash + 1) : rPath;
+
+ const QString regContent = QStringLiteral(
+ "Windows Registry Editor Version 5.00\n\n"
+ "[HKEY_LOCAL_MACHINE\\%1]\n\"%2\"=\"%3\"\n\n"
+ "[HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\%4]\n\"%5\"=\"%6\"\n\n")
+ .arg(rPath, rVal, winePath, wow64Key, rVal, winePath);
+
+ // Write temp .reg file.
+ const QString tmpDir = fluorineDataDir() + "/tmp";
+ QDir().mkpath(tmpDir);
+ const QString regFile = tmpDir + "/game_reg_apply.reg";
+
+ {
+ QFile f(regFile);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Text))
+ return QStringLiteral("Failed to write registry file: %1").arg(regFile);
+ f.write(regContent.toUtf8());
+ }
+
+ if (logCb) {
+ const QByteArray msg =
+ QStringLiteral("Applying registry for %1...").arg(gameName).toUtf8();
+ logCb(msg.constData());
+ }
+
+ // Build the command — wrap in SLR if available.
+ QProcess proc;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert("WINEPREFIX", prefixPath);
+ env.insert("WINEDLLOVERRIDES", "mshtml=d");
+ env.insert("PROTON_USE_XALIA", "0");
+ proc.setProcessEnvironment(env);
+
+ const QString slr = getSlrRunScript();
+ if (!slr.isEmpty()) {
+
+ QStringList args;
+ // Expose directories to the container.
+ const QString wineDir = QFileInfo(wineBin).absolutePath();
+ if (!wineDir.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(wineDir);
+ if (!prefixPath.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(prefixPath);
+ if (!protonPath.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(protonPath);
+ if (QDir(tmpDir).exists())
+ args << QStringLiteral("--filesystem=%1").arg(tmpDir);
+ args << "--" << wineBin << "regedit" << regFile;
+
+ proc.setProgram(slr);
+ proc.setArguments(args);
+ } else {
+ proc.setProgram(wineBin);
+ proc.setArguments({"regedit", regFile});
+ }
+
+ proc.setProcessChannelMode(QProcess::MergedChannels);
+ proc.start();
+ proc.waitForFinished(60000);
+ QFile::remove(regFile);
+
+ if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0)
+ return QStringLiteral("wine regedit failed (exit code %1)").arg(proc.exitCode());
+
+ if (logCb) {
+ const QByteArray msg =
+ QStringLiteral("Registry applied for %1").arg(gameName).toUtf8();
+ logCb(msg.constData());
+ }
+ return {}; // success
+}
}
ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
@@ -126,25 +251,20 @@ void ProtonSettingsTab::populateProtons()
{
ui->protonVersionCombo->clear();
- const NakProtonList protonList = nak_find_steam_protons();
-
- for (size_t i = 0; i < protonList.count; ++i) {
- const NakSteamProton& proton = protonList.protons[i];
+ const auto protonList = findSteamProtons();
- const QString protonName = QString::fromUtf8(proton.name ? proton.name : "");
- const QString protonPath = QString::fromUtf8(proton.path ? proton.path : "");
+ for (int i = 0; i < protonList.size(); ++i) {
+ const SteamProtonInfo& proton = protonList[i];
- if (protonName.isEmpty() || protonPath.isEmpty()) {
+ if (proton.name.isEmpty() || proton.path.isEmpty()) {
continue;
}
- ui->protonVersionCombo->addItem(protonName);
- ui->protonVersionCombo->setItemData(ui->protonVersionCombo->count() - 1, protonPath,
+ ui->protonVersionCombo->addItem(proton.name);
+ ui->protonVersionCombo->setItemData(ui->protonVersionCombo->count() - 1, proton.path,
Qt::UserRole + 1);
}
- nak_proton_list_free(protonList);
-
if (auto cfg = FluorineConfig::load(); cfg.has_value()) {
const int idx = ui->protonVersionCombo->findText(cfg->proton_name);
if (idx >= 0) {
@@ -293,7 +413,7 @@ void ProtonSettingsTab::onOpenPrefixFolder()
void ProtonSettingsTab::onDownloadSLR()
{
- if (nak_slr_is_installed()) {
+ if (isSlrInstalled()) {
QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
tr("Steam Linux Runtime is already installed."));
return;
@@ -313,21 +433,19 @@ void ProtonSettingsTab::onDownloadSLR()
*cancelFlag = 1;
});
- auto* watcher = new QFutureWatcher<char*>(this);
- connect(watcher, &QFutureWatcher<char*>::finished, this,
+ auto* watcher = new QFutureWatcher<QString>(this);
+ connect(watcher, &QFutureWatcher<QString>::finished, this,
[this, watcher, progress, cancelFlag] {
progress->close();
watcher->deleteLater();
progress->deleteLater();
ui->downloadSLRButton->setEnabled(true);
- char* err = watcher->result();
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- MOBase::log::error("[SLR] Download failed: {}", msg);
+ const QString err = watcher->result();
+ if (!err.isEmpty()) {
+ MOBase::log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(parentWidget(), tr("Steam Linux Runtime"),
- tr("Download failed:\n%1").arg(msg));
+ tr("Download failed:\n%1").arg(err));
} else {
MOBase::log::info("[SLR] Steam Linux Runtime installed successfully");
QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
@@ -337,8 +455,8 @@ void ProtonSettingsTab::onDownloadSLR()
});
int* cancelPtr = cancelFlag;
- watcher->setFuture(QtConcurrent::run([cancelPtr]() -> char* {
- return nak_download_slr(nullptr, nullptr, cancelPtr);
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString {
+ return downloadSlr(nullptr, nullptr, cancelPtr);
}));
progress->show();
}
@@ -534,12 +652,8 @@ void ProtonSettingsTab::showGameRegistryDialog()
auto* gameCombo = new QComboBox(&dialog);
- size_t knownCount = 0;
- const NakKnownGame* knownGames = nak_get_known_games(&knownCount);
-
- for (size_t i = 0; i < knownCount; ++i) {
- const QString name =
- QString::fromUtf8(knownGames[i].name ? knownGames[i].name : "");
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ const QString name = QString::fromLatin1(KNOWN_GAMES[i].name);
if (!name.isEmpty()) {
gameCombo->addItem(name);
}
@@ -572,22 +686,18 @@ void ProtonSettingsTab::showGameRegistryDialog()
const QString gameName = gameCombo->currentText();
if (gameName.isEmpty()) return;
- // Use nak_detect_all_games to find the install path
- NakGameList gameList = nak_detect_all_games();
- for (size_t i = 0; i < gameList.count; ++i) {
- const QString detected =
- QString::fromUtf8(gameList.games[i].name ? gameList.games[i].name : "");
- if (detected.contains(gameName, Qt::CaseInsensitive) ||
- gameName.contains(detected, Qt::CaseInsensitive)) {
- const QString path = QString::fromUtf8(
- gameList.games[i].install_path ? gameList.games[i].install_path : "");
- if (!path.isEmpty()) {
- pathEdit->setText(path);
+ // Use detectAllGames to find the install path
+ const GameScanResult scanResult = detectAllGames();
+ for (int i = 0; i < scanResult.games.size(); ++i) {
+ const DetectedGame& detected = scanResult.games[i];
+ if (detected.name.contains(gameName, Qt::CaseInsensitive) ||
+ gameName.contains(detected.name, Qt::CaseInsensitive)) {
+ if (!detected.install_path.isEmpty()) {
+ pathEdit->setText(detected.install_path);
break;
}
}
}
- nak_game_list_free(gameList);
};
QObject::connect(gameCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
@@ -631,24 +741,15 @@ void ProtonSettingsTab::showGameRegistryDialog()
m_pendingProtonPath = protonPath;
m_installWatcher.setFuture(
- QtConcurrent::run([prefixPath, protonName, protonPath,
+ QtConcurrent::run([prefixPath, protonPath,
selectedGame, gamePath]() -> InstallResult {
- const QByteArray prefixUtf8 = prefixPath.toUtf8();
- const QByteArray protonNmUtf8 = protonName.toUtf8();
- const QByteArray protonPthUtf8 = protonPath.toUtf8();
- const QByteArray gameUtf8 = selectedGame.toUtf8();
- const QByteArray pathUtf8 = gamePath.toUtf8();
-
- char* error = nak_apply_registry_for_game_path(
- prefixUtf8.constData(), protonNmUtf8.constData(),
- protonPthUtf8.constData(), gameUtf8.constData(),
- pathUtf8.constData(), &ProtonSettingsTab::logCallback);
+ const QString err = applyGameRegistryNative(
+ prefixPath, protonPath, selectedGame, gamePath,
+ &ProtonSettingsTab::logCallback);
InstallResult r;
- if (error != nullptr) {
- r.error = QString::fromUtf8(error);
- nak_string_free(error);
- }
+ if (!err.isEmpty())
+ r.error = err;
return r;
}));
@@ -720,3 +821,4 @@ void ProtonSettingsTab::onInstallFinished()
ui->protonStatusLabel->setText(tr("Done"));
refreshState();
}
+
diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp
new file mode 100644
index 0000000..4e7b898
--- /dev/null
+++ b/src/src/slrmanager.cpp
@@ -0,0 +1,186 @@
+#include "slrmanager.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QProcess>
+#include <QTimer>
+#include <QEventLoop>
+#include <uibase/log.h>
+
+namespace {
+
+const char* BASE_URL = "https://repo.steampowered.com/steamrt3/images/latest-public-beta";
+const char* ARCHIVE_NAME = "SteamLinuxRuntime_sniper.tar.xz";
+const char* EXTRACTED_DIR = "SteamLinuxRuntime_sniper";
+
+QString slrInstallDir()
+{
+ return QDir::homePath() + "/.local/share/fluorine/steamrt";
+}
+
+QString slrRunScriptPath()
+{
+ return slrInstallDir() + "/" + EXTRACTED_DIR + "/run";
+}
+
+QString localBuildIdPath()
+{
+ return slrInstallDir() + "/BUILD_ID.txt";
+}
+
+/// Blocking HTTP GET that returns the response body as QByteArray.
+QByteArray httpGet(const QString& url, const int* cancelFlag,
+ const std::function<void(float)>& progressCb = nullptr,
+ const QString& destFile = {})
+{
+ QNetworkAccessManager mgr;
+ QNetworkReply* reply = mgr.get(QNetworkRequest(QUrl(url)));
+ QEventLoop loop;
+
+ QFile outFile;
+ if (!destFile.isEmpty()) {
+ outFile.setFileName(destFile);
+ if (!outFile.open(QIODevice::WriteOnly))
+ return {};
+ }
+
+ qint64 totalBytes = -1;
+ qint64 received = 0;
+
+ QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
+ if (totalBytes < 0)
+ totalBytes = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong();
+ QByteArray chunk = reply->readAll();
+ received += chunk.size();
+ if (outFile.isOpen())
+ outFile.write(chunk);
+ if (progressCb && totalBytes > 0)
+ progressCb(static_cast<float>(received) / static_cast<float>(totalBytes));
+ });
+
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+
+ // Poll cancel flag via a timer.
+ QTimer cancelTimer;
+ if (cancelFlag) {
+ QObject::connect(&cancelTimer, &QTimer::timeout, [&]() {
+ if (*cancelFlag != 0) {
+ reply->abort();
+ loop.quit();
+ }
+ });
+ cancelTimer.start(200);
+ }
+
+ loop.exec();
+
+ if (outFile.isOpen())
+ outFile.close();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ reply->deleteLater();
+ if (!destFile.isEmpty())
+ QFile::remove(destFile);
+ return {};
+ }
+
+ QByteArray body;
+ if (destFile.isEmpty())
+ body = reply->readAll(); // small responses fully buffered
+ reply->deleteLater();
+ return body;
+}
+
+} // namespace
+
+bool isSlrInstalled()
+{
+ const QString script = slrRunScriptPath();
+ QFileInfo fi(script);
+ return fi.exists() && fi.isExecutable();
+}
+
+QString getSlrRunScript()
+{
+ return isSlrInstalled() ? slrRunScriptPath() : QString();
+}
+
+QString downloadSlr(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag)
+{
+ auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); };
+ auto progress = [&](float p) { if (progressCb) progressCb(p); };
+
+ // 1. Check for updates.
+ status(QStringLiteral("Checking Steam Linux Runtime version..."));
+
+ const QByteArray remoteBuildIdRaw = httpGet(
+ QStringLiteral("%1/BUILD_ID.txt").arg(QLatin1String(BASE_URL)), cancelFlag);
+ if (remoteBuildIdRaw.isEmpty())
+ return QStringLiteral("Failed to fetch SLR BUILD_ID");
+
+ const QString remoteBuildId = QString::fromUtf8(remoteBuildIdRaw).trimmed();
+
+ // Read local BUILD_ID.
+ QString localBuildId;
+ {
+ QFile f(localBuildIdPath());
+ if (f.open(QIODevice::ReadOnly))
+ localBuildId = QString::fromUtf8(f.readAll()).trimmed();
+ }
+
+ if (localBuildId == remoteBuildId && isSlrInstalled()) {
+ MOBase::log::info("Steam Linux Runtime is already up to date");
+ status(QStringLiteral("Steam Linux Runtime is already up to date"));
+ progress(1.0f);
+ return {};
+ }
+
+ MOBase::log::info("Downloading Steam Linux Runtime (BUILD_ID: {})", remoteBuildId);
+
+ const QString installDir = slrInstallDir();
+ QDir().mkpath(installDir);
+ const QString archivePath = installDir + "/" + ARCHIVE_NAME;
+
+ // 2. Download.
+ status(QStringLiteral("Downloading Steam Linux Runtime (sniper, ~180 MB)..."));
+ httpGet(QStringLiteral("%1/%2").arg(QLatin1String(BASE_URL), QLatin1String(ARCHIVE_NAME)),
+ cancelFlag, progress, archivePath);
+ progress(1.0f);
+
+ if (!QFileInfo::exists(archivePath))
+ return QStringLiteral("Download failed or was cancelled");
+
+ // 3. Extract.
+ status(QStringLiteral("Extracting Steam Linux Runtime..."));
+ const QString extractedDir = installDir + "/" + EXTRACTED_DIR;
+ if (QFileInfo::exists(extractedDir))
+ QDir(extractedDir).removeRecursively();
+
+ QProcess tar;
+ tar.setWorkingDirectory(installDir);
+ tar.start(QStringLiteral("tar"), {QStringLiteral("xJf"), archivePath});
+ tar.waitForFinished(600000);
+ QFile::remove(archivePath);
+
+ if (tar.exitStatus() != QProcess::NormalExit || tar.exitCode() != 0)
+ return QStringLiteral("tar extraction failed (exit code %1)").arg(tar.exitCode());
+
+ if (!QFileInfo::exists(slrRunScriptPath()))
+ return QStringLiteral("Extraction succeeded but run script not found");
+
+ // 4. Save BUILD_ID.
+ {
+ QFile f(localBuildIdPath());
+ if (f.open(QIODevice::WriteOnly))
+ f.write(remoteBuildId.toUtf8());
+ }
+
+ MOBase::log::info("Steam Linux Runtime installed successfully");
+ status(QStringLiteral("Steam Linux Runtime ready"));
+ return {};
+}
diff --git a/src/src/slrmanager.h b/src/src/slrmanager.h
new file mode 100644
index 0000000..c800c2e
--- /dev/null
+++ b/src/src/slrmanager.h
@@ -0,0 +1,21 @@
+#ifndef SLRMANAGER_H
+#define SLRMANAGER_H
+
+#include <QString>
+#include <functional>
+
+/// Returns true if the SLR `run` script is present and executable.
+__attribute__((visibility("default"))) bool isSlrInstalled();
+
+/// Returns the path to the SLR `run` script, or empty if not installed.
+__attribute__((visibility("default"))) QString getSlrRunScript();
+
+/// Download and install SteamLinuxRuntime_sniper (~180 MB).
+/// Skips if already at the latest version (BUILD_ID check).
+/// Returns empty string on success, or an error message.
+__attribute__((visibility("default")))
+QString downloadSlr(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag);
+
+#endif // SLRMANAGER_H
diff --git a/src/src/steamdetection.cpp b/src/src/steamdetection.cpp
new file mode 100644
index 0000000..0d97ec0
--- /dev/null
+++ b/src/src/steamdetection.cpp
@@ -0,0 +1,195 @@
+#include "steamdetection.h"
+
+#include <QDir>
+#include <QDirIterator>
+#include <QFileInfo>
+#include <QStandardPaths>
+#include <uibase/log.h>
+
+// ============================================================================
+// SteamProtonInfo
+// ============================================================================
+
+QString SteamProtonInfo::wineBinary() const
+{
+ for (const char* sub : {"files/bin/wine", "dist/bin/wine"}) {
+ const QString p = QDir(path).filePath(QString::fromLatin1(sub));
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+QString SteamProtonInfo::wineserverBinary() const
+{
+ for (const char* sub : {"files/bin/wineserver", "dist/bin/wineserver"}) {
+ const QString p = QDir(path).filePath(QString::fromLatin1(sub));
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+QString SteamProtonInfo::binDir() const
+{
+ const QString wine = wineBinary();
+ return wine.isEmpty() ? QString() : QFileInfo(wine).absolutePath();
+}
+
+// ============================================================================
+// Steam Path Detection
+// ============================================================================
+
+QString findSteamPath()
+{
+ const QString home = QDir::homePath();
+ const QStringList candidates = {
+ home + "/.steam/steam",
+ home + "/.local/share/Steam",
+ home + "/.var/app/com.valvesoftware.Steam/.steam/steam",
+ home + "/snap/steam/common/.steam/steam",
+ };
+ for (const QString& p : candidates) {
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+// ============================================================================
+// Proton Detection
+// ============================================================================
+
+namespace {
+
+/// Check if a Proton version is 10 or newer.
+bool isProton10OrNewer(const SteamProtonInfo& p)
+{
+ if (p.is_experimental || p.name.contains(QStringLiteral("Experimental")))
+ return true;
+
+ if (p.name.contains(QStringLiteral("CachyOS")))
+ return true;
+
+ if (p.name == QStringLiteral("LegacyRuntime") ||
+ p.name.contains(QStringLiteral("Runtime")))
+ return false;
+
+ auto extractMajor = [](const QString& s, QChar sep) -> int {
+ bool ok = false;
+ int v = s.section(sep, 0, 0).toInt(&ok);
+ return ok ? v : -1;
+ };
+
+ if (p.name.startsWith(QStringLiteral("GE-Proton"))) {
+ int v = extractMajor(p.name.mid(9), '-');
+ return v < 0 || v >= 10;
+ }
+ if (p.name.startsWith(QStringLiteral("Proton "))) {
+ int v = extractMajor(p.name.mid(7), '.');
+ return v < 0 || v >= 10;
+ }
+ if (p.name.startsWith(QStringLiteral("EM-"))) {
+ int v = extractMajor(p.name.mid(3), '.');
+ return v < 0 || v >= 10;
+ }
+
+ return true; // unknown format — allow
+}
+
+/// Scan a directory for Proton installations.
+void scanProtonDir(const QString& dir, bool isSteamBuiltin,
+ QVector<SteamProtonInfo>& out)
+{
+ QDir d(dir);
+ if (!d.exists())
+ return;
+
+ const QStringList entries = d.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
+ for (const QString& name : entries) {
+ const QString path = d.absoluteFilePath(name);
+
+ bool hasProton = QFileInfo::exists(path + "/proton");
+ bool hasVdf = QFileInfo::exists(path + "/compatibilitytool.vdf");
+
+ if (!hasProton && !hasVdf)
+ continue;
+
+ SteamProtonInfo info;
+ info.name = name;
+ info.path = path;
+ info.is_steam_proton = isSteamBuiltin;
+ info.is_experimental = name.contains(QStringLiteral("Experimental"));
+
+ if (isSteamBuiltin) {
+ if (info.is_experimental) {
+ info.config_name = QStringLiteral("proton_experimental");
+ } else {
+ const QString version = QString(name).remove(QStringLiteral("Proton "));
+ const QString major = version.section('.', 0, 0);
+ info.config_name = QStringLiteral("proton_%1").arg(major);
+ }
+ } else {
+ info.config_name = name;
+ }
+
+ out.append(std::move(info));
+ }
+}
+
+} // namespace
+
+QVector<SteamProtonInfo> findSteamProtons()
+{
+ QVector<SteamProtonInfo> protons;
+
+ const QString steamPath = findSteamPath();
+ if (steamPath.isEmpty())
+ return protons;
+
+ // 1. Steam's built-in Protons (only entries starting with "Proton").
+ {
+ QVector<SteamProtonInfo> builtin;
+ scanProtonDir(steamPath + "/steamapps/common", true, builtin);
+ // Keep only entries whose folder name starts with "Proton".
+ for (auto& p : builtin) {
+ if (p.name.startsWith(QStringLiteral("Proton")))
+ protons.append(std::move(p));
+ }
+ }
+
+ // 2. Custom Protons in user's compatibilitytools.d.
+ scanProtonDir(steamPath + "/compatibilitytools.d", false, protons);
+
+ // 3. System-level Protons.
+ scanProtonDir(QStringLiteral("/usr/share/steam/compatibilitytools.d"), false, protons);
+
+ // Filter to Proton 10+.
+ protons.erase(
+ std::remove_if(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& p) { return !isProton10OrNewer(p); }),
+ protons.end());
+
+ // Filter to Protons with valid wine binaries.
+ protons.erase(
+ std::remove_if(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& p) {
+ bool ok = !p.wineBinary().isEmpty();
+ if (!ok) {
+ MOBase::log::warn("Skipping Proton '{}': wine binary not found",
+ p.name);
+ }
+ return !ok;
+ }),
+ protons.end());
+
+ // Sort: Experimental first, then by name descending (newest first).
+ std::sort(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& a, const SteamProtonInfo& b) {
+ if (a.is_experimental != b.is_experimental)
+ return a.is_experimental > b.is_experimental;
+ return a.name > b.name;
+ });
+
+ return protons;
+}
diff --git a/src/src/steamdetection.h b/src/src/steamdetection.h
new file mode 100644
index 0000000..fd8dc3f
--- /dev/null
+++ b/src/src/steamdetection.h
@@ -0,0 +1,32 @@
+#ifndef STEAMDETECTION_H
+#define STEAMDETECTION_H
+
+#include <QString>
+#include <QStringList>
+#include <QVector>
+
+/// Information about an installed Proton version.
+struct SteamProtonInfo {
+ QString name;
+ QString config_name;
+ QString path;
+ bool is_steam_proton = false;
+ bool is_experimental = false;
+
+ /// Get the path to the wine binary (files/bin/wine or dist/bin/wine).
+ QString wineBinary() const;
+
+ /// Get the path to the wineserver binary.
+ QString wineserverBinary() const;
+
+ /// Get the bin directory containing wine executables.
+ QString binDir() const;
+};
+
+/// Find the Steam installation path (returns empty string if not found).
+__attribute__((visibility("default"))) QString findSteamPath();
+
+/// Find all installed Proton versions (Proton 10+ only, sorted newest first).
+__attribute__((visibility("default"))) QVector<SteamProtonInfo> findSteamProtons();
+
+#endif // STEAMDETECTION_H
diff --git a/src/src/vdfparser.cpp b/src/src/vdfparser.cpp
new file mode 100644
index 0000000..cc6bd46
--- /dev/null
+++ b/src/src/vdfparser.cpp
@@ -0,0 +1,146 @@
+#include "vdfparser.h"
+
+namespace {
+
+class VdfParser
+{
+public:
+ explicit VdfParser(const QString& text) : m_text(text), m_pos(0) {}
+
+ VdfValue parse() { return parseObject(); }
+
+private:
+ const QString& m_text;
+ int m_pos;
+
+ QChar peek() const
+ {
+ return (m_pos < m_text.size()) ? m_text[m_pos] : QChar(0);
+ }
+
+ QChar advance()
+ {
+ return (m_pos < m_text.size()) ? m_text[m_pos++] : QChar(0);
+ }
+
+ bool atEnd() const { return m_pos >= m_text.size(); }
+
+ void skipWhitespaceAndComments()
+ {
+ for (;;) {
+ while (!atEnd() && peek().isSpace())
+ advance();
+
+ if (peek() == '/') {
+ advance();
+ if (peek() == '/') {
+ advance();
+ while (!atEnd() && peek() != '\n')
+ advance();
+ continue;
+ }
+ }
+ break;
+ }
+ }
+
+ QString parseQuotedString()
+ {
+ if (advance() != '"')
+ return {};
+
+ QString result;
+ for (;;) {
+ if (atEnd()) return {};
+ QChar c = advance();
+ if (c == '"') break;
+ if (c == '\\') {
+ if (atEnd()) return {};
+ QChar esc = advance();
+ if (esc == 'n') result += '\n';
+ else if (esc == 't') result += '\t';
+ else if (esc == '\\') result += '\\';
+ else if (esc == '"') result += '"';
+ else { result += '\\'; result += esc; }
+ } else {
+ result += c;
+ }
+ }
+ return result;
+ }
+
+ VdfValue parseObject()
+ {
+ QMap<QString, VdfValue> map;
+
+ for (;;) {
+ skipWhitespaceAndComments();
+ if (atEnd()) break;
+
+ if (peek() == '}') {
+ advance();
+ break;
+ }
+
+ if (peek() == '"') {
+ QString key = parseQuotedString();
+ if (key.isNull()) break;
+
+ skipWhitespaceAndComments();
+
+ if (peek() == '"') {
+ QString value = parseQuotedString();
+ map.insert(key, VdfValue(value));
+ } else if (peek() == '{') {
+ advance();
+ map.insert(key, parseObject());
+ } else {
+ break;
+ }
+ } else {
+ advance();
+ }
+ }
+
+ return VdfValue(std::move(map));
+ }
+};
+
+} // namespace
+
+VdfValue parseVdf(const QString& content)
+{
+ VdfParser parser(content);
+ return parser.parse();
+}
+
+AppManifest AppManifest::fromVdf(const QString& content)
+{
+ VdfValue root = parseVdf(content);
+ const VdfValue* appState = root.get(QStringLiteral("AppState"));
+ if (!appState)
+ return {};
+
+ AppManifest m;
+ m.app_id = appState->getString(QStringLiteral("appid"));
+ m.name = appState->getString(QStringLiteral("name"));
+ m.install_dir = appState->getString(QStringLiteral("installdir"));
+ m.state_flags = appState->getString(QStringLiteral("StateFlags")).toUInt();
+ return m;
+}
+
+QStringList parseLibraryFolders(const QString& content)
+{
+ QStringList paths;
+ VdfValue root = parseVdf(content);
+ const VdfValue* folders = root.get(QStringLiteral("libraryfolders"));
+ if (!folders || !folders->isObject())
+ return paths;
+
+ for (const VdfValue& entry : folders->asObject()) {
+ const QString path = entry.getString(QStringLiteral("path"));
+ if (!path.isEmpty())
+ paths.append(path);
+ }
+ return paths;
+}
diff --git a/src/src/vdfparser.h b/src/src/vdfparser.h
new file mode 100644
index 0000000..faafdb6
--- /dev/null
+++ b/src/src/vdfparser.h
@@ -0,0 +1,69 @@
+#ifndef VDFPARSER_H
+#define VDFPARSER_H
+
+#include <QMap>
+#include <QString>
+#include <QStringList>
+#include <QVariant>
+#include <memory>
+
+/// A VDF value — either a string or a nested object (QMap).
+class VdfValue
+{
+public:
+ enum Type { String, Object };
+
+ VdfValue() : m_type(String) {}
+ explicit VdfValue(const QString& s) : m_type(String), m_string(s) {}
+ explicit VdfValue(QMap<QString, VdfValue>&& obj)
+ : m_type(Object), m_object(std::move(obj)) {}
+
+ Type type() const { return m_type; }
+
+ bool isString() const { return m_type == String; }
+ bool isObject() const { return m_type == Object; }
+
+ const QString& asString() const { return m_string; }
+ const QMap<QString, VdfValue>& asObject() const { return m_object; }
+
+ /// Get a nested value by key (returns nullptr if not an object or key missing).
+ const VdfValue* get(const QString& key) const
+ {
+ if (m_type != Object) return nullptr;
+ auto it = m_object.find(key);
+ return (it != m_object.end()) ? &it.value() : nullptr;
+ }
+
+ /// Get a string value by key.
+ QString getString(const QString& key) const
+ {
+ if (auto* v = get(key); v && v->isString())
+ return v->asString();
+ return {};
+ }
+
+private:
+ Type m_type;
+ QString m_string;
+ QMap<QString, VdfValue> m_object;
+};
+
+/// Parsed appmanifest_*.acf file.
+struct AppManifest {
+ QString app_id;
+ QString name;
+ QString install_dir;
+ uint32_t state_flags = 0;
+
+ bool isInstalled() const { return state_flags == 4; }
+
+ static AppManifest fromVdf(const QString& content);
+};
+
+/// Parse a VDF file content into a root VdfValue (Object).
+VdfValue parseVdf(const QString& content);
+
+/// Parse libraryfolders.vdf and return the list of library paths.
+QStringList parseLibraryFolders(const QString& content);
+
+#endif // VDFPARSER_H
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 7453631..5fe6be5 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -29,7 +29,8 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid);
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
uint64_t size,
const std::chrono::system_clock::time_point& mtime,
- const std::string& real_path = {});
+ const std::string& real_path = {},
+ mode_t cached_mode = 0);
void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath);
void maybeLogCounters(Mo2FsContext* ctx)
@@ -143,12 +144,12 @@ void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath)
}
std::scoped_lock lock(ctx->lookup_cache_mutex);
- for (auto it = ctx->lookup_cache.begin(); it != ctx->lookup_cache.end();) {
- if (it->first.first == parentIno) {
- it = ctx->lookup_cache.erase(it);
- } else {
- ++it;
+ auto idxIt = ctx->lookup_cache_by_parent.find(parentIno);
+ if (idxIt != ctx->lookup_cache_by_parent.end()) {
+ for (const auto& childName : idxIt->second) {
+ ctx->lookup_cache.erase({parentIno, childName});
}
+ ctx->lookup_cache_by_parent.erase(idxIt);
}
}
@@ -385,6 +386,7 @@ struct ChildSnapshot
uint64_t size = 0;
std::chrono::system_clock::time_point mtime{};
std::string real_path;
+ mode_t cached_mode = 0; // permission bits from stat() or VfsNode cache
};
std::vector<ChildSnapshot> listChildrenSnapshot(
@@ -408,6 +410,24 @@ std::vector<ChildSnapshot> listChildrenSnapshot(
snap.size = child->file_info.size;
snap.mtime = child->file_info.mtime;
snap.real_path = child->file_info.real_path;
+
+ // Use cached mode bits if available, otherwise stat() once and cache.
+ if (child->file_info.cached_mode != 0) {
+ snap.cached_mode = child->file_info.cached_mode;
+ } else if (!snap.real_path.empty()) {
+ struct stat real_st;
+ if (::stat(snap.real_path.c_str(), &real_st) == 0) {
+ snap.cached_mode = real_st.st_mode & 0777;
+ // Cache in the tree node for future readdir calls (safe under shared lock
+ // because mode_t is atomic-width and this is a benign data race — worst
+ // case we stat() one extra time from another thread).
+ const_cast<VfsNode*>(child)->file_info.cached_mode = snap.cached_mode;
+ } else {
+ snap.cached_mode = 0644;
+ }
+ } else {
+ snap.cached_mode = 0644;
+ }
}
out.push_back(std::move(snap));
}
@@ -434,7 +454,7 @@ std::vector<Mo2FsContext::DirEntry> buildDirEntries(
entries.push_back(
Mo2FsContext::DirEntry{ctx->inodes->getOrCreate(childPath), child.name,
child.is_dir, child.size, child.mtime,
- child.real_path});
+ child.real_path, child.cached_mode});
}
return entries;
@@ -511,14 +531,8 @@ std::vector<char> buildReaddirBlob(
if (entry.is_dir) {
st.st_mode = S_IFDIR | 0755;
} else {
- // Preserve executable bits from the real file on disk.
- mode_t mode = 0644;
- if (!entry.real_path.empty()) {
- struct stat real_st;
- if (::stat(entry.real_path.c_str(), &real_st) == 0) {
- mode = real_st.st_mode & 0777;
- }
- }
+ // Use cached mode bits (populated during tree snapshot) — no stat() needed.
+ mode_t mode = entry.cached_mode != 0 ? entry.cached_mode : static_cast<mode_t>(0644);
st.st_mode = S_IFREG | mode;
}
@@ -635,7 +649,8 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid)
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
uint64_t size,
const std::chrono::system_clock::time_point& mtime,
- const std::string& real_path)
+ const std::string& real_path,
+ mode_t cached_mode)
{
std::memset(st, 0, sizeof(struct stat));
st->st_ino = ino;
@@ -644,10 +659,11 @@ void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
st->st_gid = gid;
st->st_size = static_cast<off_t>(size);
- // Preserve executable bits from the real file on disk so native Linux
- // executables added as mods can actually be launched through the VFS.
+ // Use cached mode bits if available, otherwise stat() the real file.
mode_t mode = 0644;
- if (!real_path.empty()) {
+ if (cached_mode != 0) {
+ mode = cached_mode;
+ } else if (!real_path.empty()) {
struct stat real_st;
if (::stat(real_path.c_str(), &real_st) == 0) {
mode = real_st.st_mode & 0777;
@@ -765,18 +781,32 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
conn->want |= FUSE_CAP_SPLICE_READ;
}
- // More async readahead/writeback slots (default is 12, far too low).
- conn->max_background = 128;
- conn->congestion_threshold = 96;
+ // Allow concurrent submission of split direct I/O requests.
+ // Harmless when not triggered; helps if Wine opens files with O_DIRECT.
+ if (conn->capable & FUSE_CAP_ASYNC_DIO) {
+ conn->want |= FUSE_CAP_ASYNC_DIO;
+ }
+
+ // Softer dentry invalidation: mark entries as expired rather than
+ // forcefully removing them, reducing cascading cache evictions.
+ if (conn->capable & FUSE_CAP_EXPIRE_ONLY) {
+ conn->want |= FUSE_CAP_EXPIRE_ONLY;
+ }
+
+ // Maximize async I/O slots (default is 12). The kernel will still only
+ // dispatch as many as there are actual concurrent requests, so higher
+ // values just raise the ceiling without wasting memory.
+ conn->max_background = 32767;
+ conn->congestion_threshold = 24576;
- // Increase max read/write buffer to 1MB (kernel 4.20+ supports this).
- // Reduces FUSE round-trips for large file reads (textures, meshes, BSAs).
- constexpr unsigned int ONE_MB = 1048576;
- if (conn->max_readahead < ONE_MB) {
- conn->max_readahead = ONE_MB;
+ // Request large read/write buffers. The kernel caps these at its own
+ // compiled-in maximum, so overshooting is harmless.
+ constexpr unsigned int EIGHT_MB = 8 * 1048576;
+ if (conn->max_readahead < EIGHT_MB) {
+ conn->max_readahead = EIGHT_MB;
}
- if (conn->max_write < ONE_MB) {
- conn->max_write = ONE_MB;
+ if (conn->max_write < EIGHT_MB) {
+ conn->max_write = EIGHT_MB;
}
std::fprintf(stderr,
@@ -831,6 +861,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
lce.child_ino = 0;
lce.entry = e;
ctx->lookup_cache[cacheKey] = lce;
+ ctx->lookup_cache_by_parent[cacheKey.first].push_back(cacheKey.second);
}
fuse_reply_entry(req, &e);
@@ -889,6 +920,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
lce.child_ino = childIno;
lce.entry = e;
ctx->lookup_cache[cacheKey] = lce;
+ ctx->lookup_cache_by_parent[cacheKey.first].push_back(cacheKey.second);
}
fuse_reply_entry(req, &e);
@@ -1340,18 +1372,17 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
openedTempFd = true;
}
- std::vector<char> out(size);
- const ssize_t n = pread(localFd, out.data(), size, off);
+ // Zero-copy read: splice data directly from backing fd to /dev/fuse,
+ // bypassing userspace entirely. The kernel transfers data kernel-to-kernel.
+ struct fuse_bufvec buf = FUSE_BUFVEC_INIT(size);
+ buf.buf[0].flags = static_cast<fuse_buf_flags>(FUSE_BUF_IS_FD | FUSE_BUF_FD_SEEK);
+ buf.buf[0].fd = localFd;
+ buf.buf[0].pos = off;
+ fuse_reply_data(req, &buf, FUSE_BUF_SPLICE_MOVE);
+
if (openedTempFd) {
close(localFd);
}
-
- if (n < 0) {
- fuse_reply_err(req, EIO);
- return;
- }
-
- fuse_reply_buf(req, out.data(), static_cast<size_t>(n));
}
void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index 4ebf2bd..e105c07 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -56,6 +56,7 @@ struct Mo2FsContext
uint64_t size = 0;
std::chrono::system_clock::time_point mtime{};
std::string real_path;
+ mode_t cached_mode = 0;
};
struct OpenDir
{
@@ -97,6 +98,9 @@ struct Mo2FsContext
}
};
std::unordered_map<std::pair<fuse_ino_t, std::string>, LookupCacheEntry, PairHash> lookup_cache;
+ // Reverse index: parent_ino → set of normalized child names in lookup_cache.
+ // Makes invalidation O(children) instead of O(total_cache_size).
+ std::unordered_map<fuse_ino_t, std::vector<std::string>> lookup_cache_by_parent;
mutable std::mutex lookup_cache_mutex;
std::atomic<uint64_t> next_dh{1};
diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h
index 12baeea..2e33a9a 100644
--- a/src/src/vfs/vfstree.h
+++ b/src/src/vfs/vfstree.h
@@ -18,6 +18,7 @@ struct VfsFileInfo
std::chrono::system_clock::time_point mtime{};
std::string origin;
bool is_backing = false;
+ mode_t cached_mode = 0; // permission bits from stat() at tree-build time
};
struct CachedBaseFile