diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-23 16:09:59 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-23 16:09:59 -0500 |
| commit | 96a6ba196c72e955c3db822d93da38fce8cd7045 (patch) | |
| tree | 3fcc40e939bd609c4cb7b84f05e85875dde64fc6 | |
| parent | 6b9f0876cfbf6f8c723c86ec941ee70933aa883f (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>
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, ¬Empty); + 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; |
