From d22d8c267b012729b6b84142def2a30ca4691d7a Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Fri, 20 Feb 2026 23:53:26 -0600 Subject: Fix AppImage plugin loading, save timestamps, and INI corruption - Add AppConfig::pluginsPath()/dllsPath() that respect MO2_PLUGINS_DIR and MO2_DLLS_DIR env vars, fixing plugin discovery when plugins live inside read-only AppImage squashfs (#22, game plugin not found errors) - Preserve file modification times in save deploy/sync so games display correct save timestamps instead of all showing the same time (#17) - Replace QSettings INI reader in saves tab with direct line-by-line parser to avoid backslash-as-line-continuation corruption of sLocalSavePath=__MO_Saves\ (#22, saves tab showing wrong directory) - Add diagnostic logging to plugin symlink creation, save backup/restore operations to help diagnose silent failures through prefix symlinks - Fix Python SONAME mismatch in AppImage build (patch portable Python SONAME to match librunner.so DT_NEEDED, preventing dual libpython load) - Bundle PyQt6 Qt dependencies, improve RPATH for lib/ and plugins/ Co-Authored-By: Claude Opus 4.6 --- docker/build-inner.sh | 185 ++++--- libs/nak/src/game_finder/heroic.rs | 23 +- libs/nak/src/game_finder/known_games.rs | 159 +++++- libs/nak/src/game_finder/mod.rs | 31 +- libs/nak_ffi/include/nak_ffi.h | 1 + libs/nak_ffi/src/lib.rs | 2 + libs/plugin_python/src/runner/pythonrunner.cpp | 102 ++-- src/src/commandline.cpp | 3 +- src/src/mainwindow.cpp | 162 +++--- src/src/moapplication.cpp | 129 ++--- src/src/organizercore.cpp | 129 +++-- src/src/plugincontainer.cpp | 326 ++++++------ src/src/processrunner.cpp | 412 +++++++--------- src/src/protonlauncher.cpp | 201 ++++++-- src/src/protonlauncher.h | 2 + src/src/savestab.cpp | 655 +++++++++++++------------ src/src/shared/appconfig.cpp | 22 + src/src/shared/appconfig.h | 10 + src/src/spawn.cpp | 550 +++++++++++---------- src/src/wineprefix.cpp | 36 +- 20 files changed, 1804 insertions(+), 1336 deletions(-) diff --git a/docker/build-inner.sh b/docker/build-inner.sh index dda9b14..847bfae 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -30,35 +30,7 @@ mkdir -p "${OUT_DIR}/plugins" "${OUT_DIR}/dlls" "${OUT_DIR}/lib" # ── Main binary + helpers ── cp -f "${RUNDIR}/ModOrganizer" "${OUT_DIR}/ModOrganizer-core" -if [ -f "${RUNDIR}/umu-run" ]; then - # Patch umu-run to preserve STEAM_COMPAT_CLIENT_INSTALL_PATH from the - # parent environment. Upstream umu-run initialises this to "" and never - # picks up the caller's value, which prevents the Steam client libraries - # from being found. - UMU_PATCH_DIR="$(mktemp -d)" - (cd "${UMU_PATCH_DIR}" && "${BUILD_PY}" << PATCHEOF -import zipfile, pathlib -zf = zipfile.ZipFile('/src/${RUNDIR}/umu-run') -zf.extractall('src') -run_py = pathlib.Path('src/umu/umu_run.py') -src = run_py.read_text() - -# Patch 1: preserve STEAM_COMPAT_CLIENT_INSTALL_PATH -old1 = ' env["STEAM_COMPAT_INSTALL_PATH"] = os.environ.get("STEAM_COMPAT_INSTALL_PATH", "")' -new1 = (old1 - + '\n env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ.get(' - + '\n "STEAM_COMPAT_CLIENT_INSTALL_PATH", ""' - + '\n )') -if old1 in src and 'STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ' not in src: - src = src.replace(old1, new1) - -run_py.write_text(src) -PATCHEOF -) - "${BUILD_PY}" -m zipapp "${UMU_PATCH_DIR}/src" -o "${OUT_DIR}/umu-run" -p '/usr/bin/env python3' - chmod +x "${OUT_DIR}/umu-run" - rm -rf "${UMU_PATCH_DIR}" -fi +[ -f "${RUNDIR}/umu-run" ] && cp -f "${RUNDIR}/umu-run" "${OUT_DIR}/umu-run" [ -f "${RUNDIR}/README-PORTABLE.txt" ] && cp -f "${RUNDIR}/README-PORTABLE.txt" "${OUT_DIR}/" [ -f "/src/src/fluorine-manager" ] && cp -f "/src/src/fluorine-manager" "${OUT_DIR}/" @@ -156,6 +128,18 @@ find "${OUT_DIR}/plugins" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep find "${OUT_DIR}/lib" -name "*.so*" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" # lootcli [ -f "${OUT_DIR}/lootcli" ] && collect_deps "${OUT_DIR}/lootcli" >> "${ALL_DEPS}" +# PyQt6 extension modules — these link against Qt6 libs that MO2 binaries don't +# directly depend on (e.g. libQt6OpenGLWidgets, libQt6PrintSupport). Without +# bundling these, PyQt6 falls back to host Qt which may be a different version. +# Scan from the system dist-packages (portable Python isn't copied yet). +for pyqt_search in /usr/lib/python3/dist-packages/PyQt6 \ + "/usr/lib/python${PY_MM}/dist-packages/PyQt6" \ + "/usr/local/lib/python${PY_MM}/dist-packages/PyQt6"; do + if [ -d "${pyqt_search}" ]; then + find "${pyqt_search}" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" + break + fi +done sort -u "${ALL_DEPS}" | while read -r dep; do dep_name="$(basename "${dep}")" @@ -231,10 +215,29 @@ if [ -d "${PORTABLE_PY}" ]; then find "${OUT_DIR}/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find "${OUT_DIR}/python" -name "*.pyc" -delete 2>/dev/null || true - # Ensure versioned soname symlink exists (pybind11 links against libpython3.13.so.1.0). - if [ -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so" ] && \ - [ ! -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" ]; then - ln -sf "libpython${PY_MM}.so" "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" + # The portable Python's libpython has SONAME "libpython3.13.so", but + # pybind11::embed links librunner.so against the build-venv's libpython + # which may have SONAME "libpython3.13.so.1.0". A SONAME mismatch causes + # two copies of libpython to be loaded at runtime, making + # Py_IsInitialized() return false after Py_InitializeFromConfig() succeeds. + # + # Fix: patch the portable Python's SONAME to include the .1.0 suffix, + # matching what the linker recorded in librunner.so's DT_NEEDED. + PP_LIBPY="${OUT_DIR}/python/lib/libpython${PY_MM}.so" + if [ -f "${PP_LIBPY}" ]; then + CURRENT_SONAME="$(readelf -d "${PP_LIBPY}" 2>/dev/null | grep SONAME | sed 's/.*\[//' | sed 's/\]//')" + echo "Portable Python SONAME: ${CURRENT_SONAME}" + if [ "${CURRENT_SONAME}" = "libpython${PY_MM}.so" ]; then + echo "Patching SONAME to libpython${PY_MM}.so.1.0 ..." + patchelf --set-soname "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" + # Rename the file to match and create backwards symlink. + mv "${PP_LIBPY}" "${PP_LIBPY}.1.0" + ln -sf "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" + fi + # Ensure the .1.0 name exists (either as the real file or a symlink). + if [ ! -e "${PP_LIBPY}.1.0" ]; then + ln -sf "libpython${PY_MM}.so" "${PP_LIBPY}.1.0" + fi fi else echo "ERROR: Portable Python not found at ${PORTABLE_PY}" @@ -274,9 +277,11 @@ done # ── Fix RPATH so binaries find libs without LD_LIBRARY_PATH ── echo "Patching RPATH..." -patchelf --set-rpath '$ORIGIN/lib' "${OUT_DIR}/ModOrganizer-core" +patchelf --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core" [ -f "${OUT_DIR}/lootcli" ] && patchelf --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli" -find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../lib' {} \; 2>/dev/null || true +find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true +# Libraries in lib/ (e.g. librunner.so) need to find sibling libs and python/lib. +find "${OUT_DIR}/lib" -name "*.so" -exec patchelf --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true # ── Validate embedded Python runtime ── if ! PYTHONHOME="${OUT_DIR}/python" \ @@ -295,15 +300,34 @@ set -euo pipefail SELF="$(readlink -f "$0")" HERE="$(cd "$(dirname "$SELF")" && pwd)" export PATH="${HERE}:${PATH}" -export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/python/lib:${LD_LIBRARY_PATH:-}" + +# ── Sync portable Python to data dir (only when outdated) ── +FLUORINE_DATA="${HOME}/.local/share/fluorine" +PYTHON_DST="${FLUORINE_DATA}/python" +PYTHON_SRC="${HERE}/python" + +if [ -d "${PYTHON_SRC}" ]; then + CURRENT_VER="$(stat -c '%i:%Y' "${PYTHON_SRC}/bin/python3" 2>/dev/null || echo "unknown")" + MARKER="${PYTHON_DST}/.version" + + if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then + echo "Syncing Python runtime to ${PYTHON_DST}..." >&2 + rm -rf "${PYTHON_DST}" + mkdir -p "${PYTHON_DST}" + (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) + echo "${CURRENT_VER}" > "${MARKER}" + fi +fi + +export LD_LIBRARY_PATH="${HERE}/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" export MO2_BASE_DIR="${HERE}" export MO2_PLUGINS_DIR="${HERE}/plugins" export MO2_DLLS_DIR="${HERE}/dlls" -export MO2_PYTHON_DIR="${HERE}/python" +export MO2_PYTHON_DIR="${PYTHON_DST}" # PYTHONHOME is set only for the MO2 process (not exported to children like # umu-run/Proton which have their own Python). MO2_PYTHON_DIR lets the # binary reconstruct it internally. -MO2_PYTHONHOME="${HERE}/python" +MO2_PYTHONHOME="${PYTHON_DST}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME # Use bundled Qt6 plugins. @@ -341,6 +365,19 @@ if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" fi +# Symlink the portable Python's libpython into usr/lib/ so that: +# 1) linuxdeploy sees the dependency as already satisfied and doesn't bundle +# the container's system libpython (which lacks static built-in modules) +# 2) librunner.so (which lives in usr/lib/) can resolve libpython via $ORIGIN +PP_APPDIR_LIB="${APPDIR}/usr/bin/python/lib" +for pp_lib in "${PP_APPDIR_LIB}"/libpython*.so*; do + [ -e "${pp_lib}" ] || continue + pp_name="$(basename "${pp_lib}")" + # Remove any copy linuxdeploy might have placed, then symlink to portable. + rm -f "${APPDIR}/usr/lib/${pp_name}" + ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}" +done + # Desktop integration cp -f "${OUT_DIR}/com.fluorine.manager.desktop" "${APPDIR}/usr/share/applications/" cp -f "${OUT_DIR}/com.fluorine.manager.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/" @@ -358,10 +395,13 @@ if [ -f "/usr/share/icons/default/index.theme" ]; then cp -f "/usr/share/icons/default/index.theme" "${APPDIR}/usr/share/icons/default/" fi -# Update RPATH for new lib location -patchelf --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/ModOrganizer-core" +# Update RPATH for new lib location. +# usr/lib/ contains librunner.so and other MO2 libs. The symlinks above make +# libpython resolvable via $ORIGIN for libraries in usr/lib/. +patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core" [ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli" find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true +find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --set-rpath '$ORIGIN' {} \; 2>/dev/null || true # Create AppRun wrapper cat > "${APPDIR}/AppRun" <<'APPRUN' @@ -372,35 +412,42 @@ HERE="$(cd "$(dirname "$SELF")" && pwd)" BIN="${HERE}/usr/bin" APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")" -sync_dir() { - local name="$1" - local src="${BIN}/${name}" - local dst="${APPIMAGE_DIR}/${name}" - [ -d "${src}" ] || return 0 - mkdir -p "${dst}" - (cd "${src}" && tar --exclude-vcs -cf - .) | (cd "${dst}" && tar -xf -) -} +# ── Sync portable Python to data dir (only when outdated) ── +# Plugins and dlls are read-only and loaded directly from the squashfs mount. +# Python needs a writable copy because pip/plugins may modify site-packages. +FLUORINE_DATA="${HOME}/.local/share/fluorine" +PYTHON_DST="${FLUORINE_DATA}/python" +PYTHON_SRC="${BIN}/python" + +if [ -d "${PYTHON_SRC}" ]; then + APPIMAGE_REAL="$(readlink -f "${APPIMAGE:-$0}")" + CURRENT_VER="$(stat -c '%i:%Y' "${APPIMAGE_REAL}" 2>/dev/null || echo "unknown")" + MARKER="${PYTHON_DST}/.version" + + if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then + echo "Syncing Python runtime to ${PYTHON_DST}..." + rm -rf "${PYTHON_DST}" + mkdir -p "${PYTHON_DST}" + (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) + echo "${CURRENT_VER}" > "${MARKER}" + fi +fi -# Keep runtime payload outside the transient AppImage mount. -sync_dir "plugins" -sync_dir "dlls" -sync_dir "python" +# Save the original (pre-AppImage) environment so game launches can restore it. +# Without this, AppImage's LD_LIBRARY_PATH/PATH leak into umu-run/Proton and +# cause library conflicts that make games crash. +export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" +export FLUORINE_ORIG_PATH="${PATH}" +export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}" export PATH="${BIN}:${PATH}" -if [ -d "${APPIMAGE_DIR}/python/lib" ]; then - export LD_LIBRARY_PATH="${HERE}/usr/lib:${APPIMAGE_DIR}/python/lib:${LD_LIBRARY_PATH:-}" -else - export LD_LIBRARY_PATH="${HERE}/usr/lib:${BIN}/python/lib:${LD_LIBRARY_PATH:-}" -fi +export LD_LIBRARY_PATH="${HERE}/usr/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" export MO2_BASE_DIR="${APPIMAGE_DIR}" -export MO2_PLUGINS_DIR="${APPIMAGE_DIR}/plugins" -export MO2_DLLS_DIR="${APPIMAGE_DIR}/dlls" -if [ -d "${APPIMAGE_DIR}/python/lib" ]; then - export MO2_PYTHON_DIR="${APPIMAGE_DIR}/python" -else - export MO2_PYTHON_DIR="${BIN}/python" -fi +export MO2_PLUGINS_DIR="${BIN}/plugins" +export MO2_DLLS_DIR="${BIN}/dlls" +export MO2_PYTHON_DIR="${PYTHON_DST}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME @@ -434,12 +481,22 @@ fi # Use linuxdeploy to generate the AppImage. # We skip the Qt plugin since we already handle Qt plugin paths at runtime # and our deps are already staged. +# +# linuxdeploy scans binary deps and bundles them. We must prevent it from +# bundling the container's system libpython (which lacks statically-compiled +# extension modules like _ctypes, resource, _lzma). The portable Python +# runtime in usr/bin/python/lib/ is the correct copy. Symlinks in usr/lib/ +# point to it, and --exclude-library prevents linuxdeploy from overwriting them. export ARCH=x86_64 +# Tell linuxdeploy to skip libpython — we bundle the portable Python's copy +# via symlinks in usr/lib/ and must not replace it with the container's version. +export LINUXDEPLOY_EXCLUDE_MODULES="libpython" "${DEPLOY_DIR}/AppRun" \ --appdir "${APPDIR}" \ --output appimage \ --desktop-file "${APPDIR}/usr/share/applications/com.fluorine.manager.desktop" \ --icon-file "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png" \ + --exclude-library "libpython*" \ 2>&1 || { echo "WARNING: linuxdeploy AppImage generation failed, falling back to staging dir output" } diff --git a/libs/nak/src/game_finder/heroic.rs b/libs/nak/src/game_finder/heroic.rs index 7bdda09..e80c1e1 100644 --- a/libs/nak/src/game_finder/heroic.rs +++ b/libs/nak/src/game_finder/heroic.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; -use super::known_games::find_by_gog_id; +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}; @@ -202,11 +202,16 @@ fn detect_epic_games(heroic_path: &Path) -> Vec { } // Get title - let name = game_obj + let title = game_obj .get("title") .and_then(|v| v.as_str()) - .unwrap_or(app_name) - .to_string(); + .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); @@ -219,11 +224,11 @@ fn detect_epic_games(heroic_path: &Path) -> Vec { launcher: Launcher::Heroic { store: HeroicStore::Epic, }, - my_games_folder: None, - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: None, - registry_value: None, + 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()), }); } } diff --git a/libs/nak/src/game_finder/known_games.rs b/libs/nak/src/game_finder/known_games.rs index 8a30c18..117b0c7 100644 --- a/libs/nak/src/game_finder/known_games.rs +++ b/libs/nak/src/game_finder/known_games.rs @@ -2,6 +2,8 @@ //! //! 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 @@ -13,8 +15,10 @@ pub struct KnownGame { pub name: &'static str, /// Steam App ID pub steam_app_id: &'static str, - /// GOG App ID (if available) + /// 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) @@ -35,7 +39,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Enderal", steam_app_id: "933480", - gog_app_id: None, + 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, @@ -46,7 +51,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Enderal Special Edition", steam_app_id: "976620", - gog_app_id: None, + 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, @@ -58,6 +64,7 @@ pub const KNOWN_GAMES: &[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, @@ -68,7 +75,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Fallout 4", steam_app_id: "377160", - gog_app_id: None, + 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, @@ -80,6 +88,7 @@ pub const KNOWN_GAMES: &[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, @@ -91,6 +100,7 @@ pub const KNOWN_GAMES: &[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, @@ -102,6 +112,7 @@ pub const KNOWN_GAMES: &[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, @@ -113,6 +124,7 @@ pub const KNOWN_GAMES: &[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, @@ -123,7 +135,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Skyrim", steam_app_id: "72850", - gog_app_id: None, // Not on GOG + 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, @@ -134,7 +147,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Skyrim Special Edition", steam_app_id: "489830", - gog_app_id: Some("1711230643"), // Skyrim SE Anniversary Edition + 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, @@ -146,6 +160,7 @@ pub const KNOWN_GAMES: &[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, @@ -156,7 +171,8 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ KnownGame { name: "Starfield", steam_app_id: "1716740", - gog_app_id: None, + gog_app_id: None, // Xbox/Steam exclusive + epic_app_id: None, my_games_folder: Some("Starfield"), appdata_local_folder: None, appdata_roaming_folder: None, @@ -169,6 +185,7 @@ pub const KNOWN_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, @@ -180,6 +197,7 @@ pub const KNOWN_GAMES: &[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, @@ -192,6 +210,7 @@ pub const KNOWN_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, @@ -201,6 +220,15 @@ pub const KNOWN_GAMES: &[KnownGame] = &[ }, ]; +/// 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); @@ -209,9 +237,79 @@ pub fn find_by_steam_id(app_id: &str) -> Option<&'static KnownGame> { /// 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.gog_app_id == Some(app_id)) + .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::() + .split_whitespace() + .collect::>() + .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) @@ -234,7 +332,7 @@ fn normalize_steam_id(app_id: &str) -> &str { #[cfg(test)] mod tests { - use super::find_by_steam_id; + use super::*; #[test] fn fallout_3_goty_alias_maps_to_fallout_3() { @@ -242,4 +340,47 @@ mod tests { 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 index c383c8a..600751a 100644 --- a/libs/nak/src/game_finder/mod.rs +++ b/libs/nak/src/game_finder/mod.rs @@ -20,7 +20,10 @@ use std::path::PathBuf; pub use bottles::detect_bottles_games; pub use heroic::detect_heroic_games; -pub use known_games::{find_by_gog_id, find_by_name, find_by_steam_id, KnownGame, KNOWN_GAMES}; +pub use 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}; @@ -161,10 +164,16 @@ impl GameScanResult { // Public API // ============================================================================ -/// Detect all installed games from all supported launchers +/// 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); @@ -177,9 +186,27 @@ pub fn detect_all_games() -> GameScanResult { 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(); diff --git a/libs/nak_ffi/include/nak_ffi.h b/libs/nak_ffi/include/nak_ffi.h index 8e24041..cc66d9e 100644 --- a/libs/nak_ffi/include/nak_ffi.h +++ b/libs/nak_ffi/include/nak_ffi.h @@ -46,6 +46,7 @@ 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 */ diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs index fe18cf3..65592fa 100644 --- a/libs/nak_ffi/src/lib.rs +++ b/libs/nak_ffi/src/lib.rs @@ -212,6 +212,7 @@ 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, @@ -236,6 +237,7 @@ static KNOWN_GAMES_FFI: std::sync::LazyLock = std::sync::LazyLock 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), diff --git a/libs/plugin_python/src/runner/pythonrunner.cpp b/libs/plugin_python/src/runner/pythonrunner.cpp index b0bf48c..3bc62a1 100644 --- a/libs/plugin_python/src/runner/pythonrunner.cpp +++ b/libs/plugin_python/src/runner/pythonrunner.cpp @@ -89,54 +89,62 @@ namespace mo2::python { static const char* argv0 = "ModOrganizer.exe"; #ifndef _WIN32 -#ifdef MO2_PYTHON_SHARED_LIBRARY // Ensure libpython symbols are globally visible for extension modules // loaded later (_struct, PyQt6, etc.). - bool usedNoload = true; - void* pyHandle = - dlopen(MO2_PYTHON_SHARED_LIBRARY, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD); - if (pyHandle == nullptr) { - usedNoload = false; - pyHandle = dlopen(MO2_PYTHON_SHARED_LIBRARY, RTLD_NOW | RTLD_GLOBAL); - } - if (pyHandle == nullptr) { - MOBase::log::warn("python: failed to dlopen '{}': {}", - MO2_PYTHON_SHARED_LIBRARY, dlerror()); - } else { - MOBase::log::debug("python: '{}' promoted to RTLD_GLOBAL ({})", - MO2_PYTHON_SHARED_LIBRARY, - usedNoload ? "RTLD_NOLOAD" : "fresh load"); - } - - // Diagnose which DSO the Python init symbols resolve to globally. + // + // We must promote the *already-loaded* libpython to RTLD_GLOBAL. + // Using the compile-time filename (e.g. "libpython3.13.so.1.0") with + // RTLD_NOLOAD can fail when the portable Python's SONAME differs + // (e.g. "libpython3.13.so"), causing a second copy to be loaded and + // making Py_IsInitialized() return false after Py_InitializeFromConfig(). + // + // Instead, find the DSO that provides Py_IsInitialized via dladdr, then + // re-dlopen that exact path with RTLD_GLOBAL. { Dl_info di; - void* sym = dlsym(RTLD_DEFAULT, "Py_InitializeEx"); - if (sym && dladdr(sym, &di)) { - fprintf(stderr, "[py-diag] Py_InitializeEx global from '%s'\n", + void* sym = dlsym(RTLD_DEFAULT, "Py_IsInitialized"); + if (sym && dladdr(sym, &di) && di.dli_fname) { + void* pyHandle = + dlopen(di.dli_fname, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD); + if (pyHandle) { + MOBase::log::debug( + "python: promoted '{}' to RTLD_GLOBAL via dladdr", di.dli_fname); - MOBase::log::debug("python: Py_InitializeEx (global) from '{}'", - di.dli_fname); - } else { - fprintf(stderr, "[py-diag] Py_InitializeEx NOT in global scope " - "(sym=%p, err=%s)\n", - sym, dlerror()); - MOBase::log::warn("python: Py_InitializeEx NOT in global scope " - "(sym={}, err={})", - sym, dlerror()); - } - sym = dlsym(RTLD_DEFAULT, "Py_IsInitialized"); - if (sym && dladdr(sym, &di)) { - fprintf(stderr, "[py-diag] Py_IsInitialized global from '%s'\n", - di.dli_fname); - MOBase::log::debug("python: Py_IsInitialized (global) from '{}'", - di.dli_fname); + } else { + // Fallback: load by full path (not NOLOAD). + pyHandle = dlopen(di.dli_fname, RTLD_NOW | RTLD_GLOBAL); + if (pyHandle) { + MOBase::log::debug( + "python: loaded '{}' with RTLD_GLOBAL (fresh)", + di.dli_fname); + } else { + MOBase::log::warn( + "python: failed to promote '{}' to RTLD_GLOBAL: {}", + di.dli_fname, dlerror()); + } + } } else { - fprintf(stderr, "[py-diag] Py_IsInitialized NOT in global scope\n"); - MOBase::log::warn("python: Py_IsInitialized NOT in global scope"); + // Py_IsInitialized not yet in scope — libpython may not be loaded + // as a dependency yet. Try the compile-time name. +#ifdef MO2_PYTHON_SHARED_LIBRARY + void* pyHandle = + dlopen(MO2_PYTHON_SHARED_LIBRARY, RTLD_NOW | RTLD_GLOBAL); + if (pyHandle) { + MOBase::log::debug( + "python: loaded '{}' with RTLD_GLOBAL (compile-time name)", + MO2_PYTHON_SHARED_LIBRARY); + } else { + MOBase::log::warn( + "python: failed to dlopen '{}': {}", + MO2_PYTHON_SHARED_LIBRARY, dlerror()); + } +#else + MOBase::log::warn( + "python: Py_IsInitialized not found in global scope and " + "no compile-time library name available"); +#endif } } -#endif #endif // For portable/AppImage builds, set PYTHONHOME so the interpreter @@ -221,8 +229,18 @@ namespace mo2::python { { PyConfig config; PyConfig_InitPythonConfig(&config); - // PYTHONHOME is already set via setenv; PyConfig reads it from env. - PyStatus status = Py_InitializeFromConfig(&config); + // Set config.home directly (more reliable than env for embedded use). + std::wstring wHome = pythonHome.toStdWString(); + PyStatus status = PyConfig_SetString(&config, &config.home, wHome.c_str()); + if (PyStatus_Exception(status)) { + MOBase::log::error( + "python: PyConfig_SetString(home) failed: '{}'", + status.err_msg ? status.err_msg : "(no message)"); + PyConfig_Clear(&config); + restorePythonEnv(); + return false; + } + status = Py_InitializeFromConfig(&config); PyConfig_Clear(&config); if (PyStatus_Exception(status)) { fprintf(stderr, diff --git a/src/src/commandline.cpp b/src/src/commandline.cpp index 4613350..121fb58 100644 --- a/src/src/commandline.cpp +++ b/src/src/commandline.cpp @@ -853,8 +853,7 @@ std::optional ReloadPluginCommand::runPostOrganizer(OrganizerCore& core) const QString name = QString::fromStdString(vm()["PLUGIN"].as()); QString filepath = - QDir(AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) - .absoluteFilePath(name); + QDir(AppConfig::pluginsPath()).absoluteFilePath(name); log::debug("reloading plugin from {}", filepath); core.pluginContainer().reloadPlugin(filepath); diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 7aa6964..1eb5e36 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -544,12 +544,12 @@ MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, QApplication::instance()->installEventFilter(this); - scheduleCheckForProblems(); - refreshExecutablesList(); - updatePinnedExecutables(); - resetActionIcons(); - resetButtonIcons(); - processUpdates(); + scheduleCheckForProblems(); + refreshExecutablesList(); + updatePinnedExecutables(); + resetActionIcons(); + resetButtonIcons(); + processUpdates(); ui->modList->updateModCount(); ui->espList->updatePluginCount(); @@ -573,8 +573,8 @@ void MainWindow::setupModList() }); } -void MainWindow::resetActionIcons() -{ +void MainWindow::resetActionIcons() +{ // this is a bit of a hack // // the .qss files have historically set qproperty-icon by id and these ids @@ -636,38 +636,38 @@ void MainWindow::resetActionIcons() } } - // update the button for the potentially new icon - updateProblemsButton(); -} - -void MainWindow::resetButtonIcons() -{ - // Some icon-only QPushButtons can lose their icons after stylesheet repolish on - // Linux/AppImage. Re-apply explicit resource icons to keep these controls visible. - ui->listOptionsBtn->setIcon(QIcon::fromTheme( - "preferences-system", QIcon(":/MO/gui/settings"))); - ui->openFolderMenu->setIcon( - QIcon::fromTheme("folder-open", QIcon(":/MO/gui/open_folder"))); - ui->restoreModsButton->setIcon( - QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); - ui->saveModsButton->setIcon( - QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); - ui->sortButton->setIcon(QIcon::fromTheme("view-sort-ascending", QIcon(":/MO/gui/sort"))); - ui->restoreButton->setIcon( - QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); - ui->saveButton->setIcon(QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); - - ui->listOptionsBtn->setIconSize(QSize(16, 16)); - ui->openFolderMenu->setIconSize(QSize(16, 16)); - ui->restoreModsButton->setIconSize(QSize(16, 16)); - ui->saveModsButton->setIconSize(QSize(16, 16)); - ui->sortButton->setIconSize(QSize(16, 16)); - ui->restoreButton->setIconSize(QSize(16, 16)); - ui->saveButton->setIconSize(QSize(16, 16)); -} - -MainWindow::~MainWindow() -{ + // update the button for the potentially new icon + updateProblemsButton(); +} + +void MainWindow::resetButtonIcons() +{ + // Some icon-only QPushButtons can lose their icons after stylesheet repolish on + // Linux/AppImage. Re-apply explicit resource icons to keep these controls visible. + ui->listOptionsBtn->setIcon(QIcon::fromTheme( + "preferences-system", QIcon(":/MO/gui/settings"))); + ui->openFolderMenu->setIcon( + QIcon::fromTheme("folder-open", QIcon(":/MO/gui/open_folder"))); + ui->restoreModsButton->setIcon( + QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); + ui->saveModsButton->setIcon( + QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); + ui->sortButton->setIcon(QIcon::fromTheme("view-sort-ascending", QIcon(":/MO/gui/sort"))); + ui->restoreButton->setIcon( + QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); + ui->saveButton->setIcon(QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); + + ui->listOptionsBtn->setIconSize(QSize(16, 16)); + ui->openFolderMenu->setIconSize(QSize(16, 16)); + ui->restoreModsButton->setIconSize(QSize(16, 16)); + ui->saveModsButton->setIconSize(QSize(16, 16)); + ui->sortButton->setIconSize(QSize(16, 16)); + ui->restoreButton->setIconSize(QSize(16, 16)); + ui->saveButton->setIconSize(QSize(16, 16)); +} + +MainWindow::~MainWindow() +{ try { cleanup(); @@ -739,11 +739,11 @@ void MainWindow::allowListResize() ui->espList->header()->setStretchLastSection(true); } -void MainWindow::updateStyle(const QString&) -{ - resetActionIcons(); - resetButtonIcons(); -} +void MainWindow::updateStyle(const QString&) +{ + resetActionIcons(); + resetButtonIcons(); +} void MainWindow::resizeEvent(QResizeEvent* event) { @@ -1913,10 +1913,10 @@ bool MainWindow::refreshProfiles(bool selectProfile, QString newProfile) return profileBox->count() > 1; } -void MainWindow::refreshExecutablesList() -{ - QAbstractItemModel* model = ui->executablesListBox->model(); - +void MainWindow::refreshExecutablesList() +{ + QAbstractItemModel* model = ui->executablesListBox->model(); + auto add = [&](const QString& title, const QFileInfo& binary) { QIcon icon; if (!binary.fileName().isEmpty()) { @@ -1932,36 +1932,36 @@ void MainWindow::refreshExecutablesList() Qt::SizeHintRole); }; - ui->executablesListBox->setEnabled(false); - ui->executablesListBox->clear(); - - add(tr(""), {}); - - auto shouldSkipInLaunchDropdown = [](const Executable& exe) { - const QString title = exe.title().trimmed(); - const QString lower = title.toLower(); - if (lower == QStringLiteral("proton") || lower == QStringLiteral("prefix") || - lower.startsWith(QStringLiteral("proton ")) || - lower.startsWith(QStringLiteral("prefix "))) { - return true; - } - - return false; - }; - - for (const auto& exe : *m_OrganizerCore.executablesList()) { - if (exe.hide()) { - continue; - } - - if (shouldSkipInLaunchDropdown(exe)) { - log::debug("Skipping internal executable entry '{}' from launch dropdown", - exe.title()); - continue; - } - - add(exe.title(), exe.binaryInfo()); - } + ui->executablesListBox->setEnabled(false); + ui->executablesListBox->clear(); + + add(tr(""), {}); + + auto shouldSkipInLaunchDropdown = [](const Executable& exe) { + const QString title = exe.title().trimmed(); + const QString lower = title.toLower(); + if (lower == QStringLiteral("proton") || lower == QStringLiteral("prefix") || + lower.startsWith(QStringLiteral("proton ")) || + lower.startsWith(QStringLiteral("prefix "))) { + return true; + } + + return false; + }; + + for (const auto& exe : *m_OrganizerCore.executablesList()) { + if (exe.hide()) { + continue; + } + + if (shouldSkipInLaunchDropdown(exe)) { + log::debug("Skipping internal executable entry '{}' from launch dropdown", + exe.title()); + continue; + } + + add(exe.title(), exe.binaryInfo()); + } if (ui->executablesListBox->count() == 1) { // all executables are hidden, add an empty one to at least be able to @@ -2692,9 +2692,7 @@ void MainWindow::openInstallFolder() void MainWindow::openPluginsFolder() { - QString pluginsPath = - AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()); - shell::Explore(pluginsPath); + shell::Explore(AppConfig::pluginsPath()); } void MainWindow::openStylesheetsFolder() diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp index fd60745..3009a45 100644 --- a/src/src/moapplication.cpp +++ b/src/src/moapplication.cpp @@ -41,9 +41,9 @@ along with Mod Organizer. If not, see . #include "shared/util.h" #include "thread_utils.h" #include "tutorialmanager.h" -#include -#include -#include +#include +#include +#include #include #include #include @@ -51,10 +51,10 @@ along with Mod Organizer. If not, see . #include #include #include -#include -#include -#include -#include +#include +#include +#include +#include #ifdef _WIN32 // see addDllsToPath() below @@ -270,43 +270,43 @@ void MOApplication::firstTimeSetup(MOMultiProcess& multiProcess) Qt::QueuedConnection); } -int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) -{ - TimeThis tt("MOApplication setup()"); - std::fprintf(stderr, "[setup-diag] setup() entered\n"); - std::fflush(stderr); +int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) +{ + TimeThis tt("MOApplication setup()"); + std::fprintf(stderr, "[setup-diag] setup() entered\n"); + std::fflush(stderr); // makes plugin data path available to plugins, see // IOrganizer::getPluginDataPath() MOBase::details::setPluginDataPath(OrganizerCore::pluginDataPath()); // figuring out the current instance - m_instance = getCurrentInstance(forceSelect); - if (!m_instance) { - std::fprintf(stderr, "[setup-diag] getCurrentInstance() returned null\n"); - std::fflush(stderr); - return 1; - } - std::fprintf(stderr, "[setup-diag] instance dir='%s' ini='%s'\n", - qUtf8Printable(m_instance->directory()), - qUtf8Printable(m_instance->iniPath())); - std::fflush(stderr); + m_instance = getCurrentInstance(forceSelect); + if (!m_instance) { + std::fprintf(stderr, "[setup-diag] getCurrentInstance() returned null\n"); + std::fflush(stderr); + return 1; + } + std::fprintf(stderr, "[setup-diag] instance dir='%s' ini='%s'\n", + qUtf8Printable(m_instance->directory()), + qUtf8Printable(m_instance->iniPath())); + std::fflush(stderr); // first time the data path is available, set the global property and log // directory, then log a bunch of debug stuff const QString dataPath = m_instance->directory(); setProperty("dataPath", dataPath); - if (!setLogDirectory(dataPath)) { - std::fprintf(stderr, "[setup-diag] setLogDirectory() failed for '%s'\n", - qUtf8Printable(dataPath)); - std::fflush(stderr); - reportError(tr("Failed to create log folder.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } - std::fprintf(stderr, "[setup-diag] setLogDirectory() ok\n"); - std::fflush(stderr); + if (!setLogDirectory(dataPath)) { + std::fprintf(stderr, "[setup-diag] setLogDirectory() failed for '%s'\n", + qUtf8Printable(dataPath)); + std::fflush(stderr); + reportError(tr("Failed to create log folder.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + std::fprintf(stderr, "[setup-diag] setLogDirectory() ok\n"); + std::fflush(stderr); #ifdef _WIN32 log::debug("command line: '{}'", QString::fromWCharArray(GetCommandLineW())); @@ -336,11 +336,11 @@ int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) } // loading settings - m_settings.reset(new Settings(m_instance->iniPath(), true)); - std::fprintf(stderr, "[setup-diag] settings loaded from '%s'\n", - qUtf8Printable(m_instance->iniPath())); - std::fflush(stderr); - log::getDefault().setLevel(m_settings->diagnostics().logLevel()); + m_settings.reset(new Settings(m_instance->iniPath(), true)); + std::fprintf(stderr, "[setup-diag] settings loaded from '%s'\n", + qUtf8Printable(m_instance->iniPath())); + std::fflush(stderr); + log::getDefault().setLevel(m_settings->diagnostics().logLevel()); log::debug("using ini at '{}'", m_settings->filename()); OrganizerCore::setGlobalCoreDumpType(m_settings->diagnostics().coreDumpType()); @@ -375,30 +375,30 @@ int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) tt.start("MOApplication::doOneRun() OrganizerCore"); log::debug("initializing core"); - m_core.reset(new OrganizerCore(*m_settings)); - std::fprintf(stderr, "[setup-diag] organizer core constructed\n"); - std::fflush(stderr); - if (!m_core->bootstrap()) { - std::fprintf(stderr, "[setup-diag] organizer core bootstrap failed\n"); - std::fflush(stderr); - reportError(tr("Failed to set up data paths.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } - std::fprintf(stderr, "[setup-diag] organizer core bootstrap ok\n"); - std::fflush(stderr); + m_core.reset(new OrganizerCore(*m_settings)); + std::fprintf(stderr, "[setup-diag] organizer core constructed\n"); + std::fflush(stderr); + if (!m_core->bootstrap()) { + std::fprintf(stderr, "[setup-diag] organizer core bootstrap failed\n"); + std::fflush(stderr); + reportError(tr("Failed to set up data paths.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + std::fprintf(stderr, "[setup-diag] organizer core bootstrap ok\n"); + std::fflush(stderr); // plugins tt.start("MOApplication::doOneRun() plugins"); log::debug("initializing plugins"); - m_plugins = std::make_unique(m_core.get()); - std::fprintf(stderr, "[setup-diag] plugin container constructed, loading plugins...\n"); - std::fflush(stderr); - m_plugins->loadPlugins(); - std::fprintf(stderr, "[setup-diag] plugin loading finished\n"); - std::fflush(stderr); - log::debug("all plugins loaded"); + m_plugins = std::make_unique(m_core.get()); + std::fprintf(stderr, "[setup-diag] plugin container constructed, loading plugins...\n"); + std::fflush(stderr); + m_plugins->loadPlugins(); + std::fprintf(stderr, "[setup-diag] plugin loading finished\n"); + std::fflush(stderr); + log::debug("all plugins loaded"); // instance log::debug("entering setupInstanceLoop..."); @@ -456,14 +456,15 @@ int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) m_core->createDefaultProfile(); m_core->createOverwriteDirectories(); - log::info("using game plugin '{}' ('{}', variant {}, steam id '{}') at {}", - m_instance->gamePlugin()->gameName(), - m_instance->gamePlugin()->gameShortName(), - (m_settings->game().edition().value_or("").isEmpty() - ? "(none)" - : *m_settings->game().edition()), - m_instance->gamePlugin()->steamAPPId(), - m_instance->gamePlugin()->gameDirectory().absolutePath()); + { + const auto edition = m_settings->game().edition().value_or(""); + const auto variant = edition.isEmpty() ? QString("Steam") : edition; + log::info("using game plugin '{}' ('{}', variant {}) at {}", + m_instance->gamePlugin()->gameName(), + m_instance->gamePlugin()->gameShortName(), + variant, + m_instance->gamePlugin()->gameDirectory().absolutePath()); + } CategoryFactory::instance().loadCategories(); m_core->updateExecutablesList(); diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 630f23b..79ea90b 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -904,8 +904,7 @@ QString OrganizerCore::pluginDataPath() // separate writable directory so mkdir() never hits a read-only FS. return AppConfig::basePath() + "/plugin_data"; #else - return AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()) + - "/data"; + return AppConfig::pluginsPath() + "/data"; #endif } @@ -2193,23 +2192,23 @@ bool OrganizerCore::beforeRun( WinePrefix prefix(prefixPathStr); if (prefix.isValid()) { const QString dataDirName = resolveWineDataDirName(managedGame()); - const QString appDataLocal = prefix.appdataLocal(); - const QString pluginsTargetDir = - QDir(appDataLocal).filePath(dataDirName); - const QString documentsDir = - resolvePrefixGameDocumentsDir(prefix, dataDirName); + const QString appDataLocal = prefix.appdataLocal(); + const QString pluginsTargetDir = + QDir(appDataLocal).filePath(dataDirName); + const QString documentsDir = + resolvePrefixGameDocumentsDir(prefix, dataDirName); log::info("Wine prefix paths: AppData/Local plugins dir='{}', " "Documents/My Games INI dir='{}'", pluginsTargetDir, documentsDir); - const auto localSavesFeature = gameFeatures().gameFeature(); - const QString absoluteSaveDir = - resolveAbsoluteSaveDir(prefix, managedGame(), - localSavesFeature.get(), m_CurrentProfile); - log::info("Wine prefix save target: '{}'", absoluteSaveDir); - - // Read plugin lines from profile's plugins.txt - QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); - if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + const auto localSavesFeature = gameFeatures().gameFeature(); + const QString absoluteSaveDir = + resolveAbsoluteSaveDir(prefix, managedGame(), + localSavesFeature.get(), m_CurrentProfile); + log::info("Wine prefix save target: '{}'", absoluteSaveDir); + + // Read plugin lines from profile's plugins.txt + QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); + if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { QStringList plugins; QTextStream stream(&pluginsFile); while (!stream.atEnd()) { @@ -2219,17 +2218,17 @@ bool OrganizerCore::beforeRun( } } pluginsFile.close(); - - if (!plugins.isEmpty()) { - log::info( - "Deploying plugin list to '{}' and '{}' (load order to '{}')", - QDir(pluginsTargetDir).filePath("Plugins.txt"), - QDir(pluginsTargetDir).filePath("plugins.txt"), - QDir(pluginsTargetDir).filePath("loadorder.txt")); - if (prefix.deployPlugins(plugins, dataDirName)) { - log::debug("Deployed {} plugins to prefix '{}' (dataDirName='{}')", - plugins.size(), prefixPathStr, dataDirName); - } else { + + if (!plugins.isEmpty()) { + log::info( + "Deploying plugin list to '{}' and '{}' (load order to '{}')", + QDir(pluginsTargetDir).filePath("Plugins.txt"), + QDir(pluginsTargetDir).filePath("plugins.txt"), + QDir(pluginsTargetDir).filePath("loadorder.txt")); + if (prefix.deployPlugins(plugins, dataDirName)) { + log::debug("Deployed {} plugins to prefix '{}' (dataDirName='{}')", + plugins.size(), prefixPathStr, dataDirName); + } else { log::error("Failed to deploy {} plugins to prefix '{}' " "(dataDirName='{}')", plugins.size(), prefixPathStr, dataDirName); @@ -2244,18 +2243,18 @@ bool OrganizerCore::beforeRun( const QString targetIniBase = resolvePrefixGameDocumentsDir(prefix, dataDirName); int deployedIniCount = 0; - for (const QString& iniFile : managedGame()->iniFiles()) { - const QString sourceIni = - m_CurrentProfile->absoluteIniFilePath(iniFile); - const QString targetIni = - QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); - log::info("INI deploy target: '{}' -> '{}' (exists={})", sourceIni, - targetIni, QFileInfo::exists(sourceIni)); - if (QFileInfo::exists(sourceIni) && - prefix.deployProfileIni(sourceIni, targetIni)) { - ++deployedIniCount; - log::debug("Deployed profile INI '{}' -> '{}'", sourceIni, targetIni); - } + for (const QString& iniFile : managedGame()->iniFiles()) { + const QString sourceIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = + QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); + log::info("INI deploy target: '{}' -> '{}' (exists={})", sourceIni, + targetIni, QFileInfo::exists(sourceIni)); + if (QFileInfo::exists(sourceIni) && + prefix.deployProfileIni(sourceIni, targetIni)) { + ++deployedIniCount; + log::debug("Deployed profile INI '{}' -> '{}'", sourceIni, targetIni); + } } if (deployedIniCount > 0) { log::debug("Deployed {} profile INI files to prefix '{}'", deployedIniCount, @@ -2267,15 +2266,15 @@ bool OrganizerCore::beforeRun( managedGame()->documentsDirectory().absolutePath()); } - if (m_CurrentProfile->localSavesEnabled()) { - const QString profileSavesDir = - QDir(m_CurrentProfile->absolutePath()).filePath("saves"); - log::info("Save deploy target: '{}' -> '{}'", profileSavesDir, - absoluteSaveDir); - if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, true)) { - log::warn("Failed to deploy profile saves from '{}' to prefix '{}'", - profileSavesDir, prefixPathStr); - } else { + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + log::info("Save deploy target: '{}' -> '{}'", profileSavesDir, + absoluteSaveDir); + if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, true)) { + log::warn("Failed to deploy profile saves from '{}' to prefix '{}'", + profileSavesDir, prefixPathStr); + } else { log::debug("Deployed profile saves '{}' -> '{}' in prefix '{}'", profileSavesDir, absoluteSaveDir, prefixPathStr); } @@ -2321,29 +2320,29 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) resolveAbsoluteSaveDir(prefix, managedGame(), localSavesFeature.get(), m_CurrentProfile); - if (m_CurrentProfile->localSavesEnabled()) { - const QString profileSavesDir = - QDir(m_CurrentProfile->absolutePath()).filePath("saves"); - log::info("Save sync target: '{}' <- '{}'", profileSavesDir, - absoluteSaveDir); - if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) { - log::warn("Failed to sync saves back from prefix '{}' to '{}'", - prefixPathStr, profileSavesDir); - } - } + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + log::info("Save sync target: '{}' <- '{}'", profileSavesDir, + absoluteSaveDir); + if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) { + log::warn("Failed to sync saves back from prefix '{}' to '{}'", + prefixPathStr, profileSavesDir); + } + } if (m_CurrentProfile->localSettingsEnabled()) { const QString targetIniBase = resolvePrefixGameDocumentsDir(prefix, dataDirName); QList> iniMappings; for (const QString& iniFile : managedGame()->iniFiles()) { - const QString profileIni = - m_CurrentProfile->absoluteIniFilePath(iniFile); - const QString targetIni = - QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); - iniMappings.append({profileIni, targetIni}); - log::info("INI sync target: '{}' <- '{}'", profileIni, targetIni); - } + const QString profileIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = + QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); + iniMappings.append({profileIni, targetIni}); + log::info("INI sync target: '{}' <- '{}'", profileIni, targetIni); + } if (!iniMappings.isEmpty() && !prefix.syncProfileInisBack(iniMappings)) { diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp index dad0117..e215c0a 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -4,12 +4,12 @@ #include "organizerproxy.h" #include "report.h" #include "shared/appconfig.h" -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -22,13 +22,13 @@ using namespace MOShared; namespace bf = boost::fusion; -#ifndef _WIN32 +#ifndef _WIN32 // Ensure the instance plugin directory contains symlinks to all bundled plugins. // Real user files (not matching any bundled plugin name) are left untouched. // Stale copies of bundled plugins are replaced with symlinks so that RPATH // resolution works correctly (critical for Flatpak where libs live in /app/). -static void ensureBundledPluginsLinked(const QString& bundledDir, - const QString& instanceDir) +static void ensureBundledPluginsLinked(const QString& bundledDir, + const QString& instanceDir) { QDir().mkpath(instanceDir); QDirIterator it(bundledDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); @@ -54,16 +54,19 @@ static void ensureBundledPluginsLinked(const QString& bundledDir, } } - QFile::link(it.filePath(), target); + if (!QFile::link(it.filePath(), target)) { + log::warn("ensureBundledPluginsLinked: failed to symlink '{}' -> '{}'", + it.filePath(), target); + } } -} -#endif - -static void printPluginDiagToStderr(const QString& message) -{ - std::fprintf(stderr, "[plugin-diag] %s\n", qUtf8Printable(message)); - std::fflush(stderr); -} +} +#endif + +static void printPluginDiagToStderr(const QString& message) +{ + std::fprintf(stderr, "[plugin-diag] %s\n", qUtf8Printable(message)); + std::fflush(stderr); +} // Welcome to the wonderful world of MO2 plugin management! // @@ -446,9 +449,7 @@ QStringList PluginContainer::pluginFileNames() const std::vector proxyList = bf::at_key(m_Plugins); for (IPluginProxy* proxy : proxyList) { QStringList proxiedPlugins = proxy->pluginList( - m_PluginPath.isEmpty() - ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) - : m_PluginPath); + m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath); result.append(proxiedPlugins); } return result; @@ -643,33 +644,31 @@ IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepat return preview; } } - { // proxy plugins - IPluginProxy* proxy = qobject_cast(plugin); - if (initPlugin(proxy, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(proxy); - emit pluginRegistered(proxy); - - const QString pluginRoot = - m_PluginPath.isEmpty() - ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) - : m_PluginPath; - QStringList filepaths = proxy->pluginList(pluginRoot); - log::warn("proxy '{}' discovered {} proxied plugin candidate(s) in '{}'", - proxy->name(), filepaths.size(), - QDir::toNativeSeparators(pluginRoot)); - printPluginDiagToStderr( - QString("proxy '%1' discovered %2 proxied plugin candidate(s) in '%3'") - .arg(proxy->name()) - .arg(filepaths.size()) - .arg(QDir::toNativeSeparators(pluginRoot))); - for (const QString& filepath : filepaths) { - log::debug("proxy '{}' candidate: '{}'", proxy->name(), - QDir::toNativeSeparators(filepath)); - loadProxied(filepath, proxy); - } - return proxy; - } - } + { // proxy plugins + IPluginProxy* proxy = qobject_cast(plugin); + if (initPlugin(proxy, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(proxy); + emit pluginRegistered(proxy); + + const QString pluginRoot = + m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath; + QStringList filepaths = proxy->pluginList(pluginRoot); + log::warn("proxy '{}' discovered {} proxied plugin candidate(s) in '{}'", + proxy->name(), filepaths.size(), + QDir::toNativeSeparators(pluginRoot)); + printPluginDiagToStderr( + QString("proxy '%1' discovered %2 proxied plugin candidate(s) in '%3'") + .arg(proxy->name()) + .arg(filepaths.size()) + .arg(QDir::toNativeSeparators(pluginRoot))); + for (const QString& filepath : filepaths) { + log::debug("proxy '{}' candidate: '{}'", proxy->name(), + QDir::toNativeSeparators(filepath)); + loadProxied(filepath, proxy); + } + return proxy; + } + } { // dummy plugins // only initialize these, no processing otherwise @@ -853,70 +852,70 @@ void PluginContainer::startPluginsImpl(const std::vector& plugins) con } } -std::vector PluginContainer::loadProxied(const QString& filepath, - IPluginProxy* proxy) -{ - std::vector proxiedPlugins; - - try { - log::warn("loading proxied plugin candidate '{}' via proxy '{}'", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral(""))); - printPluginDiagToStderr( - QString("loading proxied plugin candidate '%1' via proxy '%2'") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral(""))); - - // We get a list of matching plugins as proxies can return multiple plugins - // per file and do not have a good way of supporting multiple inheritance. - QList matchingPlugins = proxy->load(filepath); - if (matchingPlugins.isEmpty()) { - log::warn("no plugins were returned for proxied candidate '{}' via proxy '{}'", - QDir::toNativeSeparators(filepath), proxy->name()); - printPluginDiagToStderr( - QString("no plugins were returned for proxied candidate '%1' via proxy '%2'") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name())); - } - - // We are going to group plugin by names and "fix" them later: - std::map> proxiedByNames; - - for (QObject* proxiedPlugin : matchingPlugins) { - if (proxiedPlugin == nullptr) { - log::warn("proxy '{}' returned a null QObject for '{}'", proxy->name(), - QDir::toNativeSeparators(filepath)); - printPluginDiagToStderr( - QString("proxy '%1' returned a null QObject for '%2'") - .arg(proxy->name()) - .arg(QDir::toNativeSeparators(filepath))); - continue; - } - - if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy); proxied) { - log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(), - proxied->version().canonicalString(), - QFileInfo(filepath).fileName(), - implementedInterfaces(proxied).join(", ")); - - // Store the plugin for later: - proxiedPlugins.push_back(proxiedPlugin); - proxiedByNames[proxied->name()].push_back(proxied); - } else { - log::warn( - "proxied candidate '{}' from proxy '{}' failed to register as an MO2 plugin", - QDir::toNativeSeparators(filepath), proxy->name()); - printPluginDiagToStderr( - QString("proxied candidate '%1' from proxy '%2' failed to register as an " - "MO2 plugin") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name())); - } - } +std::vector PluginContainer::loadProxied(const QString& filepath, + IPluginProxy* proxy) +{ + std::vector proxiedPlugins; + + try { + log::warn("loading proxied plugin candidate '{}' via proxy '{}'", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral(""))); + printPluginDiagToStderr( + QString("loading proxied plugin candidate '%1' via proxy '%2'") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral(""))); + + // We get a list of matching plugins as proxies can return multiple plugins + // per file and do not have a good way of supporting multiple inheritance. + QList matchingPlugins = proxy->load(filepath); + if (matchingPlugins.isEmpty()) { + log::warn("no plugins were returned for proxied candidate '{}' via proxy '{}'", + QDir::toNativeSeparators(filepath), proxy->name()); + printPluginDiagToStderr( + QString("no plugins were returned for proxied candidate '%1' via proxy '%2'") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name())); + } + + // We are going to group plugin by names and "fix" them later: + std::map> proxiedByNames; + + for (QObject* proxiedPlugin : matchingPlugins) { + if (proxiedPlugin == nullptr) { + log::warn("proxy '{}' returned a null QObject for '{}'", proxy->name(), + QDir::toNativeSeparators(filepath)); + printPluginDiagToStderr( + QString("proxy '%1' returned a null QObject for '%2'") + .arg(proxy->name()) + .arg(QDir::toNativeSeparators(filepath))); + continue; + } + + if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy); proxied) { + log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(), + proxied->version().canonicalString(), + QFileInfo(filepath).fileName(), + implementedInterfaces(proxied).join(", ")); + + // Store the plugin for later: + proxiedPlugins.push_back(proxiedPlugin); + proxiedByNames[proxied->name()].push_back(proxied); + } else { + log::warn( + "proxied candidate '{}' from proxy '{}' failed to register as an MO2 plugin", + QDir::toNativeSeparators(filepath), proxy->name()); + printPluginDiagToStderr( + QString("proxied candidate '%1' from proxy '%2' failed to register as an " + "MO2 plugin") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name())); + } + } // Fake masters: - for (auto& [name, proxiedPlugins] : proxiedByNames) { - if (proxiedPlugins.size() > 1) { + for (auto& [name, proxiedPlugins] : proxiedByNames) { + if (proxiedPlugins.size() > 1) { auto it = std::min_element(std::begin(proxiedPlugins), std::end(proxiedPlugins), [&](auto const& lhs, auto const& rhs) { return isBetterInterface(as_qobject(lhs), @@ -928,43 +927,43 @@ std::vector PluginContainer::loadProxied(const QString& filepath, m_Requirements.at(proxiedPlugin).setMaster(*it); } } - } - } - log::warn("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", - QDir::toNativeSeparators(filepath), proxy->name(), - proxiedPlugins.size()); - printPluginDiagToStderr( - QString("finished proxied candidate '%1' via proxy '%2': %3 plugin(s) loaded") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name()) - .arg(proxiedPlugins.size())); - } catch (const std::exception& e) { - log::error("failed to initialize proxied candidate '{}' via proxy '{}': {}", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral("")), e.what()); - printPluginDiagToStderr( - QString("failed to initialize proxied candidate '%1' via proxy '%2': %3") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral("")) - .arg(e.what())); - reportError( - QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); - } catch (...) { - log::error("failed to initialize proxied candidate '{}' via proxy '{}': " - "unknown exception", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral(""))); - printPluginDiagToStderr( - QString("failed to initialize proxied candidate '%1' via proxy '%2': unknown " - "exception") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral(""))); - reportError(QObject::tr("failed to initialize plugin %1: unknown exception") - .arg(filepath)); - } - - return proxiedPlugins; -} + } + } + log::warn("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", + QDir::toNativeSeparators(filepath), proxy->name(), + proxiedPlugins.size()); + printPluginDiagToStderr( + QString("finished proxied candidate '%1' via proxy '%2': %3 plugin(s) loaded") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name()) + .arg(proxiedPlugins.size())); + } catch (const std::exception& e) { + log::error("failed to initialize proxied candidate '{}' via proxy '{}': {}", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral("")), e.what()); + printPluginDiagToStderr( + QString("failed to initialize proxied candidate '%1' via proxy '%2': %3") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral("")) + .arg(e.what())); + reportError( + QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); + } catch (...) { + log::error("failed to initialize proxied candidate '{}' via proxy '{}': " + "unknown exception", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral(""))); + printPluginDiagToStderr( + QString("failed to initialize proxied candidate '%1' via proxy '%2': unknown " + "exception") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral(""))); + reportError(QObject::tr("failed to initialize plugin %1: unknown exception") + .arg(filepath)); + } + + return proxiedPlugins; +} QObject* PluginContainer::loadQtPlugin(const QString& filepath) { @@ -1033,9 +1032,7 @@ void PluginContainer::loadPlugin(QString const& filepath) // We need to check if this can be handled by a proxy. for (auto* proxy : this->plugins()) { auto filepaths = proxy->pluginList( - m_PluginPath.isEmpty() - ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) - : m_PluginPath); + m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath); if (filepaths.contains(filepath)) { plugins = loadProxied(filepath, proxy); break; @@ -1247,14 +1244,13 @@ void PluginContainer::loadPlugins() loadCheck.close(); } - if (!loadCheck.open(QIODevice::WriteOnly)) { - log::warn("failed to open loadcheck file for writing '{}'", - QDir::toNativeSeparators(loadCheck.fileName())); - } + if (!loadCheck.open(QIODevice::WriteOnly)) { + log::warn("failed to open loadcheck file for writing '{}'", + QDir::toNativeSeparators(loadCheck.fileName())); + } } - QString pluginPath = - AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()); + QString pluginPath = AppConfig::pluginsPath(); #ifndef _WIN32 // Per-instance plugin directory: symlink bundled plugins, then load from there. @@ -1334,15 +1330,15 @@ void PluginContainer::loadPlugins() } log::warn("user skipped plugin '{}', remembering in loadcheck", skipPlugin); - if (loadCheck.open(QIODevice::WriteOnly)) { - loadCheck.write(skipPlugin.toUtf8()); - loadCheck.write("\n"); - loadCheck.flush(); - } else { - log::warn("failed to persist skipped plugin to '{}'", - QDir::toNativeSeparators(loadCheck.fileName())); - } - } + if (loadCheck.open(QIODevice::WriteOnly)) { + loadCheck.write(skipPlugin.toUtf8()); + loadCheck.write("\n"); + loadCheck.flush(); + } else { + log::warn("failed to persist skipped plugin to '{}'", + QDir::toNativeSeparators(loadCheck.fileName())); + } + } bf::at_key(m_Plugins).push_back(this); diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index e7354f3..892fd79 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -22,43 +22,45 @@ #include #include #include +#include #include #include -#include + #endif using namespace MOBase; -void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, - const Settings& settings) -{ +void adjustForVirtualized(const IPluginGame *game, spawn::SpawnParameters &sp, + const Settings &settings) { const QString modsPath = settings.paths().mods(); // Check if this a request with either an executable or a working directory // under our mods folder then will start the process in a virtualized // "environment" with the appropriate paths fixed: // (i.e. mods\FNIS\path\exe => game\data\path\exe) - QString cwdPath = sp.currentDirectory.absolutePath(); + QString cwdPath = sp.currentDirectory.absolutePath(); QString trailedModsPath = modsPath; if (!trailedModsPath.endsWith('/')) { trailedModsPath = trailedModsPath + '/'; } - bool virtualizedCwd = cwdPath.startsWith(trailedModsPath, Qt::CaseInsensitive); - QString binPath = sp.binary.absoluteFilePath(); - bool virtualizedBin = binPath.startsWith(trailedModsPath, Qt::CaseInsensitive); + bool virtualizedCwd = + cwdPath.startsWith(trailedModsPath, Qt::CaseInsensitive); + QString binPath = sp.binary.absoluteFilePath(); + bool virtualizedBin = + binPath.startsWith(trailedModsPath, Qt::CaseInsensitive); if (virtualizedCwd || virtualizedBin) { if (virtualizedCwd) { - int cwdOffset = cwdPath.indexOf('/', trailedModsPath.length()); + int cwdOffset = cwdPath.indexOf('/', trailedModsPath.length()); QString adjustedCwd = cwdPath.mid(cwdOffset, -1); - cwdPath = game->dataDirectory().absolutePath(); + cwdPath = game->dataDirectory().absolutePath(); if (cwdOffset >= 0) cwdPath += adjustedCwd; } if (virtualizedBin) { - int binOffset = binPath.indexOf('/', trailedModsPath.length()); + int binOffset = binPath.indexOf('/', trailedModsPath.length()); QString adjustedBin = binPath.mid(binOffset, -1); - binPath = game->dataDirectory().absolutePath(); + binPath = game->dataDirectory().absolutePath(); if (binOffset >= 0) binPath += adjustedBin; } @@ -69,7 +71,7 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, .arg(QDir::toNativeSeparators(cwdPath), QDir::toNativeSeparators(binPath), sp.arguments); - sp.binary = QFileInfo(QCoreApplication::applicationFilePath()); + sp.binary = QFileInfo(QCoreApplication::applicationFilePath()); sp.arguments = cmdline; sp.currentDirectory.setPath(QCoreApplication::applicationDirPath()); #else @@ -97,8 +99,7 @@ void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, } #ifdef _WIN32 -std::optional singleWait(HANDLE handle, DWORD pid) -{ +std::optional singleWait(HANDLE handle, DWORD pid) { if (handle == INVALID_HANDLE_VALUE) { return ProcessRunner::Error; } @@ -116,7 +117,7 @@ std::optional singleWait(HANDLE handle, DWORD pid) return {}; } - case WAIT_FAILED: // fall-through + case WAIT_FAILED: // fall-through default: { // error const auto e = ::GetLastError(); @@ -126,23 +127,16 @@ std::optional singleWait(HANDLE handle, DWORD pid) } } #else -std::optional singleWait(HANDLE handle, DWORD pid) -{ +std::optional singleWait(HANDLE handle, DWORD pid) { Q_UNUSED(handle); Q_UNUSED(pid); return ProcessRunner::Completed; } #endif -enum class Interest -{ - None = 0, - Weak, - Strong -}; +enum class Interest { None = 0, Weak, Strong }; -QString toString(Interest i) -{ +QString toString(Interest i) { switch (i) { case Interest::Weak: return "weak"; @@ -150,22 +144,20 @@ QString toString(Interest i) case Interest::Strong: return "strong"; - case Interest::None: // fall-through + case Interest::None: // fall-through default: return "no"; } } -struct InterestingProcess -{ +struct InterestingProcess { env::Process p; Interest interest = Interest::None; env::HandlePtr handle; }; -InterestingProcess findRandomProcess(const env::Process& root) -{ - for (auto&& c : root.children()) { +InterestingProcess findRandomProcess(const env::Process &root) { + for (auto &&c : root.children()) { env::HandlePtr h = c.openHandleForWait(); if (h) { return {c, Interest::Weak, std::move(h)}; @@ -183,18 +175,18 @@ InterestingProcess findRandomProcess(const env::Process& root) // returns a process that's in the hidden list, or the top-level process if // they're all hidden; returns an invalid process if the list is empty // -InterestingProcess findInterestingProcessInTrees(const env::Process& root) -{ +InterestingProcess findInterestingProcessInTrees(const env::Process &root) { // Certain process names we wish to "hide" for aesthetic reason: static const std::vector hiddenList = { - QFileInfo(QCoreApplication::applicationFilePath()).fileName(), "conhost.exe"}; + QFileInfo(QCoreApplication::applicationFilePath()).fileName(), + "conhost.exe"}; if (root.children().empty()) { return {}; } - auto isHidden = [&](auto&& p) { - for (auto& h : hiddenList) { + auto isHidden = [&](auto &&p) { + for (auto &h : hiddenList) { if (p.name().contains(h, Qt::CaseInsensitive)) { return true; } @@ -203,7 +195,7 @@ InterestingProcess findInterestingProcessInTrees(const env::Process& root) return false; }; - for (auto&& p : root.children()) { + for (auto &&p : root.children()) { if (!isHidden(p)) { env::HandlePtr h = p.openHandleForWait(); if (h) { @@ -221,21 +213,19 @@ InterestingProcess findInterestingProcessInTrees(const env::Process& root) return findRandomProcess(root); } -void dump(const env::Process& p, int indent) -{ - log::debug("{}{}, pid={}, ppid={}", std::string(indent * 4, ' '), p.name(), p.pid(), - p.ppid()); +void dump(const env::Process &p, int indent) { + log::debug("{}{}, pid={}, ppid={}", std::string(indent * 4, ' '), p.name(), + p.pid(), p.ppid()); - for (auto&& c : p.children()) { + for (auto &&c : p.children()) { dump(c, indent + 1); } } -void dump(const env::Process& root) -{ +void dump(const env::Process &root) { log::debug("process tree:"); - for (auto&& p : root.children()) { + for (auto &&p : root.children()) { dump(p, 1); } } @@ -243,8 +233,7 @@ void dump(const env::Process& root) #ifdef _WIN32 // gets the most interesting process in the list // -InterestingProcess getInterestingProcess(HANDLE job) -{ +InterestingProcess getInterestingProcess(HANDLE job) { env::Process root = env::getProcessTree(job); if (root.children().empty()) { log::debug("nothing to wait for"); @@ -270,10 +259,9 @@ const std::chrono::milliseconds Infinite(-1); // waits for completion, times out after `wait` if not Infinite // std::optional timedWait(HANDLE handle, DWORD pid, - UILocker::Session* ls, + UILocker::Session *ls, std::chrono::milliseconds wait, - std::atomic& interrupt) -{ + std::atomic &interrupt) { using namespace std::chrono; high_resolution_clock::time_point start; @@ -310,7 +298,7 @@ std::optional timedWait(HANDLE handle, DWORD pid, return ProcessRunner::Cancelled; } - case UILocker::NoResult: // fall-through + case UILocker::NoResult: // fall-through default: { // shouldn't happen log::debug("unexpected result {} while waiting for {}", @@ -335,9 +323,9 @@ std::optional timedWait(HANDLE handle, DWORD pid, return ProcessRunner::ForceUnlocked; } -ProcessRunner::Results waitForProcessesThreadImpl(HANDLE job, UILocker::Session* ls, - std::atomic& interrupt) -{ +ProcessRunner::Results +waitForProcessesThreadImpl(HANDLE job, UILocker::Session *ls, + std::atomic &interrupt) { using namespace std::chrono; DWORD currentPID = 0; @@ -395,9 +383,9 @@ ProcessRunner::Results waitForProcessesThreadImpl(HANDLE job, UILocker::Session* return ProcessRunner::ForceUnlocked; } -void waitForProcessesThread(ProcessRunner::Results& result, HANDLE job, - UILocker::Session* ls, std::atomic& interrupt) -{ +void waitForProcessesThread(ProcessRunner::Results &result, HANDLE job, + UILocker::Session *ls, + std::atomic &interrupt) { result = waitForProcessesThreadImpl(job, ls, interrupt); // the session can be null when running shortcuts with locking disabled @@ -406,9 +394,9 @@ void waitForProcessesThread(ProcessRunner::Results& result, HANDLE job, } } -ProcessRunner::Results waitForProcesses(const std::vector& initialProcesses, - UILocker::Session* ls) -{ +ProcessRunner::Results +waitForProcesses(const std::vector &initialProcesses, + UILocker::Session *ls) { if (initialProcesses.empty()) { // nothing to wait for return ProcessRunner::Completed; @@ -428,7 +416,7 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces bool oneWorked = false; - for (auto&& h : initialProcesses) { + for (auto &&h : initialProcesses) { if (::AssignProcessToJobObject(job.get(), h)) { oneWorked = true; } else { @@ -457,13 +445,11 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces auto results = ProcessRunner::Running; std::atomic interrupt(false); - auto* t = QThread::create(waitForProcessesThread, std::ref(results), monitor, ls, - std::ref(interrupt)); + auto *t = QThread::create(waitForProcessesThread, std::ref(results), monitor, + ls, std::ref(interrupt)); QEventLoop events; - QObject::connect(t, &QThread::finished, [&] { - events.quit(); - }); + QObject::connect(t, &QThread::finished, [&] { events.quit(); }); t->start(); events.exec(); @@ -479,8 +465,7 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces } ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, - UILocker::Session* ls) -{ + UILocker::Session *ls) { std::vector processes = {initialProcess}; const auto r = waitForProcesses(processes, ls); @@ -489,7 +474,8 @@ ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, if (exitCode && r != ProcessRunner::Running) { if (!::GetExitCodeProcess(initialProcess, exitCode)) { const auto e = ::GetLastError(); - log::warn("failed to get exit code of process, {}", formatSystemMessage(e)); + log::warn("failed to get exit code of process, {}", + formatSystemMessage(e)); } } @@ -498,13 +484,11 @@ ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, #else // !_WIN32 -pid_t handleToPid(HANDLE h) -{ +pid_t handleToPid(HANDLE h) { return static_cast(reinterpret_cast(h)); } -QString readProcComm(pid_t pid) -{ +QString readProcComm(pid_t pid) { QFile f(QString("/proc/%1/comm").arg(pid)); if (!f.open(QIODevice::ReadOnly)) { return {}; @@ -513,21 +497,20 @@ QString readProcComm(pid_t pid) return QString::fromUtf8(f.readAll()).trimmed(); } -std::unordered_map> buildProcChildrenMap() -{ +std::unordered_map> buildProcChildrenMap() { std::unordered_map> children; - DIR* proc = opendir("/proc"); + DIR *proc = opendir("/proc"); if (!proc) { return children; } - struct dirent* entry = nullptr; + struct dirent *entry = nullptr; while ((entry = readdir(proc)) != nullptr) { if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { continue; } - const char* name = entry->d_name; + const char *name = entry->d_name; if (*name == '\0' || !std::isdigit(static_cast(*name))) { continue; } @@ -556,9 +539,8 @@ std::unordered_map> buildProcChildrenMap() return children; } -std::unordered_set -collectDescendants(pid_t root, const std::unordered_map>& children) -{ +std::unordered_set collectDescendants( + pid_t root, const std::unordered_map> &children) { std::unordered_set out; std::deque q; q.push_back(root); @@ -582,8 +564,8 @@ collectDescendants(pid_t root, const std::unordered_map 0 && trackedNameOut) { *trackedNameOut = bestName; + log::debug("findTrackedProcess: found best={}, name={}", best, + bestName.toStdString()); + } else { + log::debug("findTrackedProcess: no tracked process found for rootPid={}", + rootPid); } return best; } -DWORD exitCodeFromWaitStatus(int status) -{ +DWORD exitCodeFromWaitStatus(int status) { if (WIFEXITED(status)) { return static_cast(WEXITSTATUS(status)); } @@ -653,9 +640,9 @@ DWORD exitCodeFromWaitStatus(int status) return 0; } -ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls, - const QStringList& expected) -{ +ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, + UILocker::Session *ls, + const QStringList &expected) { if (pid <= 0) { return ProcessRunner::Error; } @@ -665,10 +652,9 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session // which works for any process owned by the same user. bool useKillPoll = false; { - int status = 0; + int status = 0; const pid_t probe = ::waitpid(pid, &status, WNOHANG); if (probe == pid) { - // Already exited before we even started waiting if (exitCode != nullptr) { *exitCode = exitCodeFromWaitStatus(status); } @@ -686,13 +672,13 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session while (true) { QString trackedName; - pid_t displayPid = pid; - QString displayName = readProcComm(pid); - const pid_t tracked = findTrackedProcess(pid, expected, &trackedName); + pid_t displayPid = pid; + QString displayName = readProcComm(pid); + const pid_t tracked = findTrackedProcess(pid, expected, &trackedName); if (tracked > 0) { seenTrackedProcess = true; - displayPid = tracked; - displayName = trackedName; + displayPid = tracked; + displayName = trackedName; } else if (seenTrackedProcess) { if (exitCode != nullptr) { *exitCode = 0; @@ -702,7 +688,8 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session } if (ls != nullptr) { - ls->setInfo(static_cast(std::max(0, displayPid)), displayName); + ls->setInfo(static_cast(std::max(0, displayPid)), + displayName); } if (useKillPoll) { @@ -716,13 +703,13 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session return ProcessRunner::Completed; } // EPERM means the process exists but we can't signal it; keep waiting - if (errno != EPERM) { + else if (errno != EPERM) { log::error("failed checking process {}, errno={}", pid, errno); return ProcessRunner::Error; } } } else { - int status = 0; + int status = 0; const pid_t waitResult = ::waitpid(pid, &status, WNOHANG); if (waitResult == pid) { @@ -776,23 +763,21 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session } ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, - UILocker::Session* ls, - const QStringList& expected) -{ + UILocker::Session *ls, + const QStringList &expected) { return waitForPid(handleToPid(initialProcess), exitCode, ls, expected); } -ProcessRunner::Results waitForProcesses(const std::vector& initialProcesses, - UILocker::Session* ls, - const QStringList& expected) -{ +ProcessRunner::Results +waitForProcesses(const std::vector &initialProcesses, + UILocker::Session *ls, const QStringList &expected) { if (initialProcesses.empty()) { return ProcessRunner::Completed; } for (HANDLE h : initialProcesses) { DWORD ignored = 0; - const auto r = waitForPid(handleToPid(h), &ignored, ls, expected); + const auto r = waitForPid(handleToPid(h), &ignored, ls, expected); if (r != ProcessRunner::Completed) { return r; } @@ -803,16 +788,14 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces #endif // _WIN32 -ProcessRunner::ProcessRunner(OrganizerCore& core, IUserInterface* ui) - : m_core(core), m_ui(ui), m_lockReason(UILocker::NoReason), m_waitFlags(NoFlags), - m_handle(INVALID_HANDLE_VALUE), m_exitCode(-1) -{ +ProcessRunner::ProcessRunner(OrganizerCore &core, IUserInterface *ui) + : m_core(core), m_ui(ui), m_lockReason(UILocker::NoReason), + m_waitFlags(NoFlags), m_handle(INVALID_HANDLE_VALUE), m_exitCode(-1) { // all processes started in ProcessRunner are hooked by default setHooked(true); } -ProcessRunner& ProcessRunner::setBinary(const QFileInfo& binary) -{ +ProcessRunner &ProcessRunner::setBinary(const QFileInfo &binary) { #ifndef _WIN32 m_sp.binary = QFileInfo(MOBase::normalizePathForHost(binary.filePath())); #else @@ -821,14 +804,12 @@ ProcessRunner& ProcessRunner::setBinary(const QFileInfo& binary) return *this; } -ProcessRunner& ProcessRunner::setArguments(const QString& arguments) -{ +ProcessRunner &ProcessRunner::setArguments(const QString &arguments) { m_sp.arguments = arguments; return *this; } -ProcessRunner& ProcessRunner::setCurrentDirectory(const QDir& directory) -{ +ProcessRunner &ProcessRunner::setCurrentDirectory(const QDir &directory) { #ifndef _WIN32 m_sp.currentDirectory.setPath(MOBase::normalizePathForHost(directory.path())); #else @@ -837,37 +818,35 @@ ProcessRunner& ProcessRunner::setCurrentDirectory(const QDir& directory) return *this; } -ProcessRunner& ProcessRunner::setSteamID(const QString& steamID) -{ +ProcessRunner &ProcessRunner::setSteamID(const QString &steamID) { m_sp.steamAppID = steamID; return *this; } -ProcessRunner& ProcessRunner::setCustomOverwrite(const QString& customOverwrite) -{ +ProcessRunner & +ProcessRunner::setCustomOverwrite(const QString &customOverwrite) { m_customOverwrite = customOverwrite; return *this; } -ProcessRunner& ProcessRunner::setForcedLibraries(const ForcedLibraries& forcedLibraries) -{ +ProcessRunner & +ProcessRunner::setForcedLibraries(const ForcedLibraries &forcedLibraries) { m_forcedLibraries = forcedLibraries; return *this; } -ProcessRunner& ProcessRunner::setProfileName(const QString& profileName) -{ +ProcessRunner &ProcessRunner::setProfileName(const QString &profileName) { m_profileName = profileName; return *this; } -ProcessRunner& ProcessRunner::setWaitForCompletion(WaitFlags flags, - UILocker::Reasons reason) -{ - m_waitFlags = flags; +ProcessRunner &ProcessRunner::setWaitForCompletion(WaitFlags flags, + UILocker::Reasons reason) { + m_waitFlags = flags; m_lockReason = reason; - if (m_waitFlags.testFlag(WaitForRefresh) && !m_waitFlags.testFlag(TriggerRefresh)) { + if (m_waitFlags.testFlag(WaitForRefresh) && + !m_waitFlags.testFlag(TriggerRefresh)) { log::warn("process runner: WaitForRefresh without TriggerRefresh " "makes no sense, will be ignored"); } @@ -875,14 +854,13 @@ ProcessRunner& ProcessRunner::setWaitForCompletion(WaitFlags flags, return *this; } -ProcessRunner& ProcessRunner::setHooked(bool b) -{ +ProcessRunner &ProcessRunner::setHooked(bool b) { m_sp.hooked = b; return *this; } -ProcessRunner& ProcessRunner::setFromFile(QWidget* parent, const QFileInfo& targetInfo) -{ +ProcessRunner &ProcessRunner::setFromFile(QWidget *parent, + const QFileInfo &targetInfo) { if (!parent && m_ui) { parent = m_ui->mainWindow(); } @@ -900,7 +878,7 @@ ProcessRunner& ProcessRunner::setFromFile(QWidget* parent, const QFileInfo& targ break; } - case spawn::FileExecutionTypes::Other: // fall-through + case spawn::FileExecutionTypes::Other: // fall-through default: { m_shellOpen = targetInfo; setHooked(false); @@ -911,8 +889,7 @@ ProcessRunner& ProcessRunner::setFromFile(QWidget* parent, const QFileInfo& targ return *this; } -ProcessRunner& ProcessRunner::setFromExecutable(const Executable& exe) -{ +ProcessRunner &ProcessRunner::setFromExecutable(const Executable &exe) { const auto profile = m_core.currentProfile(); if (!profile) { throw MyException(QObject::tr("No profile set")); @@ -941,33 +918,33 @@ ProcessRunner& ProcessRunner::setFromExecutable(const Executable& exe) return *this; } -ProcessRunner& ProcessRunner::setFromShortcut(const MOShortcut& shortcut) -{ +ProcessRunner &ProcessRunner::setFromShortcut(const MOShortcut &shortcut) { const auto currentInstance = InstanceManager::singleton().currentInstance(); if (currentInstance) { if (shortcut.hasInstance() && !shortcut.isForInstance(*currentInstance)) { - MOBase::reportError( - QObject::tr( - "This shortcut is for instance '%1' but Mod Organizer is currently " - "running for '%2'. Exit Mod Organizer before running the shortcut or " - "change the active instance.") - .arg(shortcut.instanceDisplayName()) - .arg(currentInstance->displayName())); + MOBase::reportError(QObject::tr("This shortcut is for instance '%1' but " + "Mod Organizer is currently " + "running for '%2'. Exit Mod Organizer " + "before running the shortcut or " + "change the active instance.") + .arg(shortcut.instanceDisplayName()) + .arg(currentInstance->displayName())); throw std::exception(); } } - const auto* exes = m_core.executablesList(); - const auto exe = exes->find(shortcut.executableName()); + const auto *exes = m_core.executablesList(); + const auto exe = exes->find(shortcut.executableName()); if (exe != exes->end()) { setFromExecutable(*exe); } else { - MOBase::reportError(QObject::tr("Executable '%1' does not exist in instance '%2'.") - .arg(shortcut.executableName()) - .arg(currentInstance->displayName())); + MOBase::reportError( + QObject::tr("Executable '%1' does not exist in instance '%2'.") + .arg(shortcut.executableName()) + .arg(currentInstance->displayName())); throw std::exception(); } @@ -975,11 +952,10 @@ ProcessRunner& ProcessRunner::setFromShortcut(const MOShortcut& shortcut) return *this; } -ProcessRunner& ProcessRunner::setFromFileOrExecutable( - const QString& executable, const QStringList& args, const QString& cwd, - const QString& profileOverride, const QString& forcedCustomOverwrite, - bool ignoreCustomOverwrite) -{ +ProcessRunner &ProcessRunner::setFromFileOrExecutable( + const QString &executable, const QStringList &args, const QString &cwd, + const QString &profileOverride, const QString &forcedCustomOverwrite, + bool ignoreCustomOverwrite) { const auto profile = m_core.currentProfile(); if (!profile) { throw MyException(QObject::tr("No profile set")); @@ -1004,24 +980,27 @@ ProcessRunner& ProcessRunner::setFromFileOrExecutable( } try { - const Executable& exe = m_core.executablesList()->getByBinary(m_sp.binary); + const Executable &exe = + m_core.executablesList()->getByBinary(m_sp.binary); setSteamID(exe.steamAppID()); - setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString()); + setCustomOverwrite( + profile->setting("custom_overwrites", exe.title()).toString()); if (profile->forcedLibrariesEnabled(exe.title())) { setForcedLibraries(profile->determineForcedLibraries(exe.title())); } - } catch (const std::runtime_error&) { + } catch (const std::runtime_error &) { // nop } } else { // only a file name, search executables list try { - const Executable& exe = m_core.executablesList()->get(executable); + const Executable &exe = m_core.executablesList()->get(executable); setSteamID(exe.steamAppID()); - setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString()); + setCustomOverwrite( + profile->setting("custom_overwrites", exe.title()).toString()); if (profile->forcedLibrariesEnabled(exe.title())) { setForcedLibraries(profile->determineForcedLibraries(exe.title())); @@ -1036,7 +1015,7 @@ ProcessRunner& ProcessRunner::setFromFileOrExecutable( if (cwd == "") { setCurrentDirectory(exe.workingDirectory()); } - } catch (const std::runtime_error&) { + } catch (const std::runtime_error &) { log::warn("\"{}\" not set up as executable", executable); } } @@ -1050,13 +1029,11 @@ ProcessRunner& ProcessRunner::setFromFileOrExecutable( return *this; } -bool ProcessRunner::shouldRunShell() const -{ +bool ProcessRunner::shouldRunShell() const { return !m_shellOpen.filePath().isEmpty(); } -ProcessRunner::Results ProcessRunner::run() -{ +ProcessRunner::Results ProcessRunner::run() { // check if setHooked() was called after setFromFile(); this needs to // modify the settings to run the associated executable instead of using // shell::Open() @@ -1097,9 +1074,9 @@ ProcessRunner::Results ProcessRunner::run() return postRun(); } -std::optional ProcessRunner::runShell() -{ - const auto file = MOBase::normalizePathForHost(m_shellOpen.absoluteFilePath()); +std::optional ProcessRunner::runShell() { + const auto file = + MOBase::normalizePathForHost(m_shellOpen.absoluteFilePath()); log::debug("executing from shell: '{}'", file); @@ -1122,8 +1099,7 @@ std::optional ProcessRunner::runShell() return {}; } -std::optional ProcessRunner::runBinary() -{ +std::optional ProcessRunner::runBinary() { if (m_profileName.isEmpty()) { // get the current profile name if it wasn't overridden const auto profile = m_core.currentProfile(); @@ -1143,10 +1119,10 @@ std::optional ProcessRunner::runBinary() } // parent widget used for any dialog popped up while checking for things - QWidget* parent = (m_ui ? m_ui->mainWindow() : nullptr); + QWidget *parent = (m_ui ? m_ui->mainWindow() : nullptr); - const auto* game = m_core.managedGame(); - auto& settings = m_core.settings(); + const auto *game = m_core.managedGame(); + auto &settings = m_core.settings(); if (m_sp.steamAppID.trimmed().isEmpty()) { const QString gameSteamId = game->steamAPPId().trimmed(); @@ -1158,7 +1134,8 @@ std::optional ProcessRunner::runBinary() } // start steam if needed - if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) { + if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, + settings)) { return Error; } @@ -1175,7 +1152,8 @@ std::optional ProcessRunner::runBinary() #ifdef _WIN32 m_handle.reset(startBinary(parent, m_sp)); #else - m_handle.reset(reinterpret_cast(static_cast(startBinary(parent, m_sp)))); + m_handle.reset(reinterpret_cast( + static_cast(startBinary(parent, m_sp)))); #endif if (m_handle.get() == INVALID_HANDLE_VALUE) { return Error; @@ -1184,8 +1162,7 @@ std::optional ProcessRunner::runBinary() return {}; } -bool ProcessRunner::shouldRefresh(Results r) const -{ +bool ProcessRunner::shouldRefresh(Results r) const { // afterRun() is only called with the Refresh flag; it refreshes the // directory structure and notifies plugins // @@ -1211,11 +1188,12 @@ bool ProcessRunner::shouldRefresh(Results r) const case ForceUnlocked: { // The process may still be running when the user force-unlocks. // Refreshing in that state can race with file updates. - log::debug("process runner: not refreshing because the ui was force unlocked"); + log::debug( + "process runner: not refreshing because the ui was force unlocked"); return false; } - case Error: // fall-through + case Error: // fall-through case Cancelled: case Running: default: { @@ -1224,8 +1202,7 @@ bool ProcessRunner::shouldRefresh(Results r) const } } -ProcessRunner::Results ProcessRunner::postRun() -{ +ProcessRunner::Results ProcessRunner::postRun() { const bool mustWait = (m_waitFlags & ForceWait); if (!m_sp.hooked && !mustWait) { @@ -1248,8 +1225,9 @@ ProcessRunner::Results ProcessRunner::postRun() if (mustWait) { if (!lockEnabled) { // at least tell the user what's going on - log::debug("locking is disabled, but the output of the application is required; " - "overriding this setting and locking the ui"); + log::debug( + "locking is disabled, but the output of the application is required; " + "overriding this setting and locking the ui"); } } else { // no force wait @@ -1257,10 +1235,12 @@ ProcessRunner::Results ProcessRunner::postRun() if (m_lockReason == UILocker::NoReason) { // no locking requested #ifndef _WIN32 - // Main window launches typically use TriggerRefresh without waiting/locking. - // In that mode we still need post-run refresh/sync once the process exits. + // Main window launches typically use TriggerRefresh without + // waiting/locking. In that mode we still need post-run refresh/sync once + // the process exits. if (m_waitFlags.testFlag(TriggerRefresh)) { - const pid_t pid = static_cast(reinterpret_cast(m_handle.get())); + const pid_t pid = + static_cast(reinterpret_cast(m_handle.get())); const QFileInfo binary = m_sp.binary; QPointer core = &m_core; @@ -1283,11 +1263,11 @@ ProcessRunner::Results ProcessRunner::postRun() } else if (errno == ECHILD) { // Detached process — poll with kill(0) until gone. while (::kill(pid, 0) == 0 || errno == EPERM) { - usleep(200000); // 200ms + usleep(200000); // 200ms } } else { - MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid, - errno); + MOBase::log::warn("process runner: waitpid failed for pid {}: {}", + pid, errno); } if (!core) { @@ -1295,7 +1275,8 @@ ProcessRunner::Results ProcessRunner::postRun() } QMetaObject::invokeMethod( - core, [core, binary, exitCode]() { + core, + [core, binary, exitCode]() { if (core) { core->afterRun(binary, exitCode); } @@ -1303,7 +1284,8 @@ ProcessRunner::Results ProcessRunner::postRun() Qt::QueuedConnection); }).detach(); - log::debug("process runner: scheduled async post-run refresh for pid {}", pid); + log::debug( + "process runner: scheduled async post-run refresh for pid {}", pid); } #endif return Running; @@ -1331,14 +1313,14 @@ ProcessRunner::Results ProcessRunner::postRun() // // MO will be running in the background with no visual feedback, but that's // how it is - r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables); + r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, + expectedExecutables); } else { - withLock([&](auto& ls) { + withLock([&](auto &ls) { r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables); }); } - if (shouldRefresh(r)) { QEventLoop loop; const bool wait = m_waitFlags.testFlag(WaitForRefresh); @@ -1361,38 +1343,28 @@ ProcessRunner::Results ProcessRunner::postRun() } #ifdef _WIN32 -ProcessRunner::Results ProcessRunner::attachToProcess(HANDLE h) -{ +ProcessRunner::Results ProcessRunner::attachToProcess(HANDLE h) { m_handle.reset(h); return postRun(); } #else -ProcessRunner::Results ProcessRunner::attachToProcess(pid_t pid) -{ +ProcessRunner::Results ProcessRunner::attachToProcess(pid_t pid) { m_handle.reset(reinterpret_cast(static_cast(pid))); return postRun(); } #endif -DWORD ProcessRunner::exitCode() const -{ - return m_exitCode; -} +DWORD ProcessRunner::exitCode() const { return m_exitCode; } #ifdef _WIN32 -HANDLE ProcessRunner::getProcessHandle() const -{ - return m_handle.get(); -} +HANDLE ProcessRunner::getProcessHandle() const { return m_handle.get(); } #else -pid_t ProcessRunner::getProcessHandle() const -{ +pid_t ProcessRunner::getProcessHandle() const { return static_cast(reinterpret_cast(m_handle.get())); } #endif -env::HandlePtr ProcessRunner::stealProcessHandle() -{ +env::HandlePtr ProcessRunner::stealProcessHandle() { auto h = m_handle.release(); m_handle.reset(INVALID_HANDLE_VALUE); return env::HandlePtr(h); @@ -1400,8 +1372,7 @@ env::HandlePtr ProcessRunner::stealProcessHandle() #ifdef _WIN32 ProcessRunner::Results -ProcessRunner::waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason) -{ +ProcessRunner::waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason) { m_lockReason = reason; if (!m_core.settings().interface().lockGUI()) { @@ -1412,7 +1383,7 @@ ProcessRunner::waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason) auto r = Error; for (;;) { - withLock([&](auto& ls) { + withLock([&](auto &ls) { const auto processes = getRunningUSVFSProcesses(); if (processes.empty()) { r = Completed; @@ -1439,8 +1410,7 @@ ProcessRunner::waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason) } #endif -void ProcessRunner::withLock(std::function f) -{ +void ProcessRunner::withLock(std::function f) { auto ls = UILocker::instance().lock(m_lockReason); f(*ls); } diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index f2bd7a0..599ef62 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -3,14 +3,98 @@ #include #include #include +#include #include #include #include #include #include + namespace { +// Restore the pre-AppImage environment for child processes (umu-run, Proton, +// pressure-vessel). The AppRun script saves FLUORINE_ORIG_* vars before +// modifying PATH, LD_LIBRARY_PATH, etc. We restore from those saved values +// so game processes get a clean host environment without AppImage library paths. +void cleanAppImageEnv(QProcessEnvironment& env) +{ + // Remove Fluorine/AppImage-specific vars that should never leak to game processes. + env.remove("QT_QPA_PLATFORM_PLUGIN_PATH"); + env.remove("MO2_PLUGINS_DIR"); + env.remove("MO2_DLLS_DIR"); + env.remove("MO2_PYTHON_DIR"); + env.remove("MO2_BASE_DIR"); + + // AppImage runtime injects these — they can confuse pressure-vessel/umu-run. + env.remove("APPIMAGE"); + env.remove("APPDIR"); + env.remove("OWD"); + env.remove("ARGV0"); + env.remove("APPIMAGE_ORIGINAL_EXEC"); + + env.remove("DESKTOPINTEGRATION"); + + // Restore saved pre-AppImage values. AppRun sets FLUORINE_ORIG_* before + // modifying PATH, LD_LIBRARY_PATH, etc. If those vars exist, use them to + // restore the original host environment. If not (standalone/non-AppImage), + // fall back to stripping known AppImage patterns. + auto restoreOrStrip = [](const QString& var, const QString& origVar, + QProcessEnvironment& e) { + if (e.contains(origVar)) { + const QString orig = e.value(origVar); + if (orig.isEmpty()) { + e.remove(var); + } else { + e.insert(var, orig); + } + e.remove(origVar); + } else { + // Fallback: strip AppImage mount paths by pattern. + const QString value = e.value(var); + if (value.isEmpty()) return; + QStringList kept; + for (const QString& p : value.split(':')) { + if (p.contains(".mount_Fluori") || p.contains("/fluorine/python")) { + continue; + } + kept.append(p); + } + if (kept.isEmpty()) { + e.remove(var); + } else { + e.insert(var, kept.join(':')); + } + } + }; + + const bool hasOrigVars = env.contains("FLUORINE_ORIG_PATH"); + restoreOrStrip("LD_LIBRARY_PATH", "FLUORINE_ORIG_LD_LIBRARY_PATH", env); + restoreOrStrip("PATH", "FLUORINE_ORIG_PATH", env); + restoreOrStrip("XDG_DATA_DIRS", "FLUORINE_ORIG_XDG_DATA_DIRS", env); + restoreOrStrip("QT_PLUGIN_PATH", "FLUORINE_ORIG_QT_PLUGIN_PATH", env); + + MOBase::log::debug("cleanAppImageEnv: {} (LD_LIBRARY_PATH='{}')", + hasOrigVars ? "restored from FLUORINE_ORIG_*" : "pattern-strip fallback", + env.value("LD_LIBRARY_PATH", "")); +} + +// GE-Proton crashes in update_builtin_libs() if tracked_files doesn't exist +// (e.g. prefix was created by plain Wine, not Proton). Create an empty one. +void ensureTrackedFilesExist(const QString& compatDataPath) +{ + if (compatDataPath.isEmpty()) return; + const QString tracked = QDir(compatDataPath).filePath("tracked_files"); + if (!QFileInfo::exists(tracked)) { + QDir().mkpath(compatDataPath); + QFile f(tracked); + if (f.open(QIODevice::WriteOnly)) { + f.close(); + MOBase::log::debug("created empty tracked_files at '{}'", tracked); + } + } +} + QString compatDataPathFromPrefix(const QString& prefixPath) { if (prefixPath.isEmpty()) { @@ -226,6 +310,12 @@ ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm) return *this; } +ProtonLauncher& ProtonLauncher::setStoreVariant(const QString& variant) +{ + m_storeVariant = variant.trimmed(); + return *this; +} + ProtonLauncher& ProtonLauncher::addEnvVar(const QString& key, const QString& value) { if (!key.isEmpty()) { @@ -277,6 +367,7 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); + cleanAppImageEnv(env); if (!m_prefixPath.isEmpty()) { env.insert("WINEPREFIX", m_prefixPath); @@ -284,6 +375,7 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const const QString compatDataPath = compatDataPathFromPrefix(m_prefixPath); if (!compatDataPath.isEmpty()) { + ensureTrackedFilesExist(compatDataPath); env.insert("STEAM_COMPAT_DATA_PATH", compatDataPath); } @@ -320,6 +412,10 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary); + if (!m_workingDir.isEmpty()) { + env.insert("PWD", m_workingDir); + } + return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); } @@ -335,9 +431,11 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const ensureSteamRunning(); } - // Resolve umu-run according to user preference (bundled vs system). + // Resolve umu-run: prefer the copy deployed to the Fluorine data dir + // (~/.local/share/fluorine/umu-run), then system PATH, then AppImage bundled. const QString appDir = QCoreApplication::applicationDirPath(); const QString bundled = appDir + QStringLiteral("/umu-run"); + const QString dataDir = QDir::home().filePath(".local/share/fluorine/umu-run"); // Search PATH for a system umu-run, excluding our own app directory // (the launcher prepends it to PATH, so findExecutable would find the @@ -356,25 +454,20 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const } } + // Priority: data dir > system (if preferred) > bundled > system (fallback) QString umuRun; - if (m_preferSystemUmu) { - if (!system.isEmpty()) { - umuRun = system; - } else if (QFileInfo::exists(bundled)) { - umuRun = bundled; - MOBase::log::warn( - "System umu-run preferred but not found in PATH, falling back to bundled"); - } - } else { - if (QFileInfo::exists(bundled)) { - umuRun = bundled; - } else if (!system.isEmpty()) { - umuRun = system; - } + if (QFileInfo::exists(dataDir)) { + umuRun = dataDir; + } else if (m_preferSystemUmu && !system.isEmpty()) { + umuRun = system; + } else if (QFileInfo::exists(bundled)) { + umuRun = bundled; + } else if (!system.isEmpty()) { + umuRun = system; } - MOBase::log::info("umu-run: preferSystem={}, bundled='{}' (exists={}), system='{}', selected='{}'", - m_preferSystemUmu, bundled, QFileInfo::exists(bundled), system, umuRun); + MOBase::log::info("umu-run: dataDir='{}' (exists={}), bundled='{}' (exists={}), system='{}', selected='{}'", + dataDir, QFileInfo::exists(dataDir), bundled, QFileInfo::exists(bundled), system, umuRun); if (umuRun.isEmpty()) { MOBase::log::warn("umu-run not found (bundled or in PATH)"); @@ -390,6 +483,7 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); + cleanAppImageEnv(env); if (!m_prefixPath.isEmpty()) { env.insert("WINEPREFIX", m_prefixPath); @@ -400,12 +494,8 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const } // umu-run sets STEAM_COMPAT_DATA_PATH internally from WINEPREFIX, so we - // do NOT set it here. However, the game's Steamworks DRM still needs - // STEAM_COMPAT_CLIENT_INSTALL_PATH to locate the Steam client libraries. - const QString steamPath = detectSteamPath(); - if (!steamPath.isEmpty()) { - env.insert("STEAM_COMPAT_CLIENT_INSTALL_PATH", steamPath); - } + // do NOT set it here. However, ensure tracked_files exists for Proton. + ensureTrackedFilesExist(compatDataPathFromPrefix(m_prefixPath)); uint32_t effectiveSteamAppId = m_steamAppId; if (effectiveSteamAppId == 0) { @@ -418,17 +508,57 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const } } - if (m_useSteamDrm && effectiveSteamAppId != 0) { - // umu-run expects GAMEID in "umu-" format to extract SteamAppId. + // Always pass the game's Steam App ID as GAMEID for protonfixes lookup. + // GAMEID identifies the game (for compatibility fixes), separate from DRM. + if (effectiveSteamAppId != 0) { env.insert("GAMEID", QStringLiteral("umu-") + QString::number(effectiveSteamAppId)); - env.insert("SteamAppId", QString::number(effectiveSteamAppId)); - env.insert("SteamGameId", QString::number(effectiveSteamAppId)); - env.insert("STORE", QStringLiteral("steam")); } else { - // No Steam DRM — use a generic game ID so umu-run doesn't try Steam integration. env.insert("GAMEID", QStringLiteral("umu-0")); } + if (m_useSteamDrm) { + // Steam DRM games need the Steam client path and Steam identity env vars. + env.insert("STORE", QStringLiteral("steam")); + const QString steamPath = detectSteamPath(); + if (!steamPath.isEmpty()) { + env.insert("STEAM_COMPAT_CLIENT_INSTALL_PATH", steamPath); + } + if (effectiveSteamAppId != 0) { + env.insert("SteamAppId", QString::number(effectiveSteamAppId)); + env.insert("SteamGameId", QString::number(effectiveSteamAppId)); + } + } else { + // Non-Steam games: do NOT set SteamAppId/SteamGameId — those can trigger + // Steamworks initialization in Wine which crashes GOG/Epic games. + env.remove("SteamAPPId"); + env.remove("SteamAppId"); + env.remove("SteamGameId"); + + // Set STORE so umu-run knows this is a non-Steam game. Without STORE, + // umu-run may attempt Steam-specific behavior that breaks GOG/Epic titles. + // umu-run expects lowercase values (gog, egs, etc.). + if (!m_storeVariant.isEmpty()) { + env.insert("STORE", m_storeVariant.toLower()); + } else { + env.insert("STORE", QStringLiteral("gog")); + } + } + + // Remove FUSE VFS file descriptors — these are Fluorine-internal and must + // not leak into game processes (especially pressure-vessel containers). + env.remove("_FUSE_COMMFD"); + env.remove("_FUSE_COMMFD2"); + + // Remove Qt/KDE theme vars that can confuse pressure-vessel/Wine. + env.remove("QML_DISABLE_DISK_CACHE"); + env.remove("QT_ICON_THEME_NAME"); + env.remove("QT_STYLE_OVERRIDE"); + env.remove("OLDPWD"); + + if (!m_workingDir.isEmpty()) { + env.insert("PWD", m_workingDir); + } + for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { env.insert(it.key(), it.value()); } @@ -446,13 +576,11 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const } } - MOBase::log::info("UMU launch: '{}' '{}' (game id: {}, steam: '{}')", umuRun, - m_binary, - (effectiveSteamAppId == 0 - ? QStringLiteral("") - : QStringLiteral("umu-") + - QString::number(effectiveSteamAppId)), - steamPath); + MOBase::log::info("UMU launch: '{}' '{}' (GAMEID={}, STORE={}, steam_drm={})", + umuRun, m_binary, + env.value("GAMEID"), + env.value("STORE", ""), + m_useSteamDrm); return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); } @@ -470,6 +598,7 @@ bool ProtonLauncher::launchDirect(qint64& pid) const QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); + cleanAppImageEnv(env); for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { env.insert(it.key(), it.value()); } diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h index 5480c69..7ef6e76 100644 --- a/src/src/protonlauncher.h +++ b/src/src/protonlauncher.h @@ -24,6 +24,7 @@ public: ProtonLauncher& setPreferSystemUmu(bool preferSystemUmu); ProtonLauncher& setUseSteamRun(bool useSteamRun); ProtonLauncher& setSteamDrm(bool useSteamDrm); + ProtonLauncher& setStoreVariant(const QString& variant); ProtonLauncher& addEnvVar(const QString& key, const QString& value); // Launch dispatch: UMU -> Proton -> Direct @@ -46,6 +47,7 @@ private: bool m_preferSystemUmu; bool m_useSteamRun; bool m_useSteamDrm; + QString m_storeVariant; // "GOG", "Epic", or empty for Steam QMap m_envVars; QMap m_wrapperEnvVars; }; diff --git a/src/src/savestab.cpp b/src/src/savestab.cpp index 2f921e5..29411fa 100644 --- a/src/src/savestab.cpp +++ b/src/src/savestab.cpp @@ -1,6 +1,6 @@ -#include "savestab.h" -#include "activatemodsdialog.h" -#include "organizercore.h" +#include "savestab.h" +#include "activatemodsdialog.h" +#include "organizercore.h" #include "ui_mainwindow.h" #include #include @@ -8,12 +8,55 @@ #include #include +#include +#include + using namespace MOBase; namespace { bool g_disableSaveTooltipsAfterOOM = false; +// Read a value from a Bethesda-style INI file without QSettings. +// QSettings::IniFormat interprets backslashes as line continuations, +// which corrupts values like "sLocalSavePath=__MO_Saves\". +QString readIniValueDirect(const QString& iniFile, const QString& section, + const QString& key) +{ + QFile file(iniFile); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + + const QString sectionHeader = "[" + section + "]"; + QTextStream in(&file); + bool inSection = false; + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.startsWith('[') && line.endsWith(']')) { + if (inSection) + break; + if (line.compare(sectionHeader, Qt::CaseInsensitive) == 0) + inSection = true; + continue; + } + + if (inSection && !line.isEmpty() && !line.startsWith(';') && + !line.startsWith('#')) { + int eqPos = line.indexOf('='); + if (eqPos > 0) { + QString existingKey = line.left(eqPos).trimmed(); + if (existingKey.compare(key, Qt::CaseInsensitive) == 0) { + return line.mid(eqPos + 1).trimmed(); + } + } + } + } + + return {}; +} + QString sanitizeText(QString in, int maxLen = 200) { for (int i = 0; i < in.size(); ++i) { @@ -51,48 +94,48 @@ bool isLikelyCorruptSaveText(QString const& in) } // namespace SavesTab::SavesTab(QWidget* window, OrganizerCore& core, Ui::MainWindow* mwui) - : m_window(window), m_core(core), m_CurrentSaveView(nullptr), - ui{mwui->tabWidget, mwui->savesTab, mwui->savegameList} -{ - m_SavesWatcherTimer.setSingleShot(true); - m_SavesWatcherTimer.setInterval(500); - - ui.list->installEventFilter(this); - ui.list->setMouseTracking(true); - - connect(&m_SavesWatcher, &QFileSystemWatcher::directoryChanged, [&] { - m_SavesWatcherTimer.start(); - }); - - connect(&m_SavesWatcherTimer, &QTimer::timeout, [&] { - refreshSavesIfOpen(); - }); - - connect(ui.list, &QWidget::customContextMenuRequested, [&](auto pos) { - onContextMenu(pos); - }); - - connect(ui.list, &QTreeWidget::itemEntered, [&](auto* item) { - saveSelectionChanged(item); - }); -} - -bool SavesTab::eventFilter(QObject* object, QEvent* e) -{ - if (object == ui.list) { - if (e->type() == QEvent::Leave || e->type() == QEvent::WindowDeactivate) { - hideSaveGameInfo(); - } else if (e->type() == QEvent::KeyPress) { - QKeyEvent* keyEvent = static_cast(e); - if (keyEvent->key() == Qt::Key_Delete) { - deleteSavegame(); - } - } - } - - return false; -} - + : m_window(window), m_core(core), m_CurrentSaveView(nullptr), + ui{mwui->tabWidget, mwui->savesTab, mwui->savegameList} +{ + m_SavesWatcherTimer.setSingleShot(true); + m_SavesWatcherTimer.setInterval(500); + + ui.list->installEventFilter(this); + ui.list->setMouseTracking(true); + + connect(&m_SavesWatcher, &QFileSystemWatcher::directoryChanged, [&] { + m_SavesWatcherTimer.start(); + }); + + connect(&m_SavesWatcherTimer, &QTimer::timeout, [&] { + refreshSavesIfOpen(); + }); + + connect(ui.list, &QWidget::customContextMenuRequested, [&](auto pos) { + onContextMenu(pos); + }); + + connect(ui.list, &QTreeWidget::itemEntered, [&](auto* item) { + saveSelectionChanged(item); + }); +} + +bool SavesTab::eventFilter(QObject* object, QEvent* e) +{ + if (object == ui.list) { + if (e->type() == QEvent::Leave || e->type() == QEvent::WindowDeactivate) { + hideSaveGameInfo(); + } else if (e->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(e); + if (keyEvent->key() == Qt::Key_Delete) { + deleteSavegame(); + } + } + } + + return false; +} + void SavesTab::displaySaveGameInfo(QTreeWidgetItem* newItem) { if (g_disableSaveTooltipsAfterOOM) { @@ -100,21 +143,21 @@ void SavesTab::displaySaveGameInfo(QTreeWidgetItem* newItem) } // don't display the widget if the main window doesn't have focus - // - // this goes against the standard behaviour for tooltips, which are displayed - // on hover regardless of focus, but this widget is so large and busy that - // it's probably better this way - if (!m_window->isActiveWindow()) { - return; - } - + // + // this goes against the standard behaviour for tooltips, which are displayed + // on hover regardless of focus, but this widget is so large and busy that + // it's probably better this way + if (!m_window->isActiveWindow()) { + return; + } + if (m_CurrentSaveView == nullptr) { auto info = m_core.gameFeatures().gameFeature(); - - if (info != nullptr) { - m_CurrentSaveView = info->getSaveGameWidget(m_window); - } - + + if (info != nullptr) { + m_CurrentSaveView = info->getSaveGameWidget(m_window); + } + if (m_CurrentSaveView == nullptr) { return; } @@ -135,129 +178,135 @@ void SavesTab::displaySaveGameInfo(QTreeWidgetItem* newItem) hideSaveGameInfo(); return; } - - QWindow* window = m_CurrentSaveView->window()->windowHandle(); - QRect screenRect; - if (window == nullptr) - screenRect = QGuiApplication::primaryScreen()->geometry(); - else - screenRect = window->screen()->geometry(); - - QPoint pos = QCursor::pos(); - if (pos.x() + m_CurrentSaveView->width() > screenRect.right()) { - pos.rx() -= (m_CurrentSaveView->width() + 2); - } else { - pos.rx() += 5; - } - - if (pos.y() + m_CurrentSaveView->height() > screenRect.bottom()) { - pos.ry() -= (m_CurrentSaveView->height() + 10); - } else { - pos.ry() += 20; - } - m_CurrentSaveView->move(pos); - - m_CurrentSaveView->show(); - m_CurrentSaveView->setProperty("displayItem", - QVariant::fromValue(static_cast(newItem))); -} - -void SavesTab::saveSelectionChanged(QTreeWidgetItem* newItem) -{ - if (newItem == nullptr) { - hideSaveGameInfo(); - } else if (m_CurrentSaveView == nullptr || - newItem != m_CurrentSaveView->property("displayItem").value()) { - displaySaveGameInfo(newItem); - } -} - -void SavesTab::hideSaveGameInfo() -{ - if (m_CurrentSaveView != nullptr) { - m_CurrentSaveView->deleteLater(); - m_CurrentSaveView = nullptr; - } -} - -void SavesTab::refreshSavesIfOpen() -{ - if (ui.mainTabs->currentWidget() == ui.tab) { - refreshSaveList(); - } -} - -QDir SavesTab::currentSavesDir() const -{ - // TODO: This code should probably be handled by the game plugins - QDir savesDir; - if (m_core.currentProfile()->localSavesEnabled()) { - savesDir.setPath(m_core.currentProfile()->savePath()); - } else { - auto iniFiles = m_core.managedGame()->iniFiles(); - - if (iniFiles.isEmpty() || - m_core.gameFeatures().gameFeature() == nullptr) { - return m_core.managedGame()->savesDirectory(); - } - - QString iniPath = m_core.currentProfile()->absoluteIniFilePath(iniFiles[0]); - -#ifdef _WIN32 - wchar_t path[MAX_PATH]; - if (::GetPrivateProfileStringW(L"General", L"SLocalSavePath", L"", path, MAX_PATH, - iniPath.toStdWString().c_str())) { - savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath( - QString::fromWCharArray(path))); - } else { - savesDir = m_core.managedGame()->savesDirectory(); - } -#else - // On Linux, use QSettings to read the INI file - QSettings ini(iniPath, QSettings::IniFormat); - QString savePath = ini.value("General/SLocalSavePath").toString(); - if (!savePath.isEmpty()) { - savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath(savePath)); - } else { - savesDir = m_core.managedGame()->savesDirectory(); - } -#endif - } - - return savesDir; -} - -void SavesTab::startMonitorSaves() -{ - stopMonitorSaves(); - - QDir savesDir = currentSavesDir(); - - m_SavesWatcher.addPath(savesDir.absolutePath()); -} - -void SavesTab::stopMonitorSaves() -{ - if (m_SavesWatcher.directories().length() > 0) { - m_SavesWatcher.removePaths(m_SavesWatcher.directories()); - } -} - -void SavesTab::refreshSaveList() -{ - TimeThis tt("MainWindow::refreshSaveList()"); - - startMonitorSaves(); // re-starts monitoring - - try { - QDir savesDir = currentSavesDir(); - MOBase::log::debug("reading save games from {}", savesDir.absolutePath()); - m_SaveGames = m_core.managedGame()->listSaves(savesDir); - std::sort(m_SaveGames.begin(), m_SaveGames.end(), - [](auto const& lhs, auto const& rhs) { - return lhs->getCreationTime() > rhs->getCreationTime(); - }); - + + QWindow* window = m_CurrentSaveView->window()->windowHandle(); + QRect screenRect; + if (window == nullptr) + screenRect = QGuiApplication::primaryScreen()->geometry(); + else + screenRect = window->screen()->geometry(); + + QPoint pos = QCursor::pos(); + if (pos.x() + m_CurrentSaveView->width() > screenRect.right()) { + pos.rx() -= (m_CurrentSaveView->width() + 2); + } else { + pos.rx() += 5; + } + + if (pos.y() + m_CurrentSaveView->height() > screenRect.bottom()) { + pos.ry() -= (m_CurrentSaveView->height() + 10); + } else { + pos.ry() += 20; + } + m_CurrentSaveView->move(pos); + + m_CurrentSaveView->show(); + m_CurrentSaveView->setProperty("displayItem", + QVariant::fromValue(static_cast(newItem))); +} + +void SavesTab::saveSelectionChanged(QTreeWidgetItem* newItem) +{ + if (newItem == nullptr) { + hideSaveGameInfo(); + } else if (m_CurrentSaveView == nullptr || + newItem != m_CurrentSaveView->property("displayItem").value()) { + displaySaveGameInfo(newItem); + } +} + +void SavesTab::hideSaveGameInfo() +{ + if (m_CurrentSaveView != nullptr) { + m_CurrentSaveView->deleteLater(); + m_CurrentSaveView = nullptr; + } +} + +void SavesTab::refreshSavesIfOpen() +{ + if (ui.mainTabs->currentWidget() == ui.tab) { + refreshSaveList(); + } +} + +QDir SavesTab::currentSavesDir() const +{ + // TODO: This code should probably be handled by the game plugins + QDir savesDir; + if (m_core.currentProfile()->localSavesEnabled()) { + savesDir.setPath(m_core.currentProfile()->savePath()); + } else { + auto iniFiles = m_core.managedGame()->iniFiles(); + + if (iniFiles.isEmpty() || + m_core.gameFeatures().gameFeature() == nullptr) { + return m_core.managedGame()->savesDirectory(); + } + + QString iniPath = m_core.currentProfile()->absoluteIniFilePath(iniFiles[0]); + +#ifdef _WIN32 + wchar_t path[MAX_PATH]; + if (::GetPrivateProfileStringW(L"General", L"SLocalSavePath", L"", path, MAX_PATH, + iniPath.toStdWString().c_str())) { + savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath( + QString::fromWCharArray(path))); + } else { + savesDir = m_core.managedGame()->savesDirectory(); + } +#else + // Read directly without QSettings — QSettings::IniFormat interprets + // trailing backslashes as line continuations, corrupting values like + // "sLocalSavePath=__MO_Saves\". + QString savePath = readIniValueDirect(iniPath, "General", "sLocalSavePath"); + // Strip trailing path separators (Bethesda INIs use "Saves\" with a + // trailing backslash that is part of the value, not a continuation). + while (savePath.endsWith('\\') || savePath.endsWith('/')) { + savePath.chop(1); + } + if (!savePath.isEmpty()) { + savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath(savePath)); + } else { + savesDir = m_core.managedGame()->savesDirectory(); + } +#endif + } + + return savesDir; +} + +void SavesTab::startMonitorSaves() +{ + stopMonitorSaves(); + + QDir savesDir = currentSavesDir(); + + m_SavesWatcher.addPath(savesDir.absolutePath()); +} + +void SavesTab::stopMonitorSaves() +{ + if (m_SavesWatcher.directories().length() > 0) { + m_SavesWatcher.removePaths(m_SavesWatcher.directories()); + } +} + +void SavesTab::refreshSaveList() +{ + TimeThis tt("MainWindow::refreshSaveList()"); + + startMonitorSaves(); // re-starts monitoring + + try { + QDir savesDir = currentSavesDir(); + MOBase::log::debug("reading save games from {}", savesDir.absolutePath()); + m_SaveGames = m_core.managedGame()->listSaves(savesDir); + std::sort(m_SaveGames.begin(), m_SaveGames.end(), + [](auto const& lhs, auto const& rhs) { + return lhs->getCreationTime() > rhs->getCreationTime(); + }); + ui.list->clear(); for (auto& save : m_SaveGames) { auto relpath = savesDir.relativeFilePath(save->getFilepath()); @@ -269,125 +318,125 @@ void SavesTab::refreshSaveList() ui.list->addTopLevelItem(new QTreeWidgetItem(ui.list, {display, relpath})); } } catch (std::exception& e) { - // listSaves() can throw - log::error("{}", e.what()); - } -} - -void SavesTab::deleteSavegame() -{ - auto info = m_core.gameFeatures().gameFeature(); - - QString savesMsgLabel; - QStringList deleteFiles; - - int count = 0; - - for (const QModelIndex& idx : ui.list->selectionModel()->selectedRows()) { - - auto& saveGame = m_SaveGames[idx.row()]; - - if (count < 10) { - savesMsgLabel += - "
  • " + QFileInfo(saveGame->getFilepath()).completeBaseName() + "
  • "; - } - ++count; - - deleteFiles += saveGame->allFiles(); - } - - if (count > 10) { - savesMsgLabel += "
  • ... " + tr("%1 more").arg(count - 10) + "
  • "; - } - - if (QMessageBox::question( - m_window, tr("Confirm"), - tr("Are you sure you want to remove the following %n save(s)?
    " - "
      %1

    " - "Removed saves will be sent to the Recycle Bin.", - "", count) - .arg(savesMsgLabel), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - shellDelete(deleteFiles, true); // recycle bin delete. - refreshSaveList(); - } -} - -void SavesTab::onContextMenu(const QPoint& pos) -{ - QItemSelectionModel* selection = ui.list->selectionModel(); - - if (!selection->hasSelection()) { - return; - } - - QMenu menu; - - auto info = m_core.gameFeatures().gameFeature(); - if (info != nullptr) { - QAction* action = menu.addAction(tr("Fix enabled mods...")); - action->setEnabled(false); - if (selection->selectedRows().count() == 1) { - auto& save = m_SaveGames[selection->selectedRows()[0].row()]; - SaveGameInfo::MissingAssets missing = info->getMissingAssets(*save); - if (missing.size() != 0) { - connect(action, &QAction::triggered, this, [this, missing] { - fixMods(missing); - }); - action->setEnabled(true); - } - } - } - - QString deleteMenuLabel = - tr("Delete %n save(s)", "", selection->selectedRows().count()); - menu.addAction(deleteMenuLabel, [&] { - deleteSavegame(); - }); - - menu.addAction(tr("Open in Explorer..."), [&] { - openInExplorer(); - }); - - menu.exec(ui.list->viewport()->mapToGlobal(pos)); -} - -void SavesTab::fixMods(SaveGameInfo::MissingAssets const& missingAssets) -{ - ActivateModsDialog dialog(missingAssets, m_window); - if (dialog.exec() == QDialog::Accepted) { - // activate the required mods, then enable all esps - std::set modsToActivate = dialog.getModsToActivate(); - for (std::set::iterator iter = modsToActivate.begin(); - iter != modsToActivate.end(); ++iter) { - if ((*iter != "") && (*iter != "")) { - unsigned int modIndex = ModInfo::getIndex(*iter); - m_core.currentProfile()->setModEnabled(modIndex, true); - } - } - - m_core.currentProfile()->writeModlist(); - m_core.refreshLists(); - - std::set espsToActivate = dialog.getESPsToActivate(); - for (std::set::iterator iter = espsToActivate.begin(); - iter != espsToActivate.end(); ++iter) { - m_core.pluginList()->enableESP(*iter); - } - - m_core.saveCurrentLists(); - } -} - -void SavesTab::openInExplorer() -{ - auto info = m_core.gameFeatures().gameFeature(); - - const auto sel = ui.list->selectionModel()->selectedRows(); - if (sel.empty()) { - return; - } - - auto& saveGame = m_SaveGames[sel[0].row()]; - shell::Explore(saveGame->getFilepath()); -} + // listSaves() can throw + log::error("{}", e.what()); + } +} + +void SavesTab::deleteSavegame() +{ + auto info = m_core.gameFeatures().gameFeature(); + + QString savesMsgLabel; + QStringList deleteFiles; + + int count = 0; + + for (const QModelIndex& idx : ui.list->selectionModel()->selectedRows()) { + + auto& saveGame = m_SaveGames[idx.row()]; + + if (count < 10) { + savesMsgLabel += + "
  • " + QFileInfo(saveGame->getFilepath()).completeBaseName() + "
  • "; + } + ++count; + + deleteFiles += saveGame->allFiles(); + } + + if (count > 10) { + savesMsgLabel += "
  • ... " + tr("%1 more").arg(count - 10) + "
  • "; + } + + if (QMessageBox::question( + m_window, tr("Confirm"), + tr("Are you sure you want to remove the following %n save(s)?
    " + "
      %1

    " + "Removed saves will be sent to the Recycle Bin.", + "", count) + .arg(savesMsgLabel), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + shellDelete(deleteFiles, true); // recycle bin delete. + refreshSaveList(); + } +} + +void SavesTab::onContextMenu(const QPoint& pos) +{ + QItemSelectionModel* selection = ui.list->selectionModel(); + + if (!selection->hasSelection()) { + return; + } + + QMenu menu; + + auto info = m_core.gameFeatures().gameFeature(); + if (info != nullptr) { + QAction* action = menu.addAction(tr("Fix enabled mods...")); + action->setEnabled(false); + if (selection->selectedRows().count() == 1) { + auto& save = m_SaveGames[selection->selectedRows()[0].row()]; + SaveGameInfo::MissingAssets missing = info->getMissingAssets(*save); + if (missing.size() != 0) { + connect(action, &QAction::triggered, this, [this, missing] { + fixMods(missing); + }); + action->setEnabled(true); + } + } + } + + QString deleteMenuLabel = + tr("Delete %n save(s)", "", selection->selectedRows().count()); + menu.addAction(deleteMenuLabel, [&] { + deleteSavegame(); + }); + + menu.addAction(tr("Open in Explorer..."), [&] { + openInExplorer(); + }); + + menu.exec(ui.list->viewport()->mapToGlobal(pos)); +} + +void SavesTab::fixMods(SaveGameInfo::MissingAssets const& missingAssets) +{ + ActivateModsDialog dialog(missingAssets, m_window); + if (dialog.exec() == QDialog::Accepted) { + // activate the required mods, then enable all esps + std::set modsToActivate = dialog.getModsToActivate(); + for (std::set::iterator iter = modsToActivate.begin(); + iter != modsToActivate.end(); ++iter) { + if ((*iter != "") && (*iter != "")) { + unsigned int modIndex = ModInfo::getIndex(*iter); + m_core.currentProfile()->setModEnabled(modIndex, true); + } + } + + m_core.currentProfile()->writeModlist(); + m_core.refreshLists(); + + std::set espsToActivate = dialog.getESPsToActivate(); + for (std::set::iterator iter = espsToActivate.begin(); + iter != espsToActivate.end(); ++iter) { + m_core.pluginList()->enableESP(*iter); + } + + m_core.saveCurrentLists(); + } +} + +void SavesTab::openInExplorer() +{ + auto info = m_core.gameFeatures().gameFeature(); + + const auto sel = ui.list->selectionModel()->selectedRows(); + if (sel.empty()) { + return; + } + + auto& saveGame = m_SaveGames[sel[0].row()]; + shell::Explore(saveGame->getFilepath()); +} diff --git a/src/src/shared/appconfig.cpp b/src/src/shared/appconfig.cpp index 90bbffb..579318f 100644 --- a/src/src/shared/appconfig.cpp +++ b/src/src/shared/appconfig.cpp @@ -46,6 +46,28 @@ QString basePath() return QCoreApplication::applicationDirPath(); } +QString pluginsPath() +{ +#ifndef _WIN32 + const char* envDir = std::getenv("MO2_PLUGINS_DIR"); + if (envDir && envDir[0] != '\0') { + return QString::fromUtf8(envDir); + } +#endif + return basePath() + "/" + QString::fromStdWString(pluginPath()); +} + +QString dllsPath() +{ +#ifndef _WIN32 + const char* envDir = std::getenv("MO2_DLLS_DIR"); + if (envDir && envDir[0] != '\0') { + return QString::fromUtf8(envDir); + } +#endif + return basePath() + "/dlls"; +} + namespace MOShared { #undef PARWSTRING diff --git a/src/src/shared/appconfig.h b/src/src/shared/appconfig.h index 03f3300..0c0f7b4 100644 --- a/src/src/shared/appconfig.h +++ b/src/src/shared/appconfig.h @@ -36,6 +36,16 @@ namespace AppConfig // returned; otherwise falls back to QCoreApplication::applicationDirPath(). QString basePath(); +// Returns the directory containing MO2 plugins. On Linux, if the +// MO2_PLUGINS_DIR environment variable is set (e.g. when plugins live +// inside a read-only AppImage squashfs) that value is used; otherwise +// falls back to basePath() + "/plugins". +QString pluginsPath(); + +// Returns the directory containing bundled DLLs (7z.so, etc.). +// Respects MO2_DLLS_DIR on Linux, otherwise basePath() + "/dlls". +QString dllsPath(); + namespace MOShared { #undef PARWSTRING diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index 05e07bd..850d5a2 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -53,12 +53,10 @@ along with Mod Organizer. If not, see . using namespace MOBase; using namespace MOShared; -namespace spawn::dialogs -{ +namespace spawn::dialogs { #ifdef _WIN32 -std::wstring makeRightsDetails(const env::FileSecurity& fs) -{ +std::wstring makeRightsDetails(const env::FileSecurity &fs) { if (fs.rights.normalRights) { return L"(normal rights)"; } @@ -75,22 +73,22 @@ std::wstring makeRightsDetails(const env::FileSecurity& fs) return s; } -QString makeDetails(const SpawnParameters& sp, DWORD code, const QString& more = {}) -{ +QString makeDetails(const SpawnParameters &sp, DWORD code, + const QString &more = {}) { std::wstring owner, rights; if (sp.binary.isFile()) { const auto fs = env::getFileSecurity(sp.binary.absoluteFilePath()); if (fs.error.isEmpty()) { - owner = fs.owner.toStdWString(); + owner = fs.owner.toStdWString(); rights = makeRightsDetails(fs); } else { - owner = fs.error.toStdWString(); + owner = fs.error.toStdWString(); rights = fs.error.toStdWString(); } } else { - owner = L"(file not found)"; + owner = L"(file not found)"; rights = L"(file not found)"; } @@ -98,7 +96,7 @@ QString makeDetails(const SpawnParameters& sp, DWORD code, const QString& more = (sp.currentDirectory.isEmpty() ? true : sp.currentDirectory.exists()); const auto appDir = QCoreApplication::applicationDirPath(); - const auto sep = QDir::separator(); + const auto sep = QDir::separator(); const std::wstring usvfs_x86_dll = QFileInfo(appDir + sep + "usvfs_x86.dll").isFile() ? L"ok" : L"not found"; @@ -107,10 +105,12 @@ QString makeDetails(const SpawnParameters& sp, DWORD code, const QString& more = QFileInfo(appDir + sep + "usvfs_x64.dll").isFile() ? L"ok" : L"not found"; const std::wstring usvfs_x86_proxy = - QFileInfo(appDir + sep + "usvfs_proxy_x86.exe").isFile() ? L"ok" : L"not found"; + QFileInfo(appDir + sep + "usvfs_proxy_x86.exe").isFile() ? L"ok" + : L"not found"; const std::wstring usvfs_x64_proxy = - QFileInfo(appDir + sep + "usvfs_proxy_x64.exe").isFile() ? L"ok" : L"not found"; + QFileInfo(appDir + sep + "usvfs_proxy_x64.exe").isFile() ? L"ok" + : L"not found"; std::wstring elevated; if (auto b = env::Environment().windowsInfo().isElevated()) { @@ -119,42 +119,44 @@ QString makeDetails(const SpawnParameters& sp, DWORD code, const QString& more = elevated = L"(not available)"; } - auto s = std::format(L"Error {} {}{}: {}\n" - L" . binary: '{}'\n" - L" . owner: {}\n" - L" . rights: {}\n" - L" . arguments: '{}'\n" - L" . cwd: '{}'{}\n" - L" . stdout: {}, stderr: {}, hooked: {}\n" - L" . MO elevated: {}", - code, errorCodeName(code), (more.isEmpty() ? more : ", " + more), - formatSystemMessage(code), - QDir::toNativeSeparators(sp.binary.absoluteFilePath()), owner, - rights, sp.arguments, - QDir::toNativeSeparators(sp.currentDirectory.absolutePath()), - (cwdExists ? L"" : L" (not found)"), - (sp.stdOut == INVALID_HANDLE_VALUE ? L"no" : L"yes"), - (sp.stdErr == INVALID_HANDLE_VALUE ? L"no" : L"yes"), - (sp.hooked ? L"yes" : L"no"), elevated); + auto s = std::format( + L"Error {} {}{}: {}\n" + L" . binary: '{}'\n" + L" . owner: {}\n" + L" . rights: {}\n" + L" . arguments: '{}'\n" + L" . cwd: '{}'{}\n" + L" . stdout: {}, stderr: {}, hooked: {}\n" + L" . MO elevated: {}", + code, errorCodeName(code), (more.isEmpty() ? more : ", " + more), + formatSystemMessage(code), + QDir::toNativeSeparators(sp.binary.absoluteFilePath()), owner, rights, + sp.arguments, + QDir::toNativeSeparators(sp.currentDirectory.absolutePath()), + (cwdExists ? L"" : L" (not found)"), + (sp.stdOut == INVALID_HANDLE_VALUE ? L"no" : L"yes"), + (sp.stdErr == INVALID_HANDLE_VALUE ? L"no" : L"yes"), + (sp.hooked ? L"yes" : L"no"), elevated); if (sp.hooked) { s += std::format(L"\n . usvfs x86:{} x64:{} proxy_x86:{} proxy_x64:{}", - usvfs_x86_dll, usvfs_x64_dll, usvfs_x86_proxy, usvfs_x64_proxy); + usvfs_x86_dll, usvfs_x64_dll, usvfs_x86_proxy, + usvfs_x64_proxy); } return QString::fromStdWString(s); } #else -QString makeDetails(const SpawnParameters& sp, int code, const QString& more = {}) -{ +QString makeDetails(const SpawnParameters &sp, int code, + const QString &more = {}) { const bool cwdExists = (sp.currentDirectory.isEmpty() ? true : sp.currentDirectory.exists()); QString s = QString("Error %1%2: %3\n" - " . binary: '%4'\n" - " . arguments: '%5'\n" - " . cwd: '%6'%7\n" - " . stdout: %8, stderr: %9, hooked: %10") + " . binary: '%4'\n" + " . arguments: '%5'\n" + " . cwd: '%6'%7\n" + " . stdout: %8, stderr: %9, hooked: %10") .arg(code) .arg(more.isEmpty() ? more : ", " + more) .arg(QString::fromUtf8(strerror(code))) @@ -171,19 +173,21 @@ QString makeDetails(const SpawnParameters& sp, int code, const QString& more = { #endif #ifdef _WIN32 -QString makeContent(const SpawnParameters& sp, DWORD code) -{ +QString makeContent(const SpawnParameters &sp, DWORD code) { if (code == ERROR_INVALID_PARAMETER) { return QObject::tr( - "This error typically happens because an antivirus has deleted critical " + "This error typically happens because an antivirus has deleted " + "critical " "files from Mod Organizer's installation folder or has made them " "generally inaccessible. Add an exclusion for Mod Organizer's " - "installation folder in your antivirus, reinstall Mod Organizer and try " + "installation folder in your antivirus, reinstall Mod Organizer and " + "try " "again."); } else if (code == ERROR_ACCESS_DENIED) { return QObject::tr( "This error typically happens because an antivirus is preventing Mod " - "Organizer from starting programs. Add an exclusion for Mod Organizer's " + "Organizer from starting programs. Add an exclusion for Mod " + "Organizer's " "installation folder in your antivirus and try again."); } else if (code == ERROR_FILE_NOT_FOUND) { return QObject::tr("The file '%1' does not exist.") @@ -198,15 +202,13 @@ QString makeContent(const SpawnParameters& sp, DWORD code) return QString::fromStdWString(formatSystemMessage(code)); } #else -QString makeContent(const SpawnParameters& sp, int code) -{ +QString makeContent(const SpawnParameters &sp, int code) { if (code == ENOENT) { return QObject::tr("The file '%1' does not exist.") .arg(sp.binary.absoluteFilePath()); } else if (code == EACCES) { - return QObject::tr( - "Permission denied when trying to start '%1'. " - "Check that the file is executable.") + return QObject::tr("Permission denied when trying to start '%1'. " + "Check that the file is executable.") .arg(sp.binary.absoluteFilePath()); } @@ -220,38 +222,39 @@ QString makeContent(const SpawnParameters& sp, int code) #endif #ifdef _WIN32 -QMessageBox::StandardButton badSteamReg(QWidget* parent, const QString& keyName, - const QString& valueName) -{ +QMessageBox::StandardButton badSteamReg(QWidget *parent, const QString &keyName, + const QString &valueName) { const auto details = - QString("can't start steam, registry value at '%1' is empty or doesn't exist") + QString( + "can't start steam, registry value at '%1' is empty or doesn't exist") .arg(keyName + "\\" + valueName); log::error("{}", details); return MOBase::TaskDialog(parent, QObject::tr("Cannot start Steam")) .main(QObject::tr("Cannot start Steam")) - .content( - QObject::tr("The path to the Steam executable cannot be found. You might try " - "reinstalling Steam.")) + .content(QObject::tr( + "The path to the Steam executable cannot be found. You might try " + "reinstalling Steam.")) .details(details) .icon(QMessageBox::Critical) .button({QObject::tr("Continue without starting Steam"), - QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + QObject::tr("The program may fail to launch."), + QMessageBox::Yes}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .exec(); } #endif #ifdef _WIN32 -QMessageBox::StandardButton startSteamFailed(QWidget* parent, const QString& keyName, - const QString& valueName, - const QString& exe, - const SpawnParameters& sp, DWORD e) -{ - auto details = QString("a steam install was found in the registry at '%1': '%2'\n\n") - .arg(keyName + "\\" + valueName) - .arg(exe); +QMessageBox::StandardButton +startSteamFailed(QWidget *parent, const QString &keyName, + const QString &valueName, const QString &exe, + const SpawnParameters &sp, DWORD e) { + auto details = + QString("a steam install was found in the registry at '%1': '%2'\n\n") + .arg(keyName + "\\" + valueName) + .arg(exe); details += makeDetails(sp, e); @@ -263,26 +266,27 @@ QMessageBox::StandardButton startSteamFailed(QWidget* parent, const QString& key .details(details) .icon(QMessageBox::Critical) .button({QObject::tr("Continue without starting Steam"), - QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + QObject::tr("The program may fail to launch."), + QMessageBox::Yes}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .exec(); } #endif -void spawnFailed(QWidget* parent, const SpawnParameters& sp, +void spawnFailed(QWidget *parent, const SpawnParameters &sp, #ifdef _WIN32 DWORD code #else int code #endif -) -{ +) { const auto details = makeDetails(sp, code); log::error("{}", details); const auto title = QObject::tr("Cannot launch program"); - const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + const auto mainText = + QObject::tr("Cannot start %1").arg(sp.binary.fileName()); MOBase::TaskDialog(parent, title) .main(mainText) @@ -293,10 +297,9 @@ void spawnFailed(QWidget* parent, const SpawnParameters& sp, } #ifdef _WIN32 -void helperFailed(QWidget* parent, DWORD code, const QString& why, - const std::wstring& binary, const std::wstring& cwd, - const std::wstring& args) -{ +void helperFailed(QWidget *parent, DWORD code, const QString &why, + const std::wstring &binary, const std::wstring &cwd, + const std::wstring &args) { SpawnParameters sp; sp.binary = QFileInfo(QString::fromStdWString(binary)); sp.currentDirectory.setPath(QString::fromStdWString(cwd)); @@ -307,7 +310,8 @@ void helperFailed(QWidget* parent, DWORD code, const QString& why, const auto title = QObject::tr("Cannot launch helper"); - const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + const auto mainText = + QObject::tr("Cannot start %1").arg(sp.binary.fileName()); MOBase::TaskDialog(parent, title) .main(mainText) @@ -317,20 +321,22 @@ void helperFailed(QWidget* parent, DWORD code, const QString& why, .exec(); } -bool confirmRestartAsAdmin(QWidget* parent, const SpawnParameters& sp) -{ +bool confirmRestartAsAdmin(QWidget *parent, const SpawnParameters &sp) { const auto details = makeDetails(sp, ERROR_ELEVATION_REQUIRED); log::error("{}", details); const auto title = QObject::tr("Elevation required"); - const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + const auto mainText = + QObject::tr("Cannot start %1").arg(sp.binary.fileName()); const auto content = QObject::tr( "This program is requesting to run as administrator but Mod Organizer " - "itself is not running as administrator. Running programs as administrator " - "is typically unnecessary as long as the game and Mod Organizer have been " + "itself is not running as administrator. Running programs as " + "administrator " + "is typically unnecessary as long as the game and Mod Organizer have " + "been " "installed outside \"Program Files\".\r\n\r\n" "You can restart Mod Organizer as administrator and try launching the " "program again."); @@ -344,24 +350,24 @@ bool confirmRestartAsAdmin(QWidget* parent, const SpawnParameters& sp) .details(details) .icon(QMessageBox::Question) .button({QObject::tr("Restart Mod Organizer as administrator"), - QObject::tr( - "You must allow \"helper.exe\" to make changes to the system."), + QObject::tr("You must allow \"helper.exe\" to make changes " + "to the system."), QMessageBox::Yes}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .exec(); return (r == QMessageBox::Yes); } -#endif // _WIN32 +#endif // _WIN32 -QMessageBox::StandardButton -confirmStartSteam(QWidget* parent, const SpawnParameters& sp, const QString& details) -{ - const auto title = QObject::tr("Launch Steam"); +QMessageBox::StandardButton confirmStartSteam(QWidget *parent, + const SpawnParameters &sp, + const QString &details) { + const auto title = QObject::tr("Launch Steam"); const auto mainText = QObject::tr("This program requires Steam"); - const auto content = QObject::tr( - "Mod Organizer has detected that this program likely requires Steam to be " - "running to function properly."); + const auto content = QObject::tr("Mod Organizer has detected that this " + "program likely requires Steam to be " + "running to function properly."); return MOBase::TaskDialog(parent, title) .main(mainText) @@ -377,17 +383,16 @@ confirmStartSteam(QWidget* parent, const SpawnParameters& sp, const QString& det } #ifdef _WIN32 -QMessageBox::StandardButton confirmRestartAsAdminForSteam(QWidget* parent, - const SpawnParameters& sp) -{ - const auto title = QObject::tr("Elevation required"); +QMessageBox::StandardButton +confirmRestartAsAdminForSteam(QWidget *parent, const SpawnParameters &sp) { + const auto title = QObject::tr("Elevation required"); const auto mainText = QObject::tr("Steam is running as administrator"); - const auto content = QObject::tr( + const auto content = QObject::tr( "Running Steam as administrator is typically unnecessary and can cause " - "problems when Mod Organizer itself is not running as administrator." - "\r\n\r\n" - "You can restart Mod Organizer as administrator and try launching the " - "program again."); + "problems when Mod Organizer itself is not running as administrator." + "\r\n\r\n" + "You can restart Mod Organizer as administrator and try launching the " + "program again."); return MOBase::TaskDialog(parent, title) .main(mainText) @@ -395,24 +400,25 @@ QMessageBox::StandardButton confirmRestartAsAdminForSteam(QWidget* parent, .icon(QMessageBox::Question) .button( {QObject::tr("Restart Mod Organizer as administrator"), - QObject::tr("You must allow \"helper.exe\" to make changes to the system."), + QObject::tr( + "You must allow \"helper.exe\" to make changes to the system."), QMessageBox::Yes}) - .button({QObject::tr("Continue"), QObject::tr("The program might fail to run."), - QMessageBox::No}) + .button({QObject::tr("Continue"), + QObject::tr("The program might fail to run."), QMessageBox::No}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .remember("steamAdminQuery", sp.binary.fileName()) .exec(); } -bool eventLogNotRunning(QWidget* parent, const env::Service& s, - const SpawnParameters& sp) -{ - const auto title = QObject::tr("Event Log not running"); +bool eventLogNotRunning(QWidget *parent, const env::Service &s, + const SpawnParameters &sp) { + const auto title = QObject::tr("Event Log not running"); const auto mainText = QObject::tr("The Event Log service is not running"); - const auto content = QObject::tr( - "The Windows Event Log service is not running. This can prevent USVFS from " - "running properly and your mods may not be recognized by the program being " - "launched."); + const auto content = QObject::tr("The Windows Event Log service is not " + "running. This can prevent USVFS from " + "running properly and your mods may not be " + "recognized by the program being " + "launched."); const auto r = MOBase::TaskDialog(parent, title) @@ -421,18 +427,18 @@ bool eventLogNotRunning(QWidget* parent, const env::Service& s, .details(s.toString()) .icon(QMessageBox::Question) .remember("eventLogService", sp.binary.fileName()) - .button({QObject::tr("Continue"), QObject::tr("Your mods might not work."), - QMessageBox::Yes}) + .button({QObject::tr("Continue"), + QObject::tr("Your mods might not work."), QMessageBox::Yes}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .exec(); return (r == QMessageBox::Yes); } -#endif // _WIN32 +#endif // _WIN32 -QMessageBox::StandardButton -confirmBlacklisted(QWidget* parent, const SpawnParameters& sp, Settings& settings) -{ +QMessageBox::StandardButton confirmBlacklisted(QWidget *parent, + const SpawnParameters &sp, + Settings &settings) { const auto title = QObject::tr("Blacklisted program"); const auto mainText = QObject::tr("The program %1 is blacklisted").arg(sp.binary.fileName()); @@ -446,17 +452,18 @@ confirmBlacklisted(QWidget* parent, const SpawnParameters& sp, Settings& setting "Current blacklist: " + settings.executablesBlacklist(); - auto r = MOBase::TaskDialog(parent, title) - .main(mainText) - .content(content) - .details(details) - .icon(QMessageBox::Question) - .remember("blacklistedExecutable", sp.binary.fileName()) - .button({QObject::tr("Continue"), - QObject::tr("Your mods might not work."), QMessageBox::Yes}) - .button({QObject::tr("Change the blacklist"), QMessageBox::Retry}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); + auto r = + MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(details) + .icon(QMessageBox::Question) + .remember("blacklistedExecutable", sp.binary.fileName()) + .button({QObject::tr("Continue"), + QObject::tr("Your mods might not work."), QMessageBox::Yes}) + .button({QObject::tr("Change the blacklist"), QMessageBox::Retry}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); if (r == QMessageBox::Retry) { if (!WorkaroundsSettingsTab::changeBlacklistNow(parent, settings)) { @@ -467,13 +474,11 @@ confirmBlacklisted(QWidget* parent, const SpawnParameters& sp, Settings& setting return r; } -} // namespace spawn::dialogs +} // namespace spawn::dialogs -namespace spawn -{ +namespace spawn { -void logSpawning(const SpawnParameters& sp, const QString& realCmd) -{ +void logSpawning(const SpawnParameters &sp, const QString &realCmd) { log::debug("spawning binary:\n" " . exe: '{}'\n" " . args: '{}'\n" @@ -489,23 +494,21 @@ void logSpawning(const SpawnParameters& sp, const QString& realCmd) (sp.stdOut == INVALID_HANDLE_VALUE ? "no" : "yes"), (sp.stdErr == INVALID_HANDLE_VALUE ? "no" : "yes"), #else - (sp.stdOut == -1 ? "no" : "yes"), - (sp.stdErr == -1 ? "no" : "yes"), + (sp.stdOut == -1 ? "no" : "yes"), (sp.stdErr == -1 ? "no" : "yes"), #endif realCmd); } #ifndef _WIN32 -uint32_t parseSteamAppId(const QString& steamAppId) -{ - bool ok = false; +uint32_t parseSteamAppId(const QString &steamAppId) { + bool ok = false; const auto n = steamAppId.toUInt(&ok); return (ok ? n : 0u); } -QString firstExistingSetting(const QSettings& settings, const QStringList& keys) -{ - for (const QString& key : keys) { +QString firstExistingSetting(const QSettings &settings, + const QStringList &keys) { + for (const QString &key : keys) { const QString value = settings.value(key).toString().trimmed(); if (!value.isEmpty()) { return value; @@ -515,13 +518,13 @@ QString firstExistingSetting(const QSettings& settings, const QStringList& keys) return {}; } -QString resolvePrefixPath() -{ - if (auto cfg = FluorineConfig::load(); cfg.has_value() && cfg->prefixExists()) { +QString resolvePrefixPath() { + if (auto cfg = FluorineConfig::load(); + cfg.has_value() && cfg->prefixExists()) { return cfg->prefix_path.trimmed(); } - const Settings* settings = Settings::maybeInstance(); + const Settings *settings = Settings::maybeInstance(); if (settings == nullptr) { return {}; } @@ -532,8 +535,7 @@ QString resolvePrefixPath() "Proton/prefix_path", "fluorine/prefix_path"}); } -QString resolveProtonPath() -{ +QString resolveProtonPath() { if (auto cfg = FluorineConfig::load(); cfg.has_value()) { const QString protonPath = cfg->proton_path.trimmed(); if (!protonPath.isEmpty()) { @@ -541,35 +543,34 @@ QString resolveProtonPath() } } - const Settings* settings = Settings::maybeInstance(); + const Settings *settings = Settings::maybeInstance(); if (settings == nullptr) { return {}; } const QSettings instanceSettings(settings->filename(), QSettings::IniFormat); - return firstExistingSetting(instanceSettings, - {"Settings/proton_path", "Proton/path", - "fluorine/proton_path"}); + return firstExistingSetting( + instanceSettings, + {"Settings/proton_path", "Proton/path", "fluorine/proton_path"}); } #endif #ifdef _WIN32 -DWORD spawn(const SpawnParameters& sp, HANDLE& processHandle) -{ +DWORD spawn(const SpawnParameters &sp, HANDLE &processHandle) { BOOL inheritHandles = FALSE; STARTUPINFO si = {}; - si.cb = sizeof(si); + si.cb = sizeof(si); // inherit handles if we plan to use stdout or stderr reroute if (sp.stdOut != INVALID_HANDLE_VALUE) { - si.hStdOutput = sp.stdOut; + si.hStdOutput = sp.stdOut; inheritHandles = TRUE; si.dwFlags |= STARTF_USESTDHANDLES; } if (sp.stdErr != INVALID_HANDLE_VALUE) { - si.hStdError = sp.stdErr; + si.hStdError = sp.stdErr; inheritHandles = TRUE; si.dwFlags |= STARTF_USESTDHANDLES; } @@ -583,26 +584,26 @@ DWORD spawn(const SpawnParameters& sp, HANDLE& processHandle) } const QString moPath = QCoreApplication::applicationDirPath(); - const auto oldPath = env::appendToPath(QDir::toNativeSeparators(moPath)); + const auto oldPath = env::appendToPath(QDir::toNativeSeparators(moPath)); PROCESS_INFORMATION pi = {}; - BOOL success = FALSE; + BOOL success = FALSE; logSpawning(sp, commandLine); const auto wcommandLine = commandLine.toStdWString(); - const auto wcwd = cwd.toStdWString(); + const auto wcwd = cwd.toStdWString(); const DWORD flags = CREATE_BREAKAWAY_FROM_JOB; if (sp.hooked) { success = ::usvfsCreateProcessHooked( - nullptr, const_cast(wcommandLine.c_str()), nullptr, nullptr, + nullptr, const_cast(wcommandLine.c_str()), nullptr, nullptr, inheritHandles, flags, nullptr, wcwd.c_str(), &si, &pi); } else { - success = ::CreateProcess(nullptr, const_cast(wcommandLine.c_str()), - nullptr, nullptr, inheritHandles, flags, nullptr, - wcwd.c_str(), &si, &pi); + success = ::CreateProcess( + nullptr, const_cast(wcommandLine.c_str()), nullptr, nullptr, + inheritHandles, flags, nullptr, wcwd.c_str(), &si, &pi); } const auto e = GetLastError(); @@ -618,10 +619,11 @@ DWORD spawn(const SpawnParameters& sp, HANDLE& processHandle) return ERROR_SUCCESS; } #else -int spawn(const SpawnParameters& sp, pid_t& processId) -{ - const QString bin = MOBase::normalizePathForHost(sp.binary.absoluteFilePath()); - QString cwd = MOBase::normalizePathForHost(sp.currentDirectory.absolutePath()); +int spawn(const SpawnParameters &sp, pid_t &processId) { + const QString bin = + MOBase::normalizePathForHost(sp.binary.absoluteFilePath()); + QString cwd = + MOBase::normalizePathForHost(sp.currentDirectory.absolutePath()); QStringList argList; if (!sp.arguments.isEmpty()) { @@ -634,6 +636,21 @@ int spawn(const SpawnParameters& sp, pid_t& processId) logSpawning(sp, bin + " " + sp.arguments); + // Read per-instance settings from the instance INI (not the global + // QSettings). + const Settings *instanceForLaunch = Settings::maybeInstance(); + bool useSteamDrm = true; // default + QString storeVariant; + if (instanceForLaunch) { + const QSettings instanceIni(instanceForLaunch->filename(), + QSettings::IniFormat); + useSteamDrm = instanceIni.value("fluorine/steam_drm", true).toBool(); + // settingName() maps "General" section keys to top-level (no section + // prefix), so game_edition is stored as a plain key under [General] in the + // INI. + storeVariant = instanceIni.value("game_edition").toString().trimmed(); + } + ProtonLauncher launcher; launcher.setBinary(bin) .setArguments(argList) @@ -644,14 +661,18 @@ int spawn(const SpawnParameters& sp, pid_t& processId) QSettings().value("fluorine/prefer_system_umu", false).toBool()) .setUseSteamRun( QSettings().value("fluorine/use_steam_run", false).toBool()) - .setSteamDrm(QSettings().value("fluorine/steam_drm", true).toBool()); + .setSteamDrm(useSteamDrm) + .setStoreVariant(storeVariant); const QString prefixPath = resolvePrefixPath(); if (prefixPath.isEmpty()) { - MOBase::log::warn("No Wine prefix configured - games may not launch correctly. " - "Configure a prefix in Settings > Proton or via fluorine-manager."); + MOBase::log::warn( + "No Wine prefix configured - games may not launch correctly. " + "Configure a prefix in Settings > Proton or via fluorine-manager."); } else if (!QDir(QDir(prefixPath).filePath("drive_c")).exists()) { - MOBase::log::warn("Wine prefix '{}' does not contain drive_c/ - prefix may be invalid", prefixPath); + MOBase::log::warn( + "Wine prefix '{}' does not contain drive_c/ - prefix may be invalid", + prefixPath); } else { MOBase::log::info("Using Wine prefix: {}", prefixPath); launcher.setPrefix(prefixPath); @@ -662,7 +683,8 @@ int spawn(const SpawnParameters& sp, pid_t& processId) launcher.setProtonPath(protonPath); } - const QString wrapper = QSettings().value("fluorine/launch_wrapper").toString().trimmed(); + const QString wrapper = + QSettings().value("fluorine/launch_wrapper").toString().trimmed(); if (!wrapper.isEmpty()) { launcher.setWrapper(wrapper); } @@ -678,8 +700,7 @@ int spawn(const SpawnParameters& sp, pid_t& processId) #endif #ifdef _WIN32 -bool restartAsAdmin(QWidget* parent) -{ +bool restartAsAdmin(QWidget *parent) { WCHAR cwd[MAX_PATH] = {}; if (!GetCurrentDirectory(MAX_PATH, cwd)) { cwd[0] = L'\0'; @@ -698,8 +719,7 @@ bool restartAsAdmin(QWidget* parent) return true; } -void startBinaryAdmin(QWidget* parent, const SpawnParameters& sp) -{ +void startBinaryAdmin(QWidget *parent, const SpawnParameters &sp) { if (!dialogs::confirmRestartAsAdmin(parent, sp)) { log::debug("user declined"); return; @@ -708,25 +728,23 @@ void startBinaryAdmin(QWidget* parent, const SpawnParameters& sp) log::info("restarting MO as administrator"); restartAsAdmin(parent); } -#endif // _WIN32 +#endif // _WIN32 -struct SteamStatus -{ - bool running = false; +struct SteamStatus { + bool running = false; bool accessible = false; }; -SteamStatus getSteamStatus() -{ +SteamStatus getSteamStatus() { SteamStatus ss; #ifdef _WIN32 const auto ps = env::Environment().runningProcesses(); - for (const auto& p : ps) { + for (const auto &p : ps) { if ((p.name().compare("Steam.exe", Qt::CaseInsensitive) == 0) || (p.name().compare("SteamService.exe", Qt::CaseInsensitive) == 0)) { - ss.running = true; + ss.running = true; ss.accessible = p.canAccess(); log::debug("'{}' is running, accessible={}", p.name(), @@ -742,7 +760,7 @@ SteamStatus getSteamStatus() pgrep.waitForFinished(3000); if (pgrep.exitCode() == 0) { - ss.running = true; + ss.running = true; ss.accessible = true; log::debug("steam is running"); } @@ -751,8 +769,7 @@ SteamStatus getSteamStatus() return ss; } -QString makeSteamArguments(const QString& username, const QString& password) -{ +QString makeSteamArguments(const QString &username, const QString &password) { QString args; if (username != "") { @@ -766,17 +783,17 @@ QString makeSteamArguments(const QString& username, const QString& password) return args; } -bool startSteam(QWidget* parent) -{ +bool startSteam(QWidget *parent) { #ifdef _WIN32 - const QString keyName = "HKEY_CURRENT_USER\\Software\\Valve\\Steam"; + const QString keyName = "HKEY_CURRENT_USER\\Software\\Valve\\Steam"; const QString valueName = "SteamExe"; const QSettings steamSettings(keyName, QSettings::NativeFormat); const QString exe = steamSettings.value(valueName, "").toString(); if (exe.isEmpty()) { - return (dialogs::badSteamReg(parent, keyName, valueName) == QMessageBox::Yes); + return (dialogs::badSteamReg(parent, keyName, valueName) == + QMessageBox::Yes); } SpawnParameters sp; @@ -797,10 +814,11 @@ bool startSteam(QWidget* parent) log::debug("starting steam process:\n" " . program: '{}'\n" " . username={}, password={}", - sp.binary.filePath().toStdString(), (username.isEmpty() ? "no" : "yes"), + sp.binary.filePath().toStdString(), + (username.isEmpty() ? "no" : "yes"), (password.isEmpty() ? "no" : "yes")); - HANDLE ph = INVALID_HANDLE_VALUE; + HANDLE ph = INVALID_HANDLE_VALUE; const auto e = spawn(sp, ph); ::CloseHandle(ph); @@ -809,7 +827,8 @@ bool startSteam(QWidget* parent) sp.arguments = makeSteamArguments((username.isEmpty() ? "" : "USERNAME"), (password.isEmpty() ? "" : "PASSWORD")); - const auto r = dialogs::startSteamFailed(parent, keyName, valueName, exe, sp, e); + const auto r = + dialogs::startSteamFailed(parent, keyName, valueName, exe, sp, e); return (r == QMessageBox::Yes); } @@ -840,10 +859,11 @@ bool startSteam(QWidget* parent) MOBase::TaskDialog(parent, title) .main(title) .content(QObject::tr("The Steam executable could not be found. " - "Make sure Steam is installed.")) + "Make sure Steam is installed.")) .icon(QMessageBox::Critical) .button({QObject::tr("Continue without starting Steam"), - QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + QObject::tr("The program may fail to launch."), + QMessageBox::Yes}) .button({QObject::tr("Cancel"), QMessageBox::Cancel}) .exec(); @@ -869,11 +889,12 @@ bool startSteam(QWidget* parent) #endif } -bool checkSteam(QWidget* parent, const SpawnParameters& sp, const QDir& gameDirectory, - const QString& steamAppID, const Settings& settings) -{ +bool checkSteam(QWidget *parent, const SpawnParameters &sp, + const QDir &gameDirectory, const QString &steamAppID, + const Settings &settings) { #ifdef _WIN32 - static const std::vector steamFiles = {"steam_api.dll", "steam_api64.dll"}; + static const std::vector steamFiles = {"steam_api.dll", + "steam_api64.dll"}; #else static const std::vector steamFiles = {"libsteam_api.so"}; #endif @@ -889,7 +910,7 @@ bool checkSteam(QWidget* parent, const SpawnParameters& sp, const QDir& gameDire bool steamRequired = false; QString details; - for (const auto& file : steamFiles) { + for (const auto &file : steamFiles) { const QFileInfo fi(gameDirectory.absoluteFilePath(file)); if (fi.exists()) { details = QString("managed game is located at '%1' and file '%2' exists") @@ -959,8 +980,8 @@ bool checkSteam(QWidget* parent, const SpawnParameters& sp, const QDir& gameDire return true; } -bool checkBlacklist(QWidget* parent, const SpawnParameters& sp, Settings& settings) -{ +bool checkBlacklist(QWidget *parent, const SpawnParameters &sp, + Settings &settings) { for (;;) { if (!settings.isExecutableBlacklisted(sp.binary.fileName())) { return true; @@ -975,10 +996,9 @@ bool checkBlacklist(QWidget* parent, const SpawnParameters& sp, Settings& settin } #ifdef _WIN32 -HANDLE startBinary(QWidget* parent, const SpawnParameters& sp) -{ +HANDLE startBinary(QWidget *parent, const SpawnParameters &sp) { HANDLE handle = INVALID_HANDLE_VALUE; - const auto e = spawn::spawn(sp, handle); + const auto e = spawn::spawn(sp, handle); switch (e) { case ERROR_SUCCESS: { @@ -997,8 +1017,7 @@ HANDLE startBinary(QWidget* parent, const SpawnParameters& sp) } } #else -pid_t startBinary(QWidget* parent, const SpawnParameters& sp) -{ +pid_t startBinary(QWidget *parent, const SpawnParameters &sp) { pid_t pid = -1; const auto e = spawn::spawn(sp, pid); @@ -1012,19 +1031,18 @@ pid_t startBinary(QWidget* parent, const SpawnParameters& sp) #endif #ifdef _WIN32 -QString getExecutableForJarFile(const QString& jarFile) -{ +QString getExecutableForJarFile(const QString &jarFile) { const std::wstring jarFileW = jarFile.toStdWString(); WCHAR buffer[MAX_PATH]; const auto hinst = ::FindExecutableW(jarFileW.c_str(), nullptr, buffer); - const auto r = static_cast(reinterpret_cast(hinst)); + const auto r = static_cast(reinterpret_cast(hinst)); // anything <= 32 signals failure if (r <= 32) { - log::warn("failed to find executable associated with file '{}', {}", jarFile, - shell::formatError(r)); + log::warn("failed to find executable associated with file '{}', {}", + jarFile, shell::formatError(r)); return {}; } @@ -1050,8 +1068,7 @@ QString getExecutableForJarFile(const QString& jarFile) return QString::fromWCharArray(buffer); } -QString getJavaHome() -{ +QString getJavaHome() { const QString key = "HKEY_LOCAL_MACHINE\\Software\\JavaSoft\\Java Runtime Environment"; const QString value = "CurrentVersion"; @@ -1064,11 +1081,12 @@ QString getJavaHome() } const QString currentVersion = reg.value("CurrentVersion").toString(); - const QString javaHome = QString("%1/JavaHome").arg(currentVersion); + const QString javaHome = QString("%1/JavaHome").arg(currentVersion); if (!reg.contains(javaHome)) { - log::warn("java version '{}' was found at '{}\\{}', but '{}\\{}' doesn't exist", - currentVersion, key, value, key, javaHome); + log::warn( + "java version '{}' was found at '{}\\{}', but '{}\\{}' doesn't exist", + currentVersion, key, value, key, javaHome); return {}; } @@ -1076,10 +1094,9 @@ QString getJavaHome() const auto path = reg.value(javaHome).toString(); return path + "\\bin\\javaw.exe"; } -#endif // _WIN32 +#endif // _WIN32 -QString findJavaInstallation(const QString& jarFile) -{ +QString findJavaInstallation(const QString &jarFile) { #ifdef _WIN32 // try to find java automatically based on the given jar file if (!jarFile.isEmpty()) { @@ -1108,8 +1125,7 @@ QString findJavaInstallation(const QString& jarFile) return {}; } -bool isBatchFile(const QFileInfo& target) -{ +bool isBatchFile(const QFileInfo &target) { #ifdef _WIN32 const auto batchExtensions = {"cmd", "bat"}; #else @@ -1117,7 +1133,7 @@ bool isBatchFile(const QFileInfo& target) #endif const QString extension = target.suffix(); - for (auto&& e : batchExtensions) { + for (auto &&e : batchExtensions) { if (extension.compare(e, Qt::CaseInsensitive) == 0) { return true; } @@ -1126,8 +1142,7 @@ bool isBatchFile(const QFileInfo& target) return false; } -bool isExeFile(const QFileInfo& target) -{ +bool isExeFile(const QFileInfo &target) { #ifdef _WIN32 return (target.suffix().compare("exe", Qt::CaseInsensitive) == 0); #else @@ -1136,13 +1151,11 @@ bool isExeFile(const QFileInfo& target) #endif } -bool isJavaFile(const QFileInfo& target) -{ +bool isJavaFile(const QFileInfo &target) { return (target.suffix().compare("jar", Qt::CaseInsensitive) == 0); } -QFileInfo getCmdPath() -{ +QFileInfo getCmdPath() { #ifdef _WIN32 const auto p = env::get("COMSPEC"); if (!p.isEmpty()) { @@ -1151,7 +1164,7 @@ QFileInfo getCmdPath() QString systemDirectory; - const std::size_t buffer_size = 1000; + const std::size_t buffer_size = 1000; wchar_t buffer[buffer_size + 1] = {}; const auto length = ::GetSystemDirectoryW(buffer, buffer_size); @@ -1176,8 +1189,7 @@ QFileInfo getCmdPath() #endif } -FileExecutionTypes getFileExecutionType(const QFileInfo& target) -{ +FileExecutionTypes getFileExecutionType(const QFileInfo &target) { if (isExeFile(target) || isBatchFile(target) || isJavaFile(target)) { return FileExecutionTypes::Executable; } @@ -1185,23 +1197,21 @@ FileExecutionTypes getFileExecutionType(const QFileInfo& target) return FileExecutionTypes::Other; } -FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& target) -{ +FileExecutionContext getFileExecutionContext(QWidget *parent, + const QFileInfo &target) { if (isExeFile(target)) { return {target, "", FileExecutionTypes::Executable}; } if (isBatchFile(target)) { #ifdef _WIN32 - return { - getCmdPath(), - QString("/C \"%1\"").arg(QDir::toNativeSeparators(target.absoluteFilePath())), - FileExecutionTypes::Executable}; + return {getCmdPath(), + QString("/C \"%1\"") + .arg(QDir::toNativeSeparators(target.absoluteFilePath())), + FileExecutionTypes::Executable}; #else - return { - getCmdPath(), - QString("\"%1\"").arg(target.absoluteFilePath()), - FileExecutionTypes::Executable}; + return {getCmdPath(), QString("\"%1\"").arg(target.absoluteFilePath()), + FileExecutionTypes::Executable}; #endif } @@ -1210,13 +1220,13 @@ FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& t if (java.isEmpty()) { #ifdef _WIN32 - java = - QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), QString(), - QObject::tr("Binary") + " (*.exe)"); + java = QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), + QString(), + QObject::tr("Binary") + " (*.exe)"); #else - java = - QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), QString(), - QObject::tr("Binary") + " (*)"); + java = QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), + QString(), + QObject::tr("Binary") + " (*)"); #endif } @@ -1231,15 +1241,13 @@ FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& t return {{}, {}, FileExecutionTypes::Other}; } -} // namespace spawn +} // namespace spawn #ifdef _WIN32 -namespace helper -{ +namespace helper { -bool helperExec(QWidget* parent, const std::wstring& moDirectory, - const std::wstring& commandLine, BOOL async) -{ +bool helperExec(QWidget *parent, const std::wstring &moDirectory, + const std::wstring &commandLine, BOOL async) { const std::wstring fileName = moDirectory + L"\\helper.exe"; env::HandlePtr process; @@ -1251,14 +1259,14 @@ bool helperExec(QWidget* parent, const std::wstring& moDirectory, if (!async) flags |= SEE_MASK_NOCLOSEPROCESS; - execInfo.cbSize = sizeof(SHELLEXECUTEINFOW); - execInfo.fMask = flags; - execInfo.hwnd = 0; - execInfo.lpVerb = L"runas"; - execInfo.lpFile = fileName.c_str(); + execInfo.cbSize = sizeof(SHELLEXECUTEINFOW); + execInfo.fMask = flags; + execInfo.hwnd = 0; + execInfo.lpVerb = L"runas"; + execInfo.lpFile = fileName.c_str(); execInfo.lpParameters = commandLine.c_str(); - execInfo.lpDirectory = moDirectory.c_str(); - execInfo.nShow = SW_SHOW; + execInfo.lpDirectory = moDirectory.c_str(); + execInfo.nShow = SW_SHOW; if (!::ShellExecuteExW(&execInfo) && execInfo.hProcess == 0) { const auto e = GetLastError(); @@ -1282,10 +1290,11 @@ bool helperExec(QWidget* parent, const std::wstring& moDirectory, // for WAIT_ABANDONED, the documentation doesn't mention that GetLastError() // returns something meaningful, but code ERROR_ABANDONED_WAIT_0 exists, so // use that instead - const auto code = (r == WAIT_ABANDONED ? ERROR_ABANDONED_WAIT_0 : GetLastError()); + const auto code = + (r == WAIT_ABANDONED ? ERROR_ABANDONED_WAIT_0 : GetLastError()); - spawn::dialogs::helperFailed(parent, code, "WaitForSingleObject()", fileName, - moDirectory, commandLine); + spawn::dialogs::helperFailed(parent, code, "WaitForSingleObject()", + fileName, moDirectory, commandLine); return false; } @@ -1303,22 +1312,21 @@ bool helperExec(QWidget* parent, const std::wstring& moDirectory, return (exitCode == 0); } -bool backdateBSAs(QWidget* parent, const std::wstring& moPath, - const std::wstring& dataPath) -{ +bool backdateBSAs(QWidget *parent, const std::wstring &moPath, + const std::wstring &dataPath) { const std::wstring commandLine = std::format(L"backdateBSA \"{}\"", dataPath); return helperExec(parent, moPath, commandLine, FALSE); } -bool adminLaunch(QWidget* parent, const std::wstring& moPath, - const std::wstring& moFile, const std::wstring& workingDir) -{ - const std::wstring commandLine = std::format( - L"adminLaunch {} \"{}\" \"{}\"", ::GetCurrentProcessId(), moFile, workingDir); +bool adminLaunch(QWidget *parent, const std::wstring &moPath, + const std::wstring &moFile, const std::wstring &workingDir) { + const std::wstring commandLine = + std::format(L"adminLaunch {} \"{}\" \"{}\"", ::GetCurrentProcessId(), + moFile, workingDir); return helperExec(parent, moPath, commandLine, true); } -} // namespace helper -#endif // _WIN32 +} // namespace helper +#endif // _WIN32 diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp index bb4e166..e144460 100644 --- a/src/src/wineprefix.cpp +++ b/src/src/wineprefix.cpp @@ -10,10 +10,17 @@ #include #include +#ifndef _WIN32 +#include +#include +#endif + namespace { constexpr const char* BackupIniSuffix = ".mo2linux_backup"; +// Copy a file, preserving its modification time so that games see the +// original save timestamps rather than "now". bool copyFileWithParents(const QString& source, const QString& destination) { const QFileInfo destinationInfo(destination); @@ -25,7 +32,23 @@ bool copyFileWithParents(const QString& source, const QString& destination) return false; } - return QFile::copy(source, destination); + if (!QFile::copy(source, destination)) { + return false; + } + +#ifndef _WIN32 + // QFile::copy() does not preserve timestamps. Restore the source file's + // mtime so that games display the correct save date. + struct stat srcStat; + if (stat(source.toUtf8().constData(), &srcStat) == 0) { + struct utimbuf times; + times.actime = srcStat.st_atime; + times.modtime = srcStat.st_mtime; + utime(destination.toUtf8().constData(), ×); + } +#endif + + return true; } bool copyTreeContents(const QString& sourceRoot, const QString& destinationRoot) @@ -49,16 +72,22 @@ bool restoreBackedUpSaves(const QString& liveUpper, const QString& liveLower, const QString& backupUpper, const QString& backupLower) { if (QDir(liveUpper).exists() && !QDir(liveUpper).removeRecursively()) { + MOBase::log::warn("restoreBackedUpSaves: failed to remove '{}'", liveUpper); return false; } if (QDir(liveLower).exists() && !QDir(liveLower).removeRecursively()) { + MOBase::log::warn("restoreBackedUpSaves: failed to remove '{}'", liveLower); return false; } if (QDir(backupUpper).exists() && !QDir().rename(backupUpper, liveUpper)) { + MOBase::log::warn("restoreBackedUpSaves: failed to rename '{}' -> '{}'", + backupUpper, liveUpper); return false; } if (QDir(backupLower).exists() && !QDir().rename(backupLower, liveLower)) { + MOBase::log::warn("restoreBackedUpSaves: failed to rename '{}' -> '{}'", + backupLower, liveLower); return false; } @@ -296,16 +325,21 @@ bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, if ((QDir(backupUpper).exists() || QDir(backupLower).exists()) && !restoreBackedUpSaves(absoluteSaveDir, lowerSaveDir, backupUpper, backupLower)) { + MOBase::log::warn("deployProfileSaves: failed to restore stale backup"); return false; } if (QDir(absoluteSaveDir).exists() && !QDir().rename(absoluteSaveDir, backupUpper)) { + MOBase::log::warn("deployProfileSaves: failed to backup '{}' -> '{}'", + absoluteSaveDir, backupUpper); return false; } if (absoluteSaveDir != lowerSaveDir && QDir(lowerSaveDir).exists() && !QDir().rename(lowerSaveDir, backupLower)) { + MOBase::log::warn("deployProfileSaves: failed to backup '{}' -> '{}'", + lowerSaveDir, backupLower); return false; } } -- cgit v1.3.1