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 /libs | |
| 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>
Diffstat (limited to 'libs')
15 files changed, 188 insertions, 29 deletions
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) { |
