diff options
| -rw-r--r-- | src/src/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | src/src/lootmanager.cpp | 235 | ||||
| -rw-r--r-- | src/src/lootmanager.h | 23 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 58 | ||||
| -rw-r--r-- | src/src/mainwindow.h | 1 | ||||
| -rw-r--r-- | src/src/mainwindow.ui | 10 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 58 |
7 files changed, 359 insertions, 28 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index 8c67f6e..484c343 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -43,6 +43,7 @@ file(GLOB ORGANIZER_QRC list(APPEND ORGANIZER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/fluorinepaths.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/lootmanager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/prefixsetupdialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/prefixsetuprunner.cpp ${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.cpp @@ -51,6 +52,7 @@ list(APPEND ORGANIZER_SOURCES list(APPEND ORGANIZER_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.h ${CMAKE_CURRENT_SOURCE_DIR}/fluorinepaths.h + ${CMAKE_CURRENT_SOURCE_DIR}/lootmanager.h ${CMAKE_CURRENT_SOURCE_DIR}/prefixsetupdialog.h ${CMAKE_CURRENT_SOURCE_DIR}/prefixsetuprunner.h ${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.h diff --git a/src/src/lootmanager.cpp b/src/src/lootmanager.cpp new file mode 100644 index 0000000..cd3874b --- /dev/null +++ b/src/src/lootmanager.cpp @@ -0,0 +1,235 @@ +#include "lootmanager.h" +#include "fluorinepaths.h" + +#include <QDir> +#include <QEventLoop> +#include <QFile> +#include <QFileInfo> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QNetworkAccessManager> +#include <QNetworkReply> +#include <QNetworkRequest> +#include <QProcess> +#include <QStandardPaths> +#include <QTemporaryDir> +#include <QTimer> + +#include <uibase/log.h> + +namespace +{ + +QByteArray httpGet(const QString& url, const int* cancelFlag, + const std::function<void(float)>& progressCb = nullptr, + const QString& destFile = {}) +{ + QNetworkAccessManager mgr; + QNetworkRequest req{QUrl(url)}; + req.setRawHeader("User-Agent", "Fluorine-Manager/loot"); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setAttribute(QNetworkRequest::Http2AllowedAttribute, false); + + QNetworkReply* reply = mgr.get(req); + QEventLoop loop; + + QFile outFile; + if (!destFile.isEmpty()) { + outFile.setFileName(destFile); + if (!outFile.open(QIODevice::WriteOnly)) + return {}; + } + + QByteArray inMemory; + qint64 total = -1, received = 0; + + QObject::connect(reply, &QNetworkReply::readyRead, [&]() { + if (total < 0) + total = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + QByteArray chunk = reply->readAll(); + received += chunk.size(); + if (outFile.isOpen()) + outFile.write(chunk); + else + inMemory.append(chunk); + if (progressCb && total > 0) + progressCb(static_cast<float>(received) / static_cast<float>(total)); + }); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + + QTimer cancelTimer; + if (cancelFlag) { + QObject::connect(&cancelTimer, &QTimer::timeout, [&]() { + if (*cancelFlag) { reply->abort(); loop.quit(); } + }); + cancelTimer.start(200); + } + + loop.exec(); + if (outFile.isOpen()) outFile.close(); + + if (reply->error() != QNetworkReply::NoError) { + MOBase::log::warn("LOOT download request failed: {} ({})", url, + reply->errorString()); + reply->deleteLater(); + if (!destFile.isEmpty()) QFile::remove(destFile); + return {}; + } + reply->deleteLater(); + return inMemory; +} + +// Try available 7z tools in order: bundled 7zz, then system 7z/7za/7zz. +bool extract7z(const QString& archivePath, const QString& destDir) +{ + QStringList candidates; + const QString bundled = fluorineDataDir() + "/bin/7zz"; + if (QFileInfo::exists(bundled)) + candidates << bundled; + for (const QString& n : {QStringLiteral("7z"), QStringLiteral("7za"), + QStringLiteral("7zz")}) { + const QString found = QStandardPaths::findExecutable(n); + if (!found.isEmpty() && !candidates.contains(found)) + candidates << found; + } + + for (const QString& exe : candidates) { + QProcess proc; + proc.start(exe, {QStringLiteral("x"), archivePath, + QStringLiteral("-o") + destDir, QStringLiteral("-y")}); + if (proc.waitForFinished(300000) && + proc.exitStatus() == QProcess::NormalExit && proc.exitCode() == 0) + return true; + } + return false; +} + +// Recursively find a file by name (case-insensitive) under dir. +QString findFileInDir(const QString& dir, const QString& name) +{ + for (const auto& entry : + QDir(dir).entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) { + if (entry.isFile() && entry.fileName().compare(name, Qt::CaseInsensitive) == 0) + return entry.filePath(); + if (entry.isDir()) { + const QString found = findFileInDir(entry.filePath(), name); + if (!found.isEmpty()) return found; + } + } + return {}; +} + +// Recursively move all entries from src into dst. +bool moveDirContents(const QString& src, const QString& dst) +{ + QDir().mkpath(dst); + for (const auto& entry : + QDir(src).entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) { + const QString dstPath = dst + "/" + entry.fileName(); + if (entry.isDir()) { + if (!moveDirContents(entry.filePath(), dstPath)) return false; + QDir(entry.filePath()).removeRecursively(); + } else { + QFile::remove(dstPath); + if (!QFile::rename(entry.filePath(), dstPath)) return false; + } + } + return true; +} + +} // namespace + +QString lootInstallDir() +{ + return fluorineDataDir() + "/tools/loot"; +} + +bool isLootInstalled() +{ + return QFileInfo::exists(getLootExePath()); +} + +QString getLootExePath() +{ + const QString path = lootInstallDir() + "/LOOT.exe"; + return QFileInfo::exists(path) ? path : QString{}; +} + +QString downloadLoot(const std::function<void(float)>& progressCb, + const std::function<void(const QString&)>& statusCb, + const int* cancelFlag) +{ + auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); }; + auto progress = [&](float p) { if (progressCb) progressCb(p); }; + + status(QStringLiteral("Checking latest LOOT release...")); + + const QByteArray apiData = httpGet( + QStringLiteral("https://api.github.com/repos/loot/loot/releases/latest"), + cancelFlag); + if (apiData.isEmpty()) + return QStringLiteral("Failed to fetch LOOT release info from GitHub"); + + const QJsonObject release = QJsonDocument::fromJson(apiData).object(); + const QJsonArray assets = release[QStringLiteral("assets")].toArray(); + + QString downloadUrl; + QString assetName; + for (const auto& a : assets) { + const QJsonObject asset = a.toObject(); + const QString name = asset[QStringLiteral("name")].toString(); + // Prefer win64 portable archive (not installer .exe) + if (name.contains(QStringLiteral("win64"), Qt::CaseInsensitive) && + (name.endsWith(QStringLiteral(".7z"), Qt::CaseInsensitive) || + name.endsWith(QStringLiteral(".zip"), Qt::CaseInsensitive))) { + downloadUrl = asset[QStringLiteral("browser_download_url")].toString(); + assetName = name; + break; + } + } + if (downloadUrl.isEmpty()) + return QStringLiteral("Could not find a LOOT win64 archive in the latest release"); + + const QString tagName = release[QStringLiteral("tag_name")].toString(); + MOBase::log::info("Downloading LOOT {} ({})", tagName, assetName); + status(QStringLiteral("Downloading LOOT %1...").arg(tagName)); + + QTemporaryDir staging(fluorineDataDir() + "/tools/loot-download-XXXXXX"); + if (!staging.isValid()) + return QStringLiteral("Failed to create temporary download directory"); + + const QString archivePath = staging.filePath(assetName); + httpGet(downloadUrl, cancelFlag, progress, archivePath); + progress(1.0f); + + if (!QFileInfo::exists(archivePath)) + return QStringLiteral("LOOT download failed or was cancelled"); + + status(QStringLiteral("Extracting LOOT...")); + const QString extractDir = staging.filePath(QStringLiteral("extracted")); + QDir().mkpath(extractDir); + + if (!extract7z(archivePath, extractDir)) + return QStringLiteral("Failed to extract LOOT archive (no 7z tool found)"); + + // Find LOOT.exe in the extraction output (may be directly or in a subdirectory). + const QString lootExe = findFileInDir(extractDir, QStringLiteral("LOOT.exe")); + if (lootExe.isEmpty()) + return QStringLiteral("LOOT.exe not found after extraction"); + + const QString lootRoot = QFileInfo(lootExe).absolutePath(); + + // Install to lootInstallDir(), replacing any previous version. + const QString installDir = lootInstallDir(); + QDir(installDir).removeRecursively(); + QDir().mkpath(installDir); + + if (!moveDirContents(lootRoot, installDir)) + return QStringLiteral("Failed to move LOOT files to install directory"); + + MOBase::log::info("LOOT installed to {}", installDir.toStdString()); + status(QStringLiteral("LOOT installed successfully")); + return {}; +} diff --git a/src/src/lootmanager.h b/src/src/lootmanager.h new file mode 100644 index 0000000..d33135a --- /dev/null +++ b/src/src/lootmanager.h @@ -0,0 +1,23 @@ +#ifndef LOOTMANAGER_H +#define LOOTMANAGER_H + +#include <QString> +#include <functional> + +/// Returns the LOOT install directory: ~/.local/share/fluorine/tools/loot +QString lootInstallDir(); + +/// Returns true if LOOT.exe is present in the tools directory. +bool isLootInstalled(); + +/// Returns the path to LOOT.exe, or empty if not installed. +QString getLootExePath(); + +/// Download and install LOOT from the latest GitHub release. +/// Finds the win64 .7z asset, extracts to lootInstallDir(). +/// Returns empty string on success, or an error message. +QString downloadLoot(const std::function<void(float)>& progressCb, + const std::function<void(const QString&)>& statusCb, + const int* cancelFlag = nullptr); + +#endif // LOOTMANAGER_H diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 2f7193f..acdde48 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -92,6 +92,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "shared/filesorigin.h" #include "slrmanager.h" +#include "lootmanager.h" #include <QAbstractItemDelegate> #include <QAction> #include <QApplication> @@ -349,6 +350,11 @@ MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, if (!features.gameFeature<GamePlugins>()) { ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->espTab)); } + if (m_OrganizerCore.managedGame() && + m_OrganizerCore.managedGame()->sortMechanism() != + MOBase::IPluginGame::SortMechanism::LOOT) { + ui->sortButton->setVisible(false); + } if (!features.gameFeature<DataArchives>()) { ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->bsaTab)); } @@ -4020,6 +4026,58 @@ bool MainWindow::createBackup(const QString& filePath, const QDateTime& time) } } +void MainWindow::on_sortButton_clicked() +{ + if (!isLootInstalled()) { + auto* progress = new QProgressDialog( + tr("Downloading LOOT...\nThis is required for plugin sorting."), + tr("Cancel"), 0, 0, this); + progress->setWindowTitle(tr("LOOT")); + progress->setWindowModality(Qt::WindowModal); + progress->setMinimumDuration(0); + + int cancelFlag = 0; + connect(progress, &QProgressDialog::canceled, this, + [&cancelFlag] { cancelFlag = 1; }); + + QFutureWatcher<QString> watcher; + QEventLoop loop; + connect(&watcher, &QFutureWatcher<QString>::finished, &loop, &QEventLoop::quit); + watcher.setFuture( + QtConcurrent::run([&cancelFlag]() -> QString { + return downloadLoot(nullptr, nullptr, &cancelFlag); + })); + progress->show(); + loop.exec(); + progress->close(); + progress->deleteLater(); + + if (cancelFlag) + return; + const QString err = watcher.result(); + if (!err.isEmpty()) { + log::error("[LOOT] Download failed: {}", err); + QMessageBox::warning(this, tr("LOOT"), + tr("LOOT download failed:\n%1").arg(err)); + return; + } + } + + const QString lootExe = getLootExePath(); + if (lootExe.isEmpty()) { + QMessageBox::warning(this, tr("LOOT"), + tr("LOOT executable not found after install.")); + return; + } + + m_OrganizerCore.processRunner() + .setBinary(QFileInfo(lootExe)) + .setCurrentDirectory(QDir(lootInstallDir())) + .setHooked(true) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + void MainWindow::on_saveButton_clicked() { m_OrganizerCore.savePluginList(); diff --git a/src/src/mainwindow.h b/src/src/mainwindow.h index 6443862..64214d9 100644 --- a/src/src/mainwindow.h +++ b/src/src/mainwindow.h @@ -480,6 +480,7 @@ private slots: // ui slots void on_showHiddenBox_toggled(bool checked);
void on_bsaList_itemChanged(QTreeWidgetItem* item, int column);
+ void on_sortButton_clicked();
void on_saveButton_clicked();
void on_restoreButton_clicked();
void on_restoreModsButton_clicked();
diff --git a/src/src/mainwindow.ui b/src/src/mainwindow.ui index 317057b..de462fa 100644 --- a/src/src/mainwindow.ui +++ b/src/src/mainwindow.ui @@ -820,6 +820,16 @@ </spacer> </item> <item> + <widget class="QPushButton" name="sortButton"> + <property name="toolTip"> + <string>Sort plugins using LOOT.</string> + </property> + <property name="text"> + <string>Sort</string> + </property> + </widget> + </item> + <item> <widget class="QPushButton" name="restoreButton"> <property name="toolTip"> <string>Restore a backup.</string> diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 5fa170e..fc8e0e1 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -1390,6 +1390,12 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) // auto-create it in staging so the kernel can continue resolving the path. // createDirectory uses create_directories internally, so staging parent dirs // are also created if the parent came from overwrite rather than this session. + // USVFS-parity: games sometimes skip mkdir for intermediate directories and + // go straight to CreateFile("ShaderCache/Lighting/X.pso"). The kernel's + // path-resolver needs a positive inode for each component before it can + // call mo2_create. Insert a phantom virtual directory — no physical creation. + // createFile() calls create_directories on the parent path, so the real dir + // will materialize on disk only when a file is actually written inside it. const std::string_view nameView(name); if (nameView.find('.') == std::string_view::npos) { bool parentOk = false; @@ -1397,35 +1403,31 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) if (parentOk) { const std::string childName = canonicalChildName(ctx, parentPath, name); const std::string childPath = joinPath(parentPath, childName); - std::error_code createErr; - if (ctx->overwrite->createDirectory(childPath, &createErr)) { - { - std::unique_lock lock(ctx->tree_mutex); - ctx->tree->root.insertDirectory(splitPath(childPath)); - ++ctx->tree->dir_count; - invalidateNodeCache(ctx, childPath); - } - invalidateDirCache(ctx, parentPath); - fuse_ino_t dirIno; - { - std::unique_lock lock(ctx->inode_mutex); - dirIno = ctx->inodes->getOrCreate(childPath); - } - struct fuse_entry_param e; - std::memset(&e, 0, sizeof(e)); - e.ino = dirIno; - e.attr_timeout = TTL_SECONDS; - e.entry_timeout = TTL_SECONDS; - fillStatForDir(&e.attr, dirIno, ctx->uid, ctx->gid); - { - std::scoped_lock cacheLock(ctx->lookup_cache_mutex); - ctx->lookup_cache[cacheKey] = - Mo2FsContext::LookupCacheEntry{.child_ino=dirIno, .entry=e}; - } - std::fprintf(stderr, "[VFS] auto-mkdir staging: %s\n", childPath.c_str()); - fuse_reply_entry(req, &e); - return; + { + std::unique_lock lock(ctx->tree_mutex); + ctx->tree->root.insertDirectory(splitPath(childPath)); + ++ctx->tree->dir_count; + invalidateNodeCache(ctx, childPath); } + invalidateDirCache(ctx, parentPath); + fuse_ino_t dirIno; + { + std::unique_lock lock(ctx->inode_mutex); + dirIno = ctx->inodes->getOrCreate(childPath); + } + struct fuse_entry_param e; + std::memset(&e, 0, sizeof(e)); + e.ino = dirIno; + e.attr_timeout = TTL_SECONDS; + e.entry_timeout = TTL_SECONDS; + fillStatForDir(&e.attr, dirIno, ctx->uid, ctx->gid); + { + std::scoped_lock cacheLock(ctx->lookup_cache_mutex); + ctx->lookup_cache[cacheKey] = + Mo2FsContext::LookupCacheEntry{.child_ino=dirIno, .entry=e}; + } + fuse_reply_entry(req, &e); + return; } } |
