From 5467e210668eb24de9a4429ef0ddcbc6442e8932 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Thu, 19 Feb 2026 17:25:48 -0600 Subject: Add Python init diagnostics, AppImage build system, misc Linux fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - python: switch Py_InitializeEx → Py_InitializeFromConfig for explicit error reporting; add dladdr + fprintf diagnostics to trace which DSO Python symbols resolve to and whether Py_IsInitialized succeeds - python: log RTLD_NOLOAD vs fresh-load outcome when promoting libpython to RTLD_GLOBAL - build: add Docker-based AppImage build script (build.sh), update Dockerfile and build-inner.sh for AppImage-only workflow - build: remove Flatpak support entirely; move AppImage metadata (desktop/metainfo/icon) from flatpak/ to data/ - Add Monster Hunter Wilds basic_games plugin - .gitignore: exclude squashfs-root, build-source, flatpak-repo, __pycache__, fomod-plus-settings.ini - Various Linux port fixes across wineprefix, processrunner, proton launcher, FUSE connector, instance manager, download manager, sanitychecks, and NaK Rust paths/runtime_wrap Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 21 +- build-flatpak.sh | 9 - build.sh | 58 ++++ data/com.fluorine.manager.desktop | 22 ++ data/com.fluorine.manager.metainfo.xml | 38 +++ data/com.fluorine.manager.png | Bin 0 -> 51022 bytes docker/Dockerfile | 1 + docker/build-inner.sh | 279 +++++++++++++--- flatpak/com.fluorine.manager.desktop | 22 -- flatpak/com.fluorine.manager.metainfo.xml | 38 --- flatpak/com.fluorine.manager.png | Bin 51022 -> 0 bytes flatpak/com.fluorine.manager.yml | 371 --------------------- flatpak/flatpak-install.sh | 60 ---- flatpak/fluorine-manager-wrapper.sh | 114 ------- libs/basic_games/games/baldursgate3/pak_parser.py | 21 +- libs/basic_games/games/game_monsterhunterwilds.py | 15 + libs/game_bethesda/src/*.cpp | 0 .../src/gamebryo/gamebryolocalsavegames.cpp | 4 +- libs/game_bethesda/src/gamebryo/gamegamebryo.cpp | 6 +- .../src/gamebryo/gamebryolocalsavegames.cpp | 4 +- .../installer/FomodPlusInstaller.cpp | 2 - libs/installer_fomod_plus/installer/lib/Logger.h | 2 - .../installer_fomod_plus/installer/ui/UIHelper.cpp | 1 - .../patchwizard/lib/PatchFinder.cpp | 1 - libs/installer_fomod_plus/scanner/archiveparser.h | 1 - .../installer_fomod_plus/share/FOMODData/FomodDB.h | 2 - libs/nak/src/paths.rs | 6 +- libs/nak/src/runtime_wrap.rs | 36 -- libs/plugin_python/src/proxy/proxypython.cpp | 7 +- libs/plugin_python/src/runner/pythonrunner.cpp | 182 +++++++--- src/plugins/rootbuilder.py | 52 +-- src/src/CMakeLists.txt | 45 --- src/src/commandline.cpp | 9 +- src/src/createinstancedialog.cpp | 5 +- src/src/createinstancedialogpages.cpp | 19 +- src/src/downloadlist.cpp | 12 + src/src/downloadlist.h | 2 + src/src/downloadlistview.cpp | 5 + src/src/downloadmanager.cpp | 150 ++++----- src/src/filedialogmemory.cpp | 9 - src/src/fluorinepaths.cpp | 43 ++- src/src/fluorinepaths.h | 6 +- src/src/fuseconnector.cpp | 180 ---------- src/src/fuseconnector.h | 11 - src/src/instancemanager.cpp | 14 +- src/src/instancemanagerdialog.cpp | 33 +- src/src/instancemanagerdialog.ui | 10 + src/src/mainwindow.cpp | 110 ++++-- src/src/mainwindow.h | 7 +- src/src/moapplication.cpp | 85 +++-- src/src/nxmhandler_linux.cpp | 68 ++-- src/src/organizercore.cpp | 120 +++---- src/src/plugincontainer.cpp | 214 ++++++++---- src/src/process_helper_main.cpp | 359 -------------------- src/src/processrunner.cpp | 28 +- src/src/processrunner.h | 3 - src/src/protonlauncher.cpp | 275 ++++----------- src/src/protonlauncher.h | 9 +- src/src/sanitychecks.cpp | 5 +- src/src/settingsdialogpaths.cpp | 24 +- src/src/settingsdialogproton.cpp | 58 +--- src/src/spawn.cpp | 2 +- src/src/spawn.h | 1 - src/src/vfs/vfs_helper_main.cpp | 337 ------------------- src/src/wineprefix.cpp | 10 +- 65 files changed, 1170 insertions(+), 2473 deletions(-) delete mode 100755 build-flatpak.sh create mode 100755 build.sh create mode 100644 data/com.fluorine.manager.desktop create mode 100644 data/com.fluorine.manager.metainfo.xml create mode 100644 data/com.fluorine.manager.png delete mode 100644 flatpak/com.fluorine.manager.desktop delete mode 100644 flatpak/com.fluorine.manager.metainfo.xml delete mode 100644 flatpak/com.fluorine.manager.png delete mode 100644 flatpak/com.fluorine.manager.yml delete mode 100755 flatpak/flatpak-install.sh delete mode 100755 flatpak/fluorine-manager-wrapper.sh create mode 100644 libs/basic_games/games/game_monsterhunterwilds.py create mode 100644 libs/game_bethesda/src/*.cpp delete mode 100644 src/src/process_helper_main.cpp delete mode 100644 src/src/vfs/vfs_helper_main.cpp diff --git a/.gitignore b/.gitignore index da898a0..96bb944 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,8 @@ build-container-2404-gcc14/ # Rust build artifacts target/ -# Flatpak build cache and artifacts -.flatpak-builder/ -.flatpak-build/ -flatpak-repo/ -flatpak/builddir/ -*.flatpak +# AppImage build artifacts +*.AppImage # Logs *.log @@ -26,3 +22,16 @@ compile_commands.json # OS junk .DS_Store Thumbs.db + +# AppImage extraction/build leftovers +squashfs-root/ +build-source/ +flatpak-repo/ +*.flatpak + +# Python bytecode +__pycache__/ +*.pyc + +# Generated settings files +fomod-plus-settings.ini diff --git a/build-flatpak.sh b/build-flatpak.sh deleted file mode 100755 index 68e58a5..0000000 --- a/build-flatpak.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# build-flatpak.sh — Build the Flatpak package. -# Usage: ./build-flatpak.sh [install|bundle] (default: install) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -MODE="${1:-install}" - -exec bash "${SCRIPT_DIR}/flatpak/flatpak-install.sh" "${MODE}" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..766e8d7 --- /dev/null +++ b/build.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build Fluorine Manager using Docker. +# +# Usage: +# ./build.sh # Build AppImage + staging dir +# ./build.sh shell # Drop into the build container for debugging +# +# Prerequisites: Docker or Podman + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Auto-detect container runtime +if command -v podman >/dev/null 2>&1; then + DOCKER=podman +elif command -v docker >/dev/null 2>&1; then + DOCKER=docker +else + echo "ERROR: Neither podman nor docker found in PATH" + exit 1 +fi +IMAGE_NAME="fluorine-builder" +CONTAINER_NAME="fluorine-build-$$" + +cd "${SCRIPT_DIR}" + +# Build the Docker image if it doesn't exist or Dockerfile changed +echo "=== Ensuring build image is up to date ===" +${DOCKER} build -t "${IMAGE_NAME}" docker/ + +if [ "${1:-}" = "shell" ]; then + echo "=== Dropping into build container shell ===" + exec ${DOCKER} run --rm -it \ + -v "${SCRIPT_DIR}:/src:rw" \ + -w /src \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + "${IMAGE_NAME}" \ + bash +fi + +echo "=== Starting build ===" +${DOCKER} run --rm \ + -v "${SCRIPT_DIR}:/src:rw" \ + -w /src \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --name "${CONTAINER_NAME}" \ + "${IMAGE_NAME}" \ + bash /src/docker/build-inner.sh + +echo "" +echo "=== Done ===" +if ls build/*.AppImage >/dev/null 2>&1; then + echo "AppImage: $(ls build/*.AppImage)" +fi +echo "Staging: build/staging/" diff --git a/data/com.fluorine.manager.desktop b/data/com.fluorine.manager.desktop new file mode 100644 index 0000000..ff62c76 --- /dev/null +++ b/data/com.fluorine.manager.desktop @@ -0,0 +1,22 @@ +[Desktop Entry] +Type=Application +Name=Fluorine Manager +GenericName=Mod Manager +Comment=Mod Organizer 2 for Linux - manage your game mods +Exec=fluorine-manager +Icon=com.fluorine.manager +Terminal=false +Categories=Game;Utility; +Keywords=mod;organizer;modding;skyrim;fallout; +MimeType=x-scheme-handler/nxm; +Actions=create-portable;list-instances; + +[Desktop Action create-portable] +Name=Create Portable Instance +Exec=fluorine-manager create-portable +Icon=com.fluorine.manager + +[Desktop Action list-instances] +Name=List Instances +Exec=fluorine-manager list-instances +Icon=com.fluorine.manager diff --git a/data/com.fluorine.manager.metainfo.xml b/data/com.fluorine.manager.metainfo.xml new file mode 100644 index 0000000..1200d3e --- /dev/null +++ b/data/com.fluorine.manager.metainfo.xml @@ -0,0 +1,38 @@ + + + com.fluorine.manager + Fluorine Manager + Mod Organizer 2 for Linux + CC0-1.0 + GPL-3.0-or-later + + +

+ Fluorine Manager is a Linux port of Mod Organizer 2, a powerful mod management + tool for Bethesda games and other titles. It uses a virtual filesystem to keep + your game directory clean while allowing complex mod setups with load order + management, conflict resolution, and profile support. +

+

Features:

+
    +
  • Virtual filesystem - mods never touch your game folder
  • +
  • Profile system for different mod configurations
  • +
  • Plugin load order management
  • +
  • Mod conflict detection and resolution
  • +
  • Nexus Mods integration for downloading
  • +
  • Proton/Wine prefix management for running Windows games
  • +
  • UMU launcher support
  • +
+
+ + com.fluorine.manager.desktop + + https://github.com/ModOrganizer2/modorganizer + https://github.com/ModOrganizer2/modorganizer/issues + + + fluorine-manager + + + +
diff --git a/data/com.fluorine.manager.png b/data/com.fluorine.manager.png new file mode 100644 index 0000000..3a9fbdd Binary files /dev/null and b/data/com.fluorine.manager.png differ diff --git a/docker/Dockerfile b/docker/Dockerfile index aee1635..f4098af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ qt6-webengine-dev \ libqt6websockets6-dev \ qt6-wayland \ + kde-style-breeze \ # Python python3 python3-pyqt6 \ # Misc diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 0483aa2..dda9b14 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -31,17 +31,10 @@ 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 fix two issues with Steamworks DRM: - # - # 1. 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. - # - # 2. Remove UMU_ID from the environment before Proton runs. GE-Proton - # treats games with UMU_ID as non-Steam titles and skips the steam.exe - # DRM bridge, causing "Application load error 5:0000065434". For actual - # Steam games (SteamAppId != "0") we delete UMU_ID so Proton uses the - # correct steam.exe launch path. + # 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 @@ -59,16 +52,6 @@ new1 = (old1 if old1 in src and 'STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ' not in src: src = src.replace(old1, new1) -# Patch 2: delete UMU_ID before Proton launch for Steam games -old2 = ' os.environ[key] = val' -new2 = (old2 - + '\n\n # GE-Proton treats games with UMU_ID as non-Steam titles and skips' - + '\n # the steam.exe DRM bridge. Remove it for Steam games.' - + '\n if "UMU_ID" in os.environ and env.get("SteamAppId", "0") != "0":' - + '\n del os.environ["UMU_ID"]') -if old2 in src and 'del os.environ["UMU_ID"]' not in src: - src = src.replace(old2, new2, 1) - run_py.write_text(src) PATCHEOF ) @@ -91,6 +74,7 @@ done find build/libs -type f \( \ -name "libgame_*.so" -o \ -name "libinstaller_*.so" -o \ + -name "libfomod_plus_*.so" -o \ -name "libpreview_*.so" -o \ -name "libdiagnose_*.so" -o \ -name "libcheck_*.so" -o \ @@ -117,6 +101,12 @@ for f in /src/src/plugins/*.py; do [ -f "${f}" ] && cp -f "${f}" "${OUT_DIR}/plugins/" done +# ── Stylesheets (themes) ── +if [ -d "build/src/src/stylesheets" ]; then + cp -a "build/src/src/stylesheets" "${OUT_DIR}/" + echo "Bundled stylesheets" +fi + # ── 7z runtime ── SO7="build/src/src/dlls/7z.so" if [ -f "${SO7}" ]; then @@ -140,6 +130,80 @@ for boost_lib in /lib/x86_64-linux-gnu/libboost_program_options.so* \ [ -f "${boost_lib}" ] && cp -Lf "${boost_lib}" "${OUT_DIR}/lib/" done +# ── Bundle ALL shared library dependencies ── +# Collect every .so the binary and plugins link against, then bundle +# everything except core glibc/system libs (which must come from the host). +echo "Bundling shared library dependencies..." + +# Libraries that MUST come from the host (glibc, GPU drivers, etc.) +SKIP_PATTERN="linux-vdso|ld-linux|libc\.so|libm\.so|libdl\.so|librt\.so|libpthread|libresolv|libnss|libgcc_s|libstdc\+\+" +# GPU/graphics drivers must be host-provided +SKIP_PATTERN="${SKIP_PATTERN}|libGL\.so|libEGL|libGLX|libGLdispatch|libdrm|libvulkan|libX11|libxcb|libwayland-client|libwayland-server|libwayland-cursor|libwayland-egl|libxkbcommon" +# libpython is shipped by the portable Python runtime in python/lib/ — do NOT +# copy the container's version into lib/ or it will shadow the portable one. +SKIP_PATTERN="${SKIP_PATTERN}|libpython" + +collect_deps() { + ldd "$1" 2>/dev/null | grep "=>" | awk '{print $3}' | grep "^/" | sort -u +} + +ALL_DEPS=$(mktemp) +# Main binary +collect_deps "${OUT_DIR}/ModOrganizer-core" >> "${ALL_DEPS}" +# All plugin .so files +find "${OUT_DIR}/plugins" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" +# Our own libs +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}" + +sort -u "${ALL_DEPS}" | while read -r dep; do + dep_name="$(basename "${dep}")" + # Skip system libs + if echo "${dep_name}" | grep -qE "${SKIP_PATTERN}"; then + continue + fi + # Skip if already bundled + if [ -f "${OUT_DIR}/lib/${dep_name}" ]; then + continue + fi + cp -Lf "${dep}" "${OUT_DIR}/lib/" 2>/dev/null || true +done +rm -f "${ALL_DEPS}" +# Remove any libpython that leaked into lib/ — must come from python/lib/ only. +rm -f "${OUT_DIR}/lib"/libpython*.so* 2>/dev/null || true +echo "Dependencies bundled." + +# ── Qt6 platform plugins ── +QT6_PLUGIN_DIR="/usr/lib/x86_64-linux-gnu/qt6/plugins" +if [ ! -d "${QT6_PLUGIN_DIR}" ]; then + QT6_PLUGIN_DIR="$(qtpaths6 --plugin-dir 2>/dev/null || echo "")" +fi +if [ -d "${QT6_PLUGIN_DIR}" ]; then + mkdir -p "${OUT_DIR}/qt6plugins" + for plugin_type in platforms tls networkinformation styles \ + wayland-shell-integration \ + wayland-decoration-client wayland-graphics-integration-client \ + platformthemes imageformats iconengines xcbglintegrations \ + egldeviceintegrations; do + if [ -d "${QT6_PLUGIN_DIR}/${plugin_type}" ]; then + cp -a "${QT6_PLUGIN_DIR}/${plugin_type}" "${OUT_DIR}/qt6plugins/" + fi + done + # Bundle deps of Qt plugins too + find "${OUT_DIR}/qt6plugins" -name "*.so" -exec sh -c ' + ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/" | while read dep; do + dep_name="$(basename "${dep}")" + echo "${dep_name}" | grep -qE "'"${SKIP_PATTERN}"'" && continue + [ -f "'"${OUT_DIR}"'/lib/${dep_name}" ] && continue + cp -Lf "${dep}" "'"${OUT_DIR}"'/lib/" 2>/dev/null || true + done + ' _ {} \; + echo "Bundled Qt6 plugins from ${QT6_PLUGIN_DIR}" +else + echo "WARNING: Could not find Qt6 plugin directory" +fi + # libloot (custom-built, never on user systems). if [ -f /usr/local/lib/libloot.so.0 ]; then cp -Lf /usr/local/lib/libloot.so.0 "${OUT_DIR}/lib/" @@ -242,41 +306,156 @@ export MO2_PYTHON_DIR="${HERE}/python" MO2_PYTHONHOME="${HERE}/python" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME -# Find system Qt6 plugins (compiled-in path won't match across distros). -if [ -z "${QT_PLUGIN_PATH:-}" ]; then - for qt_dir in /usr/lib/qt6/plugins \ - /usr/lib/x86_64-linux-gnu/qt6/plugins \ - /usr/lib64/qt6/plugins; do - if [ -d "${qt_dir}/platforms" ]; then - export QT_PLUGIN_PATH="${qt_dir}" - break - fi - done - if [ -z "${QT_PLUGIN_PATH:-}" ] && command -v qtpaths6 >/dev/null 2>&1; then - export QT_PLUGIN_PATH="$(qtpaths6 --plugin-dir)" - fi -fi - -# Quick dependency check. -missing="$(ldd "${HERE}/ModOrganizer-core" 2>/dev/null | grep "not found" || true)" -if [ -n "${missing}" ]; then - echo "ERROR: Missing system libraries:" - echo "${missing}" - echo "" - echo "On Arch/CachyOS: sudo pacman -S qt6-base qt6-websockets qt6-wayland" - echo "On Fedora: sudo dnf install qt6-qtbase qt6-qtwebsockets qt6-qtwayland" - echo "On Ubuntu/Debian: sudo apt install qt6-base-dev libqt6websockets6-dev qt6-wayland" - exit 1 -fi +# Use bundled Qt6 plugins. +export QT_PLUGIN_PATH="${HERE}/qt6plugins" cd "${HERE}" exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@" LAUNCH chmod +x "${OUT_DIR}/fluorine-manager" -# ── Summary ── +# ── Desktop integration files (for AppImage) ── +cp -f /src/data/com.fluorine.manager.desktop "${OUT_DIR}/" +cp -f /src/data/com.fluorine.manager.png "${OUT_DIR}/" +cp -f /src/data/com.fluorine.manager.metainfo.xml "${OUT_DIR}/" + +# ── Build AppImage ── +echo "" +echo "=== Building AppImage ===" + +APPDIR="/src/build/AppDir" +rm -rf "${APPDIR}" +mkdir -p "${APPDIR}/usr/bin" "${APPDIR}/usr/lib" "${APPDIR}/usr/share/applications" \ + "${APPDIR}/usr/plugins" \ + "${APPDIR}/usr/share/icons/hicolor/256x256/apps" \ + "${APPDIR}/usr/share/metainfo" + +# Copy staging into AppDir layout +cp -a "${OUT_DIR}"/. "${APPDIR}/usr/bin/" +# Move libs to standard location +mv "${APPDIR}/usr/bin/lib"/* "${APPDIR}/usr/lib/" 2>/dev/null || true +rmdir "${APPDIR}/usr/bin/lib" 2>/dev/null || true +# Flatpak runtime exposed Qt plugins from a stable system location. Mirror that +# layout in AppImage by placing bundled Qt plugins under /usr/plugins. +if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then + cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" +fi + +# 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/" +cp -f "${OUT_DIR}/com.fluorine.manager.metainfo.xml" "${APPDIR}/usr/share/metainfo/" + +# Bundle icon themes so QIcon::fromTheme() calls resolve inside the AppImage. +# Flatpak runtime provided this globally; AppImage must carry it explicitly. +mkdir -p "${APPDIR}/usr/share/icons" +for theme_dir in /usr/share/icons/*; do + [ -d "${theme_dir}" ] || continue + cp -a "${theme_dir}" "${APPDIR}/usr/share/icons/" +done +if [ -f "/usr/share/icons/default/index.theme" ]; then + mkdir -p "${APPDIR}/usr/share/icons/default" + 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" +[ -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 + +# Create AppRun wrapper +cat > "${APPDIR}/AppRun" <<'APPRUN' +#!/usr/bin/env bash +set -euo pipefail +SELF="$(readlink -f "$0")" +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 -) +} + +# Keep runtime payload outside the transient AppImage mount. +sync_dir "plugins" +sync_dir "dlls" +sync_dir "python" + +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 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 + +unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME + +# Use bundled Qt6 plugins. +export QT_PLUGIN_PATH="${HERE}/usr/plugins" +export QT_QPA_PLATFORM_PLUGIN_PATH="${HERE}/usr/plugins/platforms" +export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export QT_ICON_THEME_NAME="${QT_ICON_THEME_NAME:-breeze}" +export QT_STYLE_OVERRIDE="${QT_STYLE_OVERRIDE:-Breeze}" +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-KDE}" + +cd "${APPIMAGE_DIR}" +exec "${BIN}/ModOrganizer-core" "$@" +APPRUN +chmod +x "${APPDIR}/AppRun" + +# Symlinks required by AppImage spec +ln -sf usr/share/applications/com.fluorine.manager.desktop "${APPDIR}/com.fluorine.manager.desktop" +ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/com.fluorine.manager.png" +ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/.DirIcon" + +# Extract linuxdeploy (can't use FUSE inside Docker) +DEPLOY_DIR="/tmp/linuxdeploy-extract" +if [ ! -d "${DEPLOY_DIR}" ]; then + cd /opt/linuxdeploy + ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null + mv squashfs-root "${DEPLOY_DIR}" + chmod +x "${DEPLOY_DIR}/AppRun" +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. +export ARCH=x86_64 +"${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" \ + 2>&1 || { + echo "WARNING: linuxdeploy AppImage generation failed, falling back to staging dir output" +} + +# Move AppImage to output +APPIMAGE_FILE=$(ls -1 Fluorine*.AppImage 2>/dev/null || ls -1 *.AppImage 2>/dev/null || true) +if [ -n "${APPIMAGE_FILE}" ]; then + mv "${APPIMAGE_FILE}" "/src/build/" + echo "" + echo "=== AppImage built ===" + ls -lh "/src/build/"*.AppImage +else + echo "" + echo "=== Staging complete (AppImage generation skipped) ===" +fi + echo "" echo "=== Build Summary ===" du -sh "${OUT_DIR}"/*/ "${OUT_DIR}"/ModOrganizer-core 2>/dev/null | sort -rh -echo "" -echo "Staging complete." diff --git a/flatpak/com.fluorine.manager.desktop b/flatpak/com.fluorine.manager.desktop deleted file mode 100644 index ff62c76..0000000 --- a/flatpak/com.fluorine.manager.desktop +++ /dev/null @@ -1,22 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Fluorine Manager -GenericName=Mod Manager -Comment=Mod Organizer 2 for Linux - manage your game mods -Exec=fluorine-manager -Icon=com.fluorine.manager -Terminal=false -Categories=Game;Utility; -Keywords=mod;organizer;modding;skyrim;fallout; -MimeType=x-scheme-handler/nxm; -Actions=create-portable;list-instances; - -[Desktop Action create-portable] -Name=Create Portable Instance -Exec=fluorine-manager create-portable -Icon=com.fluorine.manager - -[Desktop Action list-instances] -Name=List Instances -Exec=fluorine-manager list-instances -Icon=com.fluorine.manager diff --git a/flatpak/com.fluorine.manager.metainfo.xml b/flatpak/com.fluorine.manager.metainfo.xml deleted file mode 100644 index 1200d3e..0000000 --- a/flatpak/com.fluorine.manager.metainfo.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - com.fluorine.manager - Fluorine Manager - Mod Organizer 2 for Linux - CC0-1.0 - GPL-3.0-or-later - - -

- Fluorine Manager is a Linux port of Mod Organizer 2, a powerful mod management - tool for Bethesda games and other titles. It uses a virtual filesystem to keep - your game directory clean while allowing complex mod setups with load order - management, conflict resolution, and profile support. -

-

Features:

-
    -
  • Virtual filesystem - mods never touch your game folder
  • -
  • Profile system for different mod configurations
  • -
  • Plugin load order management
  • -
  • Mod conflict detection and resolution
  • -
  • Nexus Mods integration for downloading
  • -
  • Proton/Wine prefix management for running Windows games
  • -
  • UMU launcher support
  • -
-
- - com.fluorine.manager.desktop - - https://github.com/ModOrganizer2/modorganizer - https://github.com/ModOrganizer2/modorganizer/issues - - - fluorine-manager - - - -
diff --git a/flatpak/com.fluorine.manager.png b/flatpak/com.fluorine.manager.png deleted file mode 100644 index 3a9fbdd..0000000 Binary files a/flatpak/com.fluorine.manager.png and /dev/null differ diff --git a/flatpak/com.fluorine.manager.yml b/flatpak/com.fluorine.manager.yml deleted file mode 100644 index 5ed65dc..0000000 --- a/flatpak/com.fluorine.manager.yml +++ /dev/null @@ -1,371 +0,0 @@ -app-id: com.fluorine.manager -runtime: org.kde.Platform -runtime-version: '6.10' -sdk: org.kde.Sdk -sdk-extensions: - - org.freedesktop.Sdk.Extension.rust-stable -command: fluorine-manager - -finish-args: - - --share=ipc - - --share=network - - --socket=x11 - - --socket=wayland - - --device=all - - --filesystem=home - - --filesystem=/mnt - - --filesystem=/media - - --filesystem=/run/media - - --filesystem=~/.steam:ro - - --filesystem=~/.local/share/Steam - - --filesystem=~/.var/app/com.valvesoftware.Steam:ro - - --talk-name=org.freedesktop.Flatpak - -cleanup: - - /include - - /lib/cmake - - /lib/pkgconfig - - /share/doc - - /share/man - - '*.a' - - '*.la' - -modules: - # ── 1. Boost ── - - name: boost - buildsystem: simple - build-commands: - - ./bootstrap.sh --prefix=/app --with-libraries=program_options,thread,filesystem,system,locale - - ./b2 install -j$FLATPAK_BUILDER_N_JOBS link=shared variant=release - sources: - - type: archive - url: https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.gz - sha256: f55c340aa49763b1925ccf02b2e83f35fdcf634c9d5164a2acb87540173c741d - - # ── 2. spdlog ── - - name: spdlog - buildsystem: cmake-ninja - config-opts: - - -DCMAKE_BUILD_TYPE=Release - - -DSPDLOG_BUILD_SHARED=ON - sources: - - type: git - url: https://github.com/gabime/spdlog.git - tag: v1.15.2 - - # ── 3. tinyxml2 ── - - name: tinyxml2 - buildsystem: cmake-ninja - config-opts: - - -DCMAKE_BUILD_TYPE=Release - sources: - - type: git - url: https://github.com/leethomason/tinyxml2.git - tag: 10.0.0 - - # ── 4. tomlplusplus ── - - name: tomlplusplus - buildsystem: cmake-ninja - config-opts: - - -DCMAKE_BUILD_TYPE=Release - sources: - - type: git - url: https://github.com/marzer/tomlplusplus.git - tag: v3.4.0 - - # ── 5. libfuse3 ── - # Built manually to install fusermount3 without setuid (chown root fails in sandbox). - # default_library=both produces libfuse3.a so the VFS helper can link statically. - - name: libfuse3 - buildsystem: simple - build-commands: - - meson setup _build --prefix=/app -Dexamples=false -Dtests=false -Dutils=true -Ddefault_library=both - - ninja -C _build - # Install library and headers (skip meson install which runs chown). - - ninja -C _build install || true - # Ensure fusermount3 is installed even if install_helper.sh failed. - - install -Dm755 _build/util/fusermount3 /app/bin/fusermount3 2>/dev/null || true - sources: - - type: git - url: https://github.com/libfuse/libfuse.git - tag: fuse-3.16.2 - - # ── 6. libloot ── - # libloot has a Rust crate at the root and a C++ cmake project in cpp/. - # The Rust static library must be built first, then cmake links against it. - - name: libloot - buildsystem: simple - build-options: - append-path: /usr/lib/sdk/rust-stable/bin - env: - CARGO_HOME: /run/build/libloot/cargo - # Network needed for cargo deps and cmake FetchContent (googletest). - build-args: - - --share=network - build-commands: - # Build Rust static library first. - - cargo build --release - # Build the C++ shared library (links against Rust static lib). - - | - cmake -S cpp -B cpp/build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_SHARED_LIBS=ON \ - -DLIBLOOT_INSTALL_DOCS=OFF \ - -DCMAKE_INSTALL_PREFIX=/app - - cmake --build cpp/build --parallel - - cmake --install cpp/build - # Create pkg-config metadata. - - mkdir -p /app/lib/pkgconfig - - | - printf '%s\n' \ - 'prefix=/app' \ - 'exec_prefix=${prefix}' \ - 'libdir=${prefix}/lib' \ - 'includedir=${prefix}/include' \ - '' \ - 'Name: libloot' \ - 'Description: LOOT C++ API library' \ - 'Version: 0.29.0' \ - 'Libs: -L${libdir} -lloot' \ - 'Cflags: -I${includedir}' \ - > /app/lib/pkgconfig/libloot.pc - sources: - - type: git - url: https://github.com/loot/libloot.git - branch: master - - # ── 7. icoutils ── - - name: icoutils - buildsystem: autotools - build-options: - # icoutils 0.32.3 uses K&R-style () declarations that GCC 14+ rejects. - cflags: -std=gnu17 - sources: - - type: archive - url: https://download.savannah.gnu.org/releases/icoutils/icoutils-0.32.3.tar.bz2 - sha256: 17abe02d043a253b68b47e3af69c9fc755b895db68fdc8811786125df564c6e0 - - # ── 8. cabextract ── - # Required by winetricks for extracting Windows cab archives. - - name: cabextract - buildsystem: autotools - sources: - - type: archive - url: https://www.cabextract.org.uk/cabextract-1.11.tar.gz - sha256: b5546db1155e4c718ff3d4b278573604f30dd64c3c5bfd4657cd089b823a3ac6 - - # ── 9. Portable Python runtime ── - # Pre-built Python used as the embedded interpreter for MO2's Python plugins. - # pip packages are installed via network during build (--share=network). - - name: portable-python - buildsystem: simple - build-options: - build-args: - - --share=network - build-commands: - # Extract, trim, symlink, and install pip packages in one step - # (each build-command runs in its own shell). - - | - set -e - mkdir -p /app/lib/fluorine/python - unzip portable-python.zip -d /tmp/pp - PP_DIR="$(find /tmp/pp -maxdepth 1 -mindepth 1 -type d | head -1)" - cp -a "${PP_DIR}/." /app/lib/fluorine/python/ - rm -rf /tmp/pp - # Versioned soname symlink (pybind11 links against libpython3.13.so.1.0). - if [ -f /app/lib/fluorine/python/lib/libpython3.13.so ] && \ - [ ! -f /app/lib/fluorine/python/lib/libpython3.13.so.1.0 ]; then - ln -sf libpython3.13.so /app/lib/fluorine/python/lib/libpython3.13.so.1.0 - fi - # Trim test suites and unnecessary files. - rm -rf /app/lib/fluorine/python/lib/python3.13/test \ - /app/lib/fluorine/python/lib/python3.13/unittest/test \ - /app/lib/fluorine/python/lib/python3.13/idlelib \ - /app/lib/fluorine/python/lib/python3.13/tkinter \ - /app/lib/fluorine/python/lib/python3.13/turtledemo \ - /app/lib/fluorine/python/include \ - /app/lib/fluorine/python/share \ - 2>/dev/null || true - find /app/lib/fluorine/python -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true - find /app/lib/fluorine/python -name "*.pyc" -delete 2>/dev/null || true - # Install uv for Python package management. - curl -LsSf https://astral.sh/uv/install.sh | INSTALLER_NO_MODIFY_PATH=1 UV_INSTALL_DIR=/app/bin sh - # Install Python packages via uv (PyQt6 for plugin UI, psutil, vdf). - /app/bin/uv pip install --python /app/lib/fluorine/python/bin/python3 PyQt6 psutil vdf - sources: - - type: file - url: https://github.com/bjia56/portable-python/releases/download/cpython-v3.13.9-build.0/python-headless-3.13.9-linux-x86_64.zip - sha256: d03507d107da86d74aa38bbc4957b8db5753de567024c5724a254909ae6b86d6 - dest-filename: portable-python.zip - - # ── 10. Fluorine Manager (main project) ── - # uv (installed in portable-python step) handles pybind11 + sip for the build. - - name: fluorine - buildsystem: simple - build-options: - append-path: /usr/lib/sdk/rust-stable/bin - env: - CARGO_HOME: /run/build/fluorine/cargo - PYTHONPATH: /app/lib/python3/dist-packages - # Network needed for FetchContent (7zip, etc.) and cargo deps. - build-args: - - --share=network - build-commands: - # ── Configure and build ── - # Install pybind11 + sip into /app (SDK /usr is read-only). - # Uses SDK's python3 directly (not a venv) so find_package(Python) - # finds Development headers on cmake reconfiguration. - - | - /app/bin/uv pip install --target /app/lib/python3/dist-packages pybind11==2.13.6 sip - PYBIND11_DIR="$(python3 -c 'import pybind11; print(pybind11.get_cmake_dir())')" - cmake -S . -B _build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_PREFIX_PATH=/app \ - -DPython_EXECUTABLE=/usr/bin/python3 \ - -Dpybind11_DIR="${PYBIND11_DIR}" \ - -DBUILD_PLUGIN_PYTHON=ON - - cmake --build _build --parallel - - # ── Create output layout ── - - mkdir -p /app/lib/fluorine/plugins /app/lib/fluorine/dlls /app/lib/fluorine/lib - - # Main binary. - - cp -f _build/src/src/ModOrganizer /app/lib/fluorine/ModOrganizer-core - - # umu-run (patch and repackage if present). - - | - if [ -f _build/src/src/umu-run ]; then - UMU_PATCH_DIR="$(mktemp -d)" - cd "${UMU_PATCH_DIR}" - python3 << 'PATCHEOF' - import zipfile, pathlib - zf = zipfile.ZipFile('/run/build/fluorine/_build/src/src/umu-run') - zf.extractall('src') - run_py = pathlib.Path('src/umu/umu_run.py') - src = run_py.read_text() - 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) - old2 = ' os.environ[key] = val' - new2 = (old2 - + '\n\n # GE-Proton treats games with UMU_ID as non-Steam titles and skips' - + '\n # the steam.exe DRM bridge. Remove it for Steam games.' - + '\n if "UMU_ID" in os.environ and env.get("SteamAppId", "0") != "0":' - + '\n del os.environ["UMU_ID"]') - if old2 in src and 'del os.environ["UMU_ID"]' not in src: - src = src.replace(old2, new2, 1) - run_py.write_text(src) - PATCHEOF - python3 -m zipapp src -o /app/lib/fluorine/umu-run -p '/usr/bin/env python3' - chmod +x /app/lib/fluorine/umu-run - cd /run/build/fluorine - rm -rf "${UMU_PATCH_DIR}" - fi - - # VFS helper (standalone, runs on host for Flatpak FUSE support). - # Statically linked against libfuse3 — no runtime deps beyond glibc. - - test -f _build/src/src/mo2-vfs-helper && cp -f _build/src/src/mo2-vfs-helper /app/lib/fluorine/ || true - - test -f _build/src/src/mo2-process-helper && cp -f _build/src/src/mo2-process-helper /app/lib/fluorine/ || true - - # lootcli. - - test -f _build/libs/lootcli/src/lootcli && cp -f _build/libs/lootcli/src/lootcli /app/lib/fluorine/ || true - - # icoutils (symlink from /app/bin where the icoutils module installed them). - - ln -sf /app/bin/wrestool /app/lib/fluorine/wrestool - - ln -sf /app/bin/icotool /app/lib/fluorine/icotool - - ln -sf /app/bin/fusermount3 /app/lib/fluorine/fusermount3 - - ln -sf /app/bin/cabextract /app/lib/fluorine/cabextract - - # fluorine-manager CLI helper. - - test -f src/fluorine-manager && cp -f src/fluorine-manager /app/lib/fluorine/ || true - - # MO2 plugins (.so). - - | - find _build/libs -type f \( \ - -name "libgame_*.so" -o \ - -name "libinstaller_*.so" -o \ - -name "libfomod_plus_*.so" -o \ - -name "libpreview_*.so" -o \ - -name "libdiagnose_*.so" -o \ - -name "libcheck_*.so" -o \ - -name "libtool_*.so" -o \ - -name "libinieditor.so" -o \ - -name "libinibakery.so" -o \ - -name "libbsa_extractor.so" -o \ - -name "libbsa_packer.so" -o \ - -name "libproxy.so" \ - \) -exec cp -f {} /app/lib/fluorine/plugins/ \; - - # Python plugin payload. - - | - for f in libplugin_python.so lzokay.py winreg.py \ - DDSPreview.py Form43Checker.py ScriptExtenderPluginChecker.py; do - [ -f "_build/src/src/plugins/${f}" ] && cp -f "_build/src/src/plugins/${f}" /app/lib/fluorine/plugins/ - done - for d in basic_games data libs dlls; do - [ -d "_build/src/src/plugins/${d}" ] && cp -a "_build/src/src/plugins/${d}" /app/lib/fluorine/plugins/ - done - rm -f /app/lib/fluorine/plugins/FNIS*.py - - # Source-tree Python plugins (rootbuilder, omod installer, etc.). - - | - for f in src/plugins/*.py; do - [ -f "${f}" ] && cp -f "${f}" /app/lib/fluorine/plugins/ - done - - # Stylesheets / themes. - - cp -a src/src/stylesheets /app/lib/fluorine/stylesheets - - # 7z runtime. - - | - if [ -f _build/src/src/dlls/7z.so ]; then - cp -f _build/src/src/dlls/7z.so /app/lib/fluorine/dlls/7z.so - cp -f _build/src/src/dlls/7z.so /app/lib/fluorine/dlls/7zip.dll - fi - - # Project shared libraries. - - cp -f _build/libs/uibase/src/libuibase.so /app/lib/fluorine/lib/ - - cp -f _build/libs/libbsarch/liblibbsarch.so /app/lib/fluorine/lib/ - - cp -f _build/libs/archive/src/libarchive.so /app/lib/fluorine/lib/ - - test -f _build/libs/plugin_python/src/runner/librunner.so && cp -f _build/libs/plugin_python/src/runner/librunner.so /app/lib/fluorine/lib/ || true - - test -f libs/bsa_ffi/target/release/libbsa_ffi.so && cp -f libs/bsa_ffi/target/release/libbsa_ffi.so /app/lib/fluorine/lib/ || true - - test -f libs/nak_ffi/target/release/libnak_ffi.so && cp -f libs/nak_ffi/target/release/libnak_ffi.so /app/lib/fluorine/lib/ || true - - # Boost libraries (from /app/lib where the boost module installed them). - - cp -Lf /app/lib/libboost_program_options.so* /app/lib/fluorine/lib/ 2>/dev/null || true - - cp -Lf /app/lib/libboost_thread.so* /app/lib/fluorine/lib/ 2>/dev/null || true - - # libloot (from /app/lib where the libloot module installed it). - - | - if [ -f /app/lib/libloot.so.0 ]; then - cp -Lf /app/lib/libloot.so.0 /app/lib/fluorine/lib/ - ln -sf libloot.so.0 /app/lib/fluorine/lib/libloot.so - elif [ -f /app/lib/libloot.so ]; then - cp -Lf /app/lib/libloot.so /app/lib/fluorine/lib/ - fi - - # Build-tree Python plugin payload. - - test -d _build/src/src/python && cp -a _build/src/src/python/. /app/lib/fluorine/python/ || true - - # Strip binaries. - - strip --strip-unneeded /app/lib/fluorine/ModOrganizer-core 2>/dev/null || true - - find /app/lib/fluorine/plugins -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true - - find /app/lib/fluorine/dlls \( -name "*.so" -o -name "*.dll" \) -exec strip --strip-unneeded {} \; 2>/dev/null || true - - find /app/lib/fluorine/lib -name "*.so*" -exec strip --strip-unneeded {} \; 2>/dev/null || true - - test -f /app/lib/fluorine/lootcli && strip --strip-unneeded /app/lib/fluorine/lootcli 2>/dev/null || true - - test -f /app/lib/fluorine/mo2-vfs-helper && strip --strip-unneeded /app/lib/fluorine/mo2-vfs-helper 2>/dev/null || true - - test -f /app/lib/fluorine/mo2-process-helper && strip --strip-unneeded /app/lib/fluorine/mo2-process-helper 2>/dev/null || true - - # Install wrapper script, desktop file, metainfo, and icon. - - install -Dm755 flatpak/fluorine-manager-wrapper.sh /app/bin/fluorine-manager - - install -Dm644 flatpak/com.fluorine.manager.desktop /app/share/applications/com.fluorine.manager.desktop - - install -Dm644 flatpak/com.fluorine.manager.metainfo.xml /app/share/metainfo/com.fluorine.manager.metainfo.xml - - install -Dm644 flatpak/com.fluorine.manager.png /app/share/icons/hicolor/256x256/apps/com.fluorine.manager.png - - sources: - - type: dir - path: .. diff --git a/flatpak/flatpak-install.sh b/flatpak/flatpak-install.sh deleted file mode 100755 index 984d12b..0000000 --- a/flatpak/flatpak-install.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# Build the Fluorine Manager Flatpak. -# -# Usage: -# cd flatpak && bash flatpak-install.sh # build & install locally -# cd flatpak && bash flatpak-install.sh bundle # build a .flatpak file to share -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "${SCRIPT_DIR}")" -MANIFEST="${SCRIPT_DIR}/com.fluorine.manager.yml" -APP_ID="com.fluorine.manager" -BUILD_DIR="${PROJECT_DIR}/.flatpak-build" -MODE="${1:-install}" - -# ── Ensure flathub remote exists ── -if ! flatpak remote-list --user | grep -q flathub; then - echo "Adding flathub remote..." - flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo -fi - -# ── Install required Flatpak runtime and SDK ── -echo "Installing KDE Platform and SDK (may take a while on first run)..." -flatpak install --user --noninteractive flathub org.kde.Platform//6.10 org.kde.Sdk//6.10 - -echo "Installing Rust SDK extension..." -flatpak install --user --noninteractive flathub org.freedesktop.Sdk.Extension.rust-stable//25.08 - -cd "${PROJECT_DIR}" - -if [ "${MODE}" = "bundle" ]; then - # ── Build a distributable .flatpak file ── - echo "" - echo "Building Flatpak bundle (this may take a while)..." - flatpak-builder --disable-rofiles-fuse --repo="${PROJECT_DIR}/flatpak-repo" --force-clean --ccache \ - "${BUILD_DIR}" "${MANIFEST}" - flatpak build-bundle \ - --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ - "${PROJECT_DIR}/flatpak-repo" "${PROJECT_DIR}/fluorine-manager.flatpak" "${APP_ID}" - BUNDLE_SIZE="$(du -sh fluorine-manager.flatpak | cut -f1)" - echo "" - echo "=== Bundle created: fluorine-manager.flatpak (${BUNDLE_SIZE}) ===" - echo "" - echo "Share this file with testers. They install it with:" - echo " flatpak install --user fluorine-manager.flatpak" -else - # ── Build and install locally ── - echo "" - echo "Building and installing Flatpak locally (this may take a while)..." - flatpak-builder --disable-rofiles-fuse --install --user --force-clean --ccache \ - "${BUILD_DIR}" "${MANIFEST}" - echo "" - echo "=== Flatpak installed successfully ===" -fi - -echo "" -echo "Usage:" -echo " flatpak run ${APP_ID} # launch GUI" -echo " flatpak run ${APP_ID} create-portable --name myinstance --game falloutnv" -echo " flatpak run ${APP_ID} list-instances" diff --git a/flatpak/fluorine-manager-wrapper.sh b/flatpak/fluorine-manager-wrapper.sh deleted file mode 100755 index eba2b77..0000000 --- a/flatpak/fluorine-manager-wrapper.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash -# Flatpak wrapper for Fluorine Manager (MO2 Linux) -# Sets up a writable overlay so users can add custom plugins while still -# loading the bundled ones from the read-only /app tree. -# -# For portable instances (--instance /path), the overlay is created INSIDE the -# instance directory so each portable install is fully self-contained. -# For the global instance (no --instance), a shared overlay is used. - -BUNDLED="/app/lib/fluorine" - -# ── Detect --instance argument ── -# If launching a portable instance, use its directory as the overlay target. -INSTANCE_DIR="" -PREV="" -for arg in "$@"; do - if [ "$PREV" = "--instance" ] || [ "$PREV" = "-i" ]; then - INSTANCE_DIR="$arg" - break - fi - PREV="$arg" -done - -if [ -n "$INSTANCE_DIR" ] && [ -d "$INSTANCE_DIR" ]; then - # Portable instance: overlay goes into the instance directory itself. - # Everything is self-contained: mods, profiles, plugins, dlls, libs. - USER_DIR="$INSTANCE_DIR" -else - # Global instance: shared overlay at ~/.local/share/fluorine/ - # Use $HOME directly to bypass Flatpak's XDG_DATA_HOME remapping. - USER_DIR="$HOME/.var/app/com.fluorine.manager" -fi - -# ── Create writable overlay with symlinks to bundled files ── -# This lets MO2 load bundled plugins AND any custom ones the user drops in. -# Existing files are never overwritten (user overrides take priority). -setup_overlay() { - mkdir -p "${USER_DIR}/plugins" "${USER_DIR}/dlls" "${USER_DIR}/lib" - - # Symlink bundled plugins (skip existing - user overrides take priority) - for f in "${BUNDLED}/plugins/"*; do - [ -e "$f" ] || continue - local base="$(basename "$f")" - local target="${USER_DIR}/plugins/${base}" - [ -e "$target" ] || [ -L "$target" ] || ln -sf "$f" "$target" - done - - # Symlink bundled dlls - for f in "${BUNDLED}/dlls/"*; do - [ -e "$f" ] || continue - local base="$(basename "$f")" - local target="${USER_DIR}/dlls/${base}" - [ -e "$target" ] || [ -L "$target" ] || ln -sf "$f" "$target" - done - - # Symlink bundled libs - for f in "${BUNDLED}/lib/"*; do - [ -e "$f" ] || continue - local base="$(basename "$f")" - local target="${USER_DIR}/lib/${base}" - [ -e "$target" ] || [ -L "$target" ] || ln -sf "$f" "$target" - done - - # Symlink other bundled files (binaries, tools) directly - for f in ModOrganizer-core lootcli wrestool icotool fusermount3 cabextract; do - [ -e "${BUNDLED}/$f" ] || continue - [ -e "${USER_DIR}/$f" ] || [ -L "${USER_DIR}/$f" ] || ln -sf "${BUNDLED}/$f" "${USER_DIR}/$f" - done - - # umu-run must be a real copy (not symlink to /app/) because it runs on - # the host via flatpak-spawn --host, where /app/ doesn't exist. - # Remove any stale symlink first (cp -f follows symlinks, won't replace them). - if [ -e "${BUNDLED}/umu-run" ]; then - rm -f "${USER_DIR}/umu-run" - cp "${BUNDLED}/umu-run" "${USER_DIR}/umu-run" - chmod +x "${USER_DIR}/umu-run" - fi - - # VFS helper must be a real binary copy (not a symlink to /app/) because - # it runs on the host via flatpak-spawn --host, where /app/ doesn't exist. - # libfuse3 is statically linked, so no extra .so files needed. - VFS_HELPER_DIR="$HOME/.var/app/com.fluorine.manager/bin" - mkdir -p "${VFS_HELPER_DIR}" - if [ -e "${BUNDLED}/mo2-vfs-helper" ]; then - cp -f "${BUNDLED}/mo2-vfs-helper" "${VFS_HELPER_DIR}/mo2-vfs-helper" - chmod +x "${VFS_HELPER_DIR}/mo2-vfs-helper" - fi - if [ -e "${BUNDLED}/mo2-process-helper" ]; then - cp -f "${BUNDLED}/mo2-process-helper" "${VFS_HELPER_DIR}/mo2-process-helper" - chmod +x "${VFS_HELPER_DIR}/mo2-process-helper" - fi -} - -setup_overlay - -export PATH="${USER_DIR}:${BUNDLED}:${PATH}" -export LD_LIBRARY_PATH="${USER_DIR}/lib:${BUNDLED}/lib:${BUNDLED}/python/lib:${LD_LIBRARY_PATH:-}" - -# MO2 resolves plugins/dlls relative to MO2_BASE_DIR (or basePath()). -# Point it at the overlay so custom plugins are found. -export MO2_BASE_DIR="${USER_DIR}" -export MO2_PLUGINS_DIR="${USER_DIR}/plugins" -export MO2_DLLS_DIR="${USER_DIR}/dlls" -export MO2_PYTHON_DIR="${BUNDLED}/python" - -# Do NOT set PYTHONHOME globally -- it leaks into child processes (umu-run, -# Proton, winetricks) and breaks their Python. The plugin_python runner reads -# MO2_PYTHON_DIR and sets PYTHONHOME internally before loading the interpreter. -unset PYTHONHOME PYTHONPATH PYTHONNOUSERSITE - -# Qt6 plugins from KDE runtime. -export QT_PLUGIN_PATH="/usr/lib/plugins" - -exec "${BUNDLED}/ModOrganizer-core" "$@" diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index 4ebc83c..533c72e 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -281,21 +281,12 @@ class BG3PakParser: except Exception: pass - if os.path.exists("/.flatpak-info"): - # In Flatpak, host wine binaries can't execute directly - # (missing host libraries). Use flatpak-spawn to run on host. - spawn_cmd = ["flatpak-spawn", "--host"] - if wine_prefix: - spawn_cmd.append(f"--env=WINEPREFIX={wine_prefix}") - spawn_cmd.extend(["bash", "-c", command]) - result = subprocess.run(spawn_cmd, **kwargs) - else: - env = os.environ.copy() - if wine_prefix: - env["WINEPREFIX"] = wine_prefix - kwargs["env"] = env - kwargs["shell"] = True - result = subprocess.run(command, **kwargs) + env = os.environ.copy() + if wine_prefix: + env["WINEPREFIX"] = wine_prefix + kwargs["env"] = env + kwargs["shell"] = True + result = subprocess.run(command, **kwargs) if result.returncode: qWarning( diff --git a/libs/basic_games/games/game_monsterhunterwilds.py b/libs/basic_games/games/game_monsterhunterwilds.py new file mode 100644 index 0000000..419377c --- /dev/null +++ b/libs/basic_games/games/game_monsterhunterwilds.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MonsterHunterWildsGame(BasicGame): + Name = "Monster Hunter: Wilds Support Plugin" + Author = "AbyssDragnonModding" + Version = "1.0.0" + + GameName = "Monster Hunter: Wilds" + GameShortName = "monsterhunterwilds" + GameBinary = "MonsterHunterWilds.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "bin" + GameNexusId = 6993 + GameSteamId = 2246340 diff --git a/libs/game_bethesda/src/*.cpp b/libs/game_bethesda/src/*.cpp new file mode 100644 index 0000000..e69de29 diff --git a/libs/game_bethesda/src/gamebryo/gamebryolocalsavegames.cpp b/libs/game_bethesda/src/gamebryo/gamebryolocalsavegames.cpp index e4df0ea..4c1199c 100644 --- a/libs/game_bethesda/src/gamebryo/gamebryolocalsavegames.cpp +++ b/libs/game_bethesda/src/gamebryo/gamebryolocalsavegames.cpp @@ -56,7 +56,9 @@ QString GamebryoLocalSavegames::localSavesDummy() const QDir GamebryoLocalSavegames::localSavesDirectory() const { - return QDir(m_Game->myGamesPath()).absoluteFilePath(localSavesDummy()); + QString dummy = localSavesDummy(); + dummy.replace("\\", "/"); + return QDir(m_Game->myGamesPath()).absoluteFilePath(dummy); } QDir GamebryoLocalSavegames::localGameDirectory() const diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp index b53d823..4a92440 100644 --- a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp +++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp @@ -494,11 +494,7 @@ QString GameGamebryo::myGamesPath() const return findInRegistry(HKEY_LOCAL_MACHINE, L"Software\\LOOT", L"Installed Path") + "/Loot.exe"; #else - // On Linux, look for loot in common locations - QString flatpakPath = QDir::homePath() + "/.var/app/io.github.loot.loot/data/LOOT/Loot"; - if (QFileInfo(flatpakPath).exists()) - return flatpakPath; - // Try system PATH + // On Linux, look for loot in PATH QString systemLoot = QStandardPaths::findExecutable("loot"); if (!systemLoot.isEmpty()) return systemLoot; diff --git a/libs/game_gamebryo/src/gamebryo/gamebryolocalsavegames.cpp b/libs/game_gamebryo/src/gamebryo/gamebryolocalsavegames.cpp index cf78eba..d0cc2ef 100644 --- a/libs/game_gamebryo/src/gamebryo/gamebryolocalsavegames.cpp +++ b/libs/game_gamebryo/src/gamebryo/gamebryolocalsavegames.cpp @@ -44,7 +44,9 @@ QString GamebryoLocalSavegames::localSavesDummy() const QDir GamebryoLocalSavegames::localSavesDirectory() const { - return QDir(m_Game->myGamesPath()).absoluteFilePath(localSavesDummy()); + QString dummy = localSavesDummy(); + dummy.replace("\\", "/"); + return QDir(m_Game->myGamesPath()).absoluteFilePath(dummy); } QDir GamebryoLocalSavegames::localGameDirectory() const diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp index 690bdbb..c748495 100644 --- a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp @@ -34,8 +34,6 @@ bool FomodPlusInstaller::init(IOrganizer* organizer) mOrganizer = organizer; mFomodContent = make_shared(organizer); log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log"); - std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl; - std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl; // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it. mFomodDb = std::make_unique(mOrganizer->basePath().toStdString()); diff --git a/libs/installer_fomod_plus/installer/lib/Logger.h b/libs/installer_fomod_plus/installer/lib/Logger.h index fdeb1f0..53fb7d9 100644 --- a/libs/installer_fomod_plus/installer/lib/Logger.h +++ b/libs/installer_fomod_plus/installer/lib/Logger.h @@ -90,8 +90,6 @@ public: if (mLogFile.is_open()) { writeLog(mLogFile); } - - writeLog(std::cout); } Logger& operator=(const Logger&) = delete; diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp index 56aff85..5f34e5d 100644 --- a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp +++ b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp @@ -27,7 +27,6 @@ bool CtrlClickEventFilter::eventFilter(QObject* obj, QEvent* event) if (mouseEvent->button() == Qt::LeftButton && mouseEvent->modifiers() & Qt::ControlModifier) { // TODO: Add Ctrl+click handler logic here - std::cout << "Ctrl+click detected on plugin: " << mPlugin->getName() << " in group: " << mGroup->getName() << std::endl; // For now, just fall through to default behavior } } diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp index cd06b3a..855fe2b 100644 --- a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp @@ -77,7 +77,6 @@ void PatchFinder::populateInstalledPlugins() const auto mod_tree = mod->fileTree(); for (auto it = mod_tree->begin(); it != mod_tree->end(); ++it) { if ((*it)->isFile() && isPluginFile((*it)->name())) { - std::cout << "Plugin: " << (*it)->name().toStdString() << std::endl; m_installedPlugins[mod].emplace_back((*it)->name().toStdString()); m_installedPluginsCacheSet.insert((*it)->name().toStdString()); } diff --git a/libs/installer_fomod_plus/scanner/archiveparser.h b/libs/installer_fomod_plus/scanner/archiveparser.h index 4058d43..8819a84 100644 --- a/libs/installer_fomod_plus/scanner/archiveparser.h +++ b/libs/installer_fomod_plus/scanner/archiveparser.h @@ -83,7 +83,6 @@ public: } if (hasFomodFiles(archive->getFileList())) { - std::cout << "Found FOMOD files in " << qualifiedInstallerPath.toStdString() << std::endl; return ScanResult::HAS_FOMOD; } return ScanResult::NO_FOMOD; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h index d1a9aa2..f5ea6f5 100644 --- a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h +++ b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h @@ -33,8 +33,6 @@ public: for (const auto &group: installStep.optionalFileGroups.groups) { for (const auto &plugin: group.plugins.plugins) { // Create a DB entry for the given plugin if it has an ESP - std::cout << "\nPlugin: " << plugin.name << std::endl; - for (auto file: plugin.files.files) { if (file.isFolder || !isPluginFile(file.source)) { continue; diff --git a/libs/nak/src/paths.rs b/libs/nak/src/paths.rs index e86c284..f6dde6c 100644 --- a/libs/nak/src/paths.rs +++ b/libs/nak/src/paths.rs @@ -1,11 +1,11 @@ //! Shared data directory for Fluorine Manager. //! -//! All data lives under `~/.var/app/com.fluorine.manager/`. +//! All data lives under `~/.local/share/fluorine/`. use std::path::PathBuf; -/// Returns the Fluorine data directory (`~/.var/app/com.fluorine.manager`). +/// Returns the Fluorine data directory (`~/.local/share/fluorine`). pub fn data_dir() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join(".var/app/com.fluorine.manager") + PathBuf::from(home).join(".local/share/fluorine") } diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs index d33bd59..9b5ff89 100644 --- a/libs/nak/src/runtime_wrap.rs +++ b/libs/nak/src/runtime_wrap.rs @@ -34,23 +34,6 @@ fn find_in_path(binary: &str) -> Option { } pub fn resolve_umu_run() -> Option { - if is_flatpak() { - // In Flatpak, umu-run must run on the host via flatpak-spawn --host. - let host_copy = env::var("XDG_DATA_HOME") - .ok() - .map(PathBuf::from) - .unwrap_or_else(|| { - let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join(".local/share") - }) - .join("fluorine/umu-run"); - if host_copy.exists() { - return Some(host_copy); - } - // Fall back to bare name for host PATH resolution. - return Some(PathBuf::from("umu-run")); - } - let bundled = env::var("NAK_BUNDLED_UMU_RUN") .ok() .map(PathBuf::from) @@ -64,14 +47,8 @@ pub fn resolve_umu_run() -> Option { } } -pub fn is_flatpak() -> bool { - Path::new("/.flatpak-info").exists() -} - /// Build a command to run `exe` with the given environment variables. /// -/// In Flatpak mode, `flatpak-spawn --host` is used and env vars are passed as -/// `--env=KEY=VALUE` flags (the host process does NOT inherit the sandbox env). /// In steam-run mode, the command is wrapped with `steam-run`. /// Otherwise the command runs directly. pub fn build_command>( @@ -86,19 +63,6 @@ pub fn build_command>( } return cmd; } - if is_flatpak() { - let mut cmd = Command::new("flatpak-spawn"); - cmd.arg("--host"); - for (key, value) in envs { - cmd.arg(format!( - "--env={}={}", - key, - value.as_ref().to_string_lossy() - )); - } - cmd.arg(exe.as_ref()); - return cmd; - } let mut cmd = Command::new(exe); for (key, value) in envs { cmd.env(key, value.as_ref()); diff --git a/libs/plugin_python/src/proxy/proxypython.cpp b/libs/plugin_python/src/proxy/proxypython.cpp index 196bf23..c9c7ba2 100644 --- a/libs/plugin_python/src/proxy/proxypython.cpp +++ b/libs/plugin_python/src/proxy/proxypython.cpp @@ -224,6 +224,9 @@ bool ProxyPython::init(IOrganizer* moInfo) if (!m_Runner || !m_Runner->isInitialized()) { m_LoadFailure = FailureType::INITIALIZATION; + MOBase::log::error("Python proxy: interpreter failed to initialize, " + "Python plugins will be unavailable"); + return true; // return true so the plugin stays loaded (shows diagnostic) } else { m_Runner->addDllSearchPath(pluginDataRoot / "dlls"); @@ -296,7 +299,7 @@ QStringList ProxyPython::pluginList(const QDir& pluginPath) const QList ProxyPython::load(const QString& identifier) { - if (!m_Runner) { + if (!m_Runner || !m_Runner->isInitialized()) { return {}; } return m_Runner->load(identifier); @@ -304,7 +307,7 @@ QList ProxyPython::load(const QString& identifier) void ProxyPython::unload(const QString& identifier) { - if (m_Runner) { + if (m_Runner && m_Runner->isInitialized()) { return m_Runner->unload(identifier); } } diff --git a/libs/plugin_python/src/runner/pythonrunner.cpp b/libs/plugin_python/src/runner/pythonrunner.cpp index 05a0f30..b0bf48c 100644 --- a/libs/plugin_python/src/runner/pythonrunner.cpp +++ b/libs/plugin_python/src/runner/pythonrunner.cpp @@ -85,21 +85,6 @@ namespace mo2::python { return true; } - std::optional oldPythonHome; - std::optional oldPythonPath; - auto restorePythonEnv = [&]() { - if (oldPythonHome.has_value()) { - setenv("PYTHONHOME", oldPythonHome->constData(), 1); - } else { - unsetenv("PYTHONHOME"); - } - if (oldPythonPath.has_value()) { - setenv("PYTHONPATH", oldPythonPath->constData(), 1); - } else { - unsetenv("PYTHONPATH"); - } - }; - try { static const char* argv0 = "ModOrganizer.exe"; @@ -107,14 +92,49 @@ namespace mo2::python { #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("failed to dlopen python shared library '{}': {}", + 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. + { + 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", + 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 { + fprintf(stderr, "[py-diag] Py_IsInitialized NOT in global scope\n"); + MOBase::log::warn("python: Py_IsInitialized NOT in global scope"); + } } #endif #endif @@ -130,6 +150,25 @@ namespace mo2::python { } else { pythonHome = QCoreApplication::applicationDirPath() + "/python"; } + if (!QDir(pythonHome).exists()) { + MOBase::log::warn("python: PYTHONHOME dir '{}' does not exist", + pythonHome); + } + + std::optional oldPythonHome; + std::optional oldPythonPath; + auto restorePythonEnv = [&]() { + if (oldPythonHome.has_value()) { + setenv("PYTHONHOME", oldPythonHome->constData(), 1); + } else { + unsetenv("PYTHONHOME"); + } + if (oldPythonPath.has_value()) { + setenv("PYTHONPATH", oldPythonPath->constData(), 1); + } else { + unsetenv("PYTHONPATH"); + } + }; if (const char* v = std::getenv("PYTHONHOME"); v != nullptr) { oldPythonHome = QByteArray(v); } @@ -137,48 +176,86 @@ namespace mo2::python { oldPythonPath = QByteArray(v); } - if (QDir(pythonHome).exists()) { - setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1); - - const QDir libDir(pythonHome + "/lib"); - const auto pyDirs = - libDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); - if (!pyDirs.isEmpty()) { - const QString pyver = pyDirs.first(); - const QString pyPath = QString("%1/lib/%2:%1/lib/%2/site-packages:%1") - .arg(pythonHome, pyver); - setenv("PYTHONPATH", pyPath.toUtf8().constData(), 1); - } - } - // Paths we want to prepend/append for MO2 plugin loading. auto paths = pythonPaths; - PyConfig config; - PyConfig_InitPythonConfig(&config); - - // from PyBind11 - config.parse_argv = 0; - config.install_signal_handlers = 0; - - // from MO2 - config.site_import = 1; - config.optimization_level = 2; + // Configure bundled Python like running python/bin/python3 directly. + const QDir libDir(pythonHome + "/lib"); + const auto pyDirs = + libDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); + const QString pyverDir = pyDirs.isEmpty() ? QStringLiteral("python3.13") + : pyDirs.first(); + const QString stdlibDir = pythonHome + "/lib/" + pyverDir; + const QString stdlibZip = pythonHome + "/lib/python313.zip"; + const QString rootDynloadDir = pythonHome + "/lib-dynload"; + const QString rootPlatDir = pythonHome + "/plat-linux2"; + const QString dynloadDir = stdlibDir + "/lib-dynload"; + const QString siteDir = stdlibDir + "/site-packages"; + + QStringList corePaths = {stdlibDir, siteDir, dynloadDir}; + if (QFile::exists(stdlibZip)) { + corePaths.prepend(stdlibZip); + } + if (QDir(rootDynloadDir).exists()) { + corePaths.append(rootDynloadDir); + } + if (QDir(rootPlatDir).exists()) { + corePaths.append(rootPlatDir); + } - py::initialize_interpreter(&config, 1, &argv0, true); + corePaths.append(pythonHome); + const QByteArray pyPath = corePaths.join(":").toUtf8(); + setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1); + setenv("PYTHONPATH", pyPath.constData(), 1); + + fprintf(stderr, + "[py-diag] calling Py_InitializeFromConfig, PYTHONHOME='%s', " + "Py_IsInitialized before=%d\n", + pythonHome.toUtf8().constData(), Py_IsInitialized()); + MOBase::log::debug( + "python: calling Py_InitializeFromConfig, PYTHONHOME='{}', " + "Py_IsInitialized before={}", + pythonHome, Py_IsInitialized()); + + // Use Py_InitializeFromConfig (Python 3.8+) for explicit error reporting. + { + PyConfig config; + PyConfig_InitPythonConfig(&config); + // PYTHONHOME is already set via setenv; PyConfig reads it from env. + PyStatus status = Py_InitializeFromConfig(&config); + PyConfig_Clear(&config); + if (PyStatus_Exception(status)) { + fprintf(stderr, + "[py-diag] Py_InitializeFromConfig FAILED: '%s' [in '%s']\n", + status.err_msg ? status.err_msg : "(no message)", + status.func ? status.func : "(no func)"); + MOBase::log::error( + "python: Py_InitializeFromConfig failed: '{}' [in '{}']", + status.err_msg ? status.err_msg : "(no message)", + status.func ? status.func : "(no func)"); + restorePythonEnv(); + return false; + } + } - // Restore process environment after interpreter startup so - // subprocesses (umu/NaK/tools) are not forced onto MO2's Python. - restorePythonEnv(); + fprintf(stderr, "[py-diag] Py_IsInitialized after=%d\n", + Py_IsInitialized()); + MOBase::log::debug("python: Py_IsInitialized after={}", + Py_IsInitialized()); if (!Py_IsInitialized()) { + const char* ph = std::getenv("PYTHONHOME"); + const char* pp = std::getenv("PYTHONPATH"); MOBase::log::error( - "failed to init python: failed to initialize interpreter."); - - if (PyGILState_Check()) { - PyEval_SaveThread(); - } - + "failed to init python: Py_IsInitialized() returned false."); + MOBase::log::error(" PYTHONHOME='{}', PYTHONPATH='{}'", + ph ? ph : "(null)", pp ? pp : "(null)"); + MOBase::log::error(" pythonHome='{}', exists={}", pythonHome, + QDir(pythonHome).exists() ? "yes" : "no"); + const QString encPath = pythonHome + "/lib/python3.13/encodings"; + MOBase::log::error(" encodings dir='{}', exists={}", encPath, + QDir(encPath).exists() ? "yes" : "no"); + restorePythonEnv(); return false; } @@ -201,11 +278,11 @@ namespace mo2::python { // when Python is initialized, the GIl is acquired, and if it is not // release, trying to acquire it on a different thread will deadlock PyEval_SaveThread(); + restorePythonEnv(); return true; } catch (const py::error_already_set& ex) { - restorePythonEnv(); MOBase::log::error("failed to init python: {}", ex.what()); return false; } @@ -353,7 +430,8 @@ namespace mo2::python { return allInterfaceList; } catch (const py::error_already_set& ex) { - MOBase::log::error("Failed to import plugin from {}.", identifier); + MOBase::log::error("Failed to import plugin from {}: {}", identifier, + ex.what()); throw pyexcept::PythonError(ex); } } diff --git a/src/plugins/rootbuilder.py b/src/plugins/rootbuilder.py index dcc6706..342e1a0 100644 --- a/src/plugins/rootbuilder.py +++ b/src/plugins/rootbuilder.py @@ -57,18 +57,6 @@ def _walk_files(root_dir: str): yield os.path.join(dirpath, name) -def _host_cp(src: str, dst: str) -> bool: - """Copy a file via the host OS (bypasses Flatpak sandbox restrictions).""" - try: - result = subprocess.run( - ["flatpak-spawn", "--host", "cp", "-f", "--reflink=auto", "--", src, dst], - capture_output=True, timeout=30, - ) - return result.returncode == 0 - except (subprocess.SubprocessError, OSError): - return False - - def _ensure_readable(path: str): """Ensure a file has owner-read permission (mod archives sometimes strip it).""" try: @@ -99,8 +87,6 @@ def _reflink_copy(src: str, dst: str): return except OSError as e: last_err = f"{e.strerror} (errno {e.errno})" - if _IN_FLATPAK and _host_cp(src, dst): - return raise OSError(f"Root Builder: failed to copy {src} -> {dst}: {last_err}") @@ -113,33 +99,6 @@ def _ensure_writable(path: str): pass -_IN_FLATPAK = os.path.exists("/.flatpak-info") - - -def _host_rm(path: str) -> bool: - """Remove a file via the host OS (bypasses Flatpak sandbox restrictions).""" - try: - result = subprocess.run( - ["flatpak-spawn", "--host", "rm", "-f", "--", path], - capture_output=True, timeout=10, - ) - return result.returncode == 0 - except (subprocess.SubprocessError, OSError): - return False - - -def _host_mv(src: str, dst: str) -> bool: - """Move a file via the host OS (bypasses Flatpak sandbox restrictions).""" - try: - result = subprocess.run( - ["flatpak-spawn", "--host", "mv", "-f", "--", src, dst], - capture_output=True, timeout=10, - ) - return result.returncode == 0 - except (subprocess.SubprocessError, OSError): - return False - - def _force_remove(path: str) -> bool: """Remove a file, fixing permissions if needed. Returns True on success.""" try: @@ -154,8 +113,6 @@ def _force_remove(path: str) -> bool: return True except OSError: pass - if _IN_FLATPAK and _host_rm(path): - return True qWarning(f"Root Builder: could not remove {path}") return False @@ -556,11 +513,10 @@ class RootBuilder(mobase.IPluginTool): _force_remove(dst) shutil.move(bak, dst) except OSError: - if not (_IN_FLATPAK and _host_mv(bak, dst)): - qWarning( - f"Root Builder: could not restore backup " - f"{bak} -> {dst}" - ) + qWarning( + f"Root Builder: could not restore backup " + f"{bak} -> {dst}" + ) # Clean up backup dir backup_dir = os.path.join(storage, _BACKUP_SUBDIR) diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index ab8b6c3..e85f730 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -48,14 +48,6 @@ list(REMOVE_ITEM ORGANIZER_SOURCES list(REMOVE_ITEM ORGANIZER_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/usvfsconnector.h) -# VFS helper has its own main() — exclude from organizer -list(REMOVE_ITEM ORGANIZER_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/vfs/vfs_helper_main.cpp) - -# Process helper has its own main() — exclude from organizer -list(REMOVE_ITEM ORGANIZER_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/process_helper_main.cpp) - # Remove WebEngine-dependent sources when not available if(NOT Qt6WebEngineWidgets_FOUND) list(REMOVE_ITEM ORGANIZER_SOURCES @@ -127,43 +119,6 @@ target_compile_definitions(organizer PRIVATE $<$:MO2_WEBENGINE>) if(NOT WIN32) - # ── Standalone VFS helper for Flatpak (runs on host via flatpak-spawn) ── - add_executable(mo2-vfs-helper - vfs/vfs_helper_main.cpp - vfs/vfstree.cpp - vfs/mo2filesystem.cpp - vfs/inodetable.cpp - vfs/overwritemanager.cpp) - # Prefer static libfuse3 so the helper is fully self-contained (Flatpak - # runs it on the host via flatpak-spawn where SDK .so files don't exist). - # Fall back to shared linking for source builds where libfuse3.a isn't - # available (the helper still works since libfuse3.so is on the host). - find_library(_fuse3_static libfuse3.a PATHS ${FUSE3_LIBRARY_DIRS} NO_DEFAULT_PATH) - if(NOT _fuse3_static) - find_library(_fuse3_static libfuse3.a) - endif() - target_link_directories(mo2-vfs-helper PRIVATE ${FUSE3_LIBRARY_DIRS}) - if(_fuse3_static) - target_link_libraries(mo2-vfs-helper PRIVATE - -Wl,-Bstatic -lfuse3 -Wl,-Bdynamic - Threads::Threads) - else() - message(STATUS "libfuse3.a not found, linking mo2-vfs-helper against shared libfuse3") - target_link_libraries(mo2-vfs-helper PRIVATE - PkgConfig::FUSE3 - Threads::Threads) - endif() - target_include_directories(mo2-vfs-helper PRIVATE ${FUSE3_INCLUDE_DIRS}) - target_compile_definitions(mo2-vfs-helper PRIVATE FUSE_USE_VERSION=35) - target_compile_features(mo2-vfs-helper PRIVATE cxx_std_23) - - # ── Standalone process helper for Flatpak game launching ── - # Keeps the flatpak-spawn proxy alive while monitoring the game process tree. - add_executable(mo2-process-helper - process_helper_main.cpp) - target_link_libraries(mo2-process-helper PRIVATE Threads::Threads) - target_compile_features(mo2-process-helper PRIVATE cxx_std_23) - option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD "Stage shipped Python plugin payload into build plugins/ for Linux runs" ON) diff --git a/src/src/commandline.cpp b/src/src/commandline.cpp index 09aabb8..4613350 100644 --- a/src/src/commandline.cpp +++ b/src/src/commandline.cpp @@ -1025,7 +1025,6 @@ std::optional CreatePortableCommand::runEarly() // Generate ModOrganizer.sh { - const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info")); const QString launchPath = QDir(instanceDir).filePath("ModOrganizer.sh"); QFile launchFile(launchPath); if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { @@ -1033,12 +1032,8 @@ std::optional CreatePortableCommand::runEarly() out << "#!/bin/bash\n"; out << "# Auto-generated by fluorine-manager\n"; out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; - if (flatpak) { - out << "exec flatpak run com.fluorine.manager --instance \"${INSTANCE_DIR}\" \"$@\"\n"; - } else { - out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; - out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; - } + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; launchFile.close(); QFile::setPermissions(launchPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | diff --git a/src/src/createinstancedialog.cpp b/src/src/createinstancedialog.cpp index 89506e6..3bce099 100644 --- a/src/src/createinstancedialog.cpp +++ b/src/src/createinstancedialog.cpp @@ -67,8 +67,9 @@ private: return; } - // root directory - QDir d(cs[0]); + // root directory — on Unix, splitting "/home/user" produces ["", "home", "user"] + // so cs[0] is empty; use "/" as the root to keep paths absolute. + QDir d(cs[0].isEmpty() ? QStringLiteral("/") : cs[0]); // for each directory after the root for (int i = 1; i < cs.size(); ++i) { diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp index 3138a17..5f134a0 100644 --- a/src/src/createinstancedialogpages.cpp +++ b/src/src/createinstancedialogpages.cpp @@ -14,19 +14,6 @@ namespace cid { -// On Flatpak the native file dialog returns XDG portal FUSE paths that may -// not properly expose directory contents. Returns options that force the -// Qt built-in dialog when running inside Flatpak. -static QFileDialog::Options flatpakSafeOptions() -{ - QFileDialog::Options opts; -#ifndef _WIN32 - if (qEnvironmentVariableIsSet("FLATPAK_ID")) - opts |= QFileDialog::DontUseNativeDialog; -#endif - return opts; -} - using namespace MOBase; using MOBase::TaskDialog; @@ -329,7 +316,7 @@ void GamePage::select(IPluginGame* game, const QString& dir) const auto path = QFileDialog::getExistingDirectory( &m_dlg, QObject::tr("Find game installation for %1").arg(game->displayGameName()), - {}, flatpakSafeOptions()); + {}, {}); if (path.isEmpty()) { // cancelled @@ -377,7 +364,7 @@ void GamePage::select(IPluginGame* game, const QString& dir) void GamePage::selectCustom() { const auto path = QFileDialog::getExistingDirectory( - &m_dlg, QObject::tr("Find game installation"), {}, flatpakSafeOptions()); + &m_dlg, QObject::tr("Find game installation"), {}, {}); if (path.isEmpty()) { // reselect the previous button @@ -1115,7 +1102,7 @@ void PathsPage::onChanged() void PathsPage::browse(QLineEdit* e) { const auto s = - QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), flatpakSafeOptions()); + QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), {}); if (s.isNull() || s.isEmpty()) { return; } diff --git a/src/src/downloadlist.cpp b/src/src/downloadlist.cpp index dd35ac2..457fab2 100644 --- a/src/src/downloadlist.cpp +++ b/src/src/downloadlist.cpp @@ -37,6 +37,7 @@ DownloadList::DownloadList(OrganizerCore& core, QObject* parent) { connect(&m_manager, SIGNAL(update(int)), this, SLOT(update(int))); connect(&m_manager, SIGNAL(aboutToUpdate()), this, SLOT(aboutToUpdate())); + connect(&m_manager, SIGNAL(stateChanged(int,int)), this, SLOT(rowChanged(int))); } int DownloadList::rowCount(const QModelIndex& parent) const @@ -107,6 +108,9 @@ QMimeData* DownloadList::mimeData(const QModelIndexList& indexes) const QVariant DownloadList::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) + return QVariant(); + bool pendingDownload = index.row() >= m_manager.numTotalDownloads(); if (role == Qt::DisplayRole) { if (pendingDownload) { @@ -256,6 +260,14 @@ void DownloadList::update(int row) log::error("invalid row {} in download list, update failed", row); } +void DownloadList::rowChanged(int row) +{ + if (row >= 0 && row < rowCount()) { + emit dataChanged(index(row, 0, QModelIndex()), + index(row, columnCount(QModelIndex()) - 1, QModelIndex())); + } +} + bool DownloadList::lessThanPredicate(const QModelIndex& left, const QModelIndex& right) { int leftIndex = left.row(); diff --git a/src/src/downloadlist.h b/src/src/downloadlist.h index 73e499d..1be2ea5 100644 --- a/src/src/downloadlist.h +++ b/src/src/downloadlist.h @@ -94,6 +94,8 @@ public slots: void aboutToUpdate(); + void rowChanged(int row); + private: DownloadManager& m_manager; Settings& m_settings; diff --git a/src/src/downloadlistview.cpp b/src/src/downloadlistview.cpp index 8928e6c..961af97 100644 --- a/src/src/downloadlistview.cpp +++ b/src/src/downloadlistview.cpp @@ -50,6 +50,11 @@ void DownloadProgressDelegate::paint(QPainter* painter, sourceIndex = index; } + if (sourceIndex.row() < 0 || sourceIndex.row() >= m_Manager->numTotalDownloads() + m_Manager->numPendingDownloads()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + bool pendingDownload = (sourceIndex.row() >= m_Manager->numTotalDownloads()); if (sourceIndex.column() == DownloadList::COL_STATUS && !pendingDownload && m_Manager->getState(sourceIndex.row()) == DownloadManager::STATE_DOWNLOADING) { diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp index ae3b94e..457a58e 100644 --- a/src/src/downloadmanager.cpp +++ b/src/src/downloadmanager.cpp @@ -229,13 +229,13 @@ DownloadManager::DownloadManager(NexusInterface* nexusInterface, QObject* parent m_ParentWidget(nullptr) { m_OrganizerCore = dynamic_cast(parent); - connect(&m_DirWatcher, SIGNAL(directoryChanged(QString)), this, - SLOT(directoryChanged(QString))); - m_TimeoutTimer.setSingleShot(false); - connect(&m_TimeoutTimer, &QTimer::timeout, this, - &DownloadManager::checkDownloadTimeout); - m_TimeoutTimer.start(5 * 1000); -} + connect(&m_DirWatcher, SIGNAL(directoryChanged(QString)), this, + SLOT(directoryChanged(QString))); + m_TimeoutTimer.setSingleShot(false); + connect(&m_TimeoutTimer, &QTimer::timeout, this, + &DownloadManager::checkDownloadTimeout); + m_TimeoutTimer.start(5 * 1000); +} DownloadManager::~DownloadManager() { @@ -953,19 +953,19 @@ void DownloadManager::removeDownload(int index, bool deleteFile) refreshList(); } -void DownloadManager::cancelDownload(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("cancel: invalid download index %1").arg(index)); - return; - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - if (info->m_State == STATE_DOWNLOADING || info->m_State == STATE_STARTED || - info->m_State == STATE_PAUSING) { - setState(info, STATE_CANCELING); - } -} +void DownloadManager::cancelDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("cancel: invalid download index %1").arg(index)); + return; + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (info->m_State == STATE_DOWNLOADING || info->m_State == STATE_STARTED || + info->m_State == STATE_PAUSING) { + setState(info, STATE_CANCELING); + } +} void DownloadManager::pauseDownload(int index) { @@ -1613,20 +1613,20 @@ void DownloadManager::setState(DownloadManager::DownloadInfo* info, row = i; break; } - } - info->m_State = state; - switch (state) { - case STATE_CANCELING: { - // Force termination so the download transitions through finished(). - if (info->m_Reply != nullptr && info->m_Reply->isRunning()) { - info->m_Reply->abort(); - } - } break; - case STATE_PAUSED: { - info->m_Reply->abort(); - info->m_Output.close(); - m_DownloadPaused(row); - } break; + } + info->m_State = state; + switch (state) { + case STATE_CANCELING: { + // Force termination so the download transitions through finished(). + if (info->m_Reply != nullptr && info->m_Reply->isRunning()) { + info->m_Reply->abort(); + } + } break; + case STATE_PAUSED: { + info->m_Reply->abort(); + info->m_Output.close(); + m_DownloadPaused(row); + } break; case STATE_ERROR: { info->m_Reply->abort(); info->m_Output.close(); @@ -2313,12 +2313,10 @@ void DownloadManager::downloadFinished(int index) "There may be an issue with the Nexus servers.")); emit update(-1); } else if (info->isPausedState() || info->m_State == STATE_PAUSING) { - emit aboutToUpdate(); info->m_Output.close(); createMetaFile(info); emit update(index); } else { - emit aboutToUpdate(); QString url = info->m_Urls[info->m_CurrentUrl]; if (info->m_FileInfo->userData.contains("downloadMap")) { foreach (const QVariant& server, @@ -2420,46 +2418,46 @@ void DownloadManager::managedGameChanged(MOBase::IPluginGame const* managedGame) m_ManagedGame = managedGame; } -void DownloadManager::checkDownloadTimeout() -{ - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - DownloadInfo* info = m_ActiveDownloads[i]; - if (info->m_State != STATE_DOWNLOADING || info->m_Reply == nullptr || - !info->m_Reply->isOpen()) { - continue; - } - - const bool stalled = - (info->m_StartTime.elapsed() - info->m_DownloadTimeLast) > (5 * 1000); - if (!stalled) { - continue; - } - - pauseDownload(i); - downloadFinished(i); - - // downloadFinished() can remove the download entry, so find it again. - const int index = indexByInfo(info); - if (index < 0) { - continue; - } - - if (info->m_Tries <= 0) { - emit showMessage(tr("Download stalled repeatedly and was paused. " - "Please retry manually.")); - continue; - } - - --info->m_Tries; - log::warn("download '{}' stalled, retrying ({} retries left)", info->m_FileName, - info->m_Tries); - resumeDownloadInt(index); - emit update(index); - } -} - -void DownloadManager::writeData(DownloadInfo* info) -{ +void DownloadManager::checkDownloadTimeout() +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + DownloadInfo* info = m_ActiveDownloads[i]; + if (info->m_State != STATE_DOWNLOADING || info->m_Reply == nullptr || + !info->m_Reply->isOpen()) { + continue; + } + + const bool stalled = + (info->m_StartTime.elapsed() - info->m_DownloadTimeLast) > (5 * 1000); + if (!stalled) { + continue; + } + + pauseDownload(i); + downloadFinished(i); + + // downloadFinished() can remove the download entry, so find it again. + const int index = indexByInfo(info); + if (index < 0) { + continue; + } + + if (info->m_Tries <= 0) { + emit showMessage(tr("Download stalled repeatedly and was paused. " + "Please retry manually.")); + continue; + } + + --info->m_Tries; + log::warn("download '{}' stalled, retrying ({} retries left)", info->m_FileName, + info->m_Tries); + resumeDownloadInt(index); + emit update(index); + } +} + +void DownloadManager::writeData(DownloadInfo* info) +{ if (info != nullptr) { qint64 ret = info->m_Output.write(info->m_Reply->readAll()); if (ret < info->m_Reply->size()) { diff --git a/src/src/filedialogmemory.cpp b/src/src/filedialogmemory.cpp index 3c4ea11..390843f 100644 --- a/src/src/filedialogmemory.cpp +++ b/src/src/filedialogmemory.cpp @@ -72,15 +72,6 @@ QString FileDialogMemory::getExistingDirectory(const QString& dirID, QWidget* pa } } -#ifndef _WIN32 - // On Flatpak, the native file dialog goes through the XDG Desktop Portal, - // which returns FUSE paths (/run/user/.../doc/...) that may not properly - // expose directory contents. Use the Qt built-in dialog to get real paths. - if (qEnvironmentVariableIsSet("FLATPAK_ID")) { - options |= QFileDialog::DontUseNativeDialog; - } -#endif - QString result = QFileDialog::getExistingDirectory(parent, caption, currentDir, options); diff --git a/src/src/fluorinepaths.cpp b/src/src/fluorinepaths.cpp index 98cb7aa..b6e6633 100644 --- a/src/src/fluorinepaths.cpp +++ b/src/src/fluorinepaths.cpp @@ -3,16 +3,18 @@ #include #include +#include +#include #include #include -static const QString OldRoot = - QDir::homePath() + "/.local/share/fluorine"; +static const QString OldFlatpakRoot = + QDir::homePath() + "/.var/app/com.fluorine.manager"; QString fluorineDataDir() { - return QDir::homePath() + "/.var/app/com.fluorine.manager"; + return QDir::homePath() + "/.local/share/fluorine"; } void fluorineMigrateDataDir() @@ -21,9 +23,42 @@ void fluorineMigrateDataDir() return; #endif - const QString oldRoot = OldRoot; + const QString oldRoot = OldFlatpakRoot; const QString newRoot = fluorineDataDir(); + // Always check for config.json migration, even if data was already moved. + // The Flatpak stored config.json under its sandboxed XDG_CONFIG_HOME + // (~/.var/app/com.fluorine.manager/config/), but outside Flatpak + // QStandardPaths returns ~/.config/. + { + const QString oldConfigJson = oldRoot + "/config/fluorine/config.json"; + QString configRoot = QStandardPaths::writableLocation( + QStandardPaths::ConfigLocation); + if (configRoot.isEmpty()) { + configRoot = QDir::homePath() + "/.config"; + } + const QString newConfigJson = + QDir(configRoot).filePath("fluorine/config.json"); + if (QFile::exists(oldConfigJson) && !QFile::exists(newConfigJson)) { + QDir().mkpath(QFileInfo(newConfigJson).dir().absolutePath()); + if (QFile::copy(oldConfigJson, newConfigJson)) { + fprintf(stderr, "[fluorine] Migrated config.json to %s\n", + qUtf8Printable(newConfigJson)); + // Update prefix_path if it references the old Flatpak root + if (auto cfg = FluorineConfig::load()) { + if (cfg->prefix_path.startsWith(oldRoot)) { + cfg->prefix_path.replace(oldRoot, newRoot); + cfg->save(); + fprintf(stderr, "[fluorine] updated config prefix_path\n"); + } + } + } else { + fprintf(stderr, "[fluorine] FAILED to copy config.json to %s\n", + qUtf8Printable(newConfigJson)); + } + } + } + // Already migrated or old path never existed if (QFile::exists(oldRoot + "/MOVED.txt")) { return; diff --git a/src/src/fluorinepaths.h b/src/src/fluorinepaths.h index ab7f119..2bdde33 100644 --- a/src/src/fluorinepaths.h +++ b/src/src/fluorinepaths.h @@ -3,11 +3,11 @@ #include -/// Returns the Fluorine data directory: ~/.var/app/com.fluorine.manager +/// Returns the Fluorine data directory: ~/.local/share/fluorine QString fluorineDataDir(); -/// One-time migration from ~/.local/share/fluorine/ back to -/// ~/.var/app/com.fluorine.manager/. Call before initLogging(). +/// One-time migration from ~/.var/app/com.fluorine.manager/ to +/// ~/.local/share/fluorine/. Call before initLogging(). void fluorineMigrateDataDir(); #endif // FLUORINEPATHS_H diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index 2c5454f..3ba5c69 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -1,6 +1,5 @@ #include "fuseconnector.h" -#include "fluorinepaths.h" #include "settings.h" #include "vfs/vfstree.h" @@ -49,53 +48,6 @@ namespace { namespace fs = std::filesystem; -bool isFlatpak() -{ - static const bool result = QFile::exists(QStringLiteral("/.flatpak-info")); - return result; -} - -bool waitForHelperLine(QProcess* proc, const char* expected, int timeoutMs) -{ - const QByteArray target(expected); - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); - - while (proc->state() == QProcess::Running) { - if (proc->canReadLine()) { - const QByteArray line = proc->readLine().trimmed(); - if (line == target) { - return true; - } - if (line.startsWith("error:")) { - log::error("VFS helper: {}", QString::fromUtf8(line)); - return false; - } - continue; - } - - const auto remaining = std::chrono::duration_cast( - deadline - std::chrono::steady_clock::now()) - .count(); - if (remaining <= 0) { - break; - } - proc->waitForReadyRead(static_cast(remaining)); - } - - return false; -} - -bool sendHelperCommand(QProcess* proc, const char* command, int timeoutMs) -{ - proc->write(command); - proc->write("\n"); - if (!proc->waitForBytesWritten(1000)) { - return false; - } - return waitForHelperLine(proc, "ok", timeoutMs); -} - std::string decodeProcMountField(const std::string& in) { std::string out; @@ -158,17 +110,6 @@ bool runUnmountCommand(const QString& program, const QStringList& args) return p.exitStatus() == QProcess::NormalExit && p.exitCode() == 0; }; - // In Flatpak: try sandbox-local unmount first (mount was likely created - // inside the sandbox), then fall back to host-side unmount. - if (isFlatpak()) { - if (tryRun(program, args)) { - return true; - } - QStringList spawnArgs = {QStringLiteral("--host"), program}; - spawnArgs.append(args); - return tryRun(QStringLiteral("flatpak-spawn"), spawnArgs); - } - return tryRun(program, args); } @@ -268,10 +209,6 @@ bool FuseConnector::mount( tryCleanupStaleMount(QString::fromStdString(m_mountPoint)); - if (isFlatpak()) { - return mountViaHelper(overwrite_dir, game_dir, data_dir_name, mods); - } - const fs::path overwritePath(m_overwriteDir); m_stagingDir = (overwritePath.parent_path() / "VFS_staging").string(); @@ -370,23 +307,6 @@ void FuseConnector::unmount() return; } - if (m_helperProcess) { - sendHelperCommand(m_helperProcess, "quit", 10000); - m_helperProcess->waitForFinished(5000); - if (m_helperProcess->state() != QProcess::NotRunning) { - m_helperProcess->kill(); - m_helperProcess->waitForFinished(2000); - } - delete m_helperProcess; - m_helperProcess = nullptr; - m_mounted = false; - setFuseMountPointForCrashCleanup(nullptr); - cleanupExternalMappings(); - log::debug("VFS helper stopped, FUSE unmounted from {}", - QString::fromStdString(m_mountPoint)); - return; - } - if (m_session != nullptr) { fuse_session_exit(m_session); fuse_session_unmount(m_session); @@ -435,16 +355,6 @@ void FuseConnector::rebuild( m_dataDirName = data_dir_name.toStdString(); m_lastMods = mods; - if (m_helperProcess) { - const QString dataDir = fluorineDataDir(); - const QString configPath = QDir(dataDir).filePath("vfs.cfg"); - writeVfsConfig(configPath, QString::fromStdString(m_mountPoint), - overwrite_dir, QString::fromStdString(m_gameDir), - data_dir_name, mods); - sendHelperCommand(m_helperProcess, "rebuild", 10000); - return; - } - if (m_context == nullptr) { return; } @@ -700,11 +610,6 @@ void FuseConnector::flushStagingLive() return; } - if (m_helperProcess) { - sendHelperCommand(m_helperProcess, "flush", 30000); - return; - } - if (m_context == nullptr) { return; } @@ -784,88 +689,3 @@ void FuseConnector::tryCleanupStaleMount(const QString& path) doUnmount(path); } -bool FuseConnector::mountViaHelper( - const QString& overwrite_dir, const QString& game_dir, - const QString& data_dir_name, - const std::vector>& mods) -{ - const QString dataDir = fluorineDataDir(); - const QString configPath = QDir(dataDir).filePath("vfs.cfg"); - const QString helperBin = - QDir(dataDir).filePath("bin/mo2-vfs-helper"); - - if (!QFile::exists(helperBin)) { - throw FuseConnectorException( - QObject::tr("VFS helper not found: %1").arg(helperBin)); - } - - writeVfsConfig(configPath, QString::fromStdString(m_mountPoint), overwrite_dir, - game_dir, data_dir_name, mods); - - m_helperProcess = new QProcess(this); - m_helperProcess->setProcessChannelMode(QProcess::SeparateChannels); - m_helperProcess->start(QStringLiteral("flatpak-spawn"), - {QStringLiteral("--host"), helperBin, configPath}); - - if (!m_helperProcess->waitForStarted(5000)) { - const QString err = QString::fromUtf8(m_helperProcess->readAllStandardError()); - delete m_helperProcess; - m_helperProcess = nullptr; - throw FuseConnectorException( - QObject::tr("Failed to start VFS helper process. %1").arg(err)); - } - - if (!waitForHelperLine(m_helperProcess, "mounted", 10000)) { - const QString err = QString::fromUtf8(m_helperProcess->readAllStandardError()); - const QString out = QString::fromUtf8(m_helperProcess->readAllStandardOutput()); - log::error("VFS helper stderr: {}", err); - log::error("VFS helper stdout: {}", out); - m_helperProcess->kill(); - m_helperProcess->waitForFinished(2000); - delete m_helperProcess; - m_helperProcess = nullptr; - throw FuseConnectorException( - QObject::tr("VFS helper failed to mount FUSE. %1").arg(err)); - } - - m_mounted = true; - setFuseMountPointForCrashCleanup(m_mountPoint.c_str()); - log::debug("FUSE mounted via helper on {}", - QString::fromStdString(m_mountPoint)); - return true; -} - -void FuseConnector::writeVfsConfig( - const QString& configPath, const QString& mount_point, - const QString& overwrite_dir, const QString& game_dir, - const QString& data_dir_name, - const std::vector>& mods) -{ - QDir().mkpath(QFileInfo(configPath).absolutePath()); - - QFile file(configPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - throw FuseConnectorException( - QObject::tr("Failed to write VFS config: %1").arg(configPath)); - } - - QTextStream out(&file); - out << "mount_point=" << mount_point << "\n"; - out << "game_dir=" << game_dir << "\n"; - out << "data_dir_name=" << data_dir_name << "\n"; - out << "overwrite_dir=" << overwrite_dir << "\n"; - - if (!m_customOutputDir.empty()) { - out << "output_dir=" << QString::fromStdString(m_customOutputDir) << "\n"; - } - - for (const auto& [name, path] : mods) { - out << "mod=" << QString::fromStdString(name) << "|" - << QString::fromStdString(path) << "\n"; - } - - for (const auto& [relPath, realPath] : m_extraVfsFiles) { - out << "extra_file=" << QString::fromStdString(relPath) << "|" - << QString::fromStdString(realPath) << "\n"; - } -} diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index cf99d64..9a34e55 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -7,8 +7,6 @@ #include #include -class QProcess; - #include #include #include @@ -88,15 +86,6 @@ private: struct fuse_session* m_session = nullptr; std::thread m_fuseThread; bool m_mounted = false; - - QProcess* m_helperProcess = nullptr; - bool mountViaHelper(const QString& overwrite_dir, const QString& game_dir, - const QString& data_dir_name, - const std::vector>& mods); - void writeVfsConfig(const QString& configPath, const QString& mount_point, - const QString& overwrite_dir, const QString& game_dir, - const QString& data_dir_name, - const std::vector>& mods); }; #endif diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp index 1269d26..7821761 100644 --- a/src/src/instancemanager.cpp +++ b/src/src/instancemanager.cpp @@ -55,21 +55,15 @@ Instance::Instance(QString dir, bool portable, QString profileName) if (m_portable && !m_dir.isEmpty()) { const QString launchPath = QDir(m_dir).filePath("ModOrganizer.sh"); if (!QFileInfo::exists(launchPath)) { - const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info")); - QFile launchFile(launchPath); + QFile launchFile(launchPath); if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(&launchFile); out << "#!/bin/bash\n"; out << "# Auto-generated by fluorine-manager\n"; out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; - if (flatpak) { - out << "exec flatpak run com.fluorine.manager --instance " - "\"${INSTANCE_DIR}\" \"$@\"\n"; - } else { - out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo " - "ModOrganizer)\"\n"; - out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; - } + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo " + "ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; launchFile.close(); QFile::setPermissions( launchPath, diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp index cd4b865..5cdaecf 100644 --- a/src/src/instancemanagerdialog.cpp +++ b/src/src/instancemanagerdialog.cpp @@ -8,6 +8,7 @@ #include "shared/appconfig.h" #include "shared/util.h" #include "ui_instancemanagerdialog.h" +#include #include #include #include @@ -214,6 +215,15 @@ InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* paren deleteInstance(); }); + connect(ui->steamDrmCheckBox, &QCheckBox::toggled, [&](bool checked) { + const auto* inst = singleSelection(); + if (!inst) return; + const QString ini = inst->iniPath(); + if (ini.isEmpty()) return; + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/steam_drm", checked); + }); + connect(ui->switchToInstance, &QPushButton::clicked, [&] { openSelectedInstance(); }); @@ -772,16 +782,23 @@ void InstanceManagerDialog::fillData(const Instance& ii) ui->gameName->setText(ii.gameName()); ui->gameDir->setText(ii.gameDirectory()); - // read prefix info from the instance's INI + // read prefix info and fluorine settings from the instance's INI { const QString ini = ii.iniPath(); if (!ini.isEmpty() && QFile::exists(ini)) { QSettings s(ini, QSettings::IniFormat); ui->prefixPath->setText(s.value("Settings/proton_prefix_path").toString()); ui->protonVersion->setText(s.value("fluorine/proton_name").toString()); + + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(s.value("fluorine/steam_drm", true).toBool()); + ui->steamDrmCheckBox->blockSignals(false); } else { ui->prefixPath->clear(); ui->protonVersion->clear(); + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(false); + ui->steamDrmCheckBox->blockSignals(false); } } @@ -822,6 +839,9 @@ void InstanceManagerDialog::clearData() ui->gameDir->clear(); ui->prefixPath->clear(); ui->protonVersion->clear(); + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(false); + ui->steamDrmCheckBox->blockSignals(false); setButtonsEnabled(false); @@ -845,18 +865,9 @@ void InstanceManagerDialog::setButtonsEnabled(bool b) void InstanceManagerDialog::openExistingPortable() { // On Flatpak, the native file dialog goes through the XDG Desktop Portal, - // which returns FUSE paths (/run/user/.../doc/...) that may not properly - // expose directory contents. Use the Qt built-in dialog to get real paths. - QFileDialog::Options opts; -#ifndef _WIN32 - if (qEnvironmentVariableIsSet("FLATPAK_ID")) { - opts |= QFileDialog::DontUseNativeDialog; - } -#endif - const QString dir = QFileDialog::getExistingDirectory( this, tr("Select portable instance folder"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation), opts); + QStandardPaths::writableLocation(QStandardPaths::HomeLocation)); if (dir.isEmpty()) { return; diff --git a/src/src/instancemanagerdialog.ui b/src/src/instancemanagerdialog.ui index 2b94a8d..2a1d136 100644 --- a/src/src/instancemanagerdialog.ui +++ b/src/src/instancemanagerdialog.ui @@ -314,6 +314,16 @@ + + + + Steam DRM integration + + + Enable Steam DRM bridge for this instance. Disable for GOG or DRM-free games to prevent crashes. + + + diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 425d20c..7aa6964 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -544,11 +544,12 @@ MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, QApplication::instance()->installEventFilter(this); - scheduleCheckForProblems(); - refreshExecutablesList(); - updatePinnedExecutables(); - resetActionIcons(); - processUpdates(); + scheduleCheckForProblems(); + refreshExecutablesList(); + updatePinnedExecutables(); + resetActionIcons(); + resetButtonIcons(); + processUpdates(); ui->modList->updateModCount(); ui->espList->updatePluginCount(); @@ -572,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 @@ -635,12 +636,38 @@ void MainWindow::resetActionIcons() } } - // update the button for the potentially new icon - updateProblemsButton(); -} - -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(); @@ -712,10 +739,11 @@ void MainWindow::allowListResize() ui->espList->header()->setStretchLastSection(true); } -void MainWindow::updateStyle(const QString&) -{ - resetActionIcons(); -} +void MainWindow::updateStyle(const QString&) +{ + resetActionIcons(); + resetButtonIcons(); +} void MainWindow::resizeEvent(QResizeEvent* event) { @@ -1885,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()) { @@ -1904,16 +1932,36 @@ void MainWindow::refreshExecutablesList() Qt::SizeHintRole); }; - ui->executablesListBox->setEnabled(false); - ui->executablesListBox->clear(); - - add(tr(""), {}); - - for (const auto& exe : *m_OrganizerCore.executablesList()) { - if (!exe.hide()) { - 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 diff --git a/src/src/mainwindow.h b/src/src/mainwindow.h index b931c35..3e22fd1 100644 --- a/src/src/mainwindow.h +++ b/src/src/mainwindow.h @@ -440,9 +440,10 @@ private slots: void toolBar_customContextMenuRequested(const QPoint& point); void removeFromToolbar(QAction* action); - void about(); - - void resetActionIcons(); + void about(); + + void resetActionIcons(); + void resetButtonIcons(); private slots: // ui slots // actions diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp index 29e21c4..fd60745 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,9 +51,10 @@ along with Mod Organizer. If not, see . #include #include #include -#include -#include -#include +#include +#include +#include +#include #ifdef _WIN32 // see addDllsToPath() below @@ -269,30 +270,43 @@ void MOApplication::firstTimeSetup(MOMultiProcess& multiProcess) Qt::QueuedConnection); } -int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) -{ - TimeThis tt("MOApplication setup()"); +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) { - return 1; - } + 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)) { - reportError(tr("Failed to create log folder.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } + 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())); @@ -322,8 +336,11 @@ int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) } // loading settings - m_settings.reset(new Settings(m_instance->iniPath(), true)); - 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()); @@ -358,20 +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)); - if (!m_core->bootstrap()) { - reportError(tr("Failed to set up data paths.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } + 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()); - m_plugins->loadPlugins(); - 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..."); diff --git a/src/src/nxmhandler_linux.cpp b/src/src/nxmhandler_linux.cpp index a93826d..1203585 100644 --- a/src/src/nxmhandler_linux.cpp +++ b/src/src/nxmhandler_linux.cpp @@ -174,8 +174,6 @@ QString NxmHandlerLinux::socketPath() void NxmHandlerLinux::registerHandler() const { - const bool flatpak = QFile::exists(QStringLiteral("/.flatpak-info")); - const QString home = QDir::homePath(); if (home.isEmpty()) { log::error("cannot register nxm handler: home path is empty"); @@ -190,37 +188,30 @@ void NxmHandlerLinux::registerHandler() const return; } - QString execLine; - - if (flatpak) { - // In Flatpak the desktop file is invoked on the HOST, so use flatpak run - execLine = "flatpak run com.fluorine.manager nxm-handle %u"; - } else { - // Non-Flatpak: create a wrapper script and point the desktop file at it - const QString localBin = ensureDir(home + "/.local/bin"); - if (localBin.isEmpty()) { - log::error("cannot register nxm handler: failed to create ~/.local/bin"); - return; - } + // Create a wrapper script and point the desktop file at it + const QString localBin = ensureDir(home + "/.local/bin"); + if (localBin.isEmpty()) { + log::error("cannot register nxm handler: failed to create ~/.local/bin"); + return; + } - const QString wrapperPath = localBin + "/mo2-nxm-handler"; - const QString executable = QCoreApplication::applicationFilePath(); - const QString wrapper = - QString("#!/bin/sh\nexec \"%1\" nxm-handle \"$@\"\n").arg(executable); + const QString wrapperPath = localBin + "/mo2-nxm-handler"; + const QString executable = QCoreApplication::applicationFilePath(); + const QString wrapper = + QString("#!/bin/sh\nexec \"%1\" nxm-handle \"$@\"\n").arg(executable); - if (!writeTextFile(wrapperPath, wrapper)) { - log::error("failed to write nxm wrapper script '{}'", wrapperPath); - return; - } + if (!writeTextFile(wrapperPath, wrapper)) { + log::error("failed to write nxm wrapper script '{}'", wrapperPath); + return; + } - QFile::setPermissions(wrapperPath, - QFileDevice::ReadOwner | QFileDevice::WriteOwner | - QFileDevice::ExeOwner | QFileDevice::ReadGroup | - QFileDevice::ExeGroup | QFileDevice::ReadOther | - QFileDevice::ExeOther); + QFile::setPermissions(wrapperPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); - execLine = "mo2-nxm-handler nxm-handle %u"; - } + const QString execLine = "mo2-nxm-handler nxm-handle %u"; const QString desktopPath = appsDir + "/mo2-nxm-handler.desktop"; const QString desktop = QString("[Desktop Entry]\n" @@ -240,21 +231,10 @@ void NxmHandlerLinux::registerHandler() const updateMimeAppsList(appsDir + "/mimeapps.list", "x-scheme-handler/nxm", "mo2-nxm-handler.desktop"); - if (flatpak) { - // update-desktop-database doesn't exist inside the sandbox; run it on the host - QProcess p; - p.start(QStringLiteral("flatpak-spawn"), - {QStringLiteral("--host"), QStringLiteral("update-desktop-database"), - appsDir}); - if (!p.waitForFinished(5000) || p.exitCode() != 0) { - log::warn("update-desktop-database on host exited with code {}", p.exitCode()); - } - } else { - const auto result = - QProcess::execute("update-desktop-database", QStringList() << appsDir); - if (result != 0) { - log::warn("update-desktop-database exited with code {}", result); - } + const auto result = + QProcess::execute("update-desktop-database", QStringList() << appsDir); + if (result != 0) { + log::warn("update-desktop-database exited with code {}", result); } } diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index e1bdda1..630f23b 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -2193,22 +2193,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); - - // 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()) { @@ -2218,12 +2219,17 @@ bool OrganizerCore::beforeRun( } } pluginsFile.close(); - - if (!plugins.isEmpty()) { - 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); @@ -2238,18 +2244,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::debug("INI deploy check: source='{}' exists={}, target='{}'", - sourceIni, QFileInfo::exists(sourceIni), targetIni); - 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, @@ -2261,15 +2267,15 @@ bool OrganizerCore::beforeRun( managedGame()->documentsDirectory().absolutePath()); } - if (m_CurrentProfile->localSavesEnabled()) { - const QString profileSavesDir = - QDir(m_CurrentProfile->absolutePath()).filePath("saves"); - log::debug("Resolved local save mapping: profile='{}', 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); } @@ -2315,29 +2321,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::debug("Syncing local save mapping: profile='{}', 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::debug("Sync profile INI '{}' <- '{}'", 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 98665fb..dad0117 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -4,11 +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 @@ -21,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); @@ -55,8 +56,14 @@ static void ensureBundledPluginsLinked(const QString& bundledDir, QFile::link(it.filePath(), target); } -} -#endif +} +#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! // @@ -636,22 +643,33 @@ 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); - - QStringList filepaths = proxy->pluginList( - m_PluginPath.isEmpty() - ? (AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) - : m_PluginPath); - for (const QString& filepath : filepaths) { - 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::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; + } + } { // dummy plugins // only initialize these, no processing otherwise @@ -835,44 +853,70 @@ void PluginContainer::startPluginsImpl(const std::vector& plugins) con } } -std::vector PluginContainer::loadProxied(const QString& filepath, - IPluginProxy* proxy) -{ - std::vector proxiedPlugins; - - try { - // 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); - - // We are going to group plugin by names and "fix" them later: - std::map> proxiedByNames; - - for (QObject* proxiedPlugin : matchingPlugins) { - if (proxiedPlugin != nullptr) { - - 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("plugin \"{}\" failed to load. If this plugin is for an older " - "version of MO " - "you have to update it or delete it if no update exists.", - filepath); - } - } - } +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), @@ -884,15 +928,43 @@ std::vector PluginContainer::loadProxied(const QString& filepath, m_Requirements.at(proxiedPlugin).setMaster(*it); } } - } - } - } catch (const std::exception& e) { - reportError( - QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); - } - - 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) { diff --git a/src/src/process_helper_main.cpp b/src/src/process_helper_main.cpp deleted file mode 100644 index 185111c..0000000 --- a/src/src/process_helper_main.cpp +++ /dev/null @@ -1,359 +0,0 @@ -// Standalone process helper for Flatpak game launching. -// Runs on the host via flatpak-spawn --host, keeping the flatpak-spawn -// proxy alive for MO2's PID polling while the game process tree is running. -// -// Protocol (stdin/stdout, line-oriented): -// Config phase: MO2 writes key=value lines terminated by a blank line -// program=, arg= (repeatable), env=KEY=VALUE (repeatable), -// workdir= -// Helper responds: "started " or "error " -// Runtime commands (MO2→helper): "kill" (SIGTERM child tree), "quit" -// Helper reports: "exited " when game process tree exits - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -// ── Helpers ── - -static void writeResponse(const std::string& msg) -{ - std::string line = msg + "\n"; - ::write(STDOUT_FILENO, line.data(), line.size()); -} - -static bool readLine(std::string& out, int timeoutMs) -{ - out.clear(); - - struct pollfd pfd{}; - pfd.fd = STDIN_FILENO; - pfd.events = POLLIN; - - while (true) { - int ret = ::poll(&pfd, 1, timeoutMs); - if (ret < 0) { - if (errno == EINTR) - continue; - return false; - } - if (ret == 0) { - // timeout - return false; - } - - // Try reading if data is available, even if HUP is also set - // (pipe can have buffered data when the writer closes). - if (!(pfd.revents & POLLIN)) { - // No data available — must be HUP or ERR only - return false; - } - - char ch = 0; - ssize_t n = ::read(STDIN_FILENO, &ch, 1); - if (n <= 0) { - return false; - } - if (ch == '\n') { - return true; - } - out.push_back(ch); - } -} - -// Collect all descendant PIDs of a given root by scanning /proc. -static std::unordered_set collectDescendants(pid_t root) -{ - std::unordered_set descendants; - - // Build parent→children map - struct ProcEntry { - pid_t pid; - pid_t ppid; - }; - std::vector entries; - - DIR* proc = opendir("/proc"); - if (!proc) { - return descendants; - } - - struct dirent* entry = nullptr; - while ((entry = readdir(proc)) != nullptr) { - if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { - continue; - } - - char* end = nullptr; - long pidLong = strtol(entry->d_name, &end, 10); - if (end == entry->d_name || *end != '\0' || pidLong <= 0) { - continue; - } - - pid_t pid = static_cast(pidLong); - - char statusPath[64]; - snprintf(statusPath, sizeof(statusPath), "/proc/%ld/status", pidLong); - - std::ifstream status(statusPath); - if (!status.is_open()) { - continue; - } - - std::string line; - pid_t ppid = 0; - while (std::getline(status, line)) { - if (line.rfind("PPid:", 0) == 0) { - ppid = static_cast(strtol(line.c_str() + 5, nullptr, 10)); - break; - } - } - - if (ppid > 0) { - entries.push_back({pid, ppid}); - } - } - closedir(proc); - - // BFS from root - std::vector queue; - queue.push_back(root); - - while (!queue.empty()) { - pid_t cur = queue.back(); - queue.pop_back(); - - for (const auto& e : entries) { - if (e.ppid == cur && descendants.find(e.pid) == descendants.end()) { - descendants.insert(e.pid); - queue.push_back(e.pid); - } - } - } - - return descendants; -} - -// Check if any process in the given set is still alive. -static bool anyAlive(const std::unordered_set& pids) -{ - for (pid_t p : pids) { - if (::kill(p, 0) == 0 || errno == EPERM) { - return true; - } - } - return false; -} - -// ── Main ── - -int main() -{ - // Make stdout line-buffered for reliable protocol messages. - setvbuf(stdout, nullptr, _IOLBF, 0); - - // ── Config phase: read key=value lines until blank line ── - std::string program; - std::vector args; - std::vector envVars; // "KEY=VALUE" - std::string workdir; - - while (true) { - std::string line; - if (!readLine(line, 30000)) { - writeResponse("error stdin closed or timeout during config"); - return 1; - } - - if (line.empty()) { - break; // blank line = end of config - } - - auto eq = line.find('='); - if (eq == std::string::npos) { - continue; - } - - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - - if (key == "program") { - program = val; - } else if (key == "arg") { - args.push_back(val); - } else if (key == "env") { - envVars.push_back(val); - } else if (key == "workdir") { - workdir = val; - } - } - - if (program.empty()) { - writeResponse("error no program specified"); - return 1; - } - - // ── Pipe for exec error reporting ── - // Child writes errno to this pipe if execvp fails; parent reads it. - int errPipe[2]; - if (::pipe2(errPipe, O_CLOEXEC) != 0) { - writeResponse("error pipe2 failed: " + std::string(strerror(errno))); - return 1; - } - - // ── Fork ── - pid_t child = ::fork(); - if (child < 0) { - writeResponse("error fork failed: " + std::string(strerror(errno))); - return 1; - } - - if (child == 0) { - // ── Child ── - ::close(errPipe[0]); // close read end - - // New session so we can kill the whole process group later. - ::setsid(); - - // Set environment variables. - for (const auto& ev : envVars) { - ::putenv(const_cast(ev.c_str())); - } - - // Change working directory. - if (!workdir.empty()) { - if (::chdir(workdir.c_str()) != 0) { - int err = errno; - (void)::write(errPipe[1], &err, sizeof(err)); - ::_exit(127); - } - } - - // Build argv for execvp. - std::vector argv; - argv.push_back(program.c_str()); - for (const auto& a : args) { - argv.push_back(a.c_str()); - } - argv.push_back(nullptr); - - ::execvp(program.c_str(), const_cast(argv.data())); - - // If we get here, exec failed. - int err = errno; - (void)::write(errPipe[1], &err, sizeof(err)); - ::_exit(127); - } - - // ── Parent ── - ::close(errPipe[1]); // close write end - - // Check if exec succeeded (pipe closes on successful exec due to O_CLOEXEC). - int execErr = 0; - ssize_t n = ::read(errPipe[0], &execErr, sizeof(execErr)); - ::close(errPipe[0]); - - if (n > 0) { - // exec failed in child - ::waitpid(child, nullptr, 0); - writeResponse("error exec failed: " + std::string(strerror(execErr))); - return 1; - } - - // Success - report PID. - writeResponse("started " + std::to_string(child)); - - // ── Monitor loop ── - // Wait for the direct child and then monitor descendants (handles Proton - // chain: proton → wine → game.exe). - bool childExited = false; - int childStatus = 0; - bool quit = false; - - while (!quit) { - // Check for commands on stdin. - std::string cmd; - // Non-blocking: if readLine returns false due to timeout, that's fine. - // If it returns false due to pipe close, MO2 crashed — kill child group. - struct pollfd pfd{}; - pfd.fd = STDIN_FILENO; - pfd.events = POLLIN; - - int pollRet = ::poll(&pfd, 1, 200); - - if (pollRet > 0) { - if (pfd.revents & (POLLHUP | POLLERR)) { - // MO2 crashed or closed pipe — kill child group and exit. - ::kill(-child, SIGTERM); - break; - } - - if (pfd.revents & POLLIN) { - if (readLine(cmd, 0)) { - if (cmd == "kill") { - ::kill(-child, SIGTERM); - } else if (cmd == "quit") { - quit = true; - break; - } - } else { - // read failed = pipe closed - ::kill(-child, SIGTERM); - break; - } - } - } - - // Check child status. - if (!childExited) { - int status = 0; - pid_t ret = ::waitpid(child, &status, WNOHANG); - if (ret == child) { - childExited = true; - childStatus = status; - } else if (ret < 0 && errno != EINTR) { - // Child somehow lost - childExited = true; - childStatus = 0; - } - } - - if (childExited) { - // Check for surviving descendants (e.g., game.exe still running - // after the proton wrapper exits). - auto desc = collectDescendants(child); - if (desc.empty() || !anyAlive(desc)) { - // All done. - int exitCode = 0; - if (WIFEXITED(childStatus)) { - exitCode = WEXITSTATUS(childStatus); - } else if (WIFSIGNALED(childStatus)) { - exitCode = 128 + WTERMSIG(childStatus); - } - writeResponse("exited " + std::to_string(exitCode)); - return 0; - } - // Descendants still alive, keep monitoring. - } - } - - // If we broke out of the loop, reap child if needed. - if (!childExited) { - ::waitpid(child, nullptr, 0); - } - - writeResponse("exited 0"); - return 0; -} diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index ac3c9eb..e7354f3 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -1175,7 +1175,6 @@ std::optional ProcessRunner::runBinary() #ifdef _WIN32 m_handle.reset(startBinary(parent, m_sp)); #else - m_sp.helperProcessOut = &m_processHelper; m_handle.reset(reinterpret_cast(static_cast(startBinary(parent, m_sp)))); #endif if (m_handle.get() == INVALID_HANDLE_VALUE) { @@ -1265,12 +1264,9 @@ ProcessRunner::Results ProcessRunner::postRun() const QFileInfo binary = m_sp.binary; QPointer core = &m_core; - QProcess* helper = m_processHelper; - m_processHelper = nullptr; - - std::thread([core, binary, pid, helper]() { - // For detached processes (including flatpak-spawn helper), - // waitpid will fail with ECHILD. Use kill(0) polling instead. + std::thread([core, binary, pid]() { + // For detached processes, waitpid will fail with ECHILD. + // Use kill(0) polling instead. int status = 0; pid_t waited = -1; do { @@ -1294,16 +1290,6 @@ ProcessRunner::Results ProcessRunner::postRun() errno); } - // Clean up helper QProcess on the main thread. - if (helper) { - QMetaObject::invokeMethod( - QCoreApplication::instance(), [helper]() { - helper->waitForFinished(1000); - delete helper; - }, - Qt::QueuedConnection); - } - if (!core) { return; } @@ -1352,14 +1338,6 @@ ProcessRunner::Results ProcessRunner::postRun() }); } -#ifndef _WIN32 - // Clean up the process helper (keeps flatpak-spawn alive during game). - if (m_processHelper) { - m_processHelper->waitForFinished(1000); - delete m_processHelper; - m_processHelper = nullptr; - } -#endif if (shouldRefresh(r)) { QEventLoop loop; diff --git a/src/src/processrunner.h b/src/src/processrunner.h index e825e98..2736c86 100644 --- a/src/src/processrunner.h +++ b/src/src/processrunner.h @@ -185,9 +185,6 @@ private: QFileInfo m_shellOpen; env::HandlePtr m_handle; DWORD m_exitCode; -#ifndef _WIN32 - QProcess* m_processHelper = nullptr; -#endif bool shouldRunShell() const; bool shouldRefresh(Results r) const; diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index a2aa042..f2bd7a0 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -1,5 +1,4 @@ #include "protonlauncher.h" -#include "fluorinepaths.h" #include #include @@ -102,43 +101,6 @@ void maybeWrapWithSteamRun(bool useSteamRun, QString& program, QStringList& argu arguments = wrappedArgs; } -bool isFlatpak() -{ - return QFileInfo::exists(QStringLiteral("/.flatpak-info")); -} - -// In Flatpak, Wine/Proton binaries can't execute inside the sandbox (they need -// the Steam Runtime's linker and 32-bit libs). Wrap them with flatpak-spawn -// --host so they run on the host system instead. -// -// flatpak-spawn --host runs via the Flatpak portal D-Bus interface, which does -// NOT reliably forward the caller's process environment. We must pass any -// custom env vars explicitly with --env= flags. -void maybeWrapForFlatpak(QString& program, QStringList& arguments, - const QProcessEnvironment& env) -{ - if (!isFlatpak()) { - return; - } - - QStringList wrappedArgs; - wrappedArgs.append(QStringLiteral("--host")); - - // Pass every env var that differs from the inherited system environment. - const QProcessEnvironment sysEnv = QProcessEnvironment::systemEnvironment(); - for (const QString& key : env.keys()) { - const QString val = env.value(key); - if (val != sysEnv.value(key)) { - wrappedArgs.append(QStringLiteral("--env=%1=%2").arg(key, val)); - } - } - - wrappedArgs.append(program); - wrappedArgs.append(arguments); - program = QStringLiteral("flatpak-spawn"); - arguments = wrappedArgs; -} - bool isValidEnvKey(const QString& key) { if (key.isEmpty()) { @@ -179,7 +141,7 @@ bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut ProtonLauncher::ProtonLauncher() : m_steamAppId(0), m_useUmu(false), m_preferSystemUmu(false), - m_useSteamRun(false) + m_useSteamRun(false), m_useSteamDrm(true) {} ProtonLauncher& ProtonLauncher::setBinary(const QString& path) @@ -258,6 +220,12 @@ ProtonLauncher& ProtonLauncher::setUseSteamRun(bool useSteamRun) return *this; } +ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm) +{ + m_useSteamDrm = useSteamDrm; + return *this; +} + ProtonLauncher& ProtonLauncher::addEnvVar(const QString& key, const QString& value) { if (!key.isEmpty()) { @@ -267,12 +235,6 @@ ProtonLauncher& ProtonLauncher::addEnvVar(const QString& key, const QString& val return *this; } -ProtonLauncher& ProtonLauncher::setHelperProcessOut(QProcess** out) -{ - m_helperProcessOut = out; - return *this; -} - std::pair ProtonLauncher::launch() const { qint64 pid = -1; @@ -297,7 +259,9 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const return false; } - ensureSteamRunning(); + if (m_useSteamDrm) { + ensureSteamRunning(); + } QString protonScript = m_protonPath; if (QFileInfo(protonScript).isDir()) { @@ -311,7 +275,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments); maybeWrapWithSteamRun(m_useSteamRun, program, arguments); - // Build environment BEFORE flatpak wrapping (flatpak-spawn needs --env= flags). QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); @@ -357,11 +320,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary); - if (isFlatpak()) { - return launchViaProcessHelper(program, arguments, env, m_workingDir, pid); - } - - maybeWrapForFlatpak(program, arguments, env); return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); } @@ -373,61 +331,51 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const // Steam must be running for games with Steamworks DRM (Application Load // Error 5:0000065434 occurs otherwise). - ensureSteamRunning(); + if (m_useSteamDrm) { + ensureSteamRunning(); + } // Resolve umu-run according to user preference (bundled vs system). - // In Flatpak, umu-run must run on the host (it needs Steam Runtime). - // Use the full path to our copied umu-run since the host PATH won't include it. + const QString appDir = QCoreApplication::applicationDirPath(); + const QString bundled = appDir + QStringLiteral("/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 + // bundled copy otherwise). + QString system; + const QStringList pathDirs = + QString::fromLocal8Bit(qgetenv("PATH")).split(QLatin1Char(':'), Qt::SkipEmptyParts); + for (const QString& dir : pathDirs) { + if (QDir(dir) == QDir(appDir)) + continue; + const QString candidate = QStandardPaths::findExecutable( + QStringLiteral("umu-run"), {dir}); + if (!candidate.isEmpty()) { + system = candidate; + break; + } + } + QString umuRun; - if (isFlatpak()) { - const QString flatpakUmu = fluorineDataDir() + QStringLiteral("/umu-run"); - if (QFileInfo::exists(flatpakUmu)) { - umuRun = flatpakUmu; - } else { - // Fall back to bare name (requires host to have umu-run in PATH) - umuRun = QStringLiteral("umu-run"); + 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 { - const QString appDir = QCoreApplication::applicationDirPath(); - const QString bundled = appDir + QStringLiteral("/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 - // bundled copy otherwise). - QString system; - const QStringList pathDirs = - QString::fromLocal8Bit(qgetenv("PATH")).split(QLatin1Char(':'), Qt::SkipEmptyParts); - for (const QString& dir : pathDirs) { - if (QDir(dir) == QDir(appDir)) - continue; - const QString candidate = QStandardPaths::findExecutable( - QStringLiteral("umu-run"), {dir}); - if (!candidate.isEmpty()) { - system = candidate; - break; - } + if (QFileInfo::exists(bundled)) { + umuRun = bundled; + } else if (!system.isEmpty()) { + umuRun = system; } - - 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; - } - } - - MOBase::log::info("umu-run: preferSystem={}, bundled='{}' (exists={}), system='{}', selected='{}'", - m_preferSystemUmu, bundled, QFileInfo::exists(bundled), system, umuRun); } + MOBase::log::info("umu-run: preferSystem={}, bundled='{}' (exists={}), system='{}', selected='{}'", + m_preferSystemUmu, bundled, QFileInfo::exists(bundled), system, umuRun); + if (umuRun.isEmpty()) { MOBase::log::warn("umu-run not found (bundled or in PATH)"); return false; @@ -440,7 +388,6 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const wrapProgram(m_wrapperCommands, umuRun, umuArgs, program, arguments); maybeWrapWithSteamRun(m_useSteamRun, program, arguments); - // Build environment BEFORE flatpak wrapping (flatpak-spawn needs --env= flags). QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); @@ -471,11 +418,15 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const } } - if (effectiveSteamAppId != 0) { + if (m_useSteamDrm && effectiveSteamAppId != 0) { // umu-run expects GAMEID in "umu-" format to extract SteamAppId. 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")); } for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { @@ -503,11 +454,6 @@ bool ProtonLauncher::launchWithUmu(qint64& pid) const QString::number(effectiveSteamAppId)), steamPath); - if (isFlatpak()) { - return launchViaProcessHelper(program, arguments, env, m_workingDir, pid); - } - - maybeWrapForFlatpak(program, arguments, env); return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); } @@ -531,130 +477,21 @@ bool ProtonLauncher::launchDirect(qint64& pid) const env.insert(it.key(), it.value()); } - if (isFlatpak()) { - return launchViaProcessHelper(program, arguments, env, m_workingDir, pid); - } - - maybeWrapForFlatpak(program, arguments, env); return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); } -bool ProtonLauncher::launchViaProcessHelper( - const QString& program, const QStringList& arguments, - const QProcessEnvironment& env, const QString& workingDir, - qint64& pid) const -{ - const QString helperBin = fluorineDataDir() + QStringLiteral("/bin/mo2-process-helper"); - if (!QFileInfo::exists(helperBin)) { - MOBase::log::warn("mo2-process-helper not found at '{}', falling back to " - "flatpak-spawn direct launch", helperBin); - // Fall back to old direct flatpak-spawn path. - QString prog = program; - QStringList args = arguments; - QProcessEnvironment envCopy = env; - maybeWrapForFlatpak(prog, args, envCopy); - return startDetachedWithEnv(prog, args, workingDir, envCopy, pid); - } - - auto* proc = new QProcess(); - proc->setProcessChannelMode(QProcess::SeparateChannels); - proc->start(QStringLiteral("flatpak-spawn"), - {QStringLiteral("--host"), helperBin}); - - if (!proc->waitForStarted(5000)) { - MOBase::log::error("Failed to start flatpak-spawn for process helper"); - delete proc; - return false; - } - - // Write config block to helper's stdin. - auto writeLine = [&](const QString& line) { - proc->write(line.toUtf8()); - proc->write("\n"); - }; - - writeLine(QStringLiteral("program=") + program); - for (const QString& arg : arguments) { - writeLine(QStringLiteral("arg=") + arg); - } - - // Send env vars that differ from system environment. - const QProcessEnvironment sysEnv = QProcessEnvironment::systemEnvironment(); - for (const QString& key : env.keys()) { - const QString val = env.value(key); - if (val != sysEnv.value(key)) { - writeLine(QStringLiteral("env=") + key + QStringLiteral("=") + val); - } - } - - if (!workingDir.isEmpty()) { - writeLine(QStringLiteral("workdir=") + workingDir); - } - - // Blank line terminates config. - proc->write("\n"); - proc->waitForBytesWritten(2000); - - // Read response: "started " or "error " - if (!proc->waitForReadyRead(10000)) { - MOBase::log::error("Process helper did not respond in time"); - proc->kill(); - proc->waitForFinished(2000); - delete proc; - return false; - } - - const QString response = QString::fromUtf8(proc->readLine()).trimmed(); - if (response.startsWith(QStringLiteral("started "))) { - MOBase::log::info("Process helper: {}", response); - } else { - MOBase::log::error("Process helper error: {}", response); - proc->kill(); - proc->waitForFinished(2000); - delete proc; - return false; - } - - // Use the flatpak-spawn PID for kill(pid,0) polling. - pid = proc->processId(); - - // Store the QProcess so it stays alive (keeping flatpak-spawn alive). - if (m_helperProcessOut) { - *m_helperProcessOut = proc; - } else { - // No owner provided — leak intentionally to keep the pipe alive. - // The process will clean up when the game exits. - MOBase::log::debug("No helper process owner set, helper will self-manage"); - } - - return true; -} - bool ProtonLauncher::ensureSteamRunning() { QProcess pgrep; - if (isFlatpak()) { - // In Flatpak, check for Steam on the host. - pgrep.start("flatpak-spawn", {"--host", "pgrep", "-x", "steam"}); - } else { - pgrep.start("pgrep", {"-x", "steam"}); - } + pgrep.start("pgrep", {"-x", "steam"}); if (pgrep.waitForFinished(2000) && pgrep.exitCode() == 0) { return true; } qint64 pid = -1; - if (isFlatpak()) { - if (QProcess::startDetached("flatpak-spawn", - {"--host", "steam", "-silent"}, QString(), &pid)) { - MOBase::log::warn("Steam was not running, started it on host in silent mode"); - return true; - } - } else { - if (QProcess::startDetached("steam", {"-silent"}, QString(), &pid)) { - MOBase::log::warn("Steam was not running, started it in silent mode"); - return true; - } + if (QProcess::startDetached("steam", {"-silent"}, QString(), &pid)) { + MOBase::log::warn("Steam was not running, started it in silent mode"); + return true; } return false; diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h index d0213f6..5480c69 100644 --- a/src/src/protonlauncher.h +++ b/src/src/protonlauncher.h @@ -8,8 +8,6 @@ #include #include -class QProcess; - class ProtonLauncher { public: @@ -25,8 +23,8 @@ public: ProtonLauncher& setUmu(bool useUmu); ProtonLauncher& setPreferSystemUmu(bool preferSystemUmu); ProtonLauncher& setUseSteamRun(bool useSteamRun); + ProtonLauncher& setSteamDrm(bool useSteamDrm); ProtonLauncher& addEnvVar(const QString& key, const QString& value); - ProtonLauncher& setHelperProcessOut(QProcess** out); // Launch dispatch: UMU -> Proton -> Direct std::pair launch() const; @@ -35,9 +33,6 @@ private: bool launchWithProton(qint64& pid) const; bool launchWithUmu(qint64& pid) const; bool launchDirect(qint64& pid) const; - bool launchViaProcessHelper(const QString& program, const QStringList& arguments, - const QProcessEnvironment& env, - const QString& workingDir, qint64& pid) const; static bool ensureSteamRunning(); QString m_binary; @@ -50,9 +45,9 @@ private: bool m_useUmu; bool m_preferSystemUmu; bool m_useSteamRun; + bool m_useSteamDrm; QMap m_envVars; QMap m_wrapperEnvVars; - QProcess** m_helperProcessOut = nullptr; }; #endif // PROTONLAUNCHER_H diff --git a/src/src/sanitychecks.cpp b/src/src/sanitychecks.cpp index d6b285e..df0d785 100644 --- a/src/src/sanitychecks.cpp +++ b/src/src/sanitychecks.cpp @@ -385,9 +385,8 @@ int checkMissingFiles() log::debug(" . checking Linux dependencies"); int n = 0; - // Check for FUSE (skip in Flatpak — the VFS helper runs on the host) - if (!QFileInfo::exists("/.flatpak-info") && - !QFileInfo::exists("/usr/lib/libfuse3.so") && + // Check for FUSE + if (!QFileInfo::exists("/usr/lib/libfuse3.so") && !QFileInfo::exists("/usr/lib64/libfuse3.so") && !QFileInfo::exists("/usr/lib/x86_64-linux-gnu/libfuse3.so")) { log::warn("libfuse3 not found - FUSE VFS will not work"); diff --git a/src/src/settingsdialogpaths.cpp b/src/src/settingsdialogpaths.cpp index dee5362..788ad60 100644 --- a/src/src/settingsdialogpaths.cpp +++ b/src/src/settingsdialogpaths.cpp @@ -4,18 +4,6 @@ #include #include -namespace { -QFileDialog::Options flatpakSafeOptions() -{ - QFileDialog::Options opts; -#ifndef _WIN32 - if (qEnvironmentVariableIsSet("FLATPAK_ID")) - opts |= QFileDialog::DontUseNativeDialog; -#endif - return opts; -} -} // namespace - PathsSettingsTab::PathsSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab(s, d), m_gameDir(settings().game().plugin()->gameDirectory()) { @@ -143,7 +131,7 @@ void PathsSettingsTab::on_browseBaseDirBtn_clicked() { QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text(), - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->baseDirEdit->setText(temp); } @@ -156,7 +144,7 @@ void PathsSettingsTab::on_browseDownloadDirBtn_clicked() QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select download directory"), searchPath, - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->downloadDirEdit->setText(temp); } @@ -169,7 +157,7 @@ void PathsSettingsTab::on_browseModDirBtn_clicked() QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select mod directory"), searchPath, - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->modDirEdit->setText(temp); } @@ -182,7 +170,7 @@ void PathsSettingsTab::on_browseCacheDirBtn_clicked() QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select cache directory"), searchPath, - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->cacheDirEdit->setText(temp); } @@ -195,7 +183,7 @@ void PathsSettingsTab::on_browseProfilesDirBtn_clicked() QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select profiles directory"), searchPath, - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->profilesDirEdit->setText(temp); } @@ -208,7 +196,7 @@ void PathsSettingsTab::on_browseOverwriteDirBtn_clicked() QString temp = QFileDialog::getExistingDirectory( &dialog(), QObject::tr("Select overwrite directory"), searchPath, - flatpakSafeOptions()); + {}); if (!temp.isEmpty()) { ui->overwriteDirEdit->setText(temp); } diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp index e9d1bec..f202f5f 100644 --- a/src/src/settingsdialogproton.cpp +++ b/src/src/settingsdialogproton.cpp @@ -313,14 +313,8 @@ void ProtonSettingsTab::onOpenPrefixFolder() void ProtonSettingsTab::onBrowsePrefixLocation() { - QFileDialog::Options opts; -#ifndef _WIN32 - if (qEnvironmentVariableIsSet("FLATPAK_ID")) - opts |= QFileDialog::DontUseNativeDialog; -#endif const QString dir = QFileDialog::getExistingDirectory( - parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text(), - opts); + parentWidget(), tr("Select Prefix Location"), ui->prefixLocationEdit->text()); if (!dir.isEmpty()) { ui->prefixLocationEdit->setText(dir); } @@ -425,41 +419,22 @@ void ProtonSettingsTab::onWinetricks() } } - // In Flatpak, wine/winetricks must run on the host via flatpak-spawn --host - // because Proton binaries need the host's linker and Steam Runtime libs. - const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info")); - - QString program; + QString program = winetricksPath; QStringList arguments; + arguments << QStringLiteral("--gui"); - if (flatpak) { - program = QStringLiteral("flatpak-spawn"); - arguments << QStringLiteral("--host"); - for (const QString& flag : envFlags) { - arguments << QStringLiteral("--env=%1").arg(flag); - } - arguments << winetricksPath << QStringLiteral("--gui"); - } else { - program = winetricksPath; - arguments << QStringLiteral("--gui"); - - // For non-Flatpak, set env vars directly on the process - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - for (const QString& flag : envFlags) { - const int eq = flag.indexOf('='); - if (eq > 0) { - env.insert(flag.left(eq), flag.mid(eq + 1)); - } + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + for (const QString& flag : envFlags) { + const int eq = flag.indexOf('='); + if (eq > 0) { + env.insert(flag.left(eq), flag.mid(eq + 1)); } - QProcess proc; - proc.setProcessEnvironment(env); - proc.setProgram(program); - proc.setArguments(arguments); - proc.startDetached(); - return; } - - QProcess::startDetached(program, arguments); + QProcess proc; + proc.setProcessEnvironment(env); + proc.setProgram(program); + proc.setArguments(arguments); + proc.startDetached(); } void ProtonSettingsTab::onFixGameRegistries() @@ -516,14 +491,9 @@ void ProtonSettingsTab::showGameRegistryDialog() layout->addLayout(pathLayout); QObject::connect(browseBtn, &QPushButton::clicked, &dialog, [&dialog, pathEdit]() { - QFileDialog::Options opts; -#ifndef _WIN32 - if (qEnvironmentVariableIsSet("FLATPAK_ID")) - opts |= QFileDialog::DontUseNativeDialog; -#endif const QString dir = QFileDialog::getExistingDirectory( &dialog, QObject::tr("Select Game Installation Folder"), - pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text(), opts); + pathEdit->text().isEmpty() ? QDir::homePath() : pathEdit->text()); if (!dir.isEmpty()) { pathEdit->setText(dir); } diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index c2f15eb..05e07bd 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -644,7 +644,7 @@ 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()) - .setHelperProcessOut(sp.helperProcessOut); + .setSteamDrm(QSettings().value("fluorine/steam_drm", true).toBool()); const QString prefixPath = resolvePrefixPath(); if (prefixPath.isEmpty()) { diff --git a/src/src/spawn.h b/src/src/spawn.h index 72c81be..d922ddc 100644 --- a/src/src/spawn.h +++ b/src/src/spawn.h @@ -63,7 +63,6 @@ struct SpawnParameters #else int stdOut = -1; int stdErr = -1; - QProcess** helperProcessOut = nullptr; #endif }; diff --git a/src/src/vfs/vfs_helper_main.cpp b/src/src/vfs/vfs_helper_main.cpp deleted file mode 100644 index d983574..0000000 --- a/src/src/vfs/vfs_helper_main.cpp +++ /dev/null @@ -1,337 +0,0 @@ -// Standalone VFS helper for Flatpak FUSE support. -// Runs on the host via flatpak-spawn --host, where FUSE works normally. -// Communicates with MO2 GUI via stdin/stdout pipes. - -#include "inodetable.h" -#include "mo2filesystem.h" -#include "overwritemanager.h" -#include "vfstree.h" - -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -struct HelperConfig -{ - std::string mount_point; - std::string game_dir; - std::string data_dir_name; - std::string overwrite_dir; - std::string output_dir; - std::vector> mods; - std::vector> extra_files; -}; - -static HelperConfig readConfig(const std::string& path) -{ - HelperConfig cfg; - std::ifstream in(path); - std::string line; - - while (std::getline(in, line)) { - if (line.empty() || line[0] == '#') { - continue; - } - - const auto eq = line.find('='); - if (eq == std::string::npos) { - continue; - } - - const std::string key = line.substr(0, eq); - const std::string val = line.substr(eq + 1); - - if (key == "mount_point") { - cfg.mount_point = val; - } else if (key == "game_dir") { - cfg.game_dir = val; - } else if (key == "data_dir_name") { - cfg.data_dir_name = val; - } else if (key == "overwrite_dir") { - cfg.overwrite_dir = val; - } else if (key == "output_dir") { - cfg.output_dir = val; - } else if (key == "mod") { - const auto pipe = val.find('|'); - if (pipe != std::string::npos) { - cfg.mods.emplace_back(val.substr(0, pipe), val.substr(pipe + 1)); - } - } else if (key == "extra_file") { - const auto pipe = val.find('|'); - if (pipe != std::string::npos) { - cfg.extra_files.emplace_back(val.substr(0, pipe), val.substr(pipe + 1)); - } - } - } - - return cfg; -} - -static void tryUnmountStale(const std::string& path) -{ - pid_t pid = fork(); - if (pid == 0) { - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { - dup2(devnull, STDERR_FILENO); - close(devnull); - } - execlp("fusermount3", "fusermount3", "-u", path.c_str(), nullptr); - _exit(1); - } - if (pid > 0) { - int status; - waitpid(pid, &status, 0); - } -} - -static void flushStaging(const std::string& stagingDir, - const std::string& overwriteDir, - const std::string& outputDir = {}) -{ - const fs::path staging(stagingDir); - const fs::path overwrite = outputDir.empty() - ? fs::path(overwriteDir) - : fs::path(outputDir); - if (!fs::exists(staging)) { - return; - } - - std::error_code ec; - for (auto it = fs::recursive_directory_iterator( - staging, fs::directory_options::skip_permission_denied); - it != fs::recursive_directory_iterator(); ++it) { - const auto& entry = *it; - const fs::path rel = fs::relative(entry.path(), staging, ec); - if (ec || rel.empty()) { - continue; - } - - const fs::path dest = overwrite / rel; - if (entry.is_directory(ec)) { - fs::create_directories(dest, ec); - continue; - } - - if (!entry.is_regular_file(ec)) { - continue; - } - - fs::create_directories(dest.parent_path(), ec); - fs::rename(entry.path(), dest, ec); - if (ec) { - ec.clear(); - fs::copy_file(entry.path(), dest, fs::copy_options::overwrite_existing, ec); - if (!ec) { - fs::remove(entry.path(), ec); - } - } - } - - fs::remove_all(staging, ec); -} - -static void setupFuseOps(struct fuse_lowlevel_ops* ops) -{ - std::memset(ops, 0, sizeof(struct fuse_lowlevel_ops)); - ops->lookup = mo2_lookup; - ops->getattr = mo2_getattr; - ops->readdir = mo2_readdir; - ops->open = mo2_open; - ops->read = mo2_read; - ops->write = mo2_write; - ops->create = mo2_create; - ops->rename = mo2_rename; - ops->setattr = mo2_setattr; - ops->unlink = mo2_unlink; - ops->mkdir = mo2_mkdir; - ops->release = mo2_release; -} - -static struct fuse_session* g_session = nullptr; - -static void signalHandler(int /*sig*/) -{ - if (g_session) { - fuse_session_exit(g_session); - } -} - -int main(int argc, char* argv[]) -{ - if (argc < 2) { - std::cerr << "Usage: mo2-vfs-helper \n"; - return 1; - } - - const std::string configPath = argv[1]; - auto config = readConfig(configPath); - - if (config.mount_point.empty()) { - std::cout << "error: mount_point not set in config" << std::endl; - return 1; - } - - const std::string dataDirPath = config.mount_point; - const std::string stagingDir = - (fs::path(config.overwrite_dir).parent_path() / "VFS_staging").string(); - - if (!fs::exists(dataDirPath)) { - std::cout << "error: data directory does not exist: " << dataDirPath - << std::endl; - return 1; - } - - std::error_code ec; - fs::create_directories(stagingDir, ec); - fs::create_directories(config.overwrite_dir, ec); - if (!config.output_dir.empty()) { - fs::create_directories(config.output_dir, ec); - } - - // Scan base game files BEFORE mounting (after mount they're hidden) - auto baseFileCache = scanDataDir(dataDirPath); - - // Open fd to data dir BEFORE mounting so we can access original files - int backingFd = open(dataDirPath.c_str(), O_RDONLY | O_DIRECTORY); - if (backingFd < 0) { - std::cout << "error: failed to open backing fd for " << dataDirPath - << std::endl; - return 1; - } - - // Clean up any stale FUSE mount - tryUnmountStale(dataDirPath); - - // Build VFS tree - auto tree = std::make_shared( - buildDataDirVfs(baseFileCache, dataDirPath, config.mods, - config.overwrite_dir)); - injectExtraFiles(*tree, config.extra_files); - - auto context = std::make_shared(); - context->tree = tree; - context->inodes = std::make_unique(); - context->overwrite = - std::make_unique(stagingDir, config.overwrite_dir); - context->backing_dir_fd = backingFd; - context->uid = ::getuid(); - context->gid = ::getgid(); - - // Setup FUSE - std::vector argvStorage = { - "mo2-vfs-helper", "-o", "fsname=mo2linux", "-o", "default_permissions", - "-o", "noatime"}; - - std::vector fuseArgv; - fuseArgv.reserve(argvStorage.size()); - for (auto& s : argvStorage) { - fuseArgv.push_back(s.data()); - } - - struct fuse_args args = - FUSE_ARGS_INIT(static_cast(fuseArgv.size()), fuseArgv.data()); - - struct fuse_lowlevel_ops ops; - setupFuseOps(&ops); - - struct fuse_session* session = - fuse_session_new(&args, &ops, sizeof(ops), context.get()); - if (session == nullptr) { - close(backingFd); - std::cout << "error: failed to create FUSE session" << std::endl; - return 1; - } - - if (fuse_session_mount(session, dataDirPath.c_str()) != 0) { - fuse_session_destroy(session); - close(backingFd); - std::cout << "error: failed to mount FUSE at " << dataDirPath << std::endl; - return 1; - } - - g_session = session; - - // Handle signals for clean shutdown - struct sigaction sa; - sa.sa_handler = signalHandler; - sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; - sigaction(SIGINT, &sa, nullptr); - sigaction(SIGTERM, &sa, nullptr); - - // Start FUSE event loop in background thread - std::thread fuseThread([session]() { - fuse_session_loop_mt(session, nullptr); - }); - - std::cout << "mounted" << std::endl; - - // Command loop: read commands from stdin - std::string line; - while (std::getline(std::cin, line)) { - if (line == "rebuild") { - auto newConfig = readConfig(configPath); - auto newTree = std::make_shared(buildDataDirVfs( - baseFileCache, dataDirPath, newConfig.mods, newConfig.overwrite_dir)); - injectExtraFiles(*newTree, newConfig.extra_files); - - { - std::unique_lock lock(context->tree_mutex); - context->tree.swap(newTree); - } - - config = newConfig; - std::cout << "ok" << std::endl; - } else if (line == "flush") { - flushStaging(stagingDir, config.overwrite_dir, config.output_dir); - fs::create_directories(stagingDir, ec); - - auto newTree = std::make_shared(buildDataDirVfs( - baseFileCache, dataDirPath, config.mods, config.overwrite_dir)); - injectExtraFiles(*newTree, config.extra_files); - - { - std::unique_lock lock(context->tree_mutex); - context->tree.swap(newTree); - } - - context->overwrite = - std::make_unique(stagingDir, config.overwrite_dir); - std::cout << "ok" << std::endl; - } else if (line == "quit") { - break; - } - } - - // Clean shutdown - fuse_session_exit(session); - fuse_session_unmount(session); - - if (fuseThread.joinable()) { - fuseThread.join(); - } - - fuse_session_destroy(session); - g_session = nullptr; - - flushStaging(stagingDir, config.overwrite_dir, config.output_dir); - close(backingFd); - - std::cout << "ok" << std::endl; - return 0; -} diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp index 8ff20e6..bb4e166 100644 --- a/src/src/wineprefix.cpp +++ b/src/src/wineprefix.cpp @@ -145,8 +145,8 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi } const QString pluginsDir = QDir(appdataLocal()).filePath(dataDir); - MOBase::log::debug("deployPlugins: target dir='{}', {} plugins to deploy", - pluginsDir, plugins.size()); + MOBase::log::info("deployPlugins: target dir='{}', count={}", pluginsDir, + plugins.size()); if (!QDir().mkpath(pluginsDir)) { MOBase::log::error("deployPlugins: failed to create directory '{}'", pluginsDir); @@ -179,8 +179,8 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi pluginsStream << plugin << "\r\n"; } pluginsFile.close(); - MOBase::log::debug("deployPlugins: wrote {} plugins to '{}'", plugins.size(), - pluginsPath); + MOBase::log::info("deployPlugins: wrote {} plugins to '{}'", plugins.size(), + pluginsPath); // Also write lowercase "plugins.txt" for games that expect it (e.g. FalloutNV). const QString pluginsLower = QDir(pluginsDir).filePath("plugins.txt"); @@ -208,7 +208,7 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi loadOrderStream << line << "\r\n"; } - MOBase::log::debug("deployPlugins: wrote loadorder.txt to '{}'", loadOrderPath); + MOBase::log::info("deployPlugins: wrote loadorder.txt to '{}'", loadOrderPath); return true; } -- cgit v1.3.1