aboutsummaryrefslogtreecommitdiff
path: root/docker
diff options
context:
space:
mode:
Diffstat (limited to 'docker')
-rw-r--r--docker/AppRun.sh125
-rw-r--r--docker/Dockerfile98
-rwxr-xr-xdocker/build-inner.sh296
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})"