diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-20 23:53:26 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-20 23:53:26 -0600 |
| commit | d22d8c267b012729b6b84142def2a30ca4691d7a (patch) | |
| tree | 4d04382b6c9a44fa72ade906e0bf51e8f97855a8 /docker | |
| parent | 5467e210668eb24de9a4429ef0ddcbc6442e8932 (diff) | |
Fix AppImage plugin loading, save timestamps, and INI corruption
- Add AppConfig::pluginsPath()/dllsPath() that respect MO2_PLUGINS_DIR
and MO2_DLLS_DIR env vars, fixing plugin discovery when plugins live
inside read-only AppImage squashfs (#22, game plugin not found errors)
- Preserve file modification times in save deploy/sync so games display
correct save timestamps instead of all showing the same time (#17)
- Replace QSettings INI reader in saves tab with direct line-by-line
parser to avoid backslash-as-line-continuation corruption of
sLocalSavePath=__MO_Saves\ (#22, saves tab showing wrong directory)
- Add diagnostic logging to plugin symlink creation, save backup/restore
operations to help diagnose silent failures through prefix symlinks
- Fix Python SONAME mismatch in AppImage build (patch portable Python
SONAME to match librunner.so DT_NEEDED, preventing dual libpython load)
- Bundle PyQt6 Qt dependencies, improve RPATH for lib/ and plugins/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docker')
| -rwxr-xr-x | docker/build-inner.sh | 185 |
1 files changed, 121 insertions, 64 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh index dda9b14..847bfae 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -30,35 +30,7 @@ mkdir -p "${OUT_DIR}/plugins" "${OUT_DIR}/dlls" "${OUT_DIR}/lib" # ── Main binary + helpers ── cp -f "${RUNDIR}/ModOrganizer" "${OUT_DIR}/ModOrganizer-core" -if [ -f "${RUNDIR}/umu-run" ]; then - # Patch umu-run to preserve STEAM_COMPAT_CLIENT_INSTALL_PATH from the - # parent environment. Upstream umu-run initialises this to "" and never - # picks up the caller's value, which prevents the Steam client libraries - # from being found. - UMU_PATCH_DIR="$(mktemp -d)" - (cd "${UMU_PATCH_DIR}" && "${BUILD_PY}" << PATCHEOF -import zipfile, pathlib -zf = zipfile.ZipFile('/src/${RUNDIR}/umu-run') -zf.extractall('src') -run_py = pathlib.Path('src/umu/umu_run.py') -src = run_py.read_text() - -# Patch 1: preserve STEAM_COMPAT_CLIENT_INSTALL_PATH -old1 = ' env["STEAM_COMPAT_INSTALL_PATH"] = os.environ.get("STEAM_COMPAT_INSTALL_PATH", "")' -new1 = (old1 - + '\n env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ.get(' - + '\n "STEAM_COMPAT_CLIENT_INSTALL_PATH", ""' - + '\n )') -if old1 in src and 'STEAM_COMPAT_CLIENT_INSTALL_PATH"] = os.environ' not in src: - src = src.replace(old1, new1) - -run_py.write_text(src) -PATCHEOF -) - "${BUILD_PY}" -m zipapp "${UMU_PATCH_DIR}/src" -o "${OUT_DIR}/umu-run" -p '/usr/bin/env python3' - chmod +x "${OUT_DIR}/umu-run" - rm -rf "${UMU_PATCH_DIR}" -fi +[ -f "${RUNDIR}/umu-run" ] && cp -f "${RUNDIR}/umu-run" "${OUT_DIR}/umu-run" [ -f "${RUNDIR}/README-PORTABLE.txt" ] && cp -f "${RUNDIR}/README-PORTABLE.txt" "${OUT_DIR}/" [ -f "/src/src/fluorine-manager" ] && cp -f "/src/src/fluorine-manager" "${OUT_DIR}/" @@ -156,6 +128,18 @@ find "${OUT_DIR}/plugins" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep find "${OUT_DIR}/lib" -name "*.so*" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" # lootcli [ -f "${OUT_DIR}/lootcli" ] && collect_deps "${OUT_DIR}/lootcli" >> "${ALL_DEPS}" +# PyQt6 extension modules — these link against Qt6 libs that MO2 binaries don't +# directly depend on (e.g. libQt6OpenGLWidgets, libQt6PrintSupport). Without +# bundling these, PyQt6 falls back to host Qt which may be a different version. +# Scan from the system dist-packages (portable Python isn't copied yet). +for pyqt_search in /usr/lib/python3/dist-packages/PyQt6 \ + "/usr/lib/python${PY_MM}/dist-packages/PyQt6" \ + "/usr/local/lib/python${PY_MM}/dist-packages/PyQt6"; do + if [ -d "${pyqt_search}" ]; then + find "${pyqt_search}" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" + break + fi +done sort -u "${ALL_DEPS}" | while read -r dep; do dep_name="$(basename "${dep}")" @@ -231,10 +215,29 @@ if [ -d "${PORTABLE_PY}" ]; then find "${OUT_DIR}/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find "${OUT_DIR}/python" -name "*.pyc" -delete 2>/dev/null || true - # Ensure versioned soname symlink exists (pybind11 links against libpython3.13.so.1.0). - if [ -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so" ] && \ - [ ! -f "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" ]; then - ln -sf "libpython${PY_MM}.so" "${OUT_DIR}/python/lib/libpython${PY_MM}.so.1.0" + # The portable Python's libpython has SONAME "libpython3.13.so", but + # pybind11::embed links librunner.so against the build-venv's libpython + # which may have SONAME "libpython3.13.so.1.0". A SONAME mismatch causes + # two copies of libpython to be loaded at runtime, making + # Py_IsInitialized() return false after Py_InitializeFromConfig() succeeds. + # + # Fix: patch the portable Python's SONAME to include the .1.0 suffix, + # matching what the linker recorded in librunner.so's DT_NEEDED. + PP_LIBPY="${OUT_DIR}/python/lib/libpython${PY_MM}.so" + if [ -f "${PP_LIBPY}" ]; then + CURRENT_SONAME="$(readelf -d "${PP_LIBPY}" 2>/dev/null | grep SONAME | sed 's/.*\[//' | sed 's/\]//')" + echo "Portable Python SONAME: ${CURRENT_SONAME}" + if [ "${CURRENT_SONAME}" = "libpython${PY_MM}.so" ]; then + echo "Patching SONAME to libpython${PY_MM}.so.1.0 ..." + patchelf --set-soname "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" + # Rename the file to match and create backwards symlink. + mv "${PP_LIBPY}" "${PP_LIBPY}.1.0" + ln -sf "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" + fi + # Ensure the .1.0 name exists (either as the real file or a symlink). + if [ ! -e "${PP_LIBPY}.1.0" ]; then + ln -sf "libpython${PY_MM}.so" "${PP_LIBPY}.1.0" + fi fi else echo "ERROR: Portable Python not found at ${PORTABLE_PY}" @@ -274,9 +277,11 @@ done # ── Fix RPATH so binaries find libs without LD_LIBRARY_PATH ── echo "Patching RPATH..." -patchelf --set-rpath '$ORIGIN/lib' "${OUT_DIR}/ModOrganizer-core" +patchelf --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core" [ -f "${OUT_DIR}/lootcli" ] && patchelf --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli" -find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../lib' {} \; 2>/dev/null || true +find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true +# Libraries in lib/ (e.g. librunner.so) need to find sibling libs and python/lib. +find "${OUT_DIR}/lib" -name "*.so" -exec patchelf --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true # ── Validate embedded Python runtime ── if ! PYTHONHOME="${OUT_DIR}/python" \ @@ -295,15 +300,34 @@ set -euo pipefail SELF="$(readlink -f "$0")" HERE="$(cd "$(dirname "$SELF")" && pwd)" export PATH="${HERE}:${PATH}" -export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/python/lib:${LD_LIBRARY_PATH:-}" + +# ── Sync portable Python to data dir (only when outdated) ── +FLUORINE_DATA="${HOME}/.local/share/fluorine" +PYTHON_DST="${FLUORINE_DATA}/python" +PYTHON_SRC="${HERE}/python" + +if [ -d "${PYTHON_SRC}" ]; then + CURRENT_VER="$(stat -c '%i:%Y' "${PYTHON_SRC}/bin/python3" 2>/dev/null || echo "unknown")" + MARKER="${PYTHON_DST}/.version" + + if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then + echo "Syncing Python runtime to ${PYTHON_DST}..." >&2 + rm -rf "${PYTHON_DST}" + mkdir -p "${PYTHON_DST}" + (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) + echo "${CURRENT_VER}" > "${MARKER}" + fi +fi + +export LD_LIBRARY_PATH="${HERE}/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" export MO2_BASE_DIR="${HERE}" export MO2_PLUGINS_DIR="${HERE}/plugins" export MO2_DLLS_DIR="${HERE}/dlls" -export MO2_PYTHON_DIR="${HERE}/python" +export MO2_PYTHON_DIR="${PYTHON_DST}" # PYTHONHOME is set only for the MO2 process (not exported to children like # umu-run/Proton which have their own Python). MO2_PYTHON_DIR lets the # binary reconstruct it internally. -MO2_PYTHONHOME="${HERE}/python" +MO2_PYTHONHOME="${PYTHON_DST}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME # Use bundled Qt6 plugins. @@ -341,6 +365,19 @@ if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" fi +# Symlink the portable Python's libpython into usr/lib/ so that: +# 1) linuxdeploy sees the dependency as already satisfied and doesn't bundle +# the container's system libpython (which lacks static built-in modules) +# 2) librunner.so (which lives in usr/lib/) can resolve libpython via $ORIGIN +PP_APPDIR_LIB="${APPDIR}/usr/bin/python/lib" +for pp_lib in "${PP_APPDIR_LIB}"/libpython*.so*; do + [ -e "${pp_lib}" ] || continue + pp_name="$(basename "${pp_lib}")" + # Remove any copy linuxdeploy might have placed, then symlink to portable. + rm -f "${APPDIR}/usr/lib/${pp_name}" + ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}" +done + # Desktop integration cp -f "${OUT_DIR}/com.fluorine.manager.desktop" "${APPDIR}/usr/share/applications/" cp -f "${OUT_DIR}/com.fluorine.manager.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/" @@ -358,10 +395,13 @@ if [ -f "/usr/share/icons/default/index.theme" ]; then cp -f "/usr/share/icons/default/index.theme" "${APPDIR}/usr/share/icons/default/" fi -# Update RPATH for new lib location -patchelf --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/ModOrganizer-core" +# Update RPATH for new lib location. +# usr/lib/ contains librunner.so and other MO2 libs. The symlinks above make +# libpython resolvable via $ORIGIN for libraries in usr/lib/. +patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core" [ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli" find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true +find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --set-rpath '$ORIGIN' {} \; 2>/dev/null || true # Create AppRun wrapper cat > "${APPDIR}/AppRun" <<'APPRUN' @@ -372,35 +412,42 @@ HERE="$(cd "$(dirname "$SELF")" && pwd)" BIN="${HERE}/usr/bin" APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")" -sync_dir() { - local name="$1" - local src="${BIN}/${name}" - local dst="${APPIMAGE_DIR}/${name}" - [ -d "${src}" ] || return 0 - mkdir -p "${dst}" - (cd "${src}" && tar --exclude-vcs -cf - .) | (cd "${dst}" && tar -xf -) -} +# ── Sync portable Python to data dir (only when outdated) ── +# Plugins and dlls are read-only and loaded directly from the squashfs mount. +# Python needs a writable copy because pip/plugins may modify site-packages. +FLUORINE_DATA="${HOME}/.local/share/fluorine" +PYTHON_DST="${FLUORINE_DATA}/python" +PYTHON_SRC="${BIN}/python" -# Keep runtime payload outside the transient AppImage mount. -sync_dir "plugins" -sync_dir "dlls" -sync_dir "python" +if [ -d "${PYTHON_SRC}" ]; then + APPIMAGE_REAL="$(readlink -f "${APPIMAGE:-$0}")" + CURRENT_VER="$(stat -c '%i:%Y' "${APPIMAGE_REAL}" 2>/dev/null || echo "unknown")" + MARKER="${PYTHON_DST}/.version" -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:-}" + if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then + echo "Syncing Python runtime to ${PYTHON_DST}..." + rm -rf "${PYTHON_DST}" + mkdir -p "${PYTHON_DST}" + (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) + echo "${CURRENT_VER}" > "${MARKER}" + fi fi +# Save the original (pre-AppImage) environment so game launches can restore it. +# Without this, AppImage's LD_LIBRARY_PATH/PATH leak into umu-run/Proton and +# cause library conflicts that make games crash. +export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" +export FLUORINE_ORIG_PATH="${PATH}" +export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}" + +export PATH="${BIN}:${PATH}" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" + export MO2_BASE_DIR="${APPIMAGE_DIR}" -export MO2_PLUGINS_DIR="${APPIMAGE_DIR}/plugins" -export MO2_DLLS_DIR="${APPIMAGE_DIR}/dlls" -if [ -d "${APPIMAGE_DIR}/python/lib" ]; then - export MO2_PYTHON_DIR="${APPIMAGE_DIR}/python" -else - export MO2_PYTHON_DIR="${BIN}/python" -fi +export MO2_PLUGINS_DIR="${BIN}/plugins" +export MO2_DLLS_DIR="${BIN}/dlls" +export MO2_PYTHON_DIR="${PYTHON_DST}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME @@ -434,12 +481,22 @@ fi # Use linuxdeploy to generate the AppImage. # We skip the Qt plugin since we already handle Qt plugin paths at runtime # and our deps are already staged. +# +# linuxdeploy scans binary deps and bundles them. We must prevent it from +# bundling the container's system libpython (which lacks statically-compiled +# extension modules like _ctypes, resource, _lzma). The portable Python +# runtime in usr/bin/python/lib/ is the correct copy. Symlinks in usr/lib/ +# point to it, and --exclude-library prevents linuxdeploy from overwriting them. export ARCH=x86_64 +# Tell linuxdeploy to skip libpython — we bundle the portable Python's copy +# via symlinks in usr/lib/ and must not replace it with the container's version. +export LINUXDEPLOY_EXCLUDE_MODULES="libpython" "${DEPLOY_DIR}/AppRun" \ --appdir "${APPDIR}" \ --output appimage \ --desktop-file "${APPDIR}/usr/share/applications/com.fluorine.manager.desktop" \ --icon-file "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png" \ + --exclude-library "libpython*" \ 2>&1 || { echo "WARNING: linuxdeploy AppImage generation failed, falling back to staging dir output" } |
