diff options
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(®_file)?; - file.write_all(WINE_SETTINGS_REG.as_bytes())?; - - let wine_bin = proton.wine_binary().ok_or_else(|| { - let err_msg = format!( - "Wine binary not found for Proton '{}' (checked files/bin/wine and dist/bin/wine)", - proton.name - ); - log_callback(format!("Error: {}", err_msg)); - err_msg - })?; - - let wineserver_bin = proton.wineserver_binary().unwrap_or_else(|| { - wine_bin.with_file_name("wineserver") - }); - - let bin_dir = proton.bin_dir().ok_or_else(|| { - let err_msg = "Could not determine Proton bin directory"; - log_callback(format!("Error: {}", err_msg)); - err_msg - })?; - - let path_env = format!( - "{}:{}", - bin_dir.to_string_lossy(), - std::env::var("PATH").unwrap_or_default() - ); - - log_callback("Applying Wine registry settings...".to_string()); - log_install("Running wine regedit..."); - - let reg_envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_path.display().to_string()), - ("WINE", wine_bin.display().to_string()), - ("WINESERVER", wineserver_bin.display().to_string()), - ("PATH", path_env), - ("WINEDLLOVERRIDES", "mshtml=d".to_string()), - ("PROTON_USE_XALIA", "0".to_string()), - ]; - let regedit_status = runtime_wrap::build_command(&wine_bin, ®_envs) - .arg("regedit") - .arg(®_file) - .status(); - - match regedit_status { - Ok(status) => { - if status.success() { - log_callback("Registry settings applied successfully".to_string()); - log_install("Wine registry settings applied successfully"); - } else { - let msg = format!("regedit exited with code {:?}", status.code()); - log_callback(format!("Warning: {}", msg)); - log_warning(&msg); - } - } - Err(e) => { - let msg = format!("Failed to run regedit: {}", e); - log_callback(format!("Error: {}", msg)); - log_error(&msg); - return Err(msg.into()); - } - } - - let _ = fs::remove_file(®_file); - Ok(()) -} diff --git a/libs/nak/src/installers/prefix_setup.rs b/libs/nak/src/installers/prefix_setup.rs 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(®_file, ®_content)?; - - let drive_envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_root.display().to_string()), - ("WINEDLLOVERRIDES", "mshtml=d".to_string()), - ("PROTON_USE_XALIA", "0".to_string()), - ]; - let status = runtime_wrap::build_command(&wine_bin, &drive_envs) - .arg("regedit") - .arg(®_file) - .status(); - - let _ = fs::remove_file(®_file); - - match status { - Ok(s) if s.success() => { - log_install("Registry drive entries cleaned up"); - } - Ok(s) => { - log_warning(&format!("Registry cleanup may have failed (exit code: {:?})", s.code())); - } - Err(e) => { - log_warning(&format!("Failed to run registry cleanup: {}", e)); - } - } - } - - Ok(()) -} - -/// Public wrapper to clean up Wine drives on an existing prefix -/// -/// This can be called from the UI to fix drive letter issues on existing prefixes. -pub fn cleanup_prefix_drives( - prefix_root: &Path, - proton: &SteamProton, -) -> Result<Vec<String>, Box<dyn Error>> { - let dosdevices = prefix_root.join("dosdevices"); - - if !dosdevices.exists() { - return Err("dosdevices directory not found - is this a valid Wine prefix?".into()); - } - - // Collect drives before cleanup - let mut removed = Vec::new(); - - if let Ok(entries) = fs::read_dir(&dosdevices) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_lowercase(); - if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) - && !ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { - removed.push(name.to_uppercase()); - } - } - } - - // Run the actual cleanup - cleanup_wine_drives(prefix_root, proton)?; - - Ok(removed) -} - -/// Set Windows 11 mode for the prefix using winetricks -/// -/// This should be called AFTER all components are installed. -/// Sets the Windows version to Windows 11 which is required for MO2 to work properly. -fn set_windows_11_mode( - prefix_root: &Path, - proton: &SteamProton, - ctx: &TaskContext, -) -> Result<(), Box<dyn Error>> { - use crate::deps::ensure_winetricks; - - let winetricks_path = ensure_winetricks()?; - - let Some(wine_bin) = proton.wine_binary() else { - return Err("Wine binary not found".into()); - }; - - let Some(wineserver_bin) = proton.wineserver_binary() else { - return Err("Wineserver binary not found".into()); - }; - - log_install("Running winetricks win11..."); - - let envs: Vec<(&str, String)> = vec![ - ("WINE", wine_bin.display().to_string()), - ("WINESERVER", wineserver_bin.display().to_string()), - ("WINEPREFIX", prefix_root.display().to_string()), - ]; - let mut cmd = runtime_wrap::build_command(&winetricks_path, &envs); - cmd.arg("-q").arg("win11"); - - let status = ctx.run_cancellable(cmd)?; - - if !status.success() { - return Err(format!("winetricks win11 failed with exit code: {:?}", status.code()).into()); - } - - log_install("Windows 11 mode set successfully"); - Ok(()) -} - -// ============================================================================= -// DPI Configuration -// ============================================================================= - -/// Common DPI presets with their percentage labels -pub const DPI_PRESETS: &[(u32, &str)] = &[ - (96, "100%"), - (120, "125%"), - (144, "150%"), - (192, "200%"), -]; - -/// Apply DPI setting to a Wine prefix via registry -pub fn apply_dpi( - prefix_root: &Path, - proton: &SteamProton, - dpi_value: u32, -) -> Result<(), Box<dyn Error>> { - log_install(&format!("Applying DPI {} to prefix", dpi_value)); - - let wine_bin = proton.wine_binary().ok_or_else(|| { - format!("Wine binary not found for Proton '{}'", proton.name) - })?; - - let envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_root.display().to_string()), - ("PROTON_USE_XALIA", "0".to_string()), - ]; - let status = runtime_wrap::build_command(&wine_bin, &envs) - .arg("reg") - .arg("add") - .arg(r"HKCU\Control Panel\Desktop") - .arg("/v") - .arg("LogPixels") - .arg("/t") - .arg("REG_DWORD") - .arg("/d") - .arg(dpi_value.to_string()) - .arg("/f") - .status()?; - - if !status.success() { - return Err(format!("Failed to apply DPI setting: exit code {:?}", status.code()).into()); - } - - log_install(&format!("DPI {} applied successfully", dpi_value)); - Ok(()) -} - -/// Launch a test application (winecfg, regedit, notepad, control) and return its PID -pub fn launch_dpi_test_app( - prefix_root: &Path, - proton: &SteamProton, - app_name: &str, -) -> Result<Child, Box<dyn Error>> { - let wine_bin = proton.wine_binary().ok_or_else(|| { - format!("Wine binary not found for Proton '{}'", proton.name) - })?; - - log_install(&format!( - "Launching {} with wine={:?} prefix={:?}", - app_name, wine_bin, prefix_root - )); - - if !prefix_root.exists() { - return Err(format!("Prefix not found: {:?}", prefix_root).into()); - } - - let envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_root.display().to_string()), - ("PROTON_USE_XALIA", "0".to_string()), - ]; - let child = runtime_wrap::build_command(&wine_bin, &envs) - .arg(app_name) - .spawn()?; - - Ok(child) -} - -/// Kill the wineserver for a prefix (terminates all Wine processes in that prefix) -pub fn kill_wineserver(prefix_root: &Path, proton: &SteamProton) { - log_install("Killing wineserver for prefix"); - - let Some(wineserver_bin) = proton.wineserver_binary() else { - log_install("Wineserver binary not found, skipping kill"); - return; - }; - - let envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_root.display().to_string()), - ]; - let _ = runtime_wrap::build_command(&wineserver_bin, &envs) - .arg("-k") - .status(); -} - -// ============================================================================ -// Game Registry Detection (uses game_finder module) -// ============================================================================ - -/// Auto-detect installed games and apply registry entries -/// -/// This uses the game_finder module to detect installed games across all -/// supported launchers (Steam, Heroic, Bottles) and automatically adds -/// the registry entries so mod managers can detect them. -pub fn auto_apply_game_registries( - prefix_path: &Path, - proton: &SteamProton, - log_callback: &impl Fn(String), - _app_id: Option<u32>, -) { - let Some(wine_bin) = proton.wine_binary() else { - log_warning("Wine binary not found, skipping game registry auto-detection"); - return; - }; - - // Use the new game_finder module to detect all games - let scan_result = detect_all_games(); - let mut applied_count = 0; - - for game in &scan_result.games { - // Only process games that have registry info - let (Some(reg_path), Some(reg_value)) = (&game.registry_path, &game.registry_value) else { - continue; - }; - - // Apply registry for this game - if apply_game_registry( - prefix_path, - &wine_bin, - game, - reg_path, - reg_value, - log_callback, - ) { - applied_count += 1; - } - } - - if applied_count > 0 { - log_callback(format!("Auto-configured {} game(s) in registry", applied_count)); - log_install(&format!("Auto-applied registry for {} detected game(s)", applied_count)); - } -} - -/// Apply a game's registry entry with a custom install path. -/// -/// Looks up the game by name in KNOWN_GAMES, then writes the registry entry -/// pointing to `install_path`. Use this when the game is in a custom/stock -/// folder that auto-detection won't find. -pub fn apply_registry_for_game_path( - prefix_path: &Path, - proton: &SteamProton, - game_name: &str, - install_path: &Path, - log_callback: &impl Fn(String), -) -> Result<(), String> { - let Some(wine_bin) = proton.wine_binary() else { - return Err("Wine binary not found".to_string()); - }; - - let known = known_games::find_by_name(game_name); - let (reg_path, reg_value) = if let Some(kg) = known { - (kg.registry_path, kg.registry_value) - } else { - return Err(format!("Unknown game: {game_name}")); - }; - - let fake_game = Game { - name: game_name.to_string(), - install_path: install_path.to_path_buf(), - app_id: known.map(|k| k.steam_app_id.to_string()).unwrap_or_default(), - prefix_path: None, - launcher: Launcher::Steam { is_flatpak: false, is_snap: false }, - my_games_folder: known.and_then(|k| k.my_games_folder.map(String::from)), - appdata_local_folder: known.and_then(|k| k.appdata_local_folder.map(String::from)), - appdata_roaming_folder: known.and_then(|k| k.appdata_roaming_folder.map(String::from)), - registry_path: Some(reg_path.to_string()), - registry_value: Some(reg_value.to_string()), - }; - - if apply_game_registry(prefix_path, &wine_bin, &fake_game, reg_path, reg_value, log_callback) { - Ok(()) - } else { - Err(format!("Failed to apply registry for {game_name}")) - } -} - -/// Return the list of known game names for UI display. -pub fn known_game_names() -> Vec<&'static str> { - known_games::KNOWN_GAMES.iter().map(|g| g.name).collect() -} - -/// Apply registry entry for a single game -fn apply_game_registry( - prefix_path: &Path, - wine_bin: &Path, - game: &Game, - reg_path: &str, - reg_value: &str, - log_callback: &impl Fn(String), -) -> bool { - log_callback(format!("Found {}, applying registry...", game.name)); - - // Convert Linux path to Wine Z: drive path with escaped backslashes for .reg file - let linux_path = game.install_path.to_string_lossy(); - let wine_path_reg = format!("Z:{}", linux_path.replace('/', "\\\\")); - - // Create .reg file content - let reg_content = format!( - r#"Windows Registry Editor Version 5.00 - -[HKEY_LOCAL_MACHINE\{}] -"{}"="{}" - -[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\{}] -"{}"="{}" -"#, - reg_path, - reg_value, - wine_path_reg, - reg_path.strip_prefix("Software\\").unwrap_or(reg_path), - reg_value, - wine_path_reg, - ); - - // Write temp .reg file - let tmp_dir = AppConfig::get_tmp_path(); - let reg_file = tmp_dir.join(format!("game_reg_{}.reg", game.app_id)); - - if let Err(e) = fs::write(®_file, ®_content) { - log_warning(&format!("Failed to write registry file for {}: {}", game.name, e)); - return false; - } - - // Apply registry - let reg_envs: Vec<(&str, String)> = vec![ - ("WINEPREFIX", prefix_path.display().to_string()), - ("WINEDLLOVERRIDES", "mshtml=d".to_string()), - ("PROTON_USE_XALIA", "0".to_string()), - ]; - let status = runtime_wrap::build_command(wine_bin, ®_envs) - .arg("regedit") - .arg(®_file) - .status(); - - let _ = fs::remove_file(®_file); - - match status { - Ok(s) if s.success() => { - log_install(&format!("Applied registry for {} -> {:?}", game.name, game.install_path)); - true - } - Ok(s) => { - log_warning(&format!( - "Registry for {} may have failed (exit code: {:?})", - game.name, - s.code() - )); - false - } - Err(e) => { - log_warning(&format!("Failed to apply registry for {}: {}", game.name, e)); - false - } - } -} diff --git a/libs/nak/src/installers/symlinks.rs b/libs/nak/src/installers/symlinks.rs 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 |
