aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 16:09:59 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 16:09:59 -0500
commit96a6ba196c72e955c3db822d93da38fce8cd7045 (patch)
tree3fcc40e939bd609c4cb7b84f05e85875dde64fc6
parent6b9f0876cfbf6f8c723c86ec941ee70933aa883f (diff)
Fix VFS rmdir, prefix/launch issues, add version + beta channel
- VFS: add mo2_rmdir + OverwriteManager::removeDirectory so Wrye Bash shutil.rmtree stops failing with WinError 267 (issue #47). - FileRenamer: case-insensitive fallback via new resolvePathCaseInsensitive walks each path component; bulk hide in Conflict tab no longer dies with a misleading "Input/output error" when DirectoryEntry normalised parent dir case (issue #54). formatSystemMessage stops aliasing Windows error code 5 to errno 5 (EIO). - Versioning: FLUORINE_VERSION_* + fluorine_build_info.h generated from CMake; Linux createVersionInfo returns Fluorine's version. About dialog shows Fluorine version + MO2 engine + commit. Beta builds stamp "<semver>B<yyyymmddhhmm>" (issue #51). - CI: ci.yml determines channel from ref (v* tags = stable, main = beta), publishes a rolling `beta` GitHub release whose body carries a machine-parseable fluorine-meta block (timestamp, commit) for the in-app updater. - Updater: new FluorineUpdater polls GitHub API on startup, compares against embedded metadata, notifies on new release without auto-install. Channel toggle piggybacks on the existing "beta versions" checkbox. - Bethesda plugins: determineMyGamesPath no longer creates Documents/My Games/<GameName> for every possible title at plugin load; only games with a detected install path get their dirs created (issue #55). - Starfield: dataDirectory() on Linux now points at <game>/Data instead of My Games/Starfield/Data so the single FUSE mount lands where SFSE loads plugins; My Games Data becomes a secondary symlinked mapping (issue #56). - Prefix resolution: prefer Fluorine config, then explicit fluorine/prefix_path, then legacy Settings/* keys (with a warning) so auto-detected Heroic/Bottles prefixes can't silently override a user- configured Fluorine prefix (issue #52). compatDataPathFromPrefix no longer hands Proton the wrong parent dir for plain wine prefixes. - xrandr: ensureXrandrInstalled back-fills the helper for existing SLR installs, prefix init runs it before wineboot, SLR wrap exposes xrandr-bin via --filesystem= and prepends PATH inside the container (issue #49). - Process tracking: drop the wineserver-as-last-resort fallback that made MO2 hang on Proton's session manager after game exit. Rescan the prefix for matching game executables instead (covers reparented launcher children like f4se_loader → Fallout4.exe). Unlock button now kills wineserver for the prefix (SIGTERM then SIGKILL on timeout). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--.github/workflows/ci.yml102
-rw-r--r--CHANGELOG.md42
-rw-r--r--CMakeLists.txt42
-rwxr-xr-xdocker/build-inner.sh9
-rw-r--r--libs/game_bethesda/src/gamebryo/gamegamebryo.cpp34
-rw-r--r--libs/game_bethesda/src/gamebryo/gamegamebryo.h8
-rw-r--r--libs/game_bethesda/src/games/enderalse/gameenderalse.cpp4
-rw-r--r--libs/game_bethesda/src/games/fallout3/gamefallout3.cpp2
-rw-r--r--libs/game_bethesda/src/games/fallout4/gamefallout4.cpp2
-rw-r--r--libs/game_bethesda/src/games/fallout4london/gamefo4london.cpp2
-rw-r--r--libs/game_bethesda/src/games/fallout4vr/gamefallout4vr.cpp2
-rw-r--r--libs/game_bethesda/src/games/fallout76/gamefallout76.cpp2
-rw-r--r--libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp4
-rw-r--r--libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp4
-rw-r--r--libs/game_bethesda/src/games/starfield/gamestarfield.cpp22
-rw-r--r--libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp4
-rw-r--r--libs/uibase/include/uibase/filesystemutilities.h14
-rw-r--r--libs/uibase/src/filesystemutilities.cpp57
-rw-r--r--libs/uibase/src/utility.cpp56
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/aboutdialog.cpp25
-rw-r--r--src/src/filerenamer.cpp37
-rw-r--r--src/src/fluorine_build_info.h.in24
-rw-r--r--src/src/fluorineupdater.cpp303
-rw-r--r--src/src/fluorineupdater.h75
-rw-r--r--src/src/fuseconnector.cpp1
-rw-r--r--src/src/organizercore.cpp49
-rw-r--r--src/src/organizercore.h2
-rw-r--r--src/src/prefixsetuprunner.cpp42
-rw-r--r--src/src/processrunner.cpp167
-rw-r--r--src/src/protonlauncher.cpp19
-rw-r--r--src/src/settings.cpp17
-rw-r--r--src/src/settings.h6
-rw-r--r--src/src/settingsdialoggeneral.cpp14
-rw-r--r--src/src/shared/util.cpp14
-rw-r--r--src/src/slrmanager.cpp141
-rw-r--r--src/src/slrmanager.h13
-rw-r--r--src/src/spawn.cpp33
-rw-r--r--src/src/vfs/mo2filesystem.cpp66
-rw-r--r--src/src/vfs/mo2filesystem.h1
-rw-r--r--src/src/vfs/overwritemanager.cpp29
-rw-r--r--src/src/vfs/overwritemanager.h1
42 files changed, 1378 insertions, 115 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b9c03f5..0a5f551 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,6 +46,18 @@ jobs:
restore-keys: |
ccache-${{ runner.os }}-
+ - name: Determine build channel
+ id: channel
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ echo "channel=stable" >> "$GITHUB_OUTPUT"
+ echo "timestamp=" >> "$GITHUB_OUTPUT"
+ else
+ echo "channel=beta" >> "$GITHUB_OUTPUT"
+ echo "timestamp=$(date -u +%Y%m%d%H%M)" >> "$GITHUB_OUTPUT"
+ fi
+ echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
+
- name: Build inside container
run: |
mkdir -p ~/.cache/fluorine-ccache
@@ -58,10 +70,25 @@ jobs:
-e CCACHE_SLOPPINESS=time_macros,include_file_mtime,include_file_ctime,pch_defines \
-e CCACHE_MAXSIZE=5G \
-e BUILD_MODE=tarball \
+ -e FLUORINE_BUILD_CHANNEL=${{ steps.channel.outputs.channel }} \
+ -e FLUORINE_BUILD_TIMESTAMP=${{ steps.channel.outputs.timestamp }} \
+ -e FLUORINE_BUILD_COMMIT=${{ steps.channel.outputs.commit }} \
-w /src \
fluorine-builder:latest \
bash -c 'ccache -s; bash /src/docker/build-inner.sh; echo "=== ccache stats after build ==="; ccache -s'
+ - name: Package tarball
+ run: |
+ cd build
+ if [[ "${{ steps.channel.outputs.channel }}" == "beta" ]]; then
+ ARCHIVE_NAME="fluorine-manager-beta.tar.gz"
+ else
+ ARCHIVE_NAME="fluorine-manager-${GITHUB_REF_NAME}.tar.gz"
+ fi
+ tar czf "${ARCHIVE_NAME}" fluorine-manager/
+ echo "archive=${ARCHIVE_NAME}" >> "$GITHUB_ENV"
+ echo "archive_path=build/${ARCHIVE_NAME}" >> "$GITHUB_ENV"
+
- name: Upload build
uses: actions/upload-artifact@v4
with:
@@ -70,6 +97,81 @@ jobs:
compression-level: 6
if-no-files-found: warn
+ # -------------------------------------------------------------------
+ # Beta rolling release: every push to main overwrites the `beta` tag's
+ # release assets so the in-app updater always sees the latest build
+ # without cluttering the release history with new tags.
+ # -------------------------------------------------------------------
+ - name: Publish beta rolling release
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ TAG="beta"
+ BETA_TS="${{ steps.channel.outputs.timestamp }}"
+ BETA_COMMIT="${GITHUB_SHA}"
+ BETA_SHORT="${BETA_COMMIT:0:7}"
+ TITLE="Fluorine Manager beta ${BETA_TS} (${BETA_SHORT})"
+ NOTES_FILE="$(mktemp)"
+ {
+ echo "Rolling beta build from main @ ${BETA_SHORT}."
+ echo ""
+ echo "- Channel: beta"
+ echo "- Timestamp (UTC): ${BETA_TS}"
+ echo "- Commit: ${BETA_COMMIT}"
+ echo ""
+ echo "This release is overwritten with each successful CI build on main."
+ echo "For stable releases, see tagged v* releases."
+ echo ""
+ # Machine-parseable block used by the in-app updater to decide
+ # whether the currently-installed beta build already matches. Do
+ # not remove or reformat — the updater reads it line-by-line.
+ echo "<!-- fluorine-meta"
+ echo "channel=beta"
+ echo "timestamp=${BETA_TS}"
+ echo "commit=${BETA_COMMIT}"
+ echo "short=${BETA_SHORT}"
+ echo "-->"
+ } > "${NOTES_FILE}"
+
+ # Move the rolling tag to the current commit.
+ git tag -f "${TAG}" "${GITHUB_SHA}"
+ git push -f origin "refs/tags/${TAG}"
+
+ if gh release view "${TAG}" >/dev/null 2>&1; then
+ gh release edit "${TAG}" \
+ --title "${TITLE}" \
+ --notes-file "${NOTES_FILE}" \
+ --prerelease
+ # Replace the tarball asset (clobber wins over upload+delete race).
+ gh release upload "${TAG}" "${archive_path}" --clobber
+ else
+ gh release create "${TAG}" "${archive_path}" \
+ --title "${TITLE}" \
+ --notes-file "${NOTES_FILE}" \
+ --prerelease
+ fi
+
+ # -------------------------------------------------------------------
+ # Stable release: triggered by pushing a v* tag. Uploads the tagged
+ # archive as a normal (non-prerelease) GitHub release.
+ # -------------------------------------------------------------------
+ - name: Publish stable release
+ if: startsWith(github.ref, 'refs/tags/v')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ if gh release view "${TAG}" >/dev/null 2>&1; then
+ gh release upload "${TAG}" "${archive_path}" --clobber
+ else
+ gh release create "${TAG}" "${archive_path}" \
+ --title "Fluorine Manager ${TAG}" \
+ --generate-notes
+ fi
+
build-nixos-mobase:
runs-on: ubuntu-latest
steps:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b92e886
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,42 @@
+# Fluorine Manager — Changelog
+
+All notable user-facing changes to Fluorine Manager land here. Versioning
+follows SemVer (MAJOR.MINOR.PATCH). Two distribution channels:
+
+- **stable** — tagged `v*` releases. Each tag cuts a normal GitHub release.
+- **beta** — rolling release under the `beta` tag. Every push to `main` that
+ builds successfully replaces the assets on that one release — no new tag
+ per build. Beta builds embed a `B<timestamp>` suffix in their version
+ string (e.g. `0.1.4B202604231800`) and a commit hash, so the in-app
+ updater can tell whether the build you're running already matches the
+ current rolling release.
+
+## [Unreleased]
+
+### Added
+- In-app self-update checker with channel toggle (stable / beta) in
+ Settings → General. Runs on startup (when "Check for updates" is on) and
+ surfaces new releases without auto-installing.
+- Fluorine version is now distinct from the embedded MO2 engine version.
+ About dialog shows `Fluorine Manager <version>` with the MO2 engine
+ version and build commit on the revision line.
+- CI publishes a rolling `beta` GitHub release on each push to `main`,
+ including a machine-parseable `fluorine-meta` block (timestamp, commit)
+ that the updater reads to detect matching builds.
+- FUSE VFS now handles `rmdir`, unblocking tools like Wrye Bash that tear
+ down temp directories inside the virtual Data folder.
+
+### Fixed
+- Bulk hide from Conflict tab (#54) no longer fails with a misleading
+ "Input/output error" when the DirectoryEntry tree has lowercased a
+ parent directory. `FileRenamer` now resolves case mismatches against the
+ real filesystem before giving up, and the rename path surfaces the
+ actual errno text instead of conflating the Windows error code 5 with
+ errno 5 (EIO).
+- Wrye Bash `NotADirectoryError: [WinError 267]` during Data-folder
+ initialization (#47). Rooted in the missing FUSE `rmdir` op.
+
+## [0.1.3]
+
+- First versioned Fluorine Manager release (baseline pre-CHANGELOG).
+ See git history for earlier work.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9b23ffd..deccb47 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,6 +1,48 @@
cmake_minimum_required(VERSION 3.16)
project(ModOrganizer2 VERSION 2.5.3 LANGUAGES C CXX)
+# ---------------------------------------------------------------------------
+# Fluorine Manager version (distinct from the MO2 engine version above).
+# ---------------------------------------------------------------------------
+set(FLUORINE_VERSION_MAJOR 0)
+set(FLUORINE_VERSION_MINOR 1)
+set(FLUORINE_VERSION_PATCH 4)
+set(FLUORINE_BUILD_CHANNEL "stable" CACHE STRING
+ "Build channel: 'stable' for tagged releases, 'beta' for rolling CI builds")
+set(FLUORINE_BUILD_TIMESTAMP "" CACHE STRING
+ "UTC timestamp (YYYYMMDDHHMM) appended to beta builds after a 'B' suffix")
+set(FLUORINE_BUILD_COMMIT "" CACHE STRING
+ "Short git commit SHA for the build, embedded in the About dialog")
+
+if(FLUORINE_BUILD_CHANNEL STREQUAL "beta")
+ set(FLUORINE_IS_BETA_BUILD 1)
+ if(FLUORINE_BUILD_TIMESTAMP STREQUAL "")
+ string(TIMESTAMP FLUORINE_BUILD_TIMESTAMP "%Y%m%d%H%M" UTC)
+ endif()
+else()
+ set(FLUORINE_IS_BETA_BUILD 0)
+endif()
+
+if(FLUORINE_BUILD_COMMIT STREQUAL "" AND EXISTS "${CMAKE_SOURCE_DIR}/.git")
+ find_program(GIT_EXECUTABLE git)
+ if(GIT_EXECUTABLE)
+ execute_process(
+ COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
+ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
+ OUTPUT_VARIABLE FLUORINE_BUILD_COMMIT
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ ERROR_QUIET)
+ endif()
+endif()
+
+configure_file(
+ "${CMAKE_SOURCE_DIR}/src/src/fluorine_build_info.h.in"
+ "${CMAKE_BINARY_DIR}/generated/fluorine_build_info.h"
+ @ONLY)
+include_directories("${CMAKE_BINARY_DIR}/generated")
+
+message(STATUS "[Fluorine] Version: ${FLUORINE_VERSION_MAJOR}.${FLUORINE_VERSION_MINOR}.${FLUORINE_VERSION_PATCH} channel=${FLUORINE_BUILD_CHANNEL} timestamp=${FLUORINE_BUILD_TIMESTAMP} commit=${FLUORINE_BUILD_COMMIT}")
+
if(POLICY CMP0135)
cmake_policy(SET CMP0135 NEW)
endif()
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index d272de5..c7bb131 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -22,12 +22,21 @@ fi
PYTHON_ROOT="$(dirname "$(dirname "${BUILD_PY}")")"
+# Forward version/channel settings from the CI workflow (or local overrides).
+# Defaults: stable channel, empty timestamp/commit (CMake fills commit from git).
+FLUORINE_BUILD_CHANNEL="${FLUORINE_BUILD_CHANNEL:-stable}"
+FLUORINE_BUILD_TIMESTAMP="${FLUORINE_BUILD_TIMESTAMP:-}"
+FLUORINE_BUILD_COMMIT="${FLUORINE_BUILD_COMMIT:-}"
+
cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DPython_EXECUTABLE="${BUILD_PY}" \
-DPython_ROOT_DIR="${PYTHON_ROOT}" \
${PYBIND11_DIR:+-Dpybind11_DIR="${PYBIND11_DIR}"} \
-DBUILD_PLUGIN_PYTHON=ON \
+ -DFLUORINE_BUILD_CHANNEL="${FLUORINE_BUILD_CHANNEL}" \
+ -DFLUORINE_BUILD_TIMESTAMP="${FLUORINE_BUILD_TIMESTAMP}" \
+ -DFLUORINE_BUILD_COMMIT="${FLUORINE_BUILD_COMMIT}" \
"${CMAKE_EXTRA_ARGS[@]}"
cmake --build build --parallel
diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
index 9d29dcf..fc3ba8a 100644
--- a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
+++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp
@@ -56,7 +56,7 @@ GameGamebryo::GameGamebryo() {}
void GameGamebryo::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath(gameName());
+ m_MyGamesPath = determineMyGamesPath(gameName(), !m_GamePath.isEmpty());
}
bool GameGamebryo::init(MOBase::IOrganizer* moInfo)
@@ -714,7 +714,8 @@ QString GameGamebryo::getSpecialPath(const QString& name)
}
#endif // _WIN32
-QString GameGamebryo::determineMyGamesPath(const QString& gameName)
+QString GameGamebryo::determineMyGamesPath(const QString& gameName,
+ bool createIfMissing)
{
const QString pattern = "%1/My Games/" + gameName;
@@ -781,21 +782,36 @@ QString GameGamebryo::determineMyGamesPath(const QString& gameName)
}
}
- // If no existing directory was found, try to create it in the configured prefix
- // so that the game launcher can populate it on first run.
+ // No existing directory found. By default we return the expected path
+ // (under the configured prefix) WITHOUT creating it — every Bethesda
+ // plugin constructs itself at startup, and pre-creating `My Games/<Game>`
+ // for every possible title (Fallout4, Oblivion, Morrowind, …) clutters
+ // the user's prefix with empty folders for games they don't have.
+ // See issue #55.
+ //
+ // Callers that actually need the directory (profile initialization,
+ // save writes, ini deployment) should mkpath on demand or pass
+ // createIfMissing=true explicitly.
if (!configuredPrefix.isEmpty()) {
const QString configuredDocs =
QDir(configuredPrefix).filePath("drive_c/users/steamuser/Documents");
const QString newPath = pattern.arg(configuredDocs);
- if (QDir().mkpath(newPath)) {
- MOBase::log::info("determineMyGamesPath: created '{}' for game '{}'", newPath,
- gameName);
+ if (createIfMissing) {
+ if (QDir().mkpath(newPath)) {
+ MOBase::log::info("determineMyGamesPath: created '{}' for game '{}'",
+ newPath, gameName);
+ return newPath;
+ }
+ } else {
+ // Return the expected path for reference; callers may check for
+ // existence before writing.
return newPath;
}
}
- MOBase::log::warn("determineMyGamesPath: could not find My Games path for '{}'",
- gameName);
+ MOBase::log::debug(
+ "determineMyGamesPath: no existing My Games path for '{}' (create=false)",
+ gameName);
#endif
return {};
diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.h b/libs/game_bethesda/src/gamebryo/gamegamebryo.h
index 78ac1d4..c926c4e 100644
--- a/libs/game_bethesda/src/gamebryo/gamegamebryo.h
+++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.h
@@ -148,7 +148,13 @@ protected:
WORD getArch(QString const& program) const;
- static QString determineMyGamesPath(const QString& gameName);
+ // createIfMissing=false is the safe default: the per-game plugin ctor
+ // calls this for every installed Bethesda game and we don't want to
+ // spam `Documents/My Games/<GameName>` directories for games the user
+ // isn't actually managing (see issue #55). Pass true only when we
+ // know the game is installed and likely to be used.
+ static QString determineMyGamesPath(const QString& gameName,
+ bool createIfMissing = false);
static QString parseEpicGamesLocation(const QStringList& manifests);
diff --git a/libs/game_bethesda/src/games/enderalse/gameenderalse.cpp b/libs/game_bethesda/src/games/enderalse/gameenderalse.cpp
index 26587b8..e2f8952 100644
--- a/libs/game_bethesda/src/games/enderalse/gameenderalse.cpp
+++ b/libs/game_bethesda/src/games/enderalse/gameenderalse.cpp
@@ -55,7 +55,7 @@ void GameEnderalSE::detectGame()
{
m_GamePath = identifyGamePath();
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QString GameEnderalSE::identifyGamePath() const
@@ -99,7 +99,7 @@ void GameEnderalSE::setGamePath(const QString& path)
{
m_GamePath = path;
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QDir GameEnderalSE::savesDirectory() const
diff --git a/libs/game_bethesda/src/games/fallout3/gamefallout3.cpp b/libs/game_bethesda/src/games/fallout3/gamefallout3.cpp
index 75e1ea3..b0d3be7 100644
--- a/libs/game_bethesda/src/games/fallout3/gamefallout3.cpp
+++ b/libs/game_bethesda/src/games/fallout3/gamefallout3.cpp
@@ -104,7 +104,7 @@ void GameFallout3::detectGame()
{
m_GamePath = identifyGamePath();
setGameVariant(identifyVariant());
- m_MyGamesPath = determineMyGamesPath("Fallout3");
+ m_MyGamesPath = determineMyGamesPath("Fallout3", !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFallout3::executables() const
diff --git a/libs/game_bethesda/src/games/fallout4/gamefallout4.cpp b/libs/game_bethesda/src/games/fallout4/gamefallout4.cpp
index 02d30cb..d1ba92f 100644
--- a/libs/game_bethesda/src/games/fallout4/gamefallout4.cpp
+++ b/libs/game_bethesda/src/games/fallout4/gamefallout4.cpp
@@ -63,7 +63,7 @@ QString GameFallout4::gameName() const
void GameFallout4::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath("Fallout4");
+ m_MyGamesPath = determineMyGamesPath("Fallout4", !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFallout4::executables() const
diff --git a/libs/game_bethesda/src/games/fallout4london/gamefo4london.cpp b/libs/game_bethesda/src/games/fallout4london/gamefo4london.cpp
index 646bf1c..06a39bd 100644
--- a/libs/game_bethesda/src/games/fallout4london/gamefo4london.cpp
+++ b/libs/game_bethesda/src/games/fallout4london/gamefo4london.cpp
@@ -65,7 +65,7 @@ QString GameFallout4London::gameName() const
void GameFallout4London::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath("Fallout4");
+ m_MyGamesPath = determineMyGamesPath("Fallout4", !m_GamePath.isEmpty());
}
QString GameFallout4London::identifyGamePath() const
diff --git a/libs/game_bethesda/src/games/fallout4vr/gamefallout4vr.cpp b/libs/game_bethesda/src/games/fallout4vr/gamefallout4vr.cpp
index 852bf7d..e0e5e38 100644
--- a/libs/game_bethesda/src/games/fallout4vr/gamefallout4vr.cpp
+++ b/libs/game_bethesda/src/games/fallout4vr/gamefallout4vr.cpp
@@ -55,7 +55,7 @@ QString GameFallout4VR::gameName() const
void GameFallout4VR::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath("Fallout4VR");
+ m_MyGamesPath = determineMyGamesPath("Fallout4VR", !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFallout4VR::executables() const
diff --git a/libs/game_bethesda/src/games/fallout76/gamefallout76.cpp b/libs/game_bethesda/src/games/fallout76/gamefallout76.cpp
index bfeb507..c1348f3 100644
--- a/libs/game_bethesda/src/games/fallout76/gamefallout76.cpp
+++ b/libs/game_bethesda/src/games/fallout76/gamefallout76.cpp
@@ -50,7 +50,7 @@ QString GameFallout76::gameName() const
void GameFallout76::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath(gameName());
+ m_MyGamesPath = determineMyGamesPath(gameName(), !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFallout76::executables() const
diff --git a/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp b/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp
index 7a147e5..f7b9413 100644
--- a/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp
+++ b/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp
@@ -102,7 +102,7 @@ void GameFalloutNV::setGamePath(const QString& path)
{
m_GamePath = path;
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QDir GameFalloutNV::savesDirectory() const
@@ -137,7 +137,7 @@ void GameFalloutNV::detectGame()
{
m_GamePath = identifyGamePath();
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFalloutNV::executables() const
diff --git a/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp b/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
index d0492af..377c65c 100644
--- a/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
+++ b/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
@@ -58,7 +58,7 @@ void GameSkyrimSE::detectGame()
{
m_GamePath = identifyGamePath();
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QString GameSkyrimSE::identifyGamePath() const
@@ -110,7 +110,7 @@ void GameSkyrimSE::setGamePath(const QString& path)
{
m_GamePath = path;
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QDir GameSkyrimSE::savesDirectory() const
diff --git a/libs/game_bethesda/src/games/starfield/gamestarfield.cpp b/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
index c6c211a..ebec948 100644
--- a/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
+++ b/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
@@ -67,7 +67,7 @@ QString GameStarfield::gameName() const
void GameStarfield::detectGame()
{
m_GamePath = identifyGamePath();
- m_MyGamesPath = determineMyGamesPath("Starfield");
+ m_MyGamesPath = determineMyGamesPath("Starfield", !m_GamePath.isEmpty());
}
QString GameStarfield::identifyGamePath() const
@@ -77,16 +77,36 @@ QString GameStarfield::identifyGamePath() const
QDir GameStarfield::dataDirectory() const
{
+#ifdef _WIN32
+ // On Windows, USVFS hooks both the game-side Data folder (where SFSE
+ // loads plugins from) and My Games\Starfield\Data (where loose content
+ // lives) transparently, so MO2 can report the My Games path as primary.
QDir dataDir = documentsDirectory().absoluteFilePath("Data");
if (!dataDir.exists())
dataDir.mkdir(dataDir.path());
return documentsDirectory().absoluteFilePath("Data");
+#else
+ // On Linux we have a single FUSE mount, so the primary dataDirectory
+ // MUST be the game-install Data folder — that's where SFSE looks for
+ // plugins. If the mount lived under My Games/Starfield/Data, the
+ // base game Data folder would only see dangling symlinks and SFSE
+ // plugins would silently fail to load. See issue #56.
+ return gameDirectory().absoluteFilePath("Data");
+#endif
}
QMap<QString, QDir> GameStarfield::secondaryDataDirectories() const
{
QMap<QString, QDir> directories;
+#ifdef _WIN32
directories.insert("game_data", gameDirectory().absoluteFilePath("Data"));
+#else
+ // Primary is now gameDirectory/Data; My Games/Starfield/Data still
+ // needs to exist so the launcher and loose-file content work. Get it
+ // populated via the secondary-mapping symlink path.
+ directories.insert("documents_data",
+ documentsDirectory().absoluteFilePath("Data"));
+#endif
return directories;
}
diff --git a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
index e09ddf0..7ba105b 100644
--- a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
+++ b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp
@@ -126,7 +126,7 @@ void GameFalloutTTW::setGamePath(const QString& path)
{
m_GamePath = path;
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QDir GameFalloutTTW::savesDirectory() const
@@ -166,7 +166,7 @@ void GameFalloutTTW::detectGame()
{
m_GamePath = identifyGamePath();
checkVariants();
- m_MyGamesPath = determineMyGamesPath(gameDirectoryName());
+ m_MyGamesPath = determineMyGamesPath(gameDirectoryName(), !m_GamePath.isEmpty());
}
QList<ExecutableInfo> GameFalloutTTW::executables() const
diff --git a/libs/uibase/include/uibase/filesystemutilities.h b/libs/uibase/include/uibase/filesystemutilities.h
index a98ba7b..b492687 100644
--- a/libs/uibase/include/uibase/filesystemutilities.h
+++ b/libs/uibase/include/uibase/filesystemutilities.h
@@ -43,6 +43,20 @@ QDLLEXPORT bool validFileName(const QString& name);
*/
QDLLEXPORT QString resolveFileCaseInsensitive(const QString& path);
+/**
+ * @brief Case-insensitively resolve every component of an absolute path.
+ *
+ * Unlike resolveFileCaseInsensitive(), walks each directory component and
+ * matches it case-insensitively against the real filesystem, so a path like
+ * `mods/foo/meshes/dlc01/bar.nif` resolves correctly when the on-disk
+ * directory is `meshes/DLC01/`.
+ *
+ * On Windows, returns the cleaned path as-is.
+ * On Linux, returns the first walk that resolves each component; if any
+ * component has no case-insensitive match, returns the cleaned input path.
+ */
+QDLLEXPORT QString resolvePathCaseInsensitive(const QString& path);
+
} // namespace MOBase
#endif // FILESYSTEM_H
diff --git a/libs/uibase/src/filesystemutilities.cpp b/libs/uibase/src/filesystemutilities.cpp
index b027f5f..ba05699 100644
--- a/libs/uibase/src/filesystemutilities.cpp
+++ b/libs/uibase/src/filesystemutilities.cpp
@@ -95,4 +95,61 @@ QString resolveFileCaseInsensitive(const QString& path)
#endif
}
+QString resolvePathCaseInsensitive(const QString& path)
+{
+#ifdef _WIN32
+ return QDir::cleanPath(path);
+#else
+ const QString clean = QDir::cleanPath(path);
+ if (QFileInfo::exists(clean)) {
+ return clean;
+ }
+
+ // Walk each component and match case-insensitively against the real
+ // filesystem. Stops as soon as a component has no case-insensitive match;
+ // returns the cleaned input in that case.
+ const QString prefix = clean.startsWith('/') ? QStringLiteral("/") : QString();
+ const QStringList parts =
+ clean.split('/', Qt::SkipEmptyParts);
+
+ QString current = prefix;
+ for (int i = 0; i < parts.size(); ++i) {
+ const QString& want = parts[i];
+ const QString next = current.isEmpty() ? want : current + '/' + want;
+
+ if (QFileInfo::exists(next)) {
+ current = next;
+ continue;
+ }
+
+ QDir dir(current.isEmpty() ? QStringLiteral(".") : current);
+ if (!dir.exists()) {
+ return clean;
+ }
+
+ const QDir::Filters filters = (i + 1 < parts.size())
+ ? (QDir::Dirs | QDir::NoDotAndDotDot |
+ QDir::Hidden | QDir::System)
+ : (QDir::Dirs | QDir::Files |
+ QDir::NoDotAndDotDot | QDir::Hidden |
+ QDir::System);
+
+ bool matched = false;
+ for (const QString& entry : dir.entryList(filters)) {
+ if (entry.compare(want, Qt::CaseInsensitive) == 0) {
+ current = current.isEmpty() ? entry : current + '/' + entry;
+ matched = true;
+ break;
+ }
+ }
+
+ if (!matched) {
+ return clean;
+ }
+ }
+
+ return current;
+#endif
+}
+
} // namespace MOBase
diff --git a/libs/uibase/src/utility.cpp b/libs/uibase/src/utility.cpp
index c0070c6..40774ae 100644
--- a/libs/uibase/src/utility.cpp
+++ b/libs/uibase/src/utility.cpp
@@ -19,6 +19,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include <uibase/utility.h>
+#include <uibase/filesystemutilities.h>
#include <uibase/log.h>
#include <uibase/report.h>
#ifndef _WIN32
@@ -44,10 +45,12 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <QtDebug>
#include <cerrno>
#include <cstring>
+#include <filesystem>
#include <format>
#include <iostream>
#include <memory>
#include <sstream>
+#include <system_error>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
@@ -532,18 +535,50 @@ namespace shell
Result Rename(const QFileInfo& src, const QFileInfo& dest, bool copyAllowed)
{
- if (QFile::rename(src.absoluteFilePath(), dest.absoluteFilePath())) {
+ const QString srcPath = src.absoluteFilePath();
+ const QString destPath = dest.absoluteFilePath();
+
+ std::error_code ec;
+ std::filesystem::rename(srcPath.toStdString(), destPath.toStdString(), ec);
+ if (!ec) {
return Result::makeSuccess();
}
+#ifndef _WIN32
+ // Case-sensitivity fallback: the source path may have been computed from a
+ // case-normalized index (e.g. DirectoryEntry's lowercased tree), so try
+ // resolving each component against the real filesystem.
+ if (ec.value() == ENOENT) {
+ const QString resolved = resolvePathCaseInsensitive(srcPath);
+ if (resolved != srcPath && QFileInfo::exists(resolved)) {
+ std::error_code ec2;
+ std::filesystem::rename(resolved.toStdString(), destPath.toStdString(),
+ ec2);
+ if (!ec2) {
+ return Result::makeSuccess();
+ }
+ ec = ec2;
+ }
+ }
+#endif
+
if (copyAllowed) {
- if (QFile::copy(src.absoluteFilePath(), dest.absoluteFilePath())) {
- QFile::remove(src.absoluteFilePath());
+ if (QFile::copy(srcPath, destPath)) {
+ QFile::remove(srcPath);
return Result::makeSuccess();
}
}
- return Result::makeFailure(ERROR_ACCESS_DENIED);
+ // Propagate the real errno text rather than a generic Windows code so the
+ // log line actually reflects what failed.
+ const int err = ec.value();
+ QString msg = QString::fromStdString(ec.message());
+ if (msg.isEmpty()) {
+ msg = QString("rename failed: errno=%1").arg(err);
+ }
+ return Result::makeFailure(err != 0 ? static_cast<DWORD>(err)
+ : ERROR_ACCESS_DENIED,
+ msg);
}
Result CreateDirectories(const QDir& dir)
@@ -1051,7 +1086,18 @@ std::wstring formatSystemMessage(DWORD id)
if (id == 0) {
return L"Success";
}
- // If it looks like an errno value (small numbers), use strerror
+
+ // Try the Windows-style mapping first: our callers use a mix of
+ // ERROR_ACCESS_DENIED/ERROR_FILE_NOT_FOUND/etc., which overlap the errno
+ // number space (e.g. Windows 5 == ACCESS_DENIED, but errno 5 == EIO). If
+ // formatError returns a known mapping, use it; otherwise fall back to
+ // strerror for real errno values.
+ const QString winMsg = shell::formatError(static_cast<int>(id));
+ if (!winMsg.startsWith(QStringLiteral("Unknown error "))) {
+ const std::string s = winMsg.toStdString();
+ return std::wstring(s.begin(), s.end());
+ }
+
if (id < 200) {
const char* msg = strerror(static_cast<int>(id));
if (msg) {
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index e5a8230..e59c624 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -90,6 +90,8 @@ set_target_properties(organizer PROPERTIES
target_include_directories(organizer PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
+ # Generated Fluorine build-info header lives alongside other generated files.
+ ${CMAKE_BINARY_DIR}/generated
# Bare includes: source uses <iplugin*.h>, <log.h>, etc. without uibase/ prefix
${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase
${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase/game_features)
diff --git a/src/src/aboutdialog.cpp b/src/src/aboutdialog.cpp
index 94ccfcb..421b976 100644
--- a/src/src/aboutdialog.cpp
+++ b/src/src/aboutdialog.cpp
@@ -20,6 +20,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "aboutdialog.h"
#include "shared/util.h"
#include "ui_aboutdialog.h"
+#include <fluorine_build_info.h>
#include <utility.h>
#include <QApplication>
@@ -78,17 +79,31 @@ AboutDialog::AboutDialog(const QString& version, QWidget* parent)
addLicense("DXTex Headers", LICENSE_DXTEX);
addLicense("Valve File VDF Reader", LICENSE_VDF);
+ // Show Fluorine Manager's own version (e.g. "0.1.4" stable or "0.1.4B202604231800"
+ // for beta builds) instead of the upstream MO2 engine version. The incoming
+ // `version` parameter is the engine version string and is retained on the
+ // revision line for reference.
+ QString displayVersion = QStringLiteral(FLUORINE_DISPLAY_VERSION);
+ if (FLUORINE_IS_BETA_BUILD) {
+ displayVersion += QStringLiteral(" (beta)");
+ }
ui->nameLabel->setText(
QString("<span style=\"font-size:12pt; font-weight:600;\">%1 %2</span>")
.arg(ui->nameLabel->text())
- .arg(version));
+ .arg(displayVersion));
+
+ QString revision = ui->revisionLabel->text() +
+ QStringLiteral(" MO2 engine ") + version;
+ const QString commit = QStringLiteral(FLUORINE_BUILD_COMMIT);
+ if (!commit.isEmpty()) {
+ revision += QStringLiteral(" · ") + commit;
+ }
#if defined(HGID)
- ui->revisionLabel->setText(ui->revisionLabel->text() + " " + HGID);
+ revision += QStringLiteral(" · ") + HGID;
#elif defined(GITID)
- ui->revisionLabel->setText(ui->revisionLabel->text() + " " + GITID);
-#else
- ui->revisionLabel->setText(ui->revisionLabel->text() + " unknown");
+ revision += QStringLiteral(" · ") + GITID;
#endif
+ ui->revisionLabel->setText(revision);
ui->usvfsLabel->setText(ui->usvfsLabel->text() + " FUSE 3");
ui->licenseText->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
diff --git a/src/src/filerenamer.cpp b/src/src/filerenamer.cpp
index 7319a63..1b61f7b 100644
--- a/src/src/filerenamer.cpp
+++ b/src/src/filerenamer.cpp
@@ -1,6 +1,7 @@
#include "filerenamer.h"
#include <QFileInfo>
#include <QMessageBox>
+#include <filesystemutilities.h>
#include <log.h>
#include <utility.h>
@@ -22,7 +23,29 @@ FileRenamer::RenameResults FileRenamer::rename(const QString& oldName,
{
log::debug("renaming {} to {}", oldName, newName);
- if (QFileInfo(newName).exists()) {
+ // Case-sensitivity safety net: if oldName doesn't resolve on disk but a
+ // case-folded variant does, retarget the rename to the real path. This
+ // hits when the source path comes from MO2's case-normalized
+ // DirectoryEntry tree (e.g. Conflict tab bulk-hide) while the real
+ // filesystem has different casing for parent dirs.
+ QString effectiveOld = oldName;
+ QString effectiveNew = newName;
+ if (!QFileInfo::exists(effectiveOld)) {
+ const QString resolved = MOBase::resolvePathCaseInsensitive(oldName);
+ if (resolved != oldName && QFileInfo::exists(resolved)) {
+ log::debug("resolved case-mismatched source '{}' -> '{}'", oldName,
+ resolved);
+ effectiveOld = resolved;
+
+ // Keep the destination's directory component aligned with the resolved
+ // source so we don't create a parallel lowercase directory tree.
+ const QFileInfo oldInfo(resolved);
+ const QFileInfo newInfo(newName);
+ effectiveNew = oldInfo.absolutePath() + '/' + newInfo.fileName();
+ }
+ }
+
+ if (QFileInfo(effectiveNew).exists()) {
log::debug("{} already exists", newName);
// target file already exists, confirm replacement
@@ -69,25 +92,27 @@ FileRenamer::RenameResults FileRenamer::rename(const QString& oldName,
}
// target either didn't exist or was removed correctly
- const auto r = shell::Rename(QFileInfo(oldName), QFileInfo(newName));
+ const auto r =
+ shell::Rename(QFileInfo(effectiveOld), QFileInfo(effectiveNew));
if (!r.success()) {
- log::error("failed to rename '{}' to '{}': {}", oldName, newName, r.toString());
+ log::error("failed to rename '{}' to '{}': {}", effectiveOld, effectiveNew,
+ r.toString());
// renaming failed, warn the user and allow canceling
- if (!renameFailed(oldName, newName, r)) {
+ if (!renameFailed(effectiveOld, effectiveNew, r)) {
// user wants to cancel
log::debug("canceling");
return RESULT_CANCEL;
}
// ignore this file and continue on
- log::debug("skipping {}", oldName);
+ log::debug("skipping {}", effectiveOld);
return RESULT_SKIP;
}
// everything worked
- log::debug("successfully renamed {} to {}", oldName, newName);
+ log::debug("successfully renamed {} to {}", effectiveOld, effectiveNew);
return RESULT_OK;
}
diff --git a/src/src/fluorine_build_info.h.in b/src/src/fluorine_build_info.h.in
new file mode 100644
index 0000000..32e440e
--- /dev/null
+++ b/src/src/fluorine_build_info.h.in
@@ -0,0 +1,24 @@
+#ifndef FLUORINE_BUILD_INFO_H
+#define FLUORINE_BUILD_INFO_H
+
+// Generated by CMake from fluorine_build_info.h.in — do not edit.
+
+#define FLUORINE_VERSION_MAJOR @FLUORINE_VERSION_MAJOR@
+#define FLUORINE_VERSION_MINOR @FLUORINE_VERSION_MINOR@
+#define FLUORINE_VERSION_PATCH @FLUORINE_VERSION_PATCH@
+#define FLUORINE_VERSION_STRING "@FLUORINE_VERSION_MAJOR@.@FLUORINE_VERSION_MINOR@.@FLUORINE_VERSION_PATCH@"
+
+#define FLUORINE_BUILD_CHANNEL "@FLUORINE_BUILD_CHANNEL@"
+#define FLUORINE_BUILD_TIMESTAMP "@FLUORINE_BUILD_TIMESTAMP@"
+#define FLUORINE_BUILD_COMMIT "@FLUORINE_BUILD_COMMIT@"
+#define FLUORINE_IS_BETA_BUILD @FLUORINE_IS_BETA_BUILD@
+
+// Full display string. For beta builds: "<semver>B<timestamp>".
+// For stable builds: plain "<semver>".
+#if FLUORINE_IS_BETA_BUILD
+# define FLUORINE_DISPLAY_VERSION FLUORINE_VERSION_STRING "B" FLUORINE_BUILD_TIMESTAMP
+#else
+# define FLUORINE_DISPLAY_VERSION FLUORINE_VERSION_STRING
+#endif
+
+#endif // FLUORINE_BUILD_INFO_H
diff --git a/src/src/fluorineupdater.cpp b/src/src/fluorineupdater.cpp
new file mode 100644
index 0000000..dd96eb9
--- /dev/null
+++ b/src/src/fluorineupdater.cpp
@@ -0,0 +1,303 @@
+#include "fluorineupdater.h"
+
+#include <fluorine_build_info.h>
+#include <log.h>
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QRegularExpression>
+#include <QScopeGuard>
+#include <QStringList>
+#include <QUrl>
+
+#include <array>
+
+namespace
+{
+// Hardcoded for now — if the project ever moves, these become CMake-configured
+// constants like the version info. Keeping them here avoids wiring a second
+// generated header just for two strings.
+constexpr const char* kRepoOwner = "SulfurNitride";
+constexpr const char* kRepoProject = "Fluorine-Manager";
+
+QString buildApiUrl(FluorineUpdater::Channel c)
+{
+ const QString base =
+ QStringLiteral("https://api.github.com/repos/%1/%2/releases")
+ .arg(kRepoOwner, kRepoProject);
+ return c == FluorineUpdater::Channel::Stable
+ ? base + QStringLiteral("/latest")
+ : base + QStringLiteral("/tags/beta");
+}
+
+// Returns {major, minor, patch}; components that fail to parse stay at -1.
+std::array<int, 3> parseSemver(const QString& tagOrVersion)
+{
+ QString s = tagOrVersion.trimmed();
+ if (s.startsWith('v') || s.startsWith('V')) {
+ s.remove(0, 1);
+ }
+
+ std::array<int, 3> out{-1, -1, -1};
+ const QStringList parts = s.split('.', Qt::SkipEmptyParts);
+ for (int i = 0; i < 3 && i < parts.size(); ++i) {
+ bool ok = false;
+ // Drop any prerelease suffix like "0.1.5-beta.2" — we only care about
+ // the numeric triple for the coarse newer/older decision.
+ QString num = parts[i];
+ const int dashIdx = num.indexOf(QRegularExpression("[^0-9]"));
+ if (dashIdx >= 0) {
+ num = num.left(dashIdx);
+ }
+ const int v = num.toInt(&ok);
+ if (ok) {
+ out[i] = v;
+ }
+ }
+ return out;
+}
+
+bool isStableNewerThan(const QString& releaseTag, const QString& currentSemver)
+{
+ const auto a = parseSemver(releaseTag);
+ const auto b = parseSemver(currentSemver);
+ for (int i = 0; i < 3; ++i) {
+ if (a[i] != b[i]) {
+ return a[i] > b[i];
+ }
+ }
+ return false;
+}
+} // namespace
+
+FluorineUpdater::FluorineUpdater(QObject* parent)
+ : QObject(parent), m_net(new QNetworkAccessManager(this))
+{}
+
+FluorineUpdater::~FluorineUpdater() = default;
+
+FluorineUpdater::Channel FluorineUpdater::buildChannel()
+{
+#if FLUORINE_IS_BETA_BUILD
+ return Channel::Beta;
+#else
+ return Channel::Stable;
+#endif
+}
+
+QString FluorineUpdater::channelToString(Channel c)
+{
+ return c == Channel::Beta ? QStringLiteral("beta") : QStringLiteral("stable");
+}
+
+FluorineUpdater::Channel FluorineUpdater::channelFromString(const QString& s,
+ Channel fallback)
+{
+ if (s.compare(QStringLiteral("beta"), Qt::CaseInsensitive) == 0) {
+ return Channel::Beta;
+ }
+ if (s.compare(QStringLiteral("stable"), Qt::CaseInsensitive) == 0) {
+ return Channel::Stable;
+ }
+ return fallback;
+}
+
+void FluorineUpdater::checkForUpdates(Channel channel)
+{
+ if (m_reply != nullptr) {
+ // A check is already in flight — cancel and restart with the new channel
+ // so the caller's Settings toggle wins over any stale startup probe.
+ m_reply->abort();
+ m_reply->deleteLater();
+ m_reply = nullptr;
+ }
+
+ m_pendingChannel = channel;
+
+ QNetworkRequest req{QUrl(buildApiUrl(channel))};
+ req.setRawHeader("User-Agent", "Fluorine-Manager/updater");
+ req.setRawHeader("Accept", "application/vnd.github+json");
+ req.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+
+ m_reply = m_net->get(req);
+ connect(m_reply, &QNetworkReply::finished, this,
+ &FluorineUpdater::onReplyFinished);
+}
+
+void FluorineUpdater::onReplyFinished()
+{
+ if (m_reply == nullptr) {
+ return;
+ }
+
+ QNetworkReply* reply = m_reply;
+ m_reply = nullptr;
+
+ const Channel channel = m_pendingChannel;
+ auto cleanup = qScopeGuard([reply] { reply->deleteLater(); });
+
+ if (reply->error() != QNetworkReply::NoError) {
+ const QString err = reply->errorString();
+ MOBase::log::warn("update check failed ({}): {}",
+ channelToString(channel), err);
+ emit checkFailed(err);
+ return;
+ }
+
+ const QByteArray raw = reply->readAll();
+ const QJsonDocument doc = QJsonDocument::fromJson(raw);
+ if (!doc.isObject()) {
+ emit checkFailed(tr("GitHub returned an unexpected response"));
+ return;
+ }
+
+ ReleaseInfo info;
+ info.channel = channel;
+ const QJsonObject obj = doc.object();
+
+ bool ok = false;
+ if (channel == Channel::Beta) {
+ ok = parseBetaRelease(obj, info);
+ } else {
+ ok = parseStableRelease(obj, info);
+ }
+
+ if (!ok) {
+ emit checkFailed(tr("Unable to parse release metadata"));
+ return;
+ }
+
+ if (channel == Channel::Beta) {
+ const QString currentTs = QStringLiteral(FLUORINE_BUILD_TIMESTAMP);
+ const QString currentCommit = QStringLiteral(FLUORINE_BUILD_COMMIT);
+
+ // If the fluorine-meta block is missing, fall back to timestamp-only
+ // comparison. If even the timestamp is empty, we can't make a safe
+ // decision — surface as checkFailed so the user knows.
+ if (info.timestamp.isEmpty()) {
+ emit checkFailed(
+ tr("Beta release is missing build metadata; cannot compare"));
+ return;
+ }
+
+ const bool sameCommit = !currentCommit.isEmpty() && !info.commit.isEmpty() &&
+ currentCommit == info.commit;
+ const bool sameTimestamp = !currentTs.isEmpty() && currentTs == info.timestamp;
+
+ if (sameCommit || sameTimestamp) {
+ emit upToDate(info);
+ } else {
+ emit updateAvailable(info);
+ }
+ } else {
+ const QString currentVersion = QStringLiteral(FLUORINE_VERSION_STRING);
+ if (isStableNewerThan(info.versionString, currentVersion)) {
+ emit updateAvailable(info);
+ } else {
+ emit upToDate(info);
+ }
+ }
+}
+
+bool FluorineUpdater::parseStableRelease(const QJsonObject& obj,
+ ReleaseInfo& out) const
+{
+ out.tagName = obj.value(QStringLiteral("tag_name")).toString();
+ out.name = obj.value(QStringLiteral("name")).toString();
+ out.htmlUrl = obj.value(QStringLiteral("html_url")).toString();
+
+ if (out.tagName.isEmpty()) {
+ return false;
+ }
+
+ out.versionString = out.tagName;
+ if (out.versionString.startsWith('v') || out.versionString.startsWith('V')) {
+ out.versionString.remove(0, 1);
+ }
+
+ // Pick the first tar.gz asset as the download target.
+ const QJsonArray assets = obj.value(QStringLiteral("assets")).toArray();
+ for (const QJsonValue& v : assets) {
+ const QJsonObject a = v.toObject();
+ const QString name = a.value(QStringLiteral("name")).toString();
+ if (name.endsWith(QStringLiteral(".tar.gz"), Qt::CaseInsensitive)) {
+ out.downloadUrl =
+ a.value(QStringLiteral("browser_download_url")).toString();
+ break;
+ }
+ }
+ return true;
+}
+
+bool FluorineUpdater::parseBetaRelease(const QJsonObject& obj,
+ ReleaseInfo& out) const
+{
+ out.tagName = obj.value(QStringLiteral("tag_name")).toString();
+ out.name = obj.value(QStringLiteral("name")).toString();
+ out.htmlUrl = obj.value(QStringLiteral("html_url")).toString();
+
+ // Extract fluorine-meta block from release body. Format emitted by the CI
+ // workflow:
+ // <!-- fluorine-meta
+ // channel=beta
+ // timestamp=YYYYMMDDHHMM
+ // commit=<full sha>
+ // short=<7-char sha>
+ // -->
+ const QString body = obj.value(QStringLiteral("body")).toString();
+ const int metaStart = body.indexOf(QStringLiteral("<!-- fluorine-meta"));
+ if (metaStart < 0) {
+ MOBase::log::debug("beta release body missing fluorine-meta block");
+ return true; // partially parsed — let caller surface checkFailed
+ }
+ const int metaEnd = body.indexOf(QStringLiteral("-->"), metaStart);
+ const QString block =
+ metaEnd > metaStart ? body.mid(metaStart, metaEnd - metaStart) : QString();
+
+ const QStringList lines =
+ block.split(QRegularExpression(QStringLiteral("[\\r\\n]+")),
+ Qt::SkipEmptyParts);
+ for (const QString& line : lines) {
+ const int eq = line.indexOf('=');
+ if (eq <= 0) {
+ continue;
+ }
+ const QString key = line.left(eq).trimmed();
+ const QString value = line.mid(eq + 1).trimmed();
+ if (key == QStringLiteral("timestamp")) {
+ out.timestamp = value;
+ } else if (key == QStringLiteral("commit")) {
+ out.commit = value;
+ }
+ }
+
+ // Prefer a tar.gz asset with "beta" in the name, else first tar.gz.
+ const QJsonArray assets = obj.value(QStringLiteral("assets")).toArray();
+ QString fallback;
+ for (const QJsonValue& v : assets) {
+ const QJsonObject a = v.toObject();
+ const QString name = a.value(QStringLiteral("name")).toString();
+ if (!name.endsWith(QStringLiteral(".tar.gz"), Qt::CaseInsensitive)) {
+ continue;
+ }
+ const QString url =
+ a.value(QStringLiteral("browser_download_url")).toString();
+ if (name.contains(QStringLiteral("beta"), Qt::CaseInsensitive)) {
+ out.downloadUrl = url;
+ break;
+ }
+ if (fallback.isEmpty()) {
+ fallback = url;
+ }
+ }
+ if (out.downloadUrl.isEmpty()) {
+ out.downloadUrl = fallback;
+ }
+ return true;
+}
diff --git a/src/src/fluorineupdater.h b/src/src/fluorineupdater.h
new file mode 100644
index 0000000..a573584
--- /dev/null
+++ b/src/src/fluorineupdater.h
@@ -0,0 +1,75 @@
+#ifndef FLUORINE_UPDATER_H
+#define FLUORINE_UPDATER_H
+
+#include <QJsonObject>
+#include <QObject>
+#include <QString>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// Lightweight self-update checker. Queries the GitHub Releases API for
+// Fluorine Manager and notifies when a newer build is available. Does not
+// perform auto-install — the user is given a download URL to apply manually,
+// matching the existing MO2 updater UX.
+//
+// Two channels:
+// stable: fetches the latest tagged `v*` release and compares against the
+// build's FLUORINE_VERSION_STRING as a semver-ish triple.
+// beta: fetches the rolling `beta` tag's release and compares the
+// fluorine-meta timestamp/commit block against the installed build.
+class FluorineUpdater : public QObject
+{
+ Q_OBJECT
+
+public:
+ enum class Channel
+ {
+ Stable,
+ Beta,
+ };
+
+ struct ReleaseInfo
+ {
+ Channel channel = Channel::Stable;
+ QString tagName; // "v0.1.5" or "beta"
+ QString name; // release title
+ QString htmlUrl; // release HTML page
+ QString downloadUrl; // first .tar.gz asset URL (may be empty)
+ QString timestamp; // beta only: "YYYYMMDDHHMM"
+ QString commit; // beta only: full commit SHA (from fluorine-meta)
+ QString versionString; // stable only: tag minus leading 'v'
+ };
+
+ explicit FluorineUpdater(QObject* parent = nullptr);
+ ~FluorineUpdater() override;
+
+ // Kick off an async check. Emits updateAvailable()/upToDate()/checkFailed()
+ // exactly once per call.
+ void checkForUpdates(Channel channel);
+
+ // Build channel that was baked into this binary at compile time. The
+ // Settings toggle defaults to this value.
+ static Channel buildChannel();
+
+ static QString channelToString(Channel c);
+ static Channel channelFromString(const QString& s, Channel fallback);
+
+signals:
+ void updateAvailable(const FluorineUpdater::ReleaseInfo& info);
+ void upToDate(const FluorineUpdater::ReleaseInfo& info);
+ void checkFailed(const QString& reason);
+
+private slots:
+ void onReplyFinished();
+
+private:
+ bool parseBetaRelease(const QJsonObject& obj, ReleaseInfo& out) const;
+ bool parseStableRelease(const QJsonObject& obj, ReleaseInfo& out) const;
+
+ QNetworkAccessManager* m_net;
+ QNetworkReply* m_reply = nullptr;
+ Channel m_pendingChannel = Channel::Stable;
+};
+
+#endif // FLUORINE_UPDATER_H
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 3e327bc..705bed8 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -174,6 +174,7 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->setattr = mo2_setattr;
ops->unlink = mo2_unlink;
ops->mkdir = mo2_mkdir;
+ ops->rmdir = mo2_rmdir;
ops->release = mo2_release;
ops->releasedir = mo2_releasedir;
// access handler removed: default_permissions mount option lets the kernel
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index c1cb9aa..96b3264 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -1,6 +1,7 @@
#include "organizercore.h"
#include "categoriesdialog.h"
#include "credentialsdialog.h"
+#include "fluorineupdater.h"
#include "delayedfilewriter.h"
#include "directoryrefresher.h"
#include "env.h"
@@ -114,9 +115,19 @@ QString resolveWinePrefixPath(const Settings& settings,
return cfg->prefix_path.trimmed();
}
+ // Same precedence rule as spawn.cpp's resolvePrefixPath: explicit
+ // fluorine/prefix_path wins over the legacy Settings/* keys, which may
+ // have been auto-populated with an external manager's prefix (Heroic,
+ // Bottles). Without this, switching instances or rebuilding the Fluorine
+ // config can silently drop us onto the wrong prefix (issue #52).
const QSettings instanceSettings(settings.filename(), QSettings::IniFormat);
+ const QString explicitPath =
+ instanceSettings.value("fluorine/prefix_path").toString().trimmed();
+ if (!explicitPath.isEmpty()) {
+ return explicitPath;
+ }
for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path",
- "Proton/prefix_path", "fluorine/prefix_path"}) {
+ "Proton/prefix_path"}) {
const QString value = instanceSettings.value(key).toString().trimmed();
if (!value.isEmpty()) {
return value;
@@ -389,9 +400,45 @@ void OrganizerCore::checkForUpdates()
// display the result
if (m_UserInterface != nullptr) {
m_Updater.testForUpdate(m_Settings);
+ checkForFluorineUpdates();
}
}
+void OrganizerCore::checkForFluorineUpdates()
+{
+ // Set up the Fluorine self-update checker lazily so repeated calls don't
+ // leak QNetworkAccessManager instances. The member is forward-declared in
+ // the header (pointer-only); the include lives here to keep the header
+ // lightweight for its many consumers.
+ if (m_FluorineUpdater == nullptr) {
+ m_FluorineUpdater = new FluorineUpdater(this);
+
+ connect(m_FluorineUpdater, &FluorineUpdater::updateAvailable, this,
+ [](const FluorineUpdater::ReleaseInfo& info) {
+ const QString channel =
+ FluorineUpdater::channelToString(info.channel);
+ MOBase::log::info(
+ "Fluorine update available ({}): {} at {}",
+ channel,
+ info.tagName.isEmpty() ? info.name : info.tagName,
+ info.htmlUrl);
+ });
+ connect(m_FluorineUpdater, &FluorineUpdater::upToDate, this,
+ [](const FluorineUpdater::ReleaseInfo& info) {
+ MOBase::log::debug("Fluorine is up to date ({})",
+ FluorineUpdater::channelToString(info.channel));
+ });
+ connect(m_FluorineUpdater, &FluorineUpdater::checkFailed, this,
+ [](const QString& reason) {
+ MOBase::log::debug("Fluorine update check failed: {}", reason);
+ });
+ }
+
+ const FluorineUpdater::Channel channel = FluorineUpdater::channelFromString(
+ m_Settings.fluorineUpdateChannel(), FluorineUpdater::buildChannel());
+ m_FluorineUpdater->checkForUpdates(channel);
+}
+
void OrganizerCore::connectPlugins(PluginContainer* container)
{
m_PluginContainer = container;
diff --git a/src/src/organizercore.h b/src/src/organizercore.h
index dcf07c7..2ff7484 100644
--- a/src/src/organizercore.h
+++ b/src/src/organizercore.h
@@ -257,6 +257,7 @@ public:
void updateModInfoFromDisc();
void checkForUpdates();
+ void checkForFluorineUpdates();
void startMOUpdate();
Settings& settings();
@@ -557,6 +558,7 @@ private:
Settings& m_Settings;
SelfUpdater m_Updater;
+ class FluorineUpdater* m_FluorineUpdater = nullptr;
SignalAboutToRunApplication m_AboutToRun;
SignalFinishedRunApplication m_FinishedRun;
diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp
index 8543437..8bca32e 100644
--- a/src/src/prefixsetuprunner.cpp
+++ b/src/src/prefixsetuprunner.cpp
@@ -3,6 +3,7 @@
#include "fluorinepaths.h"
#include "gamedetection.h"
+#include "slrmanager.h"
#include "steamdetection.h"
#include "prefixsymlinks.h"
@@ -592,6 +593,19 @@ QProcess* PrefixSetupRunner::buildWrappedProcess(
restoreOrStrip("XDG_DATA_DIRS", "FLUORINE_ORIG_XDG_DATA_DIRS");
restoreOrStrip("QT_PLUGIN_PATH", "FLUORINE_ORIG_QT_PLUGIN_PATH");
+ // Expose the injected xrandr (steamrt4 ships without it) so protonfixes
+ // and Proton-GE init scripts can find it. Pressure-vessel forces PATH
+ // inside the container, so we prepend the xrandr dir on the HOST PATH
+ // and also pass it through --filesystem below.
+ const QString xrandrDir =
+ QDir::homePath() + "/.local/share/fluorine/steamrt/xrandr-bin";
+ if (QDir(xrandrDir).exists()) {
+ const QString existing = env.value("PATH");
+ env.insert("PATH", existing.isEmpty()
+ ? xrandrDir
+ : xrandrDir + QLatin1Char(':') + existing);
+ }
+
// Apply caller-provided env vars.
for (auto it = extraEnv.begin(); it != extraEnv.end(); ++it) {
env.insert(it.key(), it.value());
@@ -626,7 +640,22 @@ QProcess* PrefixSetupRunner::buildWrappedProcess(
if (QDir(cacheDir).exists())
slrArgs << QStringLiteral("--filesystem=%1").arg(cacheDir);
- slrArgs << "--" << exe;
+ // Expose the injected xrandr bin dir so Proton-GE's protonfixes can
+ // invoke it during wineboot -u. Without this the container's PATH
+ // (forced to /usr/bin:/bin) has no xrandr and init fails on some
+ // modern Proton builds. See issue #49.
+ if (QDir(xrandrDir).exists())
+ slrArgs << QStringLiteral("--filesystem=%1").arg(xrandrDir);
+
+ // Pressure-vessel resets PATH inside the container. Wrap the exec
+ // through /usr/bin/env to inject the xrandr dir back into the
+ // container's PATH.
+ if (QDir(xrandrDir).exists()) {
+ slrArgs << "--" << QStringLiteral("/usr/bin/env")
+ << QStringLiteral("PATH=%1:/usr/bin:/bin").arg(xrandrDir) << exe;
+ } else {
+ slrArgs << "--" << exe;
+ }
proc->setProgram(m_slrRunScript);
proc->setArguments(slrArgs);
@@ -793,6 +822,17 @@ bool PrefixSetupRunner::stepProtonInit()
// for them. Nothing cleans them up automatically.
killStalePrefixProcesses();
+ // Proton-GE invokes `xrandr` during protonfixes at wineboot time. The
+ // steamrt4 pressure-vessel container ships without it, so back-fill our
+ // injected copy before init if missing — otherwise the protonfix
+ // silently no-ops and prefix setup can hang or fall back to a broken
+ // state on multi-monitor machines. See issue #49.
+ if (!isXrandrInjected()) {
+ emit logMessage("xrandr helper missing; downloading…");
+ ensureXrandrInstalled(
+ nullptr, [this](const QString& msg) { emit logMessage(msg); });
+ }
+
const QString steamPath = detectSteamPath();
// The compatdata path is the PARENT of the pfx directory.
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp
index 4457cdb..5a47d95 100644
--- a/src/src/processrunner.cpp
+++ b/src/src/processrunner.cpp
@@ -804,6 +804,107 @@ pid_t findTrackedProcess(pid_t rootPid, const QStringList &expected,
return best;
}
+// Scan every process owned by the current user for one whose comm or
+// cmdline matches an expected game executable (e.g. FalloutNV.exe,
+// SkyrimSE.exe). Used as a fallback after the immediate descendant tree
+// loses the game — Proton's session manager can reparent game processes
+// outside of our root PID's subtree, so a plain-descendant walk misses
+// them. Only processes inside the given WINEPREFIX are considered so we
+// don't latch onto an unrelated Wine/Proton session.
+pid_t findGameProcessInPrefix(const QStringList &expected,
+ const QString &winePrefix,
+ QString *matchedNameOut) {
+ if (expected.isEmpty()) {
+ return 0;
+ }
+
+ const uid_t myUid = ::getuid();
+ DIR *proc = opendir("/proc");
+ if (!proc) {
+ return 0;
+ }
+
+ QString expectedPrefixCanon;
+ if (!winePrefix.isEmpty()) {
+ expectedPrefixCanon = QDir(winePrefix).canonicalPath();
+ }
+
+ pid_t best = 0;
+ struct dirent *entry = nullptr;
+ while ((entry = readdir(proc)) != nullptr) {
+ if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) continue;
+ const char *name = entry->d_name;
+ if (*name == '\0' || !std::isdigit(static_cast<unsigned char>(*name)))
+ continue;
+
+ const pid_t pid = static_cast<pid_t>(std::strtol(name, nullptr, 10));
+
+ struct stat st;
+ if (::stat(QString("/proc/%1").arg(pid).toStdString().c_str(), &st) != 0 ||
+ st.st_uid != myUid) {
+ continue;
+ }
+
+ QString matched;
+ if (!processMatchesExpected(pid, expected, &matched)) {
+ continue;
+ }
+
+ // Constrain to the same WINEPREFIX so we don't latch onto an unrelated
+ // Wine process (another instance, winetricks, etc.).
+ if (!expectedPrefixCanon.isEmpty()) {
+ const QString pidPrefix = readProcEnvVar(pid, "WINEPREFIX");
+ if (pidPrefix.isEmpty()) continue;
+ if (QDir(pidPrefix).canonicalPath() != expectedPrefixCanon) continue;
+ }
+
+ best = pid;
+ if (matchedNameOut) *matchedNameOut = matched;
+ break;
+ }
+ closedir(proc);
+ return best;
+}
+
+// Best-effort "hard kill" of the wineserver (and every Wine process it
+// owns) for the given prefix. Used when the user clicks Unlock in the
+// lock dialog — Proton's session manager can keep wineserver alive for
+// tens of seconds after the game exits, and that's exactly what the
+// user is asking us to short-circuit. We call `wineserver -k` if the
+// Proton distribution exposes one, then fall back to SIGKILL on the
+// wineserver pid so the prefix is freed regardless.
+void killWineserverForPrefix(const QString &winePrefix) {
+ if (winePrefix.isEmpty()) {
+ return;
+ }
+
+ const pid_t ws = findWineserver(winePrefix);
+ if (ws > 0) {
+ log::info("sending SIGTERM to wineserver {} for prefix '{}'", ws,
+ winePrefix.toStdString());
+ if (::kill(ws, SIGTERM) != 0 && errno != ESRCH) {
+ log::warn("SIGTERM on wineserver {} failed, errno={}", ws, errno);
+ }
+ // Give wineserver a short window to tear down cleanly, then SIGKILL
+ // if it's still hanging around — the whole point of the Unlock
+ // button is to not keep the user waiting.
+ for (int i = 0; i < 10; ++i) {
+ if (::kill(ws, 0) != 0 && errno == ESRCH) {
+ break;
+ }
+ QThread::msleep(100);
+ }
+ if (::kill(ws, 0) == 0) {
+ log::warn("wineserver {} did not exit on SIGTERM, sending SIGKILL",
+ ws);
+ ::kill(ws, SIGKILL);
+ }
+ } else {
+ log::debug("killWineserverForPrefix: no wineserver found for '{}'",
+ winePrefix.toStdString());
+ }
+}
+
DWORD exitCodeFromWaitStatus(int status) {
if (WIFEXITED(status)) {
return static_cast<DWORD>(WEXITSTATUS(status));
@@ -873,30 +974,44 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
// The tracked process is no longer a descendant of the root PID.
// This can happen when:
// a) The root (proton) exits and wine/game processes get reparented
- // b) A launcher .exe (nvse_loader, skse_loader) exits after spawning
- // the actual game (FalloutNV.exe, SkyrimSE.exe)
+ // b) A launcher .exe (nvse_loader, skse_loader, f4se_loader) exits
+ // after spawning the actual game (FalloutNV.exe, SkyrimSE.exe,
+ // Fallout4.exe)
//
- // Before declaring the game exited, check if the last tracked PID is
- // still alive. If not, fall back to waiting for wineserver — it stays
- // alive as long as ANY wine process in the prefix is running.
+ // If the last tracked PID is still alive, keep polling it directly.
if (lastTrackedPid > 0 && ::kill(lastTrackedPid, 0) == 0) {
displayPid = lastTrackedPid;
displayName = readProcComm(lastTrackedPid);
} else {
- const pid_t ws = findWineserver(winePrefix);
- if (ws > 0) {
- log::info("tracked process exited, waiting for wineserver {}", ws);
- lastTrackedPid = ws;
+ // The previously tracked process is gone. Rescan the user's
+ // processes for any of the expected game executables in the same
+ // WINEPREFIX — Proton's session manager can reparent the game
+ // out of our descendant tree. If we find one, track that.
+ QString rescanName;
+ const pid_t rescanned =
+ findGameProcessInPrefix(expected, winePrefix, &rescanName);
+ if (rescanned > 0 && rescanned != lastTrackedPid) {
+ log::info("tracked process exited, resumed tracking game {}: {}",
+ rescanned, rescanName.toStdString());
+ lastTrackedPid = rescanned;
useKillPoll = true;
- displayPid = ws;
- displayName = QStringLiteral("wineserver");
+ displayPid = rescanned;
+ displayName = rescanName;
continue;
}
+
+ // No matching game process remains. Do NOT fall back to waiting
+ // for wineserver — Proton's session manager keeps wineserver
+ // alive for the prefix idle timeout (several seconds on modern
+ // Proton, much longer under load), and blocking MO2's lock on
+ // that is indistinguishable from a hang from the user's POV
+ // (issue: "Fluorine stuck tracking wineserver"). The game has
+ // truly exited once no expected executable is running.
if (exitCode != nullptr) {
*exitCode = 0;
}
- log::debug("tracked child process {} for root {} exited (no wineserver)",
- lastTrackedPid, pid);
+ log::debug("game processes for root {} exited; releasing lock "
+ "(wineserver may linger in background)", pid);
return ProcessRunner::Completed;
}
}
@@ -915,17 +1030,23 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
: pid;
if (::kill(pollPid, 0) != 0) {
if (errno == ESRCH) {
- // The polled process exited. If it was the game (not wineserver),
- // try falling back to wineserver — the real game may still be running
- // (e.g. nvse_loader exits after spawning FalloutNV.exe).
+ // The polled process exited. Rescan the prefix for any other
+ // matching game executable (launcher .exe's like f4se_loader
+ // exit after spawning the real game binary, and Proton can
+ // reparent that binary out of our root's subtree).
if (seenTrackedProcess) {
- const pid_t ws = findWineserver(winePrefix);
- if (ws > 0 && ws != pollPid) {
- log::info("polled process {} exited, falling back to wineserver {}", pollPid, ws);
- lastTrackedPid = ws;
+ QString rescanName;
+ const pid_t rescanned =
+ findGameProcessInPrefix(expected, winePrefix, &rescanName);
+ if (rescanned > 0 && rescanned != pollPid) {
+ log::info("polled process {} exited, resumed tracking game {}: {}",
+ pollPid, rescanned, rescanName.toStdString());
+ lastTrackedPid = rescanned;
continue;
}
}
+ // No game process remains — do NOT block on wineserver. See
+ // the equivalent note above for why.
if (exitCode != nullptr) {
*exitCode = 0;
}
@@ -988,6 +1109,12 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode,
if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) {
log::warn("failed to terminate {}, errno={}", displayPid, errno);
}
+ // User clicked Unlock — they're asking us to stop waiting on the
+ // prefix, so also hard-kill the wineserver. Otherwise Proton's
+ // session manager keeps it alive for its idle timeout and the
+ // next launch inherits a dirty prefix. See the "stuck tracking
+ // wineserver" user report.
+ killWineserverForPrefix(winePrefix);
return ProcessRunner::Cancelled;
case UILocker::NoResult:
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index cd96afa..e0d0d44 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -104,12 +104,31 @@ QString compatDataPathFromPrefix(const QString& prefixPath)
}
QDir prefixDir(prefixPath);
+
+ // Case 1: prefix_path is `<compatdata>/pfx` — the usual Fluorine layout.
if (prefixDir.dirName() == "pfx") {
if (prefixDir.cdUp()) {
return QDir::cleanPath(prefixDir.absolutePath());
}
}
+ // Case 2: prefix_path is the compatdata directory itself (contains `pfx/`).
+ // Steam expects STEAM_COMPAT_DATA_PATH to be the compatdata dir, not pfx.
+ if (prefixDir.exists(QStringLiteral("pfx/drive_c"))) {
+ return QDir::cleanPath(prefixDir.absolutePath());
+ }
+
+ // Case 3: prefix_path is a plain Wine prefix (contains drive_c directly,
+ // no pfx wrapper). Pointing STEAM_COMPAT_DATA_PATH at the parent would be
+ // wrong (Proton would look for `<parent>/pfx` which doesn't exist and
+ // create a fresh prefix there). Returning empty lets the caller skip
+ // setting STEAM_COMPAT_DATA_PATH — Proton then respects WINEPREFIX only.
+ if (prefixDir.exists(QStringLiteral("drive_c"))) {
+ return {};
+ }
+
+ // Last-resort legacy behaviour — keep the old fallback for callers that
+ // pass non-existent paths (prefix creation UI flow).
return QDir::cleanPath(QFileInfo(prefixPath).dir().absolutePath());
}
diff --git a/src/src/settings.cpp b/src/src/settings.cpp
index 74204c7..dfe7591 100644
--- a/src/src/settings.cpp
+++ b/src/src/settings.cpp
@@ -18,6 +18,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
*/
#include "settings.h"
+#include <fluorine_build_info.h>
#include "env.h"
#include "envmetrics.h"
#include "executableslist.h"
@@ -245,6 +246,22 @@ void Settings::setUsePrereleases(bool b)
set(m_Settings, "Settings", "use_prereleases", b);
}
+QString Settings::fluorineUpdateChannel() const
+{
+#if FLUORINE_IS_BETA_BUILD
+ const QString defaultChannel = QStringLiteral("beta");
+#else
+ const QString defaultChannel = QStringLiteral("stable");
+#endif
+ return get<QString>(m_Settings, "Settings", "fluorine_update_channel",
+ defaultChannel);
+}
+
+void Settings::setFluorineUpdateChannel(const QString& channel)
+{
+ set(m_Settings, "Settings", "fluorine_update_channel", channel);
+}
+
bool Settings::profileLocalInis() const
{
return get<bool>(m_Settings, "Settings", "profile_local_inis", true);
diff --git a/src/src/settings.h b/src/src/settings.h
index 8abb650..c1a1eb8 100644
--- a/src/src/settings.h
+++ b/src/src/settings.h
@@ -818,6 +818,12 @@ public:
bool usePrereleases() const;
void setUsePrereleases(bool b);
+ // Fluorine self-update channel: "stable" (tagged releases) or "beta"
+ // (rolling build). Defaults to whichever channel the installed binary
+ // was built under — set at first launch, mutable via Settings dialog.
+ QString fluorineUpdateChannel() const;
+ void setFluorineUpdateChannel(const QString& channel);
+
// whether profiles should default to local INIs
//
bool profileLocalInis() const;
diff --git a/src/src/settingsdialoggeneral.cpp b/src/src/settingsdialoggeneral.cpp
index 9585d3a..a53f6ba 100644
--- a/src/src/settingsdialoggeneral.cpp
+++ b/src/src/settingsdialoggeneral.cpp
@@ -23,7 +23,16 @@ GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d)
// updates
ui->checkForUpdates->setChecked(settings().checkForUpdates());
- ui->usePrereleaseBox->setChecked(settings().usePrereleases());
+ // The "beta versions" checkbox drives both the legacy Nexus-side pre-release
+ // opt-in and the Fluorine self-update channel, so a single toggle covers the
+ // user-visible concept of "I want beta builds".
+ const bool onBeta =
+ settings().fluorineUpdateChannel() == QStringLiteral("beta") ||
+ settings().usePrereleases();
+ ui->usePrereleaseBox->setChecked(onBeta);
+ ui->usePrereleaseBox->setToolTip(
+ QObject::tr("Receive rolling beta builds from the main branch. When "
+ "off, only tagged stable releases are offered."));
// profile defaults
ui->localINIs->setChecked(settings().profileLocalInis());
@@ -68,6 +77,9 @@ void GeneralSettingsTab::update()
// updates
settings().setCheckForUpdates(ui->checkForUpdates->isChecked());
settings().setUsePrereleases(ui->usePrereleaseBox->isChecked());
+ settings().setFluorineUpdateChannel(ui->usePrereleaseBox->isChecked()
+ ? QStringLiteral("beta")
+ : QStringLiteral("stable"));
// profile defaults
settings().setProfileLocalInis(ui->localINIs->isChecked());
diff --git a/src/src/shared/util.cpp b/src/src/shared/util.cpp
index b1df0a6..ec720d2 100644
--- a/src/src/shared/util.cpp
+++ b/src/src/shared/util.cpp
@@ -21,6 +21,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "../env.h"
#include "../mainwindow.h"
#include "windows_error.h"
+#include <fluorine_build_info.h>
#include <uibase/log.h>
#ifndef _WIN32
#include <pthread.h>
@@ -319,7 +320,18 @@ Version createVersionInfo()
#else
Version createVersionInfo()
{
- return Version(2, 5, 2, 0);
+ // Fluorine Manager version is the user-facing one. The numeric components
+ // are injected by CMake from top-level FLUORINE_VERSION_* variables.
+#if FLUORINE_IS_BETA_BUILD
+ // Beta builds tag themselves as Development pre-releases so the update
+ // checker can distinguish them from stable tags when comparing versions.
+ return Version(FLUORINE_VERSION_MAJOR, FLUORINE_VERSION_MINOR,
+ FLUORINE_VERSION_PATCH, 0,
+ {Version::Development});
+#else
+ return Version(FLUORINE_VERSION_MAJOR, FLUORINE_VERSION_MINOR,
+ FLUORINE_VERSION_PATCH, 0);
+#endif
}
#endif
diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp
index d2415a0..9fa529e 100644
--- a/src/src/slrmanager.cpp
+++ b/src/src/slrmanager.cpp
@@ -110,6 +110,89 @@ bool isSlrInstalled()
return fi.exists() && fi.isExecutable();
}
+QString xrandrInjectedPath()
+{
+ return slrInstallDir() + "/xrandr-bin/xrandr";
+}
+
+bool isXrandrInjected()
+{
+ QFileInfo fi(xrandrInjectedPath());
+ return fi.exists() && fi.isExecutable();
+}
+
+// Download + extract xrandr from the Debian x11-xserver-utils package into
+// the SLR install dir. Called standalone for users who installed the
+// runtime before the xrandr step existed, and inline from downloadSlr() for
+// fresh installs.
+static bool installXrandrAssets(const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb)
+{
+ auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); };
+
+ const QString installDir = slrInstallDir();
+ QDir().mkpath(installDir);
+
+ const QString debPath = installDir + "/x11-xserver-utils.deb";
+ status(QStringLiteral("Downloading xrandr..."));
+ httpGet(QString::fromLatin1(XRANDR_DEB_URL), cancelFlag, nullptr, debPath);
+ if (!QFileInfo::exists(debPath)) {
+ MOBase::log::warn("Failed to download xrandr .deb — runtime will lack xrandr");
+ return false;
+ }
+
+ const QString tmpExtract = installDir + "/xrandr_tmp";
+ QDir(tmpExtract).removeRecursively();
+ QDir().mkpath(tmpExtract);
+
+ QProcess ar;
+ ar.setWorkingDirectory(tmpExtract);
+ ar.start(QStringLiteral("ar"),
+ {QStringLiteral("x"), debPath, QStringLiteral("data.tar.xz")});
+ ar.waitForFinished(30000);
+
+ QProcess untar;
+ untar.setWorkingDirectory(tmpExtract);
+ untar.start(QStringLiteral("tar"),
+ {QStringLiteral("xf"), QStringLiteral("data.tar.xz"),
+ QStringLiteral("./usr/bin/xrandr")});
+ untar.waitForFinished(30000);
+
+ const QString xrandrSrc = tmpExtract + "/usr/bin/xrandr";
+ bool ok = false;
+ if (QFileInfo::exists(xrandrSrc)) {
+ const QString xrandrDir = installDir + "/xrandr-bin";
+ QDir().mkpath(xrandrDir);
+ const QString dst = xrandrDir + "/xrandr";
+ QFile::remove(dst);
+ if (QFile::copy(xrandrSrc, dst)) {
+ QFile::setPermissions(dst, QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner | QFileDevice::ReadGroup |
+ QFileDevice::ExeGroup | QFileDevice::ReadOther |
+ QFileDevice::ExeOther);
+ MOBase::log::info("Installed xrandr to {}", dst.toStdString());
+ ok = true;
+ } else {
+ MOBase::log::warn("Failed to copy xrandr into fluorine bin dir");
+ }
+ } else {
+ MOBase::log::warn("xrandr .deb extracted but binary not found");
+ }
+
+ QDir(tmpExtract).removeRecursively();
+ QFile::remove(debPath);
+ return ok;
+}
+
+bool ensureXrandrInstalled(const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb)
+{
+ if (isXrandrInjected()) {
+ return true;
+ }
+ return installXrandrAssets(cancelFlag, statusCb);
+}
+
QString getSlrRunScript()
{
return isSlrInstalled() ? slrRunScriptPath() : QString();
@@ -142,6 +225,13 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
if (localBuildId == remoteBuildId && isSlrInstalled()) {
MOBase::log::info("Steam Linux Runtime is already up to date");
+ // Existing installs from earlier Fluorine versions may not have the
+ // xrandr helper (issue #49). Back-fill it so Proton-GE prefix init
+ // doesn't silently fail on distros without host xrandr exposed.
+ if (!isXrandrInjected()) {
+ status(QStringLiteral("Injecting xrandr into existing runtime..."));
+ installXrandrAssets(cancelFlag, statusCb);
+ }
status(QStringLiteral("Steam Linux Runtime is already up to date"));
progress(1.0f);
return {};
@@ -182,55 +272,8 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
// 4. Inject xrandr into the container (steamrt4 ships without it, but
// Proton-GE and several protonfixes invoke xrandr during launch).
- {
- status(QStringLiteral("Injecting xrandr into runtime..."));
- const QString debPath = installDir + "/x11-xserver-utils.deb";
- httpGet(QString::fromLatin1(XRANDR_DEB_URL), cancelFlag, nullptr, debPath);
- if (!QFileInfo::exists(debPath)) {
- MOBase::log::warn("Failed to download xrandr .deb — runtime will lack xrandr");
- } else {
- const QString tmpExtract = installDir + "/xrandr_tmp";
- QDir(tmpExtract).removeRecursively();
- QDir().mkpath(tmpExtract);
-
- QProcess ar;
- ar.setWorkingDirectory(tmpExtract);
- ar.start(QStringLiteral("ar"), {QStringLiteral("x"), debPath, QStringLiteral("data.tar.xz")});
- ar.waitForFinished(30000);
-
- QProcess untar;
- untar.setWorkingDirectory(tmpExtract);
- untar.start(QStringLiteral("tar"),
- {QStringLiteral("xf"), QStringLiteral("data.tar.xz"),
- QStringLiteral("./usr/bin/xrandr")});
- untar.waitForFinished(30000);
-
- const QString xrandrSrc = tmpExtract + "/usr/bin/xrandr";
- if (QFileInfo::exists(xrandrSrc)) {
- // Place xrandr in a dedicated dir that Fluorine's installer never
- // touches (the fluorine/bin dir gets overwritten on every install).
- // Launchers expose this dir via --filesystem and prepend it to PATH.
- const QString xrandrDir = installDir + "/xrandr-bin";
- QDir().mkpath(xrandrDir);
- const QString dst = xrandrDir + "/xrandr";
- QFile::remove(dst);
- if (QFile::copy(xrandrSrc, dst)) {
- QFile::setPermissions(dst, QFileDevice::ReadOwner | QFileDevice::WriteOwner |
- QFileDevice::ExeOwner | QFileDevice::ReadGroup |
- QFileDevice::ExeGroup | QFileDevice::ReadOther |
- QFileDevice::ExeOther);
- MOBase::log::info("Installed xrandr to {}", dst.toStdString());
- } else {
- MOBase::log::warn("Failed to copy xrandr into fluorine bin dir");
- }
- } else {
- MOBase::log::warn("xrandr .deb extracted but binary not found");
- }
-
- QDir(tmpExtract).removeRecursively();
- QFile::remove(debPath);
- }
- }
+ status(QStringLiteral("Injecting xrandr into runtime..."));
+ installXrandrAssets(cancelFlag, statusCb);
// 5. Save BUILD_ID.
{
diff --git a/src/src/slrmanager.h b/src/src/slrmanager.h
index e0a1b3f..6a842b9 100644
--- a/src/src/slrmanager.h
+++ b/src/src/slrmanager.h
@@ -18,4 +18,17 @@ QString downloadSlr(const std::function<void(float)>& progressCb,
const std::function<void(const QString&)>& statusCb,
const int* cancelFlag);
+/// Returns true if our injected xrandr helper is present.
+__attribute__((visibility("default"))) bool isXrandrInjected();
+
+/// Ensure xrandr is extracted into the steamrt install dir. Blocking, but
+/// small (~150 KB download). Used to back-fill xrandr for users whose SLR
+/// was installed before Fluorine started injecting it (issue #49) — pre-
+/// existing installs short-circuit downloadSlr() and would otherwise never
+/// get xrandr. Safe to call unconditionally.
+__attribute__((visibility("default")))
+bool ensureXrandrInstalled(
+ const int* cancelFlag,
+ const std::function<void(const QString&)>& statusCb);
+
#endif // SLRMANAGER_H
diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp
index 59bb7d6..8e97ed0 100644
--- a/src/src/spawn.cpp
+++ b/src/src/spawn.cpp
@@ -518,8 +518,13 @@ QString firstExistingSetting(const QSettings &settings,
}
QString resolvePrefixPath() {
+ // The Fluorine config is authoritative: it's the prefix the user
+ // explicitly created through Settings > Proton and that Fluorine itself
+ // initialises with wineboot/DLL installs. Always prefer it.
if (auto cfg = FluorineConfig::load();
cfg.has_value() && cfg->prefixExists()) {
+ MOBase::log::debug("resolvePrefixPath: using Fluorine config prefix '{}'",
+ cfg->prefix_path);
return cfg->prefix_path.trimmed();
}
@@ -528,10 +533,34 @@ QString resolvePrefixPath() {
return {};
}
+ // Fallbacks, in priority order. `fluorine/prefix_path` is set only by
+ // explicit user action (CLI `--prefix` or the instance creation wizard),
+ // so we trust it above the `Settings/*` keys that game-detection can
+ // populate automatically with an external manager's prefix (Heroic,
+ // Bottles, Lutris). Those external prefixes are fine as discovery hints
+ // but must not silently override the user's chosen Fluorine prefix —
+ // see issue #52.
const QSettings instanceSettings(settings->filename(), QSettings::IniFormat);
- return firstExistingSetting(
+ const QString explicitPath =
+ instanceSettings.value("fluorine/prefix_path").toString().trimmed();
+ if (!explicitPath.isEmpty()) {
+ MOBase::log::debug(
+ "resolvePrefixPath: using explicit fluorine/prefix_path '{}'",
+ explicitPath);
+ return explicitPath;
+ }
+
+ const QString fallback = firstExistingSetting(
instanceSettings, {"Settings/proton_prefix_path", "Settings/prefix_path",
- "Proton/prefix_path", "fluorine/prefix_path"});
+ "Proton/prefix_path"});
+ if (!fallback.isEmpty()) {
+ MOBase::log::warn(
+ "resolvePrefixPath: falling back to auto-detected prefix '{}' — this "
+ "may point at an external manager's prefix (Heroic/Bottles). Create a "
+ "Fluorine prefix in Settings > Proton to override.",
+ fallback);
+ }
+ return fallback;
}
QString resolveProtonPath() {
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 7dab850..3d39de2 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -2110,6 +2110,72 @@ void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mod
replyEntryFromSnapshot(req, ctx, dirIno, snap);
}
+void mo2_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name)
+{
+ Mo2FsContext* ctx = getContext(req);
+ if (ctx == nullptr || name == nullptr) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+ if (std::strcmp(name, ".") == 0 || std::strcmp(name, "..") == 0) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+
+ bool ok = false;
+ const std::string parentPath = inodeToPath(ctx, parent, &ok);
+ if (!ok) {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+
+ const std::string relative =
+ joinPath(parentPath, canonicalChildName(ctx, parentPath, name));
+
+ // Check VFS tree: the directory must exist and be empty in the merged view.
+ // Backing/mod entries are read-only, so reject rmdir on anything that still
+ // has children visible through the VFS.
+ {
+ std::shared_lock lock(ctx->tree_mutex);
+ const VfsNode* node = ctx->tree->root.resolve(splitPath(relative));
+ if (node == nullptr) {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+ if (!node->is_directory) {
+ fuse_reply_err(req, ENOTDIR);
+ return;
+ }
+ if (!node->dir_info.children.empty()) {
+ fuse_reply_err(req, ENOTEMPTY);
+ return;
+ }
+ }
+
+ // Try to remove the real directory from staging/overwrite. If the directory
+ // is purely virtual (no real backing in staging/overwrite) that's fine —
+ // still accept the rmdir so the VFS view reflects the caller's intent.
+ bool notEmpty = false;
+ const bool removed = ctx->overwrite->removeDirectory(relative, &notEmpty);
+ if (notEmpty) {
+ fuse_reply_err(req, ENOTEMPTY);
+ return;
+ }
+ (void)removed;
+
+ {
+ std::unique_lock lock(ctx->tree_mutex);
+ invalidateNodeCache(ctx, relative);
+ if (ctx->tree->root.removeFromTree(splitPath(relative))) {
+ ctx->tree->dir_count =
+ ctx->tree->dir_count > 0 ? ctx->tree->dir_count - 1 : 0;
+ }
+ }
+ invalidateDirCache(ctx, parentPath);
+
+ fuse_reply_err(req, 0);
+}
+
void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
{
Mo2FsContext* ctx = getContext(req);
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index aa248ed..b343e3b 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -161,6 +161,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
struct fuse_file_info* fi);
void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name);
void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode);
+void mo2_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name);
void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
void mo2_releasedir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
#if FUSE_USE_VERSION < 35
diff --git a/src/src/vfs/overwritemanager.cpp b/src/src/vfs/overwritemanager.cpp
index 092a756..d5f84f3 100644
--- a/src/src/vfs/overwritemanager.cpp
+++ b/src/src/vfs/overwritemanager.cpp
@@ -171,6 +171,35 @@ bool OverwriteManager::removeFile(const std::string& relative_path)
return false;
}
+bool OverwriteManager::removeDirectory(const std::string& relative_path,
+ bool* out_not_empty)
+{
+ if (out_not_empty)
+ *out_not_empty = false;
+
+ std::error_code ec;
+ bool removedAny = false;
+
+ for (const fs::path candidate : {fs::path(stagingPath(relative_path)),
+ fs::path(overwritePath(relative_path))}) {
+ if (!fs::exists(candidate, ec))
+ continue;
+ if (!fs::is_directory(candidate, ec))
+ return false;
+
+ if (!fs::is_empty(candidate, ec)) {
+ if (out_not_empty)
+ *out_not_empty = true;
+ return false;
+ }
+
+ if (fs::remove(candidate, ec))
+ removedAny = true;
+ }
+
+ return removedAny;
+}
+
bool OverwriteManager::createDirectory(const std::string& relative_path)
{
std::error_code ec;
diff --git a/src/src/vfs/overwritemanager.h b/src/src/vfs/overwritemanager.h
index abc09d9..4ce2fb8 100644
--- a/src/src/vfs/overwritemanager.h
+++ b/src/src/vfs/overwritemanager.h
@@ -20,6 +20,7 @@ public:
bool rename(const std::string& old_relative, const std::string& new_relative);
bool removeFile(const std::string& relative_path);
+ bool removeDirectory(const std::string& relative_path, bool* out_not_empty = nullptr);
bool createDirectory(const std::string& relative_path);
bool exists(const std::string& relative_path) const;