From 630c3221bf8a6a6d3072ca7fef7d47210e9c9205 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 8 Mar 2026 15:52:56 -0500 Subject: Fix LOOT sort for PluginsTxt games, improve desktop shortcuts, add Browse VFS LOOT sort fix: For PluginsTxt games (FO4, SkyrimSE), LOOT only modifies plugins.txt, not loadorder.txt. Previously the stale loadorder.txt was copied back, causing readPluginLists() to use the old order. Now we regenerate loadorder.txt from the sorted plugins.txt. Desktop shortcuts: Add "Application Launcher" option on Linux that creates .desktop entries in ~/.local/share/applications/. Include instance name in both filename (New-Vegas-NVSE.desktop) and Name= field (NVSE (New Vegas)) to avoid collisions. Icons saved to ~/.local/share/icons/fluorine/ with game-specific names. Browse VFS: Add button in Data tab that mounts the FUSE VFS and opens the game Data folder in the system file manager, allowing users to browse the merged mod files as the game sees them. Co-Authored-By: Claude Opus 4.6 --- src/src/datatab.cpp | 42 +++++++++++++++++++ src/src/datatab.h | 3 ++ src/src/envshortcut.cpp | 106 ++++++++++++++++++++++++++++++++++++++---------- src/src/envshortcut.h | 8 +++- src/src/loot.cpp | 43 +++++++++++++++++++- src/src/mainwindow.cpp | 14 ++++++- src/src/mainwindow.ui | 10 +++++ 7 files changed, 201 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/src/datatab.cpp b/src/src/datatab.cpp index ea1430b..e55c417 100644 --- a/src/src/datatab.cpp +++ b/src/src/datatab.cpp @@ -9,6 +9,9 @@ #include #include +#include +#include + using namespace MOShared; using namespace MOBase; @@ -21,6 +24,7 @@ DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, ui{mwui->tabWidget, mwui->dataTab, mwui->dataTabRefresh, + mwui->dataTabBrowseVFS, mwui->dataTree, mwui->dataTabShowOnlyConflicts, mwui->dataTabShowFromArchives, @@ -42,6 +46,14 @@ DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, ensureFullyLoaded(); }); +#ifdef _WIN32 + ui.browseVFS->setVisible(false); +#else + connect(ui.browseVFS, &QPushButton::clicked, [&] { + onBrowseVFS(); + }); +#endif + connect(ui.refresh, &QPushButton::clicked, [&] { onRefresh(); }); @@ -123,6 +135,36 @@ void DataTab::onRefresh() m_core.refreshDirectoryStructure(); } +void DataTab::onBrowseVFS() +{ +#ifndef _WIN32 + QString dataPath = m_core.managedGame()->dataDirectory().absolutePath(); + + // Mount the FUSE VFS so the file manager sees the merged mod files. + log::info("Mounting VFS for Browse..."); + m_core.prepareVFS(); + + // Open the game data folder in the system file manager. + shell::Explore(dataPath); + + // Show a modal dialog that keeps the VFS mounted. When the user + // closes it, we unmount. + QMessageBox box(QMessageBox::Information, + QObject::tr("Browse VFS"), + QObject::tr("The virtual filesystem is mounted.\n\n" + "The game Data folder has been opened in your " + "file manager. You can browse the merged mod " + "files as the game would see them.\n\n" + "Close this dialog to unmount the VFS."), + QMessageBox::Close, m_parent); + box.setWindowModality(Qt::WindowModal); + box.exec(); + + log::info("Unmounting VFS after Browse..."); + m_core.unmountVFS(); +#endif +} + void DataTab::updateTree() { if (isActive()) { diff --git a/src/src/datatab.h b/src/src/datatab.h index 35f8bd9..cf5bc99 100644 --- a/src/src/datatab.h +++ b/src/src/datatab.h @@ -4,6 +4,7 @@ #include "modinfo.h" #include "modinfodialogfwd.h" #include +#include #include #include #include @@ -51,6 +52,7 @@ private: QTabWidget* tabs; QWidget* tab; QPushButton* refresh; + QPushButton* browseVFS; QTreeView* tree; QCheckBox* conflicts; QCheckBox* archives; @@ -67,6 +69,7 @@ private: bool m_needUpdate; void onRefresh(); + void onBrowseVFS(); void onItemExpanded(QTreeWidgetItem* item); void onConflicts(); void onArchives(); diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp index 49a122b..55600fe 100644 --- a/src/src/envshortcut.cpp +++ b/src/src/envshortcut.cpp @@ -341,6 +341,9 @@ QString toString(Shortcut::Locations loc) case Shortcut::StartMenu: return "start menu"; + case Shortcut::ApplicationMenu: + return "application menu"; + default: return QString("? (%1)").arg(static_cast(loc)); } @@ -377,12 +380,62 @@ static QString appImageOrBinary() Shortcut::Shortcut() : m_iconIndex(0) {} +// Sanitize a string for use in a .desktop filename (replace spaces/special chars). +static QString sanitizeDesktopName(const QString& s) +{ + QString result; + for (const QChar& c : s) { + if (c.isLetterOrNumber() || c == '-' || c == '_' || c == '.') { + result += c; + } else if (c == ' ') { + result += '-'; + } + } + return result; +} + +// Install a game-specific icon to ~/.local/share/icons/fluorine/ and return +// the absolute path. Falls back to the bundled Fluorine icon. +static QString installIcon(const QString& iconBaseName) +{ + QString iconDir = QDir::homePath() + "/.local/share/icons/fluorine"; + QString iconDest = iconDir + "/" + iconBaseName + ".png"; + + if (QFile::exists(iconDest)) { + return iconDest; + } + + // No game-specific icon available — install the bundled Fluorine icon + // under the game-specific name so each shortcut gets its own file. + QDir().mkpath(iconDir); + + QString appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); + if (!appDir.isEmpty()) { + QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; + if (QFile::exists(bundled)) { + QFile::copy(bundled, iconDest); + return iconDest; + } + } + + // Also keep the hicolor copy for compatibility. + QString hicolorDir = QDir::homePath() + "/.local/share/icons/hicolor/256x256/apps"; + QString hicolorDest = hicolorDir + "/com.fluorine.manager.png"; + if (QFile::exists(hicolorDest)) { + QFile::copy(hicolorDest, iconDest); + return iconDest; + } + + return "com.fluorine.manager"; +} + Shortcut::Shortcut(const Executable& exe) : Shortcut() { const auto i = *InstanceManager::singleton().currentInstance(); - m_name = exe.title(); - m_target = appImageOrBinary(); + m_name = exe.title(); + m_instanceName = i.displayName(); + m_target = appImageOrBinary(); // For portable instances, use the absolute directory path so MO2 can // find it (line 595 in instancemanager.cpp handles abs path lookup). @@ -396,7 +449,7 @@ Shortcut::Shortcut(const Executable& exe) : Shortcut() m_description = QString("Run %1 with Fluorine").arg(exe.title()); // .exe icons can't be used on Linux — only use native icon formats. - // Fall back to the Fluorine app icon bundled in the AppImage. + // Install a game-specific icon to ~/.local/share/icons/fluorine/. if (exe.usesOwnIcon()) { QString iconPath = exe.binaryInfo().absoluteFilePath(); if (!iconPath.endsWith(".exe", Qt::CaseInsensitive)) { @@ -404,21 +457,11 @@ Shortcut::Shortcut(const Executable& exe) : Shortcut() } } if (m_icon.isEmpty()) { - // Install the Fluorine icon to the user's icon theme so it persists - // after the AppImage unmounts, then reference it by icon name. - QString appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); - if (!appDir.isEmpty()) { - QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; - if (QFile::exists(bundled)) { - QString destDir = QDir::homePath() + "/.local/share/icons/hicolor/256x256/apps"; - QString dest = destDir + "/com.fluorine.manager.png"; - if (!QFile::exists(dest)) { - QDir().mkpath(destDir); - QFile::copy(bundled, dest); - } - } - } - m_icon = "com.fluorine.manager"; + // Build a unique icon name from the instance + executable title, + // e.g. "NewVegas-NVSE" → ~/.local/share/icons/fluorine/NewVegas-NVSE.png + QString iconBase = sanitizeDesktopName(m_instanceName) + "-" + + sanitizeDesktopName(m_name); + m_icon = installIcon(iconBase); } m_workingDirectory = QFileInfo(m_target).absolutePath(); @@ -477,7 +520,7 @@ bool Shortcut::toggle(Locations loc) bool Shortcut::add(Locations loc) { - if (loc != Desktop) { + if (loc != Desktop && loc != ApplicationMenu) { return false; } @@ -493,10 +536,18 @@ bool Shortcut::add(Locations loc) return false; } + // Include instance name in the display name to distinguish shortcuts + // for the same executable across different instances (e.g. "NVSE (New Vegas)" + // vs "NVSE (New Vegas 2)"). + QString displayName = m_name.isEmpty() ? "Fluorine" : m_name; + if (!m_instanceName.isEmpty()) { + displayName += " (" + m_instanceName + ")"; + } + QTextStream out(&file); out << "[Desktop Entry]\n"; out << "Type=Application\n"; - out << "Name=" << (m_name.isEmpty() ? "Fluorine" : m_name) << "\n"; + out << "Name=" << displayName << "\n"; if (!m_description.isEmpty()) { out << "Comment=" << m_description << "\n"; } @@ -551,6 +602,9 @@ QString Shortcut::shortcutDirectory(Locations loc) const } return QDir::homePath() + "/Desktop"; } + if (loc == ApplicationMenu) { + return QDir::homePath() + "/.local/share/applications"; + } return {}; } @@ -559,7 +613,14 @@ QString Shortcut::shortcutFilename() const if (m_name.isEmpty()) { return "fluorine.desktop"; } - return m_name + ".desktop"; + // Include instance name in the filename to avoid collisions when + // multiple instances have the same executable (e.g. BodySlide_x64 + // for both FNV and SkyrimSE). Example: "New Vegas-NVSE.desktop" + QString base = m_name; + if (!m_instanceName.isEmpty()) { + base = sanitizeDesktopName(m_instanceName) + "-" + base; + } + return base + ".desktop"; } QString toString(Shortcut::Locations loc) @@ -574,6 +635,9 @@ QString toString(Shortcut::Locations loc) case Shortcut::StartMenu: return "start menu"; + case Shortcut::ApplicationMenu: + return "application menu"; + default: return QString("? (%1)").arg(static_cast(loc)); } diff --git a/src/src/envshortcut.h b/src/src/envshortcut.h index 43830b4..2048d7f 100644 --- a/src/src/envshortcut.h +++ b/src/src/envshortcut.h @@ -22,8 +22,11 @@ public: // on the desktop Desktop, - // in the start menu - StartMenu + // in the start menu (Windows only) + StartMenu, + + // in ~/.local/share/applications/ (Linux only) + ApplicationMenu }; // empty shortcut @@ -78,6 +81,7 @@ public: private: QString m_name; + QString m_instanceName; QString m_target; QString m_arguments; QString m_description; diff --git a/src/src/loot.cpp b/src/src/loot.cpp index c96a26c..00c66e2 100644 --- a/src/src/loot.cpp +++ b/src/src/loot.cpp @@ -1516,7 +1516,12 @@ static bool launchLootGui(QWidget* parent, OrganizerCore& core) log::info("Copied LOOT {} back to profile", lootPluginsActual); } - if (!isFileTime) { + bool isPluginsTxt = core.managedGame()->loadOrderMechanism() == + MOBase::IPluginGame::LoadOrderMechanism::PluginsTxt; + + if (!isFileTime && !isPluginsTxt) { + // For games that use a separate loadorder.txt (not FileTime, not + // PluginsTxt), copy LOOT's loadorder.txt back. QString lootLoadOrderActual; { QString capitalL = localPath + "/Loadorder.txt"; @@ -1533,6 +1538,42 @@ static bool launchLootGui(QWidget* parent, OrganizerCore& core) } } + if (isPluginsTxt && !lootPluginsActual.isEmpty()) { + // For PluginsTxt games (FO4, SkyrimSE, etc.), LOOT writes the sorted + // order into plugins.txt but does NOT update loadorder.txt. If we + // blindly copy the stale loadorder.txt back, readPluginLists() will + // prefer the old order from loadorder.txt over the freshly-sorted + // plugins.txt. Fix this by regenerating loadorder.txt from the + // sorted plugins.txt so both files are consistent. + QFile pluginsFile(profilePlugins); + if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + QStringList sortedOrder; + while (!pluginsFile.atEnd()) { + QString line = QString::fromUtf8(pluginsFile.readLine()).trimmed(); + if (line.isEmpty() || line.startsWith('#')) + continue; + if (line.startsWith('*')) + line.remove(0, 1); + if (!line.isEmpty()) + sortedOrder.append(line); + } + pluginsFile.close(); + + if (!sortedOrder.isEmpty()) { + QFile lo(profileLoadOrder); + if (lo.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&lo); + for (const auto& p : sortedOrder) { + out << p << "\n"; + } + lo.close(); + log::info("Regenerated loadorder.txt from LOOT-sorted plugins.txt " + "({} plugins)", sortedOrder.size()); + } + } + } + } + return true; } #endif diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index e94939f..a01b1c4 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -376,7 +376,9 @@ MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, m_LinkStartMenu = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Start Menu"), this, SLOT(linkMenu())); #else - m_LinkStartMenu = nullptr; + m_LinkStartMenu = linkMenu->addAction(QIcon(":/MO/gui/link"), + tr("Application Launcher"), this, + SLOT(linkMenu())); #endif ui->linkButton->setMenu(linkMenu); @@ -2806,7 +2808,11 @@ void MainWindow::linkDesktop() void MainWindow::linkMenu() { if (auto* exe = getSelectedExecutable()) { +#ifdef _WIN32 env::Shortcut(*exe).toggle(env::Shortcut::StartMenu); +#else + env::Shortcut(*exe).toggle(env::Shortcut::ApplicationMenu); +#endif } } @@ -2828,8 +2834,14 @@ void MainWindow::on_linkButton_pressed() : addIcon); if (m_LinkStartMenu) { +#ifdef _WIN32 m_LinkStartMenu->setIcon(shortcut.exists(env::Shortcut::StartMenu) ? removeIcon : addIcon); +#else + m_LinkStartMenu->setIcon(shortcut.exists(env::Shortcut::ApplicationMenu) + ? removeIcon + : addIcon); +#endif } } diff --git a/src/src/mainwindow.ui b/src/src/mainwindow.ui index 0e8e2bf..c84bb41 100644 --- a/src/src/mainwindow.ui +++ b/src/src/mainwindow.ui @@ -1108,6 +1108,16 @@ + + + + Mount the virtual filesystem and open the game Data folder in a file manager. + + + Browse VFS + + + -- cgit v1.3.1