diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-30 18:32:24 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-30 18:32:24 -0500 |
| commit | aba2dc0df5c50c244904f2b1bb458334a851d426 (patch) | |
| tree | bfef0b3de1cad72522ace9f0aa447def18670c00 /src | |
| parent | 6243e1a79550e9cff9837f5449f3b9150127b8e8 (diff) | |
Clean up empty dirs left by VFS, suppress noisy Qt debug
Fixes #64: VFS now tracks the directories it creates outside the data
dir (external mappings + RootBuilder game-root deploys) and removes
them on unmount, but only if still empty. Pre-existing user dirs and
the game root itself are never touched. RootBuilder manifest gains a
"dirs" field so the cleanup survives a crashed session.
Also drops a Path-repr qDebug in bg3_file_mapper that crashed Qt's
logger on surrogate-escaped paths, and defaults QT_LOGGING_RULES to
default.debug=false in the launcher and AppRun (still overridable) so
the same class of plugin bug stays muted in shipped builds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/fuseconnector.cpp | 122 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 5 |
2 files changed, 113 insertions, 14 deletions
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index ac2b29a..3685560 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -791,12 +791,37 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping, // (running through Proton) can see the files. std::error_code ec; + // Helper: create a directory (and all missing parents) and record each + // segment we actually created so cleanup can remove it later. + auto createTrackedDirs = [&](const fs::path& dirPath) { + std::vector<fs::path> toCreate; + for (fs::path p = dirPath; !p.empty() && !fs::exists(p, ec); + p = p.parent_path()) { + toCreate.push_back(p); + if (p == p.root_path()) { + break; + } + } + // Build top-down so nested dirs succeed. + for (auto it = toCreate.rbegin(); it != toCreate.rend(); ++it) { + if (fs::create_directory(*it, ec) && !ec) { + m_externalDirs.push_back(it->string()); + } + } + }; + if (map.isDirectory) { const fs::path srcPath(src.toStdString()); if (!fs::exists(srcPath, ec)) { continue; } + // Pre-create the createTarget root so an empty source still leaves + // the target tracked for removal. + if (map.createTarget) { + createTrackedDirs(fs::path(dst.toStdString())); + } + for (auto it = fs::recursive_directory_iterator( srcPath, fs::directory_options::skip_permission_denied); it != fs::recursive_directory_iterator(); ++it) { @@ -808,9 +833,9 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping, const fs::path destPath = fs::path(dst.toStdString()) / rel; if (entry.is_directory(ec)) { - fs::create_directories(destPath, ec); + createTrackedDirs(destPath); } else if (entry.is_regular_file(ec) || entry.is_symlink(ec)) { - fs::create_directories(destPath.parent_path(), ec); + createTrackedDirs(destPath.parent_path()); if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) { // Never overwrite real game files — only replace our own symlinks. continue; @@ -832,7 +857,7 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping, } else { // Single file symlink. const fs::path destPath(dst.toStdString()); - fs::create_directories(destPath.parent_path(), ec); + createTrackedDirs(destPath.parent_path()); if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) { continue; } @@ -861,7 +886,7 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping, void FuseConnector::cleanupExternalMappings() { - if (m_externalSymlinks.empty()) { + if (m_externalSymlinks.empty() && m_externalDirs.empty()) { return; } @@ -872,8 +897,27 @@ void FuseConnector::cleanupExternalMappings() } } - log::debug("Cleaned up {} external symlinks", m_externalSymlinks.size()); + // Remove created dirs deepest-first so children are gone before parents. + // Only removes if empty — any user-placed file inside causes the dir (and + // its ancestors) to stay, which is the safe behavior. + std::sort(m_externalDirs.begin(), m_externalDirs.end(), + [](const std::string& a, const std::string& b) { + return a.size() > b.size(); + }); + std::size_t removedDirs = 0; + for (const auto& path : m_externalDirs) { + if (fs::is_directory(path, ec) && fs::is_empty(path, ec)) { + fs::remove(path, ec); + if (!ec) { + ++removedDirs; + } + } + } + + log::debug("Cleaned up {} external symlinks, {} dirs", + m_externalSymlinks.size(), removedDirs); m_externalSymlinks.clear(); + m_externalDirs.clear(); } void FuseConnector::updateParams(MOBase::log::Levels /*logLevel*/, @@ -1117,6 +1161,7 @@ static bool reflinkCopy(const std::string& src, const std::string& dst) static void loadRootManifest(const std::string& storageDir, std::vector<std::string>& deployed, + std::vector<std::string>& dirs, std::map<std::string, std::string>& backups) { namespace fs = std::filesystem; @@ -1127,8 +1172,6 @@ static void loadRootManifest(const std::string& storageDir, try { std::string const content((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); - // Simple JSON parsing — the manifest is { "deployed": [...], "backups": {...} } - // Use Qt's JSON for simplicity QJsonDocument const doc = QJsonDocument::fromJson( QByteArray::fromStdString(content)); if (doc.isNull()) return; @@ -1137,6 +1180,10 @@ static void loadRootManifest(const std::string& storageDir, for (const auto& v : obj["deployed"].toArray()) { deployed.push_back(v.toString().toStdString()); } + // "dirs" was added later — older manifests won't have it, which is fine. + for (const auto& v : obj["dirs"].toArray()) { + dirs.push_back(v.toString().toStdString()); + } const auto bk = obj["backups"].toObject(); for (auto it = bk.begin(); it != bk.end(); ++it) { backups[it.key().toStdString()] = it.value().toString().toStdString(); @@ -1146,6 +1193,7 @@ static void loadRootManifest(const std::string& storageDir, static void saveRootManifest(const std::string& storageDir, const std::vector<std::string>& deployed, + const std::vector<std::string>& dirs, const std::map<std::string, std::string>& backups) { namespace fs = std::filesystem; @@ -1157,6 +1205,11 @@ static void saveRootManifest(const std::string& storageDir, arr.append(QString::fromStdString(f)); } + QJsonArray dirArr; + for (const auto& d : dirs) { + dirArr.append(QString::fromStdString(d)); + } + QJsonObject bk; for (const auto& [dst, bak] : backups) { bk[QString::fromStdString(dst)] = QString::fromStdString(bak); @@ -1164,6 +1217,7 @@ static void saveRootManifest(const std::string& storageDir, QJsonObject obj; obj["deployed"] = arr; + obj["dirs"] = dirArr; obj["backups"] = bk; const auto manifestPath = fs::path(storageDir) / "manifest.json"; @@ -1187,11 +1241,33 @@ void FuseConnector::deployRootFiles( clearRootFiles(); m_rootDeployedFiles.clear(); + m_rootDeployedDirs.clear(); m_rootBackups.clear(); + const fs::path gameRoot(m_gameDir); const std::string backupDir = (fs::path(m_rootStorageDir) / "backup").string(); std::set<std::string> deployedSet; + // Create dst.parent_path() one segment at a time, recording each segment + // we actually had to create (so it can be removed on cleanup if empty). + // Stops walking up once it hits an existing dir or the gameRoot itself — + // we never want to remove the game root or any pre-existing user dir. + auto trackedCreateParents = [&](const fs::path& filePath) { + std::error_code ec2; + std::vector<fs::path> toCreate; + for (fs::path p = filePath.parent_path(); + !p.empty() && p != gameRoot && !fs::exists(p, ec2); + p = p.parent_path()) { + toCreate.push_back(p); + if (p == p.root_path()) break; + } + for (auto it = toCreate.rbegin(); it != toCreate.rend(); ++it) { + if (fs::create_directory(*it, ec2) && !ec2) { + m_rootDeployedDirs.push_back(it->string()); + } + } + }; + for (const auto& [modName, modPath] : mods) { const auto rootDir = findRootDir(modPath); if (rootDir.empty()) continue; @@ -1218,7 +1294,7 @@ void FuseConnector::deployRootFiles( if (fs::exists(dst, ec) || fs::is_symlink(dst, ec)) { fs::remove(dst, ec); } - fs::create_directories(fs::path(dst).parent_path(), ec); + trackedCreateParents(fs::path(dst)); if (!reflinkCopy(entry.path().string(), dst)) { std::fprintf(stderr, "[RootBuilder] failed to copy '%s' -> '%s'\n", @@ -1231,7 +1307,8 @@ void FuseConnector::deployRootFiles( } } - saveRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups); + saveRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootDeployedDirs, + m_rootBackups); const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - t0).count(); @@ -1248,11 +1325,12 @@ void FuseConnector::clearRootFiles() std::error_code ec; // Load manifest if we don't have in-memory state - if (m_rootDeployedFiles.empty()) { - loadRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups); + if (m_rootDeployedFiles.empty() && m_rootDeployedDirs.empty()) { + loadRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootDeployedDirs, + m_rootBackups); } - if (m_rootDeployedFiles.empty()) return; + if (m_rootDeployedFiles.empty() && m_rootDeployedDirs.empty()) return; int removed = 0; for (const auto& dst : m_rootDeployedFiles) { @@ -1270,14 +1348,30 @@ void FuseConnector::clearRootFiles() } } + // Remove dirs we created in game root, deepest-first, only if empty. + // A non-empty dir means the user (or game) put something there — leave it. + std::sort(m_rootDeployedDirs.begin(), m_rootDeployedDirs.end(), + [](const std::string& a, const std::string& b) { + return a.size() > b.size(); + }); + std::size_t removedDirs = 0; + for (const auto& d : m_rootDeployedDirs) { + if (fs::is_directory(d, ec) && fs::is_empty(d, ec)) { + fs::remove(d, ec); + if (!ec) ++removedDirs; + } + } + // Clean up backup directory and manifest const auto backupDir = fs::path(m_rootStorageDir) / "backup"; fs::remove_all(backupDir, ec); fs::remove(fs::path(m_rootStorageDir) / "manifest.json", ec); - std::fprintf(stderr, "[RootBuilder] cleared %d deployed files, restored %zu backups\n", - removed, m_rootBackups.size()); + std::fprintf(stderr, + "[RootBuilder] cleared %d deployed files, %zu dirs, restored %zu backups\n", + removed, removedDirs, m_rootBackups.size()); m_rootDeployedFiles.clear(); + m_rootDeployedDirs.clear(); m_rootBackups.clear(); } diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index fcff0d8..1f6d1fa 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -90,6 +90,10 @@ private: // Symlinks created for non-data-dir mappings (e.g. Paks, OBSE, UE4SS). std::vector<std::string> m_externalSymlinks; + // Directories we created for non-data-dir mappings — removed (only if + // empty) on cleanup. Stored deepest-first so reverse-iteration unwinds + // child dirs before their parents. + std::vector<std::string> m_externalDirs; // File-level mappings targeting the data directory (e.g. plugins.txt). // Injected into the VFS tree after building. (relPath, absRealPath) std::vector<std::pair<std::string, std::string>> m_extraVfsFiles; @@ -107,6 +111,7 @@ private: bool m_rootBuilderEnabled = false; std::string m_rootStorageDir; std::vector<std::string> m_rootDeployedFiles; + std::vector<std::string> m_rootDeployedDirs; // dirs we created in game root std::map<std::string, std::string> m_rootBackups; // dst -> backup path }; |
