{ 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 </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 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 entire app to ~/.local/share/fluorine/bin/ so instance plugin # symlinks always point to a stable location. FLUORINE_DATA="''${HOME}/.local/share/fluorine" BIN_DST="''${FLUORINE_DATA}/bin" CURRENT_VER="$(stat -c '%i:%Y' "''${HERE}/ModOrganizer-core" 2>/dev/null || echo "unknown")" MARKER="''${BIN_DST}/.version" if [ ! -f "''${MARKER}" ] || [ "$(cat "''${MARKER}" 2>/dev/null)" != "''${CURRENT_VER}" ]; then echo "Syncing Fluorine to ''${BIN_DST}..." >&2 rm -rf "''${BIN_DST}" mkdir -p "''${BIN_DST}" (cd "''${HERE}" && tar --exclude-vcs -cf - .) | (cd "''${BIN_DST}" && tar -xf -) echo "''${CURRENT_VER}" > "''${MARKER}" fi RUN="''${BIN_DST}" PYTHON_DIR="''${RUN}/python" export PATH="''${RUN}:''${PATH}" # NOTE: Do NOT set LD_LIBRARY_PATH — DT_RPATH handles library resolution. # Setting it on a capability-bearing binary drops file capabilities (AT_SECURE). export MO2_BASE_DIR="''${RUN}" export MO2_PLUGINS_DIR="''${RUN}/plugins" export MO2_DLLS_DIR="''${RUN}/dlls" export MO2_PYTHON_DIR="''${PYTHON_DIR}" MO2_PYTHONHOME="''${PYTHON_DIR}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME export QT_PLUGIN_PATH="''${RUN}/qt6plugins" cd "''${RUN}" exec env PYTHONHOME="''${MO2_PYTHONHOME}" "''${RUN}/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 libx11 libxcb libxext libxrandr libxcursor libxi libxfixes libxrender libxcomposite libxdamage libxtst libxscrnsaver 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 libx11 libxcb libxext libxrandr libxcursor libxi libxfixes libxrender libxcomposite libxdamage libxtst 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"; }; }; }; }; }; }