aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xbuild.sh43
-rw-r--r--docker/Dockerfile38
-rwxr-xr-xdocker/build-inner.sh364
-rw-r--r--flake.nix574
-rw-r--r--libs/nak/src/runtime_wrap.rs27
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/fuseconnector.cpp39
-rw-r--r--src/src/fuseconnector.h2
-rw-r--r--src/src/organizercore.cpp32
-rw-r--r--src/src/protonlauncher.cpp23
-rw-r--r--src/src/protonlauncher.h2
-rw-r--r--src/src/settingsdialog.ui44
-rw-r--r--src/src/settingsdialogproton.cpp69
-rw-r--r--src/src/settingsdialogproton.h3
-rw-r--r--src/src/spawn.cpp2
-rw-r--r--src/src/vfs/mo2filesystem.cpp125
-rw-r--r--src/src/vfs/mo2filesystem.h12
-rw-r--r--src/src/vfs/trackedwrites.cpp20
18 files changed, 1165 insertions, 256 deletions
diff --git a/build.sh b/build.sh
index 751a916..dac1aec 100755
--- a/build.sh
+++ b/build.sh
@@ -4,7 +4,11 @@ set -euo pipefail
# Build Fluorine Manager using Docker.
#
# Usage:
-# ./build.sh # Build AppImage + staging dir
+# ./build.sh # Build portable .tar.gz (default)
+# ./build.sh tarball # Build portable .tar.gz only
+# ./build.sh installer # Build self-extracting .bin installer only
+# ./build.sh appimage # Build AppImage (legacy)
+# ./build.sh all # Build tarball + AppImage (if available)
# ./build.sh shell # Drop into the build container for debugging
#
# Prerequisites: Docker or Podman
@@ -25,15 +29,36 @@ CONTAINER_NAME="fluorine-build-$$"
cd "${SCRIPT_DIR}"
-# Build the Docker image if it doesn't exist or Dockerfile changed
+# Determine build mode from first argument
+BUILD_MODE="${1:-tarball}"
+case "${BUILD_MODE}" in
+ tarball|installer|appimage|all|shell) ;;
+ *)
+ echo "Usage: ./build.sh [tarball|installer|appimage|all|shell]"
+ echo ""
+ echo " tarball Build portable .tar.gz"
+ echo " installer Build self-extracting .bin installer"
+ echo " appimage Build AppImage (legacy)"
+ echo " all Build tarball (+ AppImage if available)"
+ echo " shell Drop into build container"
+ exit 1
+ ;;
+esac
+
+# Build the Docker image if it doesn't exist or Dockerfile changed.
+# Pass BUILD_APPIMAGE=1 only when AppImage builds are requested.
echo "=== Ensuring build image is up to date ==="
-${DOCKER} build -t "${IMAGE_NAME}" docker/
+DOCKER_BUILD_ARGS=()
+if [ "${BUILD_MODE}" = "appimage" ] || [ "${BUILD_MODE}" = "all" ]; then
+ DOCKER_BUILD_ARGS+=(--build-arg BUILD_APPIMAGE=1)
+fi
+${DOCKER} build "${DOCKER_BUILD_ARGS[@]}" -t "${IMAGE_NAME}" docker/
# Persistent ccache directory for faster rebuilds.
CCACHE_DIR="${HOME}/.cache/fluorine-ccache"
mkdir -p "${CCACHE_DIR}"
-if [ "${1:-}" = "shell" ]; then
+if [ "${BUILD_MODE}" = "shell" ]; then
echo "=== Dropping into build container shell ==="
exec ${DOCKER} run --rm -it \
-v "${SCRIPT_DIR}:/src:rw" \
@@ -46,11 +71,12 @@ if [ "${1:-}" = "shell" ]; then
bash
fi
-echo "=== Starting build ==="
+echo "=== Starting build (mode: ${BUILD_MODE}) ==="
${DOCKER} run --rm \
-v "${SCRIPT_DIR}:/src:rw" \
-v "${CCACHE_DIR}:/ccache:rw" \
-e CCACHE_DIR=/ccache \
+ -e BUILD_MODE="${BUILD_MODE}" \
-w /src \
--device /dev/fuse \
--cap-add SYS_ADMIN \
@@ -60,7 +86,6 @@ ${DOCKER} run --rm \
echo ""
echo "=== Done ==="
-if ls build/*.AppImage >/dev/null 2>&1; then
- echo "AppImage: $(ls build/*.AppImage)"
-fi
-echo "Staging: build/staging/"
+echo "Build outputs:"
+ls -lh build/fluorine-manager.tar.gz build/fluorine-manager.bin build/*.AppImage 2>/dev/null || echo " (none found)"
+echo "Staging: build/staging/"
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 153ff62..7f319e4 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -87,21 +87,27 @@ RUN git clone --depth 1 --branch ${LIBLOOT_REF} \
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
+# ── Pre-download linuxdeploy tooling (optional, only for AppImage builds) ──
+# Set BUILD_APPIMAGE=1 to include linuxdeploy in the image.
+# Without it, only tarball and installer builds are available.
+ARG BUILD_APPIMAGE=0
+RUN if [ "${BUILD_APPIMAGE}" = "1" ]; then \
+ 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 && \
+ 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; \
+ else \
+ echo "Skipping linuxdeploy (BUILD_APPIMAGE not set)"; \
+ fi
WORKDIR /build
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index 1bc33e1..bd0ed73 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -98,7 +98,6 @@ fi
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 ──
@@ -291,12 +290,16 @@ for tool in wrestool icotool lootcli; do
done
# ── Fix RPATH so binaries find libs without LD_LIBRARY_PATH ──
+# IMPORTANT: Use --force-rpath to set DT_RPATH (not DT_RUNPATH).
+# DT_RPATH is honored even when the binary has file capabilities
+# (e.g. cap_sys_admin for FUSE passthrough). DT_RUNPATH and
+# LD_LIBRARY_PATH are both ignored by the kernel for capability binaries.
echo "Patching RPATH..."
-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:$ORIGIN/../python/lib' {} \; 2>/dev/null || true
+patchelf --force-rpath --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core"
+[ -f "${OUT_DIR}/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli"
+find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --force-rpath --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
+find "${OUT_DIR}/lib" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true
# ── Validate embedded Python runtime ──
if ! PYTHONHOME="${OUT_DIR}/python" \
@@ -316,6 +319,14 @@ SELF="$(readlink -f "$0")"
HERE="$(cd "$(dirname "$SELF")" && pwd)"
export PATH="${HERE}:${PATH}"
+# Save the original environment so game launches (Proton/Wine) can restore it.
+# Without this, our bundled LD_LIBRARY_PATH leaks into game processes and
+# causes library conflicts.
+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:-}"
+
# ── Sync portable Python to data dir (only when outdated) ──
FLUORINE_DATA="${HOME}/.local/share/fluorine"
PYTHON_DST="${FLUORINE_DATA}/python"
@@ -353,73 +364,200 @@ exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@"
LAUNCH
chmod +x "${OUT_DIR}/fluorine-manager"
-# ── Desktop integration files (for AppImage) ──
+# ── Desktop integration files ──
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 ==="
+# ── Determine build mode ──
+# BUILD_MODE is passed from build.sh: tarball (default), installer, appimage, all
+BUILD_MODE="${BUILD_MODE:-tarball}"
-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"
+# ── Build tarball (portable distribution) ──
+build_tarball() {
+ echo ""
+ echo "=== Building tarball ==="
+ cd /src/build
+ # Create a clean directory name for the archive
+ TARBALL_NAME="fluorine-manager"
+ rm -rf "${TARBALL_NAME}"
+ cp -a staging "${TARBALL_NAME}"
+ tar czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}"/
+ rm -rf "${TARBALL_NAME}"
+ echo "Tarball: /src/build/${TARBALL_NAME}.tar.gz"
+ ls -lh "/src/build/${TARBALL_NAME}.tar.gz"
+}
-# 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
+# ── Build self-extracting installer (.bin frontloader) ──
+build_installer() {
+ echo ""
+ echo "=== Building installer ==="
-# 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
+ # Create the installer header script
+ INSTALLER_SCRIPT="/src/build/installer-header.sh"
+ cat > "${INSTALLER_SCRIPT}" <<'INSTALLER_HEADER'
+#!/usr/bin/env bash
+set -euo pipefail
-# 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/"
+APP_NAME="Fluorine Manager"
+INSTALL_DIR="${HOME}/.local/share/fluorine/bin"
+DESKTOP_DIR="${HOME}/.local/share/applications"
+ICON_DIR="${HOME}/.local/share/icons/hicolor/256x256/apps"
-# 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/"
+echo ""
+echo "╔══════════════════════════════════════════╗"
+echo "║ Fluorine Manager Installer ║"
+echo "╚══════════════════════════════════════════╝"
+echo ""
+
+# Detect existing installation
+if [ -d "${INSTALL_DIR}" ] && [ -f "${INSTALL_DIR}/ModOrganizer-core" ]; then
+ echo "Existing installation detected at: ${INSTALL_DIR}"
+ echo ""
+ echo " 1) Update existing installation"
+ echo " 2) Extract portable copy here (./fluorine-manager/)"
+ echo " 3) Cancel"
+ echo ""
+ read -rp "Choose [1/2/3]: " CHOICE
+else
+ echo " 1) Install to ${INSTALL_DIR} (global)"
+ echo " 2) Extract portable copy here (./fluorine-manager/)"
+ echo " 3) Cancel"
+ echo ""
+ read -rp "Choose [1/2/3]: " CHOICE
fi
-# 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
+case "${CHOICE}" in
+ 1)
+ echo ""
+ echo "Installing to ${INSTALL_DIR}..."
+ mkdir -p "${INSTALL_DIR}"
+ # Extract payload (everything after the __PAYLOAD__ marker)
+ ARCHIVE_START=$(awk '/^__PAYLOAD__$/{print NR + 1; exit 0;}' "$0")
+ tail -n +"${ARCHIVE_START}" "$0" | tar xzf - -C "${INSTALL_DIR}" --strip-components=1
+
+ # Create desktop shortcut
+ mkdir -p "${DESKTOP_DIR}" "${ICON_DIR}"
+ cp -f "${INSTALL_DIR}/com.fluorine.manager.png" "${ICON_DIR}/"
+
+ cat > "${DESKTOP_DIR}/com.fluorine.manager.desktop" <<DESKTOP_EOF
+[Desktop Entry]
+Type=Application
+Name=Fluorine Manager
+Comment=Mod Organizer for Linux
+Exec=${INSTALL_DIR}/fluorine-manager %u
+Icon=com.fluorine.manager
+Terminal=false
+Categories=Game;Utility;
+StartupWMClass=ModOrganizer
+MimeType=x-scheme-handler/nxm;x-scheme-handler/nxm-hierarchical;
+DESKTOP_EOF
+ chmod +x "${DESKTOP_DIR}/com.fluorine.manager.desktop"
+
+ # Update desktop database if available
+ command -v update-desktop-database >/dev/null 2>&1 && \
+ update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
+
+ echo ""
+ echo "Installation complete!"
+ echo " Binary: ${INSTALL_DIR}/fluorine-manager"
+ echo " Shortcut: ${DESKTOP_DIR}/com.fluorine.manager.desktop"
+ echo ""
+ read -rp "Launch now? [Y/n]: " LAUNCH
+ if [ "${LAUNCH,,}" != "n" ]; then
+ exec "${INSTALL_DIR}/fluorine-manager" "$@"
+ fi
+ ;;
+ 2)
+ echo ""
+ PORTABLE_DIR="$(pwd)/fluorine-manager"
+ echo "Extracting portable copy to ${PORTABLE_DIR}..."
+ mkdir -p "${PORTABLE_DIR}"
+ ARCHIVE_START=$(awk '/^__PAYLOAD__$/{print NR + 1; exit 0;}' "$0")
+ tail -n +"${ARCHIVE_START}" "$0" | tar xzf - -C "${PORTABLE_DIR}" --strip-components=1
+
+ echo ""
+ echo "Portable extraction complete!"
+ echo " Run: ${PORTABLE_DIR}/fluorine-manager"
+ ;;
+ *)
+ echo "Cancelled."
+ exit 0
+ ;;
+esac
+exit 0
+__PAYLOAD__
+INSTALLER_HEADER
+
+ # Build the tarball payload
+ cd /src/build
+ TARBALL_NAME="fluorine-manager"
+ rm -rf "${TARBALL_NAME}"
+ cp -a staging "${TARBALL_NAME}"
+ tar czf "${TARBALL_NAME}-payload.tar.gz" "${TARBALL_NAME}"/
+ rm -rf "${TARBALL_NAME}"
+
+ # Combine header + payload into self-extracting .bin
+ cat "${INSTALLER_SCRIPT}" "${TARBALL_NAME}-payload.tar.gz" > "${TARBALL_NAME}.bin"
+ chmod +x "${TARBALL_NAME}.bin"
+ rm -f "${INSTALLER_SCRIPT}" "${TARBALL_NAME}-payload.tar.gz"
-# Create AppRun wrapper
-cat > "${APPDIR}/AppRun" <<'APPRUN'
+ echo "Installer: /src/build/${TARBALL_NAME}.bin"
+ ls -lh "/src/build/${TARBALL_NAME}.bin"
+}
+
+# ── Build AppImage (legacy, optional) ──
+build_appimage() {
+ echo ""
+ echo "=== Building AppImage ==="
+
+ if [ ! -d /opt/linuxdeploy ]; then
+ echo "ERROR: linuxdeploy not available. Rebuild Docker image with --build-arg BUILD_APPIMAGE=1"
+ return 1
+ fi
+
+ 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"
+
+ cp -a "${OUT_DIR}"/. "${APPDIR}/usr/bin/"
+ mv "${APPDIR}/usr/bin/lib"/* "${APPDIR}/usr/lib/" 2>/dev/null || true
+ rmdir "${APPDIR}/usr/bin/lib" 2>/dev/null || true
+ if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then
+ cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/"
+ fi
+
+ 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}")"
+ rm -f "${APPDIR}/usr/lib/${pp_name}"
+ ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}"
+ done
+
+ 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/"
+
+ 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
+
+ patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core"
+ [ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli"
+ find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true
+ find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --force-rpath --set-rpath '$ORIGIN' {} \; 2>/dev/null || true
+
+ cat > "${APPDIR}/AppRun" <<'APPRUN'
#!/usr/bin/env bash
set -euo pipefail
SELF="$(readlink -f "$0")"
@@ -427,9 +565,6 @@ HERE="$(cd "$(dirname "$SELF")" && pwd)"
BIN="${HERE}/usr/bin"
APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")"
-# ── 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"
@@ -448,9 +583,6 @@ if [ -d "${PYTHON_SRC}" ]; then
fi
fi
-# Save the original (pre-AppImage) environment so game launches can restore it.
-# Without this, AppImage's LD_LIBRARY_PATH/PATH leak into 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}"
@@ -466,7 +598,6 @@ export MO2_PYTHON_DIR="${PYTHON_DST}"
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}"
@@ -477,57 +608,66 @@ export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-KDE}"
cd "${APPIMAGE_DIR}"
exec "${BIN}/ModOrganizer-core" "$@"
APPRUN
-chmod +x "${APPDIR}/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"
+ 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
+ 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
+
+ export ARCH=x86_64
+ 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"
+ }
-# 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"
+ 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 "AppImage: /src/build/${APPIMAGE_FILE}"
+ ls -lh "/src/build/"*.AppImage
+ fi
}
-# 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
+# ── Execute requested build mode ──
+case "${BUILD_MODE}" in
+ tarball)
+ build_tarball
+ ;;
+ installer)
+ build_installer
+ ;;
+ appimage)
+ build_appimage
+ ;;
+ all)
+ build_tarball
+ if [ -d /opt/linuxdeploy ]; then
+ build_appimage
+ fi
+ ;;
+ *)
+ echo "ERROR: Unknown BUILD_MODE '${BUILD_MODE}'. Use: tarball, installer, appimage, all"
+ exit 1
+ ;;
+esac
echo ""
echo "=== Build Summary ==="
du -sh "${OUT_DIR}"/*/ "${OUT_DIR}"/ModOrganizer-core 2>/dev/null | sort -rh
+echo ""
+echo "Build outputs:"
+ls -lh /src/build/fluorine-manager.tar.gz /src/build/fluorine-manager.bin /src/build/*.AppImage 2>/dev/null || echo " (none found)"
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..192f3ee
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,574 @@
+{
+ description = "Fluorine Manager — Mod Organizer 2 for Linux";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ system = "x86_64-linux";
+ pkgs = import nixpkgs { inherit system; };
+
+ version = "0.1.0";
+ pythonVersion = "3.13";
+ portablePythonVersion = "3.13.9";
+
+ # ── libloot (not in nixpkgs — build from source) ──
+ libloot = pkgs.stdenv.mkDerivation {
+ pname = "libloot";
+ version = "0.29.0";
+
+ src = pkgs.fetchFromGitHub {
+ owner = "loot";
+ repo = "libloot";
+ rev = "master";
+ hash = ""; # nix will tell you the correct hash on first build
+ fetchSubmodules = true;
+ };
+
+ nativeBuildInputs = with pkgs; [ cmake ninja ];
+
+ cmakeDir = "../cpp";
+ cmakeFlags = [
+ "-DBUILD_SHARED_LIBS=ON"
+ "-DLIBLOOT_INSTALL_DOCS=OFF"
+ "-DBUILD_TESTING=OFF"
+ ];
+
+ postInstall = ''
+ mkdir -p $out/lib/pkgconfig
+ cat > $out/lib/pkgconfig/libloot.pc <<EOF
+ prefix=$out
+ 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}
+ EOF
+ '';
+ };
+
+ # ── Rust FFI crates (built separately, then injected into cmake) ──
+
+ # bsa_ffi is self-contained (no local path deps).
+ bsa-ffi = pkgs.rustPlatform.buildRustPackage {
+ pname = "bsa-ffi";
+ inherit version;
+
+ src = ./libs/bsa_ffi;
+ cargoLock.lockFile = ./libs/bsa_ffi/Cargo.lock;
+
+ # Output is a cdylib (.so).
+ installPhase = ''
+ mkdir -p $out/lib
+ cp target/release/libbsa_ffi.so $out/lib/
+ '';
+ };
+
+ # nak_ffi depends on nak_rust via `path = "../nak"`. We give
+ # rustPlatform the parent directory so both crates are visible,
+ # and build only nak_ffi.
+ nak-ffi = pkgs.rustPlatform.buildRustPackage {
+ pname = "nak-ffi";
+ inherit version;
+
+ # Source must include both libs/nak and libs/nak_ffi.
+ src = pkgs.lib.cleanSourceWith {
+ src = ./libs;
+ filter = path: type:
+ let baseName = builtins.baseNameOf path; in
+ # Include nak/ and nak_ffi/ directories (and their contents).
+ builtins.match ".*/libs/(nak|nak_ffi)(/.*|$)" path != null
+ # Also include the top-level libs/ dir itself.
+ || type == "directory" && baseName == "libs";
+ };
+
+ sourceRoot = "source/nak_ffi";
+ cargoLock.lockFile = ./libs/nak_ffi/Cargo.lock;
+
+ installPhase = ''
+ mkdir -p $out/lib
+ cp target/release/libnak_ffi.so $out/lib/
+ '';
+ };
+
+ # ── Portable Python runtime (bundled, not for compilation) ──
+ portable-python = pkgs.fetchzip {
+ url = "https://github.com/bjia56/portable-python/releases/download/cpython-v${portablePythonVersion}-build.0/python-headless-${portablePythonVersion}-linux-x86_64.zip";
+ hash = ""; # nix will tell you the correct hash on first build
+ stripRoot = false;
+ };
+
+ # ── Build Python with packages for compilation (pybind11, sip) ──
+ buildPython = pkgs.python313.withPackages (ps: with ps; [
+ pybind11
+ sip
+ psutil
+ ]);
+
+ # ── Build Fluorine from source ──
+ fluorine-unwrapped = pkgs.stdenv.mkDerivation {
+ pname = "fluorine-manager-unwrapped";
+ inherit version;
+
+ src = self;
+
+ nativeBuildInputs = with pkgs; [
+ cmake
+ ninja
+ pkg-config
+ patchelf
+ qt6.wrapQtAppsHook
+ ];
+
+ buildInputs = with pkgs; [
+ # Qt 6
+ qt6.qtbase
+ qt6.qtwebengine
+ qt6.qtwebsockets
+ qt6.qtsvg
+ qt6.qtwayland
+
+ # Boost
+ boost
+
+ # Python (build-time)
+ buildPython
+
+ # System libraries
+ sqlite
+ tinyxml2
+ spdlog
+ fuse3
+ lz4
+ zlib
+ zstd
+ bzip2
+ xz
+ openssl
+ curl
+ tomlplusplus
+ fontconfig
+ freetype
+
+ # libloot (built above)
+ libloot
+ ];
+
+ # Disable Rust FFI builds in cmake — we built them separately.
+ cmakeFlags = [
+ "-DCMAKE_BUILD_TYPE=RelWithDebInfo"
+ "-DPython_EXECUTABLE=${buildPython}/bin/python3"
+ "-Dpybind11_DIR=${buildPython}/lib/python${pythonVersion}/site-packages/pybind11/share/cmake/pybind11"
+ "-DBUILD_PLUGIN_PYTHON=ON"
+ "-DBUILD_NAK_FFI=OFF"
+ "-DBUILD_BSA_FFI=OFF"
+ ];
+
+ # Replicate the staging logic from docker/build-inner.sh.
+ installPhase = ''
+ runHook preInstall
+
+ local RUNDIR=src/src
+ local PY_MM="${pythonVersion}"
+
+ mkdir -p $out/opt/fluorine-manager/{plugins,dlls,lib,qt6plugins}
+
+ # ── Main binary + helpers ──
+ cp -f $RUNDIR/ModOrganizer $out/opt/fluorine-manager/ModOrganizer-core
+ [ -f libs/lootcli/src/lootcli ] && cp -f libs/lootcli/src/lootcli $out/opt/fluorine-manager/
+
+ # ── MO2 plugins (.so) ──
+ find 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 {} $out/opt/fluorine-manager/plugins/ \;
+
+ # Python plugin payload.
+ for f in libplugin_python.so lzokay.py winreg.py pyCfg.py \
+ DDSPreview.py Form43Checker.py ScriptExtenderPluginChecker.py; do
+ [ -f "src/src/plugins/$f" ] && cp -f "src/src/plugins/$f" $out/opt/fluorine-manager/plugins/
+ done
+ for d in basic_games data libs dlls; do
+ [ -d "src/src/plugins/$d" ] && cp -a "src/src/plugins/$d" $out/opt/fluorine-manager/plugins/
+ done
+ rm -f $out/opt/fluorine-manager/plugins/FNIS*.py
+
+ # Source-tree Python plugins.
+ for f in ../src/plugins/*.py; do
+ [ -f "$f" ] && cp -f "$f" $out/opt/fluorine-manager/plugins/
+ done
+
+ # ── Stylesheets ──
+ [ -d src/src/stylesheets ] && cp -a src/src/stylesheets $out/opt/fluorine-manager/
+
+ # ── 7z runtime ──
+ [ -f src/src/dlls/7z.so ] && cp -f src/src/dlls/7z.so $out/opt/fluorine-manager/dlls/
+
+ # ── Project libraries ──
+ cp -f libs/uibase/src/libuibase.so $out/opt/fluorine-manager/lib/
+ cp -f libs/libbsarch/liblibbsarch.so $out/opt/fluorine-manager/lib/
+ cp -f libs/archive/src/libarchive.so $out/opt/fluorine-manager/lib/
+ cp -f libs/plugin_python/src/runner/librunner.so $out/opt/fluorine-manager/lib/
+
+ # ── Pre-built Rust FFI libraries ──
+ cp -f ${bsa-ffi}/lib/libbsa_ffi.so $out/opt/fluorine-manager/lib/
+ cp -f ${nak-ffi}/lib/libnak_ffi.so $out/opt/fluorine-manager/lib/
+
+ # ── libloot ──
+ cp -Lf ${libloot}/lib/libloot.so* $out/opt/fluorine-manager/lib/ 2>/dev/null || true
+
+ # ── Boost (from nix store) ──
+ for boost_lib in ${pkgs.boost}/lib/libboost_program_options.so* \
+ ${pkgs.boost}/lib/libboost_thread.so*; do
+ [ -f "$boost_lib" ] && cp -Lf "$boost_lib" $out/opt/fluorine-manager/lib/
+ done
+
+ # ── Qt6 plugins ──
+ QT6_PLUGIN_DIR="${pkgs.qt6.qtbase}/lib/qt-6/plugins"
+ for plugin_type in platforms tls networkinformation styles \
+ imageformats iconengines xcbglintegrations \
+ egldeviceintegrations; do
+ [ -d "$QT6_PLUGIN_DIR/$plugin_type" ] && \
+ cp -a "$QT6_PLUGIN_DIR/$plugin_type" $out/opt/fluorine-manager/qt6plugins/
+ done
+ # Wayland plugins.
+ WAYLAND_PLUGIN_DIR="${pkgs.qt6.qtwayland}/lib/qt-6/plugins"
+ for plugin_type in wayland-shell-integration \
+ wayland-decoration-client wayland-graphics-integration-client; do
+ [ -d "$WAYLAND_PLUGIN_DIR/$plugin_type" ] && \
+ cp -a "$WAYLAND_PLUGIN_DIR/$plugin_type" $out/opt/fluorine-manager/qt6plugins/
+ done
+ # SVG plugin.
+ SVG_PLUGIN_DIR="${pkgs.qt6.qtsvg}/lib/qt-6/plugins"
+ [ -d "$SVG_PLUGIN_DIR/imageformats" ] && \
+ cp -a "$SVG_PLUGIN_DIR/imageformats"/. $out/opt/fluorine-manager/qt6plugins/imageformats/
+
+ # ── Bundle shared library dependencies ──
+ SKIP_PATTERN="linux-vdso|ld-linux|libc\.so|libm\.so|libdl\.so|librt\.so|libpthread|libresolv|libnss|libgcc_s|libstdc\+\+"
+ SKIP_PATTERN="$SKIP_PATTERN|libGL\.so|libEGL|libGLX|libGLdispatch|libdrm|libvulkan|libX11|libxcb|libwayland|libxkbcommon"
+ SKIP_PATTERN="$SKIP_PATTERN|libpython"
+
+ collect_and_bundle() {
+ ldd "$1" 2>/dev/null | grep "=>" | awk '{print $3}' | grep "^/" | while read -r dep; do
+ dep_name="$(basename "$dep")"
+ echo "$dep_name" | grep -qE "$SKIP_PATTERN" && continue
+ [ -f "$out/opt/fluorine-manager/lib/$dep_name" ] && continue
+ cp -Lf "$dep" "$out/opt/fluorine-manager/lib/" 2>/dev/null || true
+ done
+ }
+ collect_and_bundle "$out/opt/fluorine-manager/ModOrganizer-core"
+ find "$out/opt/fluorine-manager/plugins" -name "*.so" -exec bash -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"'/opt/fluorine-manager/lib/$dep_name" ] && continue
+ cp -Lf "$dep" "'"$out"'/opt/fluorine-manager/lib/" 2>/dev/null || true
+ done' _ {} \;
+ find "$out/opt/fluorine-manager/lib" -name "*.so*" -exec bash -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"'/opt/fluorine-manager/lib/$dep_name" ] && continue
+ cp -Lf "$dep" "'"$out"'/opt/fluorine-manager/lib/" 2>/dev/null || true
+ done' _ {} \;
+ [ -f "$out/opt/fluorine-manager/lootcli" ] && collect_and_bundle "$out/opt/fluorine-manager/lootcli"
+ rm -f "$out/opt/fluorine-manager/lib"/libpython*.so* 2>/dev/null || true
+
+ # ── Portable Python runtime ──
+ PP_SRC=$(echo ${portable-python}/python-headless-*)
+ if [ ! -d "$PP_SRC" ]; then
+ PP_SRC="${portable-python}"
+ fi
+ cp -a "$PP_SRC" $out/opt/fluorine-manager/python
+ chmod -R u+w $out/opt/fluorine-manager/python
+
+ # Trim portable Python.
+ PP_STDLIB="$out/opt/fluorine-manager/python/lib/python$PY_MM"
+ rm -rf "$PP_STDLIB/test" "$PP_STDLIB/idlelib" "$PP_STDLIB/tkinter" \
+ "$PP_STDLIB/turtledemo" "$out/opt/fluorine-manager/python/include" \
+ "$out/opt/fluorine-manager/python/share" 2>/dev/null || true
+ find "$out/opt/fluorine-manager/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
+
+ # Patch portable Python SONAME.
+ PP_LIBPY="$out/opt/fluorine-manager/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/\]//')"
+ if [ "$CURRENT_SONAME" = "libpython$PY_MM.so" ]; then
+ patchelf --set-soname "libpython$PY_MM.so.1.0" "$PP_LIBPY"
+ mv "$PP_LIBPY" "$PP_LIBPY.1.0"
+ ln -sf "libpython$PY_MM.so.1.0" "$PP_LIBPY"
+ fi
+ [ ! -e "$PP_LIBPY.1.0" ] && ln -sf "libpython$PY_MM.so" "$PP_LIBPY.1.0"
+ fi
+
+ # Bundle PyQt6 into portable Python site-packages.
+ PYSITE="$out/opt/fluorine-manager/python/lib/python$PY_MM/site-packages"
+ mkdir -p "$PYSITE"
+ for search_dir in ${pkgs.python313Packages.pyqt6}/lib/python${pythonVersion}/site-packages; do
+ if [ -d "$search_dir/PyQt6" ]; then
+ 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
+
+ # Build-tree Python plugin payload.
+ [ -d src/src/python ] && cp -a src/src/python/. $out/opt/fluorine-manager/python/
+
+ # ── icoutils (for .exe icon extraction) ──
+ cp -f ${pkgs.icoutils}/bin/wrestool $out/opt/fluorine-manager/ 2>/dev/null || true
+ cp -f ${pkgs.icoutils}/bin/icotool $out/opt/fluorine-manager/ 2>/dev/null || true
+
+ # ── Strip binaries ──
+ strip --strip-unneeded $out/opt/fluorine-manager/ModOrganizer-core 2>/dev/null || true
+ find $out/opt/fluorine-manager/plugins -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true
+ find $out/opt/fluorine-manager/lib -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true
+ [ -f "$out/opt/fluorine-manager/lootcli" ] && strip --strip-unneeded "$out/opt/fluorine-manager/lootcli" 2>/dev/null || true
+
+ # ── Patch RPATH ──
+ patchelf --force-rpath --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' \
+ $out/opt/fluorine-manager/ModOrganizer-core
+ [ -f "$out/opt/fluorine-manager/lootcli" ] && \
+ patchelf --force-rpath --set-rpath '$ORIGIN/lib' $out/opt/fluorine-manager/lootcli
+ find $out/opt/fluorine-manager/plugins -name "*.so" \
+ -exec patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true
+ find $out/opt/fluorine-manager/lib -name "*.so" \
+ -exec patchelf --force-rpath --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true
+
+ # ── Launcher script ──
+ cat > $out/opt/fluorine-manager/fluorine-manager <<'LAUNCH'
+#!/usr/bin/env bash
+set -euo pipefail
+SELF="$(readlink -f "$0")"
+HERE="$(cd "$(dirname "$SELF")" && pwd)"
+export PATH="${HERE}:${PATH}"
+
+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:-}"
+
+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="${PYTHON_DST}"
+MO2_PYTHONHOME="${PYTHON_DST}"
+unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME
+
+export QT_PLUGIN_PATH="${HERE}/qt6plugins"
+
+cd "${HERE}"
+exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@"
+LAUNCH
+ chmod +x $out/opt/fluorine-manager/fluorine-manager
+
+ # ── Desktop integration files ──
+ cp -f ../data/com.fluorine.manager.desktop $out/opt/fluorine-manager/ 2>/dev/null || true
+ cp -f ../data/com.fluorine.manager.png $out/opt/fluorine-manager/ 2>/dev/null || true
+ cp -f ../data/com.fluorine.manager.metainfo.xml $out/opt/fluorine-manager/ 2>/dev/null || true
+
+ mkdir -p $out/share/icons/hicolor/256x256/apps
+ mkdir -p $out/share/metainfo
+ cp $out/opt/fluorine-manager/com.fluorine.manager.png \
+ $out/share/icons/hicolor/256x256/apps/ 2>/dev/null || true
+ cp $out/opt/fluorine-manager/com.fluorine.manager.metainfo.xml \
+ $out/share/metainfo/ 2>/dev/null || true
+
+ runHook postInstall
+ '';
+ };
+
+ desktopItem = pkgs.makeDesktopItem {
+ name = "com.fluorine.manager";
+ exec = "fluorine-manager";
+ icon = "com.fluorine.manager";
+ desktopName = "Fluorine Manager";
+ genericName = "Mod Manager";
+ comment = "Mod Organizer 2 for Linux — manage your game mods";
+ categories = [ "Game" "Utility" ];
+ mimeTypes = [ "x-scheme-handler/nxm" ];
+ startupWMClass = "ModOrganizer";
+ keywords = [ "mod" "organizer" "modding" "skyrim" "fallout" ];
+ };
+
+ # ── FHS wrapper ──
+ fluorine-manager = pkgs.buildFHSEnv {
+ pname = "fluorine-manager";
+ inherit version;
+
+ runScript = "${fluorine-unwrapped}/opt/fluorine-manager/fluorine-manager";
+
+ targetPkgs = pkgs: with pkgs; [
+ glibc
+ stdenv.cc.cc.lib
+
+ libGL
+ libglvnd
+ libdrm
+ vulkan-loader
+ mesa
+
+ xorg.libX11
+ xorg.libxcb
+ xorg.libXext
+ xorg.libXrandr
+ xorg.libXcursor
+ xorg.libXi
+ xorg.libXfixes
+ xorg.libXrender
+ xorg.libXcomposite
+ xorg.libXdamage
+ xorg.libXtst
+ xorg.libXScrnSaver
+ xorg.libXinerama
+ libxkbcommon
+
+ wayland
+
+ fuse3
+
+ fontconfig
+ freetype
+
+ nss
+ nspr
+
+ zlib
+ dbus
+ glib
+ udev
+ libcap
+ polkit
+ ];
+
+ multiPkgs = pkgs: with pkgs; [
+ glibc
+ libGL
+ libglvnd
+ vulkan-loader
+ libdrm
+ udev
+ alsa-lib
+ libpulseaudio
+ dbus
+ freetype
+ fontconfig
+ zlib
+ glib
+ xorg.libX11
+ xorg.libxcb
+ xorg.libXext
+ xorg.libXrandr
+ xorg.libXcursor
+ xorg.libXi
+ xorg.libXfixes
+ xorg.libXrender
+ xorg.libXcomposite
+ xorg.libXdamage
+ xorg.libXtst
+ xorg.libXinerama
+ wayland
+ libxkbcommon
+ gnutls
+ SDL2
+ ncurses
+ cups
+ ];
+
+ multiArch = true;
+
+ unshareIpc = false;
+ unsharePid = false;
+
+ extraInstallCommands = ''
+ mkdir -p $out/share
+ ln -s ${desktopItem}/share/applications $out/share/applications
+ ln -s ${fluorine-unwrapped}/share/icons $out/share/icons
+ ln -s ${fluorine-unwrapped}/share/metainfo $out/share/metainfo
+ '';
+
+ meta = with pkgs.lib; {
+ description = "Mod Organizer 2 for Linux";
+ homepage = "https://github.com/SulfurNitride/Fluorine-Manager";
+ license = licenses.gpl3;
+ platforms = [ "x86_64-linux" ];
+ mainProgram = "fluorine-manager";
+ };
+ };
+
+ in {
+ packages.${system} = {
+ default = fluorine-manager;
+ inherit fluorine-manager fluorine-unwrapped libloot bsa-ffi nak-ffi;
+ };
+
+ apps.${system}.default = {
+ type = "app";
+ program = "${fluorine-manager}/bin/fluorine-manager";
+ };
+
+ # NixOS module for system-wide installation.
+ nixosModules.default = { config, lib, ... }: {
+ options.programs.fluorine-manager = {
+ enable = lib.mkEnableOption "Fluorine Manager (Mod Organizer 2 for Linux)";
+
+ fusePassthrough = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = ''
+ Enable FUSE passthrough (kernel-direct I/O for mod files).
+ Grants CAP_SYS_ADMIN to the Fluorine binary via a
+ security wrapper. Requires kernel 6.9+.
+ '';
+ };
+ };
+
+ config = lib.mkIf config.programs.fluorine-manager.enable {
+ environment.systemPackages = [ self.packages.${system}.default ];
+
+ programs.fuse.userAllowOther = true;
+
+ security.wrappers = lib.mkIf config.programs.fluorine-manager.fusePassthrough {
+ fluorine-manager = {
+ source = "${self.packages.${system}.default}/bin/fluorine-manager";
+ capabilities = "cap_sys_admin+ep";
+ owner = "root";
+ group = "root";
+ };
+ };
+ };
+ };
+ };
+}
diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs
index 69f693e..37980d3 100644
--- a/libs/nak/src/runtime_wrap.rs
+++ b/libs/nak/src/runtime_wrap.rs
@@ -1,38 +1,11 @@
-use std::env;
use std::ffi::OsStr;
use std::process::Command;
-fn env_flag(name: &str) -> bool {
- matches!(
- env::var(name)
- .unwrap_or_default()
- .trim()
- .to_ascii_lowercase()
- .as_str(),
- "1" | "true" | "yes" | "on"
- )
-}
-
-pub fn use_steam_run() -> bool {
- env_flag("NAK_USE_STEAM_RUN")
-}
-
/// Build a command to run `exe` with the given environment variables.
-///
-/// In steam-run mode, the command is wrapped with `steam-run`.
-/// Otherwise the command runs directly.
pub fn build_command<S: AsRef<OsStr>>(
exe: impl AsRef<OsStr>,
envs: &[(&str, S)],
) -> Command {
- if use_steam_run() {
- let mut cmd = Command::new("steam-run");
- cmd.arg(exe.as_ref());
- for (key, value) in envs {
- cmd.env(key, value.as_ref());
- }
- return cmd;
- }
let mut cmd = Command::new(exe);
for (key, value) in envs {
cmd.env(key, value.as_ref());
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index e69a814..d7a7642 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -115,7 +115,7 @@ target_link_libraries(organizer PRIVATE
target_compile_definitions(organizer PRIVATE
SPDLOG_USE_STD_FORMAT
- FUSE_USE_VERSION=35
+ FUSE_USE_VERSION=312
$<$<TARGET_EXISTS:Qt6::WebEngineWidgets>:MO2_WEBENGINE>)
if(NOT WIN32)
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index c749d69..b146359 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -160,9 +160,10 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->init = mo2_init;
ops->lookup = mo2_lookup;
ops->getattr = mo2_getattr;
- ops->opendir = mo2_opendir;
- ops->readdir = mo2_readdir;
- ops->open = mo2_open;
+ ops->opendir = mo2_opendir;
+ ops->readdir = mo2_readdir;
+ ops->readdirplus = mo2_readdirplus;
+ ops->open = mo2_open;
ops->read = mo2_read;
ops->write = mo2_write;
ops->create = mo2_create;
@@ -291,14 +292,15 @@ bool FuseConnector::mount(
std::fprintf(stderr, "[VFS] WARNING: tracking file path is empty!\n");
}
- m_context = std::make_shared<Mo2FsContext>();
- m_context->tree = tree;
- m_context->inodes = std::make_unique<InodeTable>();
- m_context->overwrite = std::make_unique<OverwriteManager>(m_stagingDir, m_overwriteDir);
- m_context->tracked_writes = m_trackedWrites;
- m_context->backing_dir_fd = m_backingFd;
- m_context->uid = ::getuid();
- m_context->gid = ::getgid();
+ m_context = std::make_shared<Mo2FsContext>();
+ m_context->tree = tree;
+ m_context->inodes = std::make_unique<InodeTable>();
+ m_context->overwrite = std::make_unique<OverwriteManager>(m_stagingDir, m_overwriteDir);
+ m_context->tracked_writes = m_trackedWrites;
+ m_context->backing_dir_fd = m_backingFd;
+ m_context->uid = ::getuid();
+ m_context->gid = ::getgid();
+ m_context->passthrough_requested = m_passthroughEnabled;
// NOTE: Do NOT include mount_point here — low-level API passes it
// separately to fuse_session_mount(). Including it here causes
@@ -335,7 +337,13 @@ bool FuseConnector::mount(
}
m_fuseThread = std::thread([this]() {
- fuse_session_loop_mt(m_session, nullptr);
+ // Enable clone_fd: each worker thread gets its own /dev/fuse fd,
+ // eliminating contention on a single fd lock under heavy parallel I/O.
+ struct fuse_loop_config* cfg = fuse_loop_cfg_create();
+ fuse_loop_cfg_set_clone_fd(cfg, 1);
+ fuse_loop_cfg_set_max_threads(cfg, 16);
+ fuse_session_loop_mt(m_session, cfg);
+ fuse_loop_cfg_destroy(cfg);
});
m_mounted = true;
@@ -438,6 +446,13 @@ void FuseConnector::setTrackingFilePath(const std::string& path)
std::fprintf(stderr, "[VFS] setTrackingFilePath: '%s'\n", path.c_str());
}
+void FuseConnector::setPassthroughEnabled(bool enabled)
+{
+ m_passthroughEnabled = enabled;
+ std::fprintf(stderr, "[VFS] passthrough %s by user\n",
+ enabled ? "enabled" : "disabled");
+}
+
std::shared_ptr<TrackedWrites> FuseConnector::trackedWrites() const
{
return m_trackedWrites;
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index a13f014..6d089bb 100644
--- a/src/src/fuseconnector.h
+++ b/src/src/fuseconnector.h
@@ -42,6 +42,7 @@ public:
void setPluginLoadOrder(const std::vector<std::string>& load_order);
void setTrackingFilePath(const std::string& path);
+ void setPassthroughEnabled(bool enabled);
std::shared_ptr<TrackedWrites> trackedWrites() const;
void unmount();
@@ -96,6 +97,7 @@ private:
std::thread m_fuseThread;
bool m_mounted = false;
bool m_discardStaging = false;
+ bool m_passthroughEnabled = false;
};
#endif
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index c0f1bf9..3896039 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -661,6 +661,13 @@ void OrganizerCore::prepareVFS()
owPath.toStdString().c_str(), trackPath.toStdString().c_str());
m_USVFS.setTrackingFilePath(trackPath.toStdString());
}
+
+ // FUSE passthrough: read the per-instance setting and pass it to the VFS.
+ {
+ const bool passthrough =
+ QSettings().value("fluorine/fuse_passthrough", false).toBool();
+ m_USVFS.setPassthroughEnabled(passthrough);
+ }
#endif
m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString()));
}
@@ -2100,20 +2107,29 @@ void OrganizerCore::syncOverwrite()
#ifndef _WIN32
// Track files that were moved out of overwrite to mods.
// Files that existed before but are gone now were synced to a mod.
+ // Use profile priority order (highest-priority mod wins) so writes
+ // go to the correct mod when multiple mods contain the same path.
const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods());
+
+ // Mod names in ascending priority order (last = highest priority).
+ const QStringList modsByPriority =
+ m_ModList.allModsByProfilePriority(m_CurrentProfile.get());
+
for (const auto& relPath : beforeFiles) {
const QString owFile = modInfo->absolutePath() + "/" + relPath;
if (!QFile::exists(owFile)) {
- // Find which mod folder it ended up in
- QDirIterator modDirIter(modsDir, QDir::Dirs | QDir::NoDotAndDotDot);
- while (modDirIter.hasNext()) {
- modDirIter.next();
- const QString candidate = modDirIter.filePath() + "/" + relPath;
- if (QFile::exists(candidate)) {
- trackOverwriteMove(relPath, modDirIter.filePath());
- break;
+ // Find highest-priority mod that has this file.
+ QString bestModPath;
+ for (const auto& modName : modsByPriority) {
+ const QString modPath = modsDir + "/" + modName;
+ if (QFile::exists(modPath + "/" + relPath)) {
+ bestModPath = modPath;
+ // Don't break — keep going to find the highest-priority match.
}
}
+ if (!bestModPath.isEmpty()) {
+ trackOverwriteMove(relPath, bestModPath);
+ }
}
}
#endif
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 4339920..e2895e2 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -200,19 +200,6 @@ void wrapProgram(const QStringList& wrapperCommands, const QString& program,
wrappedArguments.append(arguments);
}
-void maybeWrapWithSteamRun(bool useSteamRun, QString& program, QStringList& arguments)
-{
- if (!useSteamRun) {
- return;
- }
-
- QStringList wrappedArgs;
- wrappedArgs.append(program);
- wrappedArgs.append(arguments);
- program = QStringLiteral("steam-run");
- arguments = wrappedArgs;
-}
-
bool isValidEnvKey(const QString& key)
{
if (key.isEmpty()) {
@@ -252,7 +239,7 @@ bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut
} // namespace
ProtonLauncher::ProtonLauncher()
- : m_steamAppId(0), m_useSteamRun(false), m_useSteamDrm(true)
+ : m_steamAppId(0), m_useSteamDrm(true)
{}
ProtonLauncher& ProtonLauncher::setBinary(const QString& path)
@@ -313,12 +300,6 @@ ProtonLauncher& ProtonLauncher::setWrapper(const QString& wrapperCmd)
return *this;
}
-ProtonLauncher& ProtonLauncher::setUseSteamRun(bool useSteamRun)
-{
- m_useSteamRun = useSteamRun;
- return *this;
-}
-
ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm)
{
m_useSteamDrm = useSteamDrm;
@@ -375,7 +356,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
QString program;
QStringList arguments;
wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments);
- maybeWrapWithSteamRun(m_useSteamRun, program, arguments);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.remove("PYTHONHOME");
@@ -448,7 +428,6 @@ bool ProtonLauncher::launchDirect(qint64& pid) const
QString program;
QStringList arguments;
wrapProgram(m_wrapperCommands, m_binary, m_arguments, program, arguments);
- maybeWrapWithSteamRun(m_useSteamRun, program, arguments);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.remove("PYTHONHOME");
diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h
index 6b3ee42..363d92b 100644
--- a/src/src/protonlauncher.h
+++ b/src/src/protonlauncher.h
@@ -20,7 +20,6 @@ public:
ProtonLauncher& setPrefix(const QString& path);
ProtonLauncher& setSteamAppId(uint32_t id);
ProtonLauncher& setWrapper(const QString& wrapperCmd);
- ProtonLauncher& setUseSteamRun(bool useSteamRun);
ProtonLauncher& setSteamDrm(bool useSteamDrm);
ProtonLauncher& setStoreVariant(const QString& variant);
ProtonLauncher& addEnvVar(const QString& key, const QString& value);
@@ -40,7 +39,6 @@ private:
QString m_prefixPath;
uint32_t m_steamAppId;
QStringList m_wrapperCommands;
- bool m_useSteamRun;
bool m_useSteamDrm;
QString m_storeVariant; // "GOG", "Epic", or empty for Steam
QMap<QString, QString> m_envVars;
diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui
index 6588a71..7704b56 100644
--- a/src/src/settingsdialog.ui
+++ b/src/src/settingsdialog.ui
@@ -1866,16 +1866,6 @@ If you disable this feature, MO will only display official DLCs this way. Please
</property>
<layout class="QVBoxLayout" name="verticalLayout_38">
<item>
- <widget class="QCheckBox" name="steamRunCheckBox">
- <property name="text">
- <string>Wrap launches with steam-run</string>
- </property>
- <property name="toolTip">
- <string>Run launch and prefix setup commands through steam-run. Useful on NixOS and other immutable setups.</string>
- </property>
- </widget>
- </item>
- <item>
<layout class="QHBoxLayout" name="horizontalLayout_14">
<item>
<widget class="QLabel" name="label_65">
@@ -1893,6 +1883,40 @@ If you disable this feature, MO will only display official DLCs this way. Please
</widget>
</item>
<item>
+ <widget class="QGroupBox" name="groupBox_vfsPerformance">
+ <property name="title">
+ <string>VFS Performance</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_vfsPerf">
+ <item>
+ <widget class="QCheckBox" name="fusePassthroughCheckBox">
+ <property name="text">
+ <string>Enable FUSE Passthrough (near-native I/O)</string>
+ </property>
+ <property name="toolTip">
+ <string>Let the kernel serve game file reads directly, bypassing userspace. Dramatically improves I/O performance for loading textures, meshes, and archives. Requires a one-time privilege grant (sudo prompt). Needs kernel 6.9+ and libfuse 3.16+.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="passthroughStatusLabel">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="font">
+ <font>
+ <pointsize>9</pointsize>
+ </font>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
<spacer name="verticalSpacer_16">
<property name="orientation">
<enum>Qt::Vertical</enum>
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 0199744..36838c4 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -9,7 +9,9 @@
#include <uibase/utility.h>
#include <nak_ffi.h>
#include <atomic>
+#include <QCheckBox>
#include <QComboBox>
+#include <QCoreApplication>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
@@ -40,12 +42,56 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
ui->protonProgressBar->setValue(0);
ui->protonProgressBar->setVisible(false);
- ui->steamRunCheckBox->setChecked(
- QSettings().value("fluorine/use_steam_run", false).toBool());
-
ui->launchWrapperEdit->setPlaceholderText("mangohud --dlsym");
ui->launchWrapperEdit->setText(QSettings().value("fluorine/launch_wrapper").toString());
+ // FUSE passthrough toggle — requires CAP_SYS_ADMIN on the binary.
+ ui->fusePassthroughCheckBox->setChecked(
+ QSettings().value("fluorine/fuse_passthrough", false).toBool());
+ ui->passthroughStatusLabel->setText(QString());
+
+ QObject::connect(ui->fusePassthroughCheckBox, &QCheckBox::toggled, this,
+ [this](bool checked) {
+ if (!checked) {
+ // Disabling doesn't need sudo — just save the setting.
+ QSettings().setValue("fluorine/fuse_passthrough", false);
+ ui->passthroughStatusLabel->setText(tr("Passthrough disabled. Takes effect on next game launch."));
+ return;
+ }
+
+ // Enabling requires setting CAP_SYS_ADMIN on the binary.
+ // Find the actual binary path (ModOrganizer-core).
+ const QString binary = QCoreApplication::applicationFilePath();
+ if (binary.isEmpty()) {
+ ui->fusePassthroughCheckBox->setChecked(false);
+ ui->passthroughStatusLabel->setText(tr("Could not determine binary path."));
+ return;
+ }
+
+ ui->passthroughStatusLabel->setText(
+ tr("Granting FUSE passthrough capability... (sudo prompt)"));
+
+ // Use pkexec (graphical sudo) to set the file capability.
+ // This is a one-time operation — the capability persists across runs.
+ QProcess proc;
+ proc.setProgram("pkexec");
+ proc.setArguments({"setcap", "cap_sys_admin+ep", binary});
+ proc.start();
+ proc.waitForFinished(60000); // 60s timeout for user to auth
+
+ if (proc.exitCode() == 0) {
+ QSettings().setValue("fluorine/fuse_passthrough", true);
+ ui->passthroughStatusLabel->setText(
+ tr("Passthrough enabled. Takes effect on next game launch."));
+ } else {
+ ui->fusePassthroughCheckBox->setChecked(false);
+ const QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed();
+ ui->passthroughStatusLabel->setText(
+ tr("Failed to set capability: %1").arg(
+ err.isEmpty() ? tr("authorization denied or pkexec not found") : err));
+ }
+ });
+
populateProtons();
QObject::connect(ui->protonVersionCombo, &QComboBox::currentIndexChanged, this,
@@ -114,8 +160,6 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
void ProtonSettingsTab::update()
{
- QSettings().setValue("fluorine/use_steam_run",
- ui->steamRunCheckBox->isChecked());
QSettings().setValue("fluorine/launch_wrapper", ui->launchWrapperEdit->text());
}
@@ -237,8 +281,7 @@ void ProtonSettingsTab::onCreatePrefix()
ui->nakInstallLog->setVisible(true);
ui->toggleInstallLog->setChecked(true);
- startInstallTask(0, pfxPath, protonName, protonPath,
- ui->steamRunCheckBox->isChecked());
+ startInstallTask(0, pfxPath, protonName, protonPath);
}
void ProtonSettingsTab::onDeletePrefix()
@@ -287,8 +330,7 @@ void ProtonSettingsTab::onRecreatePrefix()
ui->toggleInstallLog->setChecked(true);
startInstallTask(cfg->app_id, cfg->prefix_path, cfg->proton_name,
- cfg->proton_path,
- ui->steamRunCheckBox->isChecked());
+ cfg->proton_path);
}
void ProtonSettingsTab::onOpenPrefixFolder()
@@ -579,8 +621,7 @@ void ProtonSettingsTab::showGameRegistryDialog()
void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPath,
const QString& protonName,
- const QString& protonPath,
- bool useSteamRun)
+ const QString& protonPath)
{
m_pendingAppId = appId;
m_pendingPrefixPath = prefixPath;
@@ -595,14 +636,11 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa
appId,
prefixPath,
protonName,
- protonPath,
- useSteamRun]() -> InstallResult {
+ protonPath]() -> InstallResult {
const QByteArray prefixPathUtf8 = prefixPath.toUtf8();
const QByteArray protonNameUtf8 = protonName.toUtf8();
const QByteArray protonPathUtf8 = protonPath.toUtf8();
- qputenv("NAK_USE_STEAM_RUN", useSteamRun ? "1" : "0");
-
// Set WINEPREFIX so NAK (and its child processes like winetricks) always
// target the correct prefix during Proton init.
qputenv("WINEPREFIX", prefixPathUtf8);
@@ -629,7 +667,6 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa
}
const auto restoreNakEnv = qScopeGuard([protonWineUtf8] {
- qunsetenv("NAK_USE_STEAM_RUN");
qunsetenv("WINEPREFIX");
if (!protonWineUtf8.isEmpty()) {
qunsetenv("WINE");
diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h
index 58c43f2..a1221c7 100644
--- a/src/src/settingsdialogproton.h
+++ b/src/src/settingsdialogproton.h
@@ -39,8 +39,7 @@ private:
QString findProtonWine(const QString& protonPath);
void startInstallTask(uint32_t appId, const QString& prefixPath,
- const QString& protonName, const QString& protonPath,
- bool useSteamRun);
+ const QString& protonName, const QString& protonPath);
void enqueueStatus(const QString& message);
void enqueueProgress(float progress);
diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp
index 28010d0..4c91fbe 100644
--- a/src/src/spawn.cpp
+++ b/src/src/spawn.cpp
@@ -656,8 +656,6 @@ int spawn(const SpawnParameters &sp, pid_t &processId) {
.setArguments(argList)
.setWorkingDir(cwd)
.setSteamAppId(parseSteamAppId(sp.steamAppID))
- .setUseSteamRun(
- QSettings().value("fluorine/use_steam_run", false).toBool())
.setSteamDrm(useSteamDrm)
.setStoreVariant(storeVariant);
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 962a458..42e90c0 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -18,9 +18,12 @@ namespace
{
namespace fs = std::filesystem;
-constexpr double TTL_SECONDS = 30.0;
-constexpr double NEGATIVE_TTL_SECONDS = 5.0;
-constexpr double ATTR_CACHE_SECONDS = 30.0;
+// Mod files are immutable during a game session, so cache aggressively.
+// The VFS tree is built once at mount time and only mutated by our own
+// create/rename/unlink handlers (which invalidate affected entries).
+constexpr double TTL_SECONDS = 86400.0; // 24 hours
+constexpr double NEGATIVE_TTL_SECONDS = 3600.0; // 1 hour — Wine probes many non-existent files
+constexpr double ATTR_CACHE_SECONDS = 86400.0;
void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid);
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
@@ -513,11 +516,80 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
{
auto* ctx = static_cast<Mo2FsContext*>(userdata);
- // Keep kernel page cache valid across open/close as long as mtime/size are
- // unchanged. Mod files are immutable during a game session, so this avoids
- // re-reading file data from userspace on every open().
- if (conn->capable & FUSE_CAP_AUTO_INVAL_DATA) {
- conn->want |= FUSE_CAP_AUTO_INVAL_DATA;
+ // ── Disable AUTO_INVAL_DATA (CRITICAL for performance) ──
+ // AUTO_INVAL_DATA forces a getattr() on EVERY read() to check mtime,
+ // completely bypassing attr_timeout. This alone causes ~4x throughput
+ // reduction. Our VFS tree is immutable during a session — we handle
+ // invalidation ourselves via fuse_lowlevel_notify_inval_inode() when
+ // files are created/renamed/deleted through our own handlers.
+ conn->want &= ~FUSE_CAP_AUTO_INVAL_DATA;
+
+ // Let us control page cache invalidation explicitly.
+ if (conn->capable & FUSE_CAP_EXPLICIT_INVAL_DATA) {
+ conn->want |= FUSE_CAP_EXPLICIT_INVAL_DATA;
+ }
+
+ // Force readdirplus always (unset adaptive mode). Our stat info is
+ // free (in-memory tree), so always return full entries to pre-populate
+ // the kernel dentry + attr caches on every directory listing.
+ if (conn->capable & FUSE_CAP_READDIRPLUS) {
+ conn->want |= FUSE_CAP_READDIRPLUS;
+ conn->want &= ~FUSE_CAP_READDIRPLUS_AUTO;
+ }
+
+ // NOTE: FUSE_CAP_WRITEBACK_CACHE intentionally NOT enabled.
+ // It causes extra getattr calls for cache coherency, which hurts
+ // our read-heavy VFS more than the write buffering helps.
+
+ // Cache symlink targets in the kernel page cache.
+ if (conn->capable & FUSE_CAP_CACHE_SYMLINKS) {
+ conn->want |= FUSE_CAP_CACHE_SYMLINKS;
+ }
+
+ // Allow concurrent lookup()/readdir() on the same directory.
+ if (conn->capable & FUSE_CAP_PARALLEL_DIROPS) {
+ conn->want |= FUSE_CAP_PARALLEL_DIROPS;
+ }
+
+ // Splice: reduce kernel↔userspace data copies for reads and writes.
+ if (conn->capable & FUSE_CAP_SPLICE_WRITE) {
+ conn->want |= FUSE_CAP_SPLICE_WRITE;
+ }
+ if (conn->capable & FUSE_CAP_SPLICE_MOVE) {
+ conn->want |= FUSE_CAP_SPLICE_MOVE;
+ }
+ if (conn->capable & FUSE_CAP_SPLICE_READ) {
+ conn->want |= FUSE_CAP_SPLICE_READ;
+ }
+
+ // More async readahead/writeback slots (default is 12, far too low).
+ conn->max_background = 128;
+ conn->congestion_threshold = 96;
+
+ // FUSE passthrough: if requested by the user and supported by the kernel,
+ // enable kernel-level passthrough for read-only file opens. This lets the
+ // kernel serve reads directly from the backing file without round-tripping
+ // through userspace — near-native I/O performance for game file reads.
+ // Requires: kernel 6.9+, libfuse 3.16+, CAP_SYS_ADMIN on the binary.
+ ctx->passthrough_active = false;
+ if constexpr (FUSE_CAP_PASSTHROUGH != 0) {
+ if (ctx->passthrough_requested &&
+ (conn->capable & FUSE_CAP_PASSTHROUGH)) {
+ conn->want |= FUSE_CAP_PASSTHROUGH;
+ ctx->passthrough_active = true;
+ std::fprintf(stderr, "[VFS] FUSE passthrough enabled (kernel supports it)\n");
+ } else if (ctx->passthrough_requested) {
+ std::fprintf(stderr,
+ "[VFS] FUSE passthrough requested but NOT supported by kernel "
+ "(capable=0x%x). Falling back to userspace I/O.\n",
+ conn->capable);
+ }
+ } else {
+ if (ctx->passthrough_requested) {
+ std::fprintf(stderr,
+ "[VFS] FUSE passthrough requested but NOT available at compile time "
+ "(libfuse too old). Falling back to userspace I/O.\n");
+ }
}
// Increase max read/write buffer to 1MB (kernel 4.20+ supports this).
@@ -531,9 +603,13 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
}
std::fprintf(stderr,
- "[VFS] init: auto_inval=%d max_readahead=%u max_write=%u\n",
+ "[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d "
+ "passthrough=%d max_bg=%u max_readahead=%u\n",
(conn->want & FUSE_CAP_AUTO_INVAL_DATA) ? 1 : 0,
- conn->max_readahead, conn->max_write);
+ (conn->want & FUSE_CAP_EXPLICIT_INVAL_DATA) ? 1 : 0,
+ (conn->want & FUSE_CAP_READDIRPLUS) ? 1 : 0,
+ ctx->passthrough_active ? 1 : 0,
+ conn->max_background, conn->max_readahead);
}
void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
@@ -685,7 +761,8 @@ void mo2_opendir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
}
fi->fh = dh;
- fi->cache_readdir = 1;
+ fi->keep_cache = 1; // Don't invalidate cached readdir on reopen
+ fi->cache_readdir = 1; // Let kernel cache directory entries
fuse_reply_open(req, fi);
}
@@ -964,6 +1041,32 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
fi->fh = fh;
fi->keep_cache = 1;
+
+ // FUSE passthrough: for read-only opens, let the kernel serve reads directly
+ // from the backing file. This bypasses userspace entirely — the kernel handles
+ // read() syscalls by reading the real file, giving near-native I/O performance.
+ // Only eligible for non-writable, non-COW files (pure reads).
+ if constexpr (FUSE_CAP_PASSTHROUGH != 0) {
+ if (ctx->passthrough_active && !writable && !cowPending && fd < 0) {
+ // Open the real file so the kernel can passthrough reads from it.
+ int ptFd = -1;
+ if (isBacking && ctx->backing_dir_fd >= 0) {
+ ptFd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
+ } else {
+ ptFd = open(realPath.c_str(), O_RDONLY);
+ }
+ if (ptFd >= 0) {
+ fi->backing_id = static_cast<uint32_t>(ptFd);
+ // Store the fd so we can close it on release
+ std::scoped_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fh);
+ if (it != ctx->open_files.end()) {
+ it->second.fd = ptFd;
+ }
+ }
+ }
+ }
+
fuse_reply_open(req, fi);
}
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index 2c4b257..2883a18 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -16,6 +16,13 @@
#include <unordered_map>
#include <vector>
+// FUSE passthrough support (kernel 6.9+, libfuse 3.16+).
+// When available, the kernel serves reads directly from the backing file
+// without round-tripping through userspace — near-native I/O performance.
+#ifndef FUSE_CAP_PASSTHROUGH
+#define FUSE_CAP_PASSTHROUGH 0
+#endif
+
struct Mo2FsContext
{
std::shared_ptr<VfsTree> tree;
@@ -90,6 +97,11 @@ struct Mo2FsContext
uid_t uid = 0;
gid_t gid = 0;
+
+ // FUSE passthrough: when true AND kernel supports it, read-only file opens
+ // use kernel-level passthrough (zero-copy I/O bypassing userspace).
+ bool passthrough_requested = false;
+ bool passthrough_active = false; // set in mo2_init after capability check
};
void mo2_init(void* userdata, struct fuse_conn_info* conn);
diff --git a/src/src/vfs/trackedwrites.cpp b/src/src/vfs/trackedwrites.cpp
index 66e83e5..4392db3 100644
--- a/src/src/vfs/trackedwrites.cpp
+++ b/src/src/vfs/trackedwrites.cpp
@@ -272,7 +272,8 @@ void TrackedWrites::detectManualMoves(
if (missing.empty())
return;
- // For each missing file, check if it now exists in any mod folder
+ // For each missing file, check if it now exists in any mod folder.
+ // Use the LAST match (highest priority — mods are ordered low→high).
int detected = 0;
for (const auto& relPath : missing) {
const std::string key = toLower(relPath);
@@ -280,16 +281,23 @@ void TrackedWrites::detectManualMoves(
if (m_tracked.count(key))
continue;
+ std::string matchedMod;
+ std::string matchedPath;
for (const auto& [modName, modPath] : mods) {
std::error_code ec;
if (fs::exists(fs::path(modPath) / relPath, ec)) {
- m_tracked[key] = modPath;
- ++detected;
- std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n",
- relPath.c_str(), modName.c_str());
- break;
+ matchedMod = modName;
+ matchedPath = modPath;
+ // Don't break — keep going to find the highest-priority match.
}
}
+
+ if (!matchedPath.empty()) {
+ m_tracked[key] = matchedPath;
+ ++detected;
+ std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n",
+ relPath.c_str(), matchedMod.c_str());
+ }
}
if (detected > 0)