diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /docker | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docker')
| -rw-r--r-- | docker/AppRun.sh | 125 | ||||
| -rw-r--r-- | docker/Dockerfile | 98 | ||||
| -rwxr-xr-x | docker/build-inner.sh | 296 |
3 files changed, 519 insertions, 0 deletions
diff --git a/docker/AppRun.sh b/docker/AppRun.sh new file mode 100644 index 0000000..f0ae807 --- /dev/null +++ b/docker/AppRun.sh @@ -0,0 +1,125 @@ +#!/bin/bash +HERE="$(dirname "$(readlink -f "$0")")" +APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")" + +cleanup_stale_fuse_mounts() { + local root="$1" + [ -n "${root}" ] || return 0 + [ -r /proc/mounts ] || return 0 + + local src mp fstype rest mount_path stat_out + while IFS=' ' read -r src mp fstype rest; do + # Decode escaped paths from /proc/mounts. + mount_path="${mp//\\040/ }" + mount_path="${mount_path//\\011/$'\t'}" + mount_path="${mount_path//\\012/$'\n'}" + + case "${mount_path}" in + "${root}"/*) ;; + *) continue ;; + esac + + case "${fstype}" in + fuse*|fuse.*) ;; + *) continue ;; + esac + + stat_out="$(LC_ALL=C stat "${mount_path}" 2>&1 || true)" + if printf "%s" "${stat_out}" | grep -Eq "Transport endpoint is not connected|Stale file handle|Input/output error"; then + echo "[Fluorine] Recovering stale FUSE mount: ${mount_path}" + fusermount3 -uz "${mount_path}" >/dev/null 2>&1 || \ + fusermount -uz "${mount_path}" >/dev/null 2>&1 || \ + umount -l "${mount_path}" >/dev/null 2>&1 || true + fi + done < /proc/mounts +} + +cleanup_stale_fuse_mounts "${APPIMAGE_DIR}" + +sync_dir_overwrite_files() { + local name="$1" + local src="${HERE}/usr/share/fluorine/${name}" + local dst="${APPIMAGE_DIR}/${name}" + + [ -d "${src}" ] || return 0 + + if [ ! -d "${dst}" ]; then + echo "[Fluorine] First run: extracting ${name}/ next to AppImage..." + else + echo "[Fluorine] Updating ${name}/ bundled files..." + fi + mkdir -p "${dst}" + # Merge bundled payload while skipping VCS metadata (.git, etc.). + ( + cd "${src}" + tar --exclude-vcs -cf - . + ) | ( + cd "${dst}" + tar -xf - + ) +} + +# ── Extract/sync writable dirs ── +sync_dir_overwrite_files "plugins" +sync_dir_overwrite_files "dlls" +sync_dir_overwrite_files "python" + +# Some existing Windows portable setups include plugins/plugin_python with +# .pyd/.dll payload. On Linux, the proxy will prefer this folder if present, +# which can hide the correct Linux mobase module. Overlay Linux runtime files +# into that folder to keep compatibility without deleting user content. +if [ -d "${APPIMAGE_DIR}/plugins/plugin_python" ]; then + if find "${APPIMAGE_DIR}/plugins/plugin_python" -type f \( -name '*.pyd' -o -name 'python*.dll' \) | grep -q .; then + echo "[Fluorine] Detected Windows plugin_python payload, overlaying Linux runtime files..." + mkdir -p "${APPIMAGE_DIR}/plugins/plugin_python/libs" "${APPIMAGE_DIR}/plugins/plugin_python/dlls" + [ -d "${APPIMAGE_DIR}/plugins/libs" ] && cp -a "${APPIMAGE_DIR}/plugins/libs/." "${APPIMAGE_DIR}/plugins/plugin_python/libs/" + [ -d "${APPIMAGE_DIR}/plugins/dlls" ] && cp -a "${APPIMAGE_DIR}/plugins/dlls/." "${APPIMAGE_DIR}/plugins/plugin_python/dlls/" + PYTHON_ROOT_CANDIDATE="${HERE}/usr/share/fluorine/python" + [ -d "${PYTHON_ROOT_CANDIDATE}/lib" ] || PYTHON_ROOT_CANDIDATE="${APPIMAGE_DIR}/python" + if [ -d "${PYTHON_ROOT_CANDIDATE}/lib" ]; then + PYVER_DIR="$(find "${PYTHON_ROOT_CANDIDATE}/lib" -mindepth 1 -maxdepth 1 -type d -name 'python3.*' | head -n 1)" + if [ -n "${PYVER_DIR}" ]; then + SITE_DIR="${PYVER_DIR}/site-packages" + [ -d "${SITE_DIR}/PyQt6" ] && cp -a "${SITE_DIR}/PyQt6" "${APPIMAGE_DIR}/plugins/plugin_python/libs/" + [ -d "${SITE_DIR}/PyQt6_sip" ] && cp -a "${SITE_DIR}/PyQt6_sip" "${APPIMAGE_DIR}/plugins/plugin_python/libs/" + [ -d "${SITE_DIR}/sip" ] && cp -a "${SITE_DIR}/sip" "${APPIMAGE_DIR}/plugins/plugin_python/libs/" + fi + fi + fi +fi + +# ── Environment ── +export PATH="${HERE}/usr/bin:${HERE}/usr/libexec:${PATH}" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/libexec:${LD_LIBRARY_PATH:-}" + +# Qt plugins (read-only, inside AppImage) +export QT_PLUGIN_PATH="${HERE}/usr/plugins" +export QT_QPA_PLATFORM_PLUGIN_PATH="${HERE}/usr/plugins/platforms" +if [ -x "${HERE}/usr/libexec/QtWebEngineProcess" ]; then + export QTWEBENGINEPROCESS_PATH="${HERE}/usr/libexec/QtWebEngineProcess" +fi +if [ -d "${HERE}/usr/resources" ]; then + export QTWEBENGINE_RESOURCES_PATH="${HERE}/usr/resources" +fi +if [ -d "${HERE}/usr/translations/qtwebengine_locales" ]; then + export QTWEBENGINE_LOCALES_PATH="${HERE}/usr/translations/qtwebengine_locales" +fi + +# Tell the app to use the writable dirs next to the AppImage. +# MO2_BASE_DIR overrides qApp->applicationDirPath() for plugin/dll discovery. +export MO2_BASE_DIR="${APPIMAGE_DIR}" +export MO2_PLUGINS_DIR="${APPIMAGE_DIR}/plugins" +export MO2_DLLS_DIR="${APPIMAGE_DIR}/dlls" +MO2_PYTHON_BUNDLED="${HERE}/usr/share/fluorine/python" +if [ -d "${MO2_PYTHON_BUNDLED}/lib" ]; then + export MO2_PYTHON_DIR="${MO2_PYTHON_BUNDLED}" +else + export MO2_PYTHON_DIR="${APPIMAGE_DIR}/python" +fi +# Do not export PYTHONHOME/PYTHONPATH globally here. MO2 sets Python runtime +# internally for plugin_python, while child processes (umu/NaK/launchers) must +# use their own system Python environment. +unset PYTHONHOME PYTHONPATH PYTHONNOUSERSITE + +cd "${APPIMAGE_DIR}" +exec "${HERE}/usr/bin/ModOrganizer.bin" "$@" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..fc62dc8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,98 @@ +FROM ubuntu:25.10 + +ENV DEBIAN_FRONTEND=noninteractive +ENV RUSTUP_HOME=/opt/rust/rustup +ENV CARGO_HOME=/opt/rust/cargo +ENV PATH="/opt/rust/cargo/bin:${PATH}" + +# ── System build tools + all library dependencies ── +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Build essentials + build-essential cmake ninja-build ccache \ + git curl wget file binutils patchelf pkg-config ca-certificates \ + # Library dependencies + libboost-all-dev \ + libsqlite3-dev \ + libtinyxml2-dev \ + libfontconfig1-dev \ + libspdlog-dev \ + libfuse3-dev fuse3 \ + liblz4-dev zlib1g-dev libzstd-dev libbz2-dev liblzma-dev \ + libssl-dev libcurl4-openssl-dev \ + libtomlplusplus-dev \ + # Qt 6 + qt6-base-dev qt6-base-dev-tools \ + qt6-webengine-dev \ + libqt6websockets6-dev \ + qt6-wayland \ + # Python + python3 python3-dev python3-pip python3-venv \ + pybind11-dev \ + python3-pyqt6 \ + # Misc + icoutils zip unzip \ + && rm -rf /var/lib/apt/lists/* + +# ── Python SIP tooling + runtime deps ── +RUN pip3 install --break-system-packages sip psutil vdf || true + +# ── Rust toolchain ── +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --default-toolchain stable --profile minimal + +# ── Portable Python runtime (for bundling, not compilation) ── +ARG PORTABLE_PYTHON_VERSION=3.13.9 +RUN curl -L --retry 3 -o /tmp/portable-python.zip \ + "https://github.com/bjia56/portable-python/releases/download/cpython-v${PORTABLE_PYTHON_VERSION}-build.0/python-headless-${PORTABLE_PYTHON_VERSION}-linux-x86_64.zip" && \ + mkdir -p /tmp/pp-extract && \ + unzip -q /tmp/portable-python.zip -d /tmp/pp-extract && \ + mv /tmp/pp-extract/python-headless-*/ /opt/portable-python && \ + rm -rf /tmp/portable-python.zip /tmp/pp-extract + +# ── Build and install libloot ── +ARG LIBLOOT_REF=master +RUN git clone --depth 1 --branch ${LIBLOOT_REF} \ + https://github.com/loot/libloot.git /tmp/libloot && \ + cmake -S /tmp/libloot/cpp -B /tmp/libloot/build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=ON \ + -DLIBLOOT_INSTALL_DOCS=OFF \ + -DBUILD_TESTING=OFF \ + -DCMAKE_INSTALL_PREFIX=/usr/local && \ + cmake --build /tmp/libloot/build --parallel && \ + cmake --install /tmp/libloot/build && \ + # Create pkg-config metadata + mkdir -p /usr/local/lib/pkgconfig && \ + printf '%s\n' \ + 'prefix=/usr/local' \ + '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}' \ + > /usr/local/lib/pkgconfig/libloot.pc && \ + ldconfig && \ + rm -rf /tmp/libloot + +# ── Pre-download linuxdeploy tooling ── +RUN mkdir -p /opt/linuxdeploy && \ + curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage && \ + curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage && \ + chmod +x /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ + /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage && \ + # Extract Qt plugin (avoids FUSE requirement at runtime) + cd /opt/linuxdeploy && \ + ./linuxdeploy-plugin-qt-x86_64.AppImage --appimage-extract >/dev/null && \ + mv squashfs-root linuxdeploy-plugin-qt.AppDir && \ + chmod +x linuxdeploy-plugin-qt.AppDir/AppRun && \ + ln -sf /opt/linuxdeploy/linuxdeploy-plugin-qt.AppDir/AppRun \ + /opt/linuxdeploy/linuxdeploy-plugin-qt && \ + chmod -x /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage + +WORKDIR /build diff --git a/docker/build-inner.sh b/docker/build-inner.sh new file mode 100755 index 0000000..efbb3d7 --- /dev/null +++ b/docker/build-inner.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── Build ── +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DPython3_EXECUTABLE="$(command -v python3)" \ + -DBUILD_PLUGIN_PYTHON=ON + +cmake --build build --parallel + +MODORG_BIN="build/src/src/ModOrganizer" +if [ ! -f "${MODORG_BIN}" ]; then + echo "ERROR: ModOrganizer binary not found at ${MODORG_BIN}" + exit 1 +fi +RUNDIR="build/src/src" + +PY_MM="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + +# ── Output layout ── +OUT_DIR="/src/build/ModOrganizer-portable" +ZIP_OUT="/src/build/ModOrganizer-linux-x86_64.zip" +rm -rf "${OUT_DIR}" "${ZIP_OUT}" +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. + UMU_PATCH_DIR="$(mktemp -d)" + (cd "${UMU_PATCH_DIR}" && python3 << PATCHEOF +import zipfile, pathlib +zf = zipfile.ZipFile('/src/${RUNDIR}/umu-run') +zf.extractall('src') +run_py = pathlib.Path('src/umu/umu_run.py') +src = run_py.read_text() + +# Patch 1: preserve STEAM_COMPAT_CLIENT_INSTALL_PATH +old1 = ' env["STEAM_COMPAT_INSTALL_PATH"] = os.environ.get("STEAM_COMPAT_INSTALL_PATH", "")' +new1 = (old1 + + '\n env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ.get(' + + '\n "STEAM_COMPAT_CLIENT_INSTALL_PATH", ""' + + '\n )') +if old1 in src and 'STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ' not in src: + src = src.replace(old1, new1) + +# 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 +) + python3 -m zipapp "${UMU_PATCH_DIR}/src" -o "${OUT_DIR}/umu-run" -p '/usr/bin/env python3' + chmod +x "${OUT_DIR}/umu-run" + rm -rf "${UMU_PATCH_DIR}" +fi +[ -f "${RUNDIR}/README-PORTABLE.txt" ] && cp -f "${RUNDIR}/README-PORTABLE.txt" "${OUT_DIR}/" +[ -f "/src/src/fluorine-manager" ] && cp -f "/src/src/fluorine-manager" "${OUT_DIR}/" + +# lootcli (spawned by MO2 for load-order sorting). +LOOTCLI="build/libs/lootcli/src/lootcli" +[ -f "${LOOTCLI}" ] && cp -f "${LOOTCLI}" "${OUT_DIR}/" + +for tool in wrestool icotool; do + command -v "${tool}" >/dev/null 2>&1 && cp -f "$(command -v "${tool}")" "${OUT_DIR}/" +done + +# ── MO2 plugins (.so) ── +find build/libs -type f \( \ + -name "libgame_*.so" -o \ + -name "libinstaller_*.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 {} "${OUT_DIR}/plugins/" \; + +# Python plugin payload. +for f in libplugin_python.so lzokay.py winreg.py pyCfg.py \ + DDSPreview.py Form43Checker.py ScriptExtenderPluginChecker.py; do + [ -f "build/src/src/plugins/${f}" ] && cp -f "build/src/src/plugins/${f}" "${OUT_DIR}/plugins/" +done +for d in basic_games data libs dlls; do + [ -d "build/src/src/plugins/${d}" ] && cp -a "build/src/src/plugins/${d}" "${OUT_DIR}/plugins/" +done +rm -f "${OUT_DIR}/plugins/FNIS"*.py + +# ── 7z runtime ── +SO7="build/src/src/dlls/7z.so" +if [ -f "${SO7}" ]; then + cp -f "${SO7}" "${OUT_DIR}/dlls/7z.so" + cp -f "${SO7}" "${OUT_DIR}/dlls/7zip.dll" +fi + +# ── Project-specific shared libraries ── +cp -f build/libs/uibase/src/libuibase.so "${OUT_DIR}/lib/" +cp -f build/libs/libbsarch/liblibbsarch.so "${OUT_DIR}/lib/" +cp -f build/libs/archive/src/libarchive.so "${OUT_DIR}/lib/" +cp -f build/libs/plugin_python/src/runner/librunner.so "${OUT_DIR}/lib/" +for ffi in libs/bsa_ffi/target/release/libbsa_ffi.so \ + libs/nak_ffi/target/release/libnak_ffi.so; do + [ -f "${ffi}" ] && cp -f "${ffi}" "${OUT_DIR}/lib/" +done + +# Boost (version-pinned to container, won't exist on most user systems). +for boost_lib in /lib/x86_64-linux-gnu/libboost_program_options.so* \ + /lib/x86_64-linux-gnu/libboost_thread.so*; do + [ -f "${boost_lib}" ] && cp -Lf "${boost_lib}" "${OUT_DIR}/lib/" +done + +# 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/" + # Create the unversioned symlink too. + ln -sf libloot.so.0 "${OUT_DIR}/lib/libloot.so" +fi + +# ── Portable Python runtime ── +PORTABLE_PY="/opt/portable-python" +if [ -d "${PORTABLE_PY}" ]; then + echo "Bundling portable Python from ${PORTABLE_PY}..." + cp -a "${PORTABLE_PY}" "${OUT_DIR}/python" + + # Trim unnecessary files from portable Python. + PP_STDLIB="${OUT_DIR}/python/lib/python${PY_MM}" + rm -rf "${PP_STDLIB}/test" \ + "${PP_STDLIB}/unittest/test" \ + "${PP_STDLIB}/idlelib" \ + "${PP_STDLIB}/tkinter" \ + "${PP_STDLIB}/turtledemo" \ + "${PP_STDLIB}/__pycache__" \ + "${OUT_DIR}/python/include" \ + "${OUT_DIR}/python/share" \ + 2>/dev/null || true + find "${OUT_DIR}/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find "${OUT_DIR}/python" -name "*.pyc" -delete 2>/dev/null || true + + # Ensure versioned soname symlink exists (pybind11 links against libpython3.13.so.1.0). + if [ -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so" ] && \ + [ ! -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" ]; then + ln -sf "libpython${PY_MM}.so" "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" + fi +else + echo "ERROR: Portable Python not found at ${PORTABLE_PY}" + exit 1 +fi + +# Bundle PyQt6 from system into portable Python's site-packages. +PYSITE="${OUT_DIR}/python/lib/python${PY_MM}/site-packages" +mkdir -p "${PYSITE}" +for search_dir in /usr/lib/python3/dist-packages \ + "/usr/lib/python${PY_MM}/dist-packages" \ + "/usr/local/lib/python${PY_MM}/dist-packages"; do + if [ -d "${search_dir}/PyQt6" ]; then + echo "Bundling PyQt6 from ${search_dir}..." + cp -a "${search_dir}/PyQt6" "${PYSITE}/" + [ -d "${search_dir}/PyQt6_sip" ] && cp -a "${search_dir}/PyQt6_sip" "${PYSITE}/" + [ -d "${search_dir}/sip" ] && cp -a "${search_dir}/sip" "${PYSITE}/" + break + fi +done + +# Bundle pip-installed Python packages (psutil etc.). +for search_dir in "/usr/local/lib/python${PY_MM}/dist-packages" \ + /usr/lib/python3/dist-packages \ + "/usr/lib/python${PY_MM}/dist-packages"; do + for pkg in psutil vdf; do + [ -d "${search_dir}/${pkg}" ] && [ ! -d "${PYSITE}/${pkg}" ] && \ + cp -a "${search_dir}/${pkg}" "${PYSITE}/" + done +done + +# Build-tree Python plugin payload. +[ -d build/src/src/python ] && cp -a build/src/src/python/. "${OUT_DIR}/python/" + +# ── Strip all MO2 binaries (not portable Python) ── +echo "Stripping MO2 binaries..." +strip --strip-unneeded "${OUT_DIR}/ModOrganizer-core" 2>/dev/null || true +find "${OUT_DIR}/plugins" -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true +find "${OUT_DIR}/dlls" -name "*.so" -o -name "*.dll" | xargs -r strip --strip-unneeded 2>/dev/null || true +find "${OUT_DIR}/lib" -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true +for tool in wrestool icotool lootcli; do + [ -f "${OUT_DIR}/${tool}" ] && strip --strip-unneeded "${OUT_DIR}/${tool}" 2>/dev/null || true +done + +# ── Validate embedded Python runtime ── +cat > /tmp/mo2_embed_py_check.c <<'C' +#include <Python.h> +int main(void) { + Py_Initialize(); + int rc = PyRun_SimpleString( + "import zlib\n" + "import runpy\n" + "import zipimport\n" + "print('python embed check ok')\n"); + if (PyErr_Occurred()) { + PyErr_Print(); + } + Py_Finalize(); + return rc; +} +C +gcc /tmp/mo2_embed_py_check.c -o /tmp/mo2_embed_py_check $(python3-config --embed --cflags --ldflags) +if ! PYTHONHOME="${OUT_DIR}/python" \ + PYTHONPATH="${OUT_DIR}/python/lib/python${PY_MM}:${PYSITE}" \ + LD_LIBRARY_PATH="${OUT_DIR}/lib:${OUT_DIR}/python/lib:${LD_LIBRARY_PATH:-}" \ + /tmp/mo2_embed_py_check; then + echo "ERROR: Embedded Python runtime check failed." + exit 1 +fi + +# ── Launcher script ── +cat > "${OUT_DIR}/ModOrganizer" <<'LAUNCH' +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +export PATH="${HERE}:${PATH}" +export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/python/lib:${LD_LIBRARY_PATH:-}" +export MO2_BASE_DIR="${HERE}" +export MO2_PLUGINS_DIR="${HERE}/plugins" +export MO2_DLLS_DIR="${HERE}/dlls" +export MO2_PYTHON_DIR="${HERE}/python" +# PYTHONHOME is set only for the MO2 process (not exported to children like +# umu-run/Proton which have their own Python). MO2_PYTHON_DIR lets the +# binary reconstruct it internally. +MO2_PYTHONHOME="${HERE}/python" +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 + +cd "${HERE}" +exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@" +LAUNCH +chmod +x "${OUT_DIR}/ModOrganizer" + +# ── ZIP ── +( + cd "${OUT_DIR}" + zip -r -9 "${ZIP_OUT}" . +) + +# ── Summary ── +echo "" +echo "=== Package Summary ===" +du -sh "${OUT_DIR}"/*/ "${OUT_DIR}"/ModOrganizer-core 2>/dev/null | sort -rh +echo "" +ZIP_SIZE="$(du -sh "${ZIP_OUT}" | cut -f1)" +echo "Done! Portable ZIP at: build/ModOrganizer-linux-x86_64.zip (${ZIP_SIZE})" |
