diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-12 07:20:42 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-12 07:20:42 -0500 |
| commit | 06826ca53dd54e73169514b3de2f81f2a7ca31f4 (patch) | |
| tree | dc2d91797aef6d3a979ae9e136684b73d7ea868a | |
| parent | 755698f1adceef55f52c8e44c9746537885c9bf7 (diff) | |
Add native Root Builder and OMOD Installer plugins
- libs/rootbuilder_native/: Native C++ port of rootbuilder.py (IPluginTool)
- Root/ dir scanning, copy (reflink/CoW) and symlink modes
- JSON manifest tracking, backup/restore, auto-deploy hooks
- Legacy migration and third-party conflict detection
- libs/installer_omod_native/: Native C++ port of installer_omod.py
- Handles .omod archives (Oblivion Mod Manager format)
- Binary config parsing, .NET string format, CRC file parsing
- zlib raw deflate and LZMA decompression
- Repackages to zip for MO2's installation manager
- Added both to build-inner.sh plugin packaging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | CMakeLists.txt | 2 | ||||
| -rwxr-xr-x | docker/build-inner.sh | 4 | ||||
| -rw-r--r-- | libs/installer_omod_native/CMakeLists.txt | 5 | ||||
| -rw-r--r-- | libs/installer_omod_native/src/CMakeLists.txt | 11 | ||||
| -rw-r--r-- | libs/installer_omod_native/src/omodinstaller.cpp | 472 | ||||
| -rw-r--r-- | libs/installer_omod_native/src/omodinstaller.h | 101 | ||||
| -rw-r--r-- | libs/rootbuilder_native/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | libs/rootbuilder_native/src/CMakeLists.txt | 18 | ||||
| -rw-r--r-- | libs/rootbuilder_native/src/rootbuilder.cpp | 674 | ||||
| -rw-r--r-- | libs/rootbuilder_native/src/rootbuilder.h | 79 |
10 files changed, 1368 insertions, 1 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 3080998..700e55f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -216,6 +216,7 @@ add_subdirectory(libs/game_features) add_subdirectory(libs/game_bethesda) add_subdirectory(libs/installer_fomod) add_subdirectory(libs/installer_fomod_plus) +add_subdirectory(libs/installer_omod_native) add_subdirectory(libs/installer_bain) add_subdirectory(libs/installer_bundle) add_subdirectory(libs/installer_manual) @@ -232,6 +233,7 @@ add_subdirectory(libs/basic_games_native) add_subdirectory(libs/check_fnis) add_subdirectory(libs/tool_inibakery) add_subdirectory(libs/tool_inieditor) +add_subdirectory(libs/rootbuilder_native) if(BUILD_DOTNET_PLUGINS) if(WIN32 AND MSVC) add_subdirectory(libs/installer_fomod_csharp) diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 0bcb8b0..d4d3755 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -71,7 +71,9 @@ find build/libs -type f \( \ -name "libform43_checker_native.so" -o \ -name "libscript_extender_checker_native.so" -o \ -name "libpreview_dds_native.so" -o \ - -name "libbasic_games_native.so" \ + -name "libbasic_games_native.so" -o \ + -name "librootbuilder_native.so" -o \ + -name "libinstaller_omod_native.so" \ \) -exec cp -f {} "${OUT_DIR}/plugins/" \; # Python plugin loader (small — kept for optional Python support). diff --git a/libs/installer_omod_native/CMakeLists.txt b/libs/installer_omod_native/CMakeLists.txt new file mode 100644 index 0000000..920de28 --- /dev/null +++ b/libs/installer_omod_native/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +project(installer_omod_native) + +add_subdirectory(src) diff --git a/libs/installer_omod_native/src/CMakeLists.txt b/libs/installer_omod_native/src/CMakeLists.txt new file mode 100644 index 0000000..a4564e1 --- /dev/null +++ b/libs/installer_omod_native/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +file(GLOB installer_omod_native_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h +) + +add_library(installer_omod_native SHARED ${installer_omod_native_SOURCES}) +mo2_configure_plugin(installer_omod_native NO_SOURCES WARNINGS OFF) +target_link_libraries(installer_omod_native PRIVATE mo2::uibase Qt6::Widgets z lzma) +mo2_install_plugin(installer_omod_native) diff --git a/libs/installer_omod_native/src/omodinstaller.cpp b/libs/installer_omod_native/src/omodinstaller.cpp new file mode 100644 index 0000000..592111a --- /dev/null +++ b/libs/installer_omod_native/src/omodinstaller.cpp @@ -0,0 +1,472 @@ +#include "omodinstaller.h" + +#include <uibase/guessedvalue.h> +#include <uibase/iinstallationmanager.h> +#include <uibase/log.h> + +#include <QDir> +#include <QDirIterator> +#include <QFile> +#include <QFileInfo> +#include <QProcess> +#include <QTemporaryDir> + +#include <zlib.h> +#include <lzma.h> + +#include <cstring> +#include <stdexcept> + +// ============================================================================ +// BinaryReader +// ============================================================================ + +OmodInstaller::BinaryReader::BinaryReader(const QByteArray& data) : m_data(data) {} + +uint8_t OmodInstaller::BinaryReader::readByte() +{ + if (m_pos >= m_data.size()) + throw std::runtime_error("BinaryReader: read past end"); + return static_cast<uint8_t>(m_data[m_pos++]); +} + +int32_t OmodInstaller::BinaryReader::readInt32LE() +{ + if (m_pos + 4 > m_data.size()) + throw std::runtime_error("BinaryReader: read past end"); + int32_t val; + std::memcpy(&val, m_data.constData() + m_pos, 4); + m_pos += 4; + return val; +} + +uint32_t OmodInstaller::BinaryReader::readUInt32LE() +{ + if (m_pos + 4 > m_data.size()) + throw std::runtime_error("BinaryReader: read past end"); + uint32_t val; + std::memcpy(&val, m_data.constData() + m_pos, 4); + m_pos += 4; + return val; +} + +int64_t OmodInstaller::BinaryReader::readInt64LE() +{ + if (m_pos + 8 > m_data.size()) + throw std::runtime_error("BinaryReader: read past end"); + int64_t val; + std::memcpy(&val, m_data.constData() + m_pos, 8); + m_pos += 8; + return val; +} + +void OmodInstaller::BinaryReader::skip(int bytes) +{ + m_pos += bytes; + if (m_pos > m_data.size()) + throw std::runtime_error("BinaryReader: skip past end"); +} + +int OmodInstaller::BinaryReader::read7BitEncodedInt() +{ + int result = 0; + int shift = 0; + while (true) { + uint8_t b = readByte(); + result |= (b & 0x7F) << shift; + shift += 7; + if ((b & 0x80) == 0) + break; + } + return result; +} + +QString OmodInstaller::BinaryReader::readNetString() +{ + int length = read7BitEncodedInt(); + if (length == 0) + return {}; + QByteArray raw = readBytes(length); + return QString::fromUtf8(raw); +} + +QByteArray OmodInstaller::BinaryReader::readBytes(int count) +{ + if (m_pos + count > m_data.size()) + throw std::runtime_error("BinaryReader: read past end"); + QByteArray result = m_data.mid(m_pos, count); + m_pos += count; + return result; +} + +bool OmodInstaller::BinaryReader::atEnd() const +{ + return m_pos >= m_data.size(); +} + +// ============================================================================ +// OmodInstaller +// ============================================================================ + +OmodInstaller::OmodInstaller() = default; + +bool OmodInstaller::init(MOBase::IOrganizer* organizer) +{ + m_organizer = organizer; + return true; +} + +QString OmodInstaller::name() const +{ + return "OMOD Installer (Native)"; +} + +QString OmodInstaller::localizedName() const +{ + return "OMOD Installer (Native)"; +} + +QString OmodInstaller::author() const +{ + return "Fluorine Manager"; +} + +QString OmodInstaller::description() const +{ + return "Installer for .omod archives (Oblivion Mod Manager format)"; +} + +MOBase::VersionInfo OmodInstaller::version() const +{ + return MOBase::VersionInfo(1, 0, 0); +} + +QList<MOBase::PluginSetting> OmodInstaller::settings() const +{ + return {}; +} + +unsigned int OmodInstaller::priority() const +{ + return 500; +} + +bool OmodInstaller::isManualInstaller() const +{ + return false; +} + +bool OmodInstaller::isArchiveSupported( + std::shared_ptr<const MOBase::IFileTree> tree) const +{ + if (!tree) + return false; + + // An OMOD zip always contains a "config" entry. + for (auto it = tree->begin(); it != tree->end(); ++it) { + auto entry = *it; + if (entry && !entry->isDir() && entry->name().compare("config", Qt::CaseInsensitive) == 0) + return true; + } + return false; +} + +bool OmodInstaller::isArchiveSupported(const QString& archiveName) const +{ + return archiveName.toLower().endsWith(".omod"); +} + +std::set<QString> OmodInstaller::supportedExtensions() const +{ + return {"omod"}; +} + +OmodInstaller::EInstallResult OmodInstaller::install( + MOBase::GuessedValue<QString>& modName, QString /*gameName*/, + const QString& archiveName, const QString& /*version*/, int /*nexusID*/) +{ + try { + return doInstall(modName, archiveName); + } catch (const std::exception& e) { + MOBase::log::error("OMOD install failed: {}", e.what()); + return RESULT_FAILED; + } +} + +OmodInstaller::EInstallResult OmodInstaller::doInstall( + MOBase::GuessedValue<QString>& modName, const QString& archiveName) +{ + // Extract the .omod (zip) to a temp directory using unzip. + QTemporaryDir omodTmp; + if (!omodTmp.isValid()) { + MOBase::log::error("OMOD: failed to create temp directory"); + return RESULT_FAILED; + } + + { + QProcess unzip; + unzip.setWorkingDirectory(omodTmp.path()); + unzip.start("unzip", {"-o", archiveName, "-d", omodTmp.path()}); + unzip.waitForFinished(60000); + if (unzip.exitCode() != 0) { + MOBase::log::error("OMOD: unzip failed: {}", + unzip.readAllStandardError().toStdString()); + return RESULT_FAILED; + } + } + + // Read and parse the config entry. + QString configPath = QDir(omodTmp.path()).filePath("config"); + if (!QFile::exists(configPath)) { + MOBase::log::warn("OMOD: no config entry found"); + return RESULT_NOTATTEMPTED; + } + + QFile configFile(configPath); + if (!configFile.open(QIODevice::ReadOnly)) { + MOBase::log::error("OMOD: could not read config"); + return RESULT_FAILED; + } + QByteArray configData = configFile.readAll(); + configFile.close(); + + OmodConfig config = parseConfig(configData); + if (!config.modName.isEmpty()) { + modName.update(config.modName, MOBase::GUESS_META); + } + + // Create a separate temp directory for the extracted files. + QTemporaryDir extractTmp; + if (!extractTmp.isValid()) { + MOBase::log::error("OMOD: failed to create extraction temp directory"); + return RESULT_FAILED; + } + + // Extract data and plugins streams. + extractStream(omodTmp.path(), "data", "data.crc", config.compressionType, + extractTmp.path()); + extractStream(omodTmp.path(), "plugins", "plugins.crc", config.compressionType, + extractTmp.path()); + + // Copy readme files if present. + QDir omodDir(omodTmp.path()); + QStringList entries = omodDir.entryList(QDir::Files); + for (const QString& entry : entries) { + QString lower = entry.toLower(); + if (lower == "readme" || lower.startsWith("readme.")) { + QFile::copy(omodDir.filePath(entry), + QDir(extractTmp.path()).filePath(entry)); + } + } + + // Verify we have files to install. + QDirIterator dirIt(extractTmp.path(), QDir::Files, QDirIterator::Subdirectories); + if (!dirIt.hasNext()) { + MOBase::log::warn("OMOD: no files extracted"); + return RESULT_FAILED; + } + + // Repackage extracted files as a standard zip for MO2's installer. + QString repackPath = QDir(extractTmp.path()).filePath("_repack.zip"); + { + QProcess zip; + zip.setWorkingDirectory(extractTmp.path()); + // Collect all files relative to extractTmp, excluding the repack zip itself. + QStringList zipArgs; + zipArgs << "-r" << repackPath << "."; + zipArgs << "-x" << "./_repack.zip"; + zip.start("zip", zipArgs); + zip.waitForFinished(120000); + if (zip.exitCode() != 0) { + MOBase::log::error("OMOD: zip repackaging failed: {}", + zip.readAllStandardError().toStdString()); + return RESULT_FAILED; + } + } + + return manager()->installArchive(modName, repackPath); +} + +// ============================================================================ +// OMOD binary format parsing +// ============================================================================ + +OmodInstaller::OmodConfig OmodInstaller::parseConfig(const QByteArray& data) +{ + BinaryReader reader(data); + OmodConfig config; + + config.fileVersion = reader.readByte(); + config.modName = reader.readNetString(); + config.major = reader.readInt32LE(); + config.minor = reader.readInt32LE(); + config.authorName = reader.readNetString(); + config.email = reader.readNetString(); + config.website = reader.readNetString(); + config.desc = reader.readNetString(); + + // Windows FILETIME (8 bytes) - skip. + reader.skip(8); + + // Compression type: 0 = deflate, 1 = lzma. + config.compressionType = reader.readByte(); + + return config; +} + +std::vector<OmodInstaller::CrcEntry> +OmodInstaller::parseCrcFile(const QByteArray& data) +{ + BinaryReader reader(data); + int count = reader.read7BitEncodedInt(); + + std::vector<CrcEntry> entries; + entries.reserve(count); + + for (int i = 0; i < count; ++i) { + CrcEntry entry; + entry.path = reader.readNetString(); + entry.crc = reader.readUInt32LE(); + entry.size = reader.readInt64LE(); + entries.push_back(std::move(entry)); + } + + return entries; +} + +QByteArray OmodInstaller::decompressStream(const QByteArray& data, + uint8_t compressionType) +{ + if (compressionType == 0) { + // Raw deflate (zlib with wbits = -15, no header). + z_stream strm{}; + if (inflateInit2(&strm, -15) != Z_OK) { + throw std::runtime_error("inflateInit2 failed"); + } + + strm.next_in = reinterpret_cast<Bytef*>(const_cast<char*>(data.constData())); + strm.avail_in = static_cast<uInt>(data.size()); + + QByteArray result; + char buffer[65536]; + + int ret; + do { + strm.next_out = reinterpret_cast<Bytef*>(buffer); + strm.avail_out = sizeof(buffer); + ret = inflate(&strm, Z_NO_FLUSH); + if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR) { + inflateEnd(&strm); + throw std::runtime_error("zlib inflate failed"); + } + int have = sizeof(buffer) - strm.avail_out; + result.append(buffer, have); + } while (ret != Z_STREAM_END); + + inflateEnd(&strm); + return result; + + } else if (compressionType == 1) { + // LZMA decompression. + lzma_stream strm = LZMA_STREAM_INIT; + lzma_ret ret = lzma_alone_decoder(&strm, UINT64_MAX); + if (ret != LZMA_OK) { + throw std::runtime_error("lzma_alone_decoder init failed"); + } + + strm.next_in = reinterpret_cast<const uint8_t*>(data.constData()); + strm.avail_in = data.size(); + + QByteArray result; + uint8_t buffer[65536]; + + do { + strm.next_out = buffer; + strm.avail_out = sizeof(buffer); + ret = lzma_code(&strm, LZMA_FINISH); + if (ret != LZMA_OK && ret != LZMA_STREAM_END) { + lzma_end(&strm); + throw std::runtime_error("lzma decompression failed"); + } + size_t have = sizeof(buffer) - strm.avail_out; + result.append(reinterpret_cast<const char*>(buffer), static_cast<int>(have)); + } while (ret != LZMA_STREAM_END); + + lzma_end(&strm); + return result; + + } else { + throw std::runtime_error( + std::string("Unknown OMOD compression type: ") + + std::to_string(compressionType)); + } +} + +void OmodInstaller::extractStream(const QString& omodDir, const QString& streamName, + const QString& crcName, uint8_t compressionType, + const QString& outDir) +{ + QDir dir(omodDir); + QString streamPath = dir.filePath(streamName); + QString crcPath = dir.filePath(crcName); + + if (!QFile::exists(streamPath)) + return; + + if (!QFile::exists(crcPath)) { + MOBase::log::warn("OMOD: {} present but {} missing", streamName.toStdString(), + crcName.toStdString()); + return; + } + + // Read CRC file. + QFile crcFile(crcPath); + if (!crcFile.open(QIODevice::ReadOnly)) + return; + QByteArray crcData = crcFile.readAll(); + crcFile.close(); + + std::vector<CrcEntry> fileList = parseCrcFile(crcData); + if (fileList.empty()) + return; + + // Read and decompress the stream. + QFile streamFile(streamPath); + if (!streamFile.open(QIODevice::ReadOnly)) + return; + QByteArray rawData = streamFile.readAll(); + streamFile.close(); + + QByteArray decompressed = decompressStream(rawData, compressionType); + + // Split the decompressed data into individual files. + int offset = 0; + for (const CrcEntry& entry : fileList) { + if (offset + entry.size > decompressed.size()) { + MOBase::log::warn( + "OMOD: truncated stream for {} (need {} bytes at offset {}, have {})", + entry.path.toStdString(), entry.size, offset, decompressed.size()); + break; + } + + QByteArray fileData = decompressed.mid(offset, static_cast<int>(entry.size)); + offset += static_cast<int>(entry.size); + + // Normalise Windows path separators. + QString normalPath = entry.path; + normalPath.replace('\\', '/'); + + QString destPath = QDir(outDir).filePath(normalPath); + + // Ensure parent directory exists. + QDir().mkpath(QFileInfo(destPath).absolutePath()); + + QFile outFile(destPath); + if (outFile.open(QIODevice::WriteOnly)) { + outFile.write(fileData); + outFile.close(); + } else { + MOBase::log::warn("OMOD: could not write {}", destPath.toStdString()); + } + } +} diff --git a/libs/installer_omod_native/src/omodinstaller.h b/libs/installer_omod_native/src/omodinstaller.h new file mode 100644 index 0000000..de48b77 --- /dev/null +++ b/libs/installer_omod_native/src/omodinstaller.h @@ -0,0 +1,101 @@ +#ifndef OMODINSTALLER_H +#define OMODINSTALLER_H + +#include <uibase/iplugininstallercustom.h> + +#include <QByteArray> +#include <QList> +#include <QString> + +#include <cstdint> +#include <vector> + +class OmodInstaller : public MOBase::IPluginInstallerCustom +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller MOBase::IPluginInstallerCustom) + Q_PLUGIN_METADATA(IID "com.tannin.ModOrganizer.PluginInstallerCustom/1.0") + +public: + OmodInstaller(); + + // IPlugin + bool init(MOBase::IOrganizer* organizer) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + + // IPluginInstaller + unsigned int priority() const override; + bool isManualInstaller() const override; + bool isArchiveSupported(std::shared_ptr<const MOBase::IFileTree> tree) const override; + + // IPluginInstallerCustom + bool isArchiveSupported(const QString& archiveName) const override; + std::set<QString> supportedExtensions() const override; + EInstallResult install(MOBase::GuessedValue<QString>& modName, QString gameName, + const QString& archiveName, const QString& version, + int nexusID) override; + +private: + MOBase::IOrganizer* m_organizer = nullptr; + + // OMOD config parsed from the binary "config" entry. + struct OmodConfig + { + uint8_t fileVersion = 0; + QString modName; + int32_t major = 0; + int32_t minor = 0; + QString authorName; + QString email; + QString website; + QString desc; + uint8_t compressionType = 0; // 0 = deflate, 1 = lzma + }; + + // Entry from data.crc / plugins.crc. + struct CrcEntry + { + QString path; + uint32_t crc = 0; + int64_t size = 0; + }; + + // Binary reader helper over a QByteArray. + class BinaryReader + { + public: + explicit BinaryReader(const QByteArray& data); + + uint8_t readByte(); + int32_t readInt32LE(); + uint32_t readUInt32LE(); + int64_t readInt64LE(); + void skip(int bytes); + int read7BitEncodedInt(); + QString readNetString(); + QByteArray readBytes(int count); + bool atEnd() const; + + private: + const QByteArray& m_data; + int m_pos = 0; + }; + + OmodConfig parseConfig(const QByteArray& data); + std::vector<CrcEntry> parseCrcFile(const QByteArray& data); + QByteArray decompressStream(const QByteArray& data, uint8_t compressionType); + + EInstallResult doInstall(MOBase::GuessedValue<QString>& modName, + const QString& archiveName); + + void extractStream(const QString& omodDir, const QString& streamName, + const QString& crcName, uint8_t compressionType, + const QString& outDir); +}; + +#endif // OMODINSTALLER_H diff --git a/libs/rootbuilder_native/CMakeLists.txt b/libs/rootbuilder_native/CMakeLists.txt new file mode 100644 index 0000000..583c0ac --- /dev/null +++ b/libs/rootbuilder_native/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.16) +project(rootbuilder_native) +add_subdirectory(src) diff --git a/libs/rootbuilder_native/src/CMakeLists.txt b/libs/rootbuilder_native/src/CMakeLists.txt new file mode 100644 index 0000000..438c068 --- /dev/null +++ b/libs/rootbuilder_native/src/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.16) + +find_package(Qt6 REQUIRED COMPONENTS Widgets) +qt_standard_project_setup() + +file(GLOB SOURCES *.cpp *.h) + +add_library(rootbuilder_native SHARED ${SOURCES}) + +set_target_properties(rootbuilder_native PROPERTIES + AUTOMOC ON + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED ON + PREFIX "lib" +) + +target_link_libraries(rootbuilder_native PRIVATE mo2::uibase Qt6::Widgets) +install(TARGETS rootbuilder_native LIBRARY DESTINATION bin/plugins) diff --git a/libs/rootbuilder_native/src/rootbuilder.cpp b/libs/rootbuilder_native/src/rootbuilder.cpp new file mode 100644 index 0000000..6ee2e1c --- /dev/null +++ b/libs/rootbuilder_native/src/rootbuilder.cpp @@ -0,0 +1,674 @@ +#include "rootbuilder.h" + +#include <uibase/imodinterface.h> +#include <uibase/imodlist.h> +#include <uibase/iplugingame.h> +#include <uibase/versioninfo.h> + +#include <QCoreApplication> +#include <QCheckBox> +#include <QComboBox> +#include <QDialog> +#include <QDir> +#include <QDirIterator> +#include <QFile> +#include <QFileInfo> +#include <QHBoxLayout> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QLabel> +#include <QProcess> +#include <QPushButton> +#include <QVBoxLayout> + +#include <algorithm> +#include <set> + +// Storage constants +static const QString STORAGE_SUBDIR = QStringLiteral("rootbuilder"); +static const QString MANIFEST_NAME = QStringLiteral("manifest.json"); +static const QString SETTINGS_NAME = QStringLiteral("settings.json"); +static const QString BACKUP_SUBDIR = QStringLiteral("backup"); +static const QString LEGACY_MANIFEST = QStringLiteral(".rootbuilder_manifest.json"); +static const QString LEGACY_BACKUP = QStringLiteral(".rootbuilder_backup"); + +// ============================================================================ +// Construction / IPlugin +// ============================================================================ + +RootBuilderNative::RootBuilderNative() = default; + +bool RootBuilderNative::init(MOBase::IOrganizer* organizer) +{ + m_organizer = organizer; + migrateLegacy(); + checkThirdPartyRootBuilder(); + + organizer->onAboutToRun([this](const QString& exe) { + return onAboutToRun(exe); + }); + organizer->onFinishedRun([this](const QString& exe, unsigned int code) { + onFinishedRun(exe, code); + }); + + return true; +} + +QString RootBuilderNative::name() const +{ + return QStringLiteral("Root Builder (Native)"); +} + +QString RootBuilderNative::localizedName() const +{ + return QStringLiteral("Root Builder (Native)"); +} + +QString RootBuilderNative::author() const +{ + return QStringLiteral("Fluorine Manager"); +} + +QString RootBuilderNative::description() const +{ + return QStringLiteral( + "Deploys mod files from Root/ subdirectories to the game's root directory. " + "Supports copy and symlink modes with auto-deploy on launch."); +} + +MOBase::VersionInfo RootBuilderNative::version() const +{ + return MOBase::VersionInfo(1, 0, 0); +} + +QList<MOBase::PluginSetting> RootBuilderNative::settings() const +{ + return {}; +} + +bool RootBuilderNative::enabledByDefault() const +{ + return true; +} + +// ============================================================================ +// IPluginTool +// ============================================================================ + +QString RootBuilderNative::displayName() const +{ + return QStringLiteral("Root Builder"); +} + +QString RootBuilderNative::tooltip() const +{ + return QStringLiteral("Deploy mod Root/ files to the game directory"); +} + +QIcon RootBuilderNative::icon() const +{ + return QIcon(); +} + +void RootBuilderNative::display() const +{ + QJsonObject settings = loadSettings(); + + // We need non-const this for build/clear — display() is const in the + // interface, but build/clear mutate the filesystem (not object state). + auto* self = const_cast<RootBuilderNative*>(this); + + auto* dialog = new QDialog(parentWidget()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(QStringLiteral("Root Builder")); + dialog->resize(350, 220); + + auto* layout = new QVBoxLayout(dialog); + + auto* desc = new QLabel( + QStringLiteral("Deploys files from mod Root/ folders to the game directory.")); + desc->setWordWrap(true); + layout->addWidget(desc); + + // Enable checkbox + auto* enableCheck = new QCheckBox(QStringLiteral("Auto-deploy on game launch")); + enableCheck->setChecked(settings.value(QStringLiteral("enabled")).toBool(false)); + layout->addWidget(enableCheck); + + // Mode selector + auto* modeLayout = new QHBoxLayout(); + modeLayout->addWidget(new QLabel(QStringLiteral("Deploy mode:"))); + auto* modeCombo = new QComboBox(); + modeCombo->addItems({QStringLiteral("copy"), QStringLiteral("link")}); + modeCombo->setCurrentText( + settings.value(QStringLiteral("mode")).toString(QStringLiteral("copy"))); + modeLayout->addWidget(modeCombo); + layout->addLayout(modeLayout); + + // Manual build/clear buttons + auto* btnLayout = new QHBoxLayout(); + auto* buildBtn = new QPushButton(QStringLiteral("Build Now")); + auto* clearBtn = new QPushButton(QStringLiteral("Clear Now")); + btnLayout->addWidget(buildBtn); + btnLayout->addWidget(clearBtn); + layout->addLayout(btnLayout); + + // Status label + auto* statusLabel = new QLabel(); + layout->addWidget(statusLabel); + + // Close button + auto* closeBtn = new QPushButton(QStringLiteral("Close")); + layout->addWidget(closeBtn); + + // Save helper — captures enableCheck and modeCombo + auto doSave = [this, enableCheck, modeCombo]() { + QJsonObject s; + s[QStringLiteral("enabled")] = enableCheck->isChecked(); + s[QStringLiteral("mode")] = modeCombo->currentText(); + saveSettings(s); + }; + + QObject::connect(enableCheck, &QCheckBox::stateChanged, dialog, [doSave](int) { + doSave(); + }); + QObject::connect(modeCombo, &QComboBox::currentTextChanged, dialog, + [doSave](const QString&) { doSave(); }); + + QObject::connect(buildBtn, &QPushButton::clicked, dialog, + [self, statusLabel]() { + int count = self->build(); + statusLabel->setText( + QStringLiteral("Deployed %1 file(s).").arg(count)); + }); + + QObject::connect(clearBtn, &QPushButton::clicked, dialog, + [self, statusLabel]() { + int count = self->clear(); + statusLabel->setText( + QStringLiteral("Cleared %1 file(s).").arg(count)); + }); + + QObject::connect(closeBtn, &QPushButton::clicked, dialog, &QDialog::accept); + + dialog->exec(); +} + +// ============================================================================ +// Hooks +// ============================================================================ + +bool RootBuilderNative::onAboutToRun(const QString& /*executable*/) +{ + if (isAutoEnabled()) + build(); + return true; +} + +void RootBuilderNative::onFinishedRun(const QString& /*executable*/, + unsigned int /*exitCode*/) +{ + if (isAutoEnabled()) + clear(); +} + +// ============================================================================ +// Storage paths +// ============================================================================ + +QString RootBuilderNative::storageDir() const +{ + QString d = m_organizer->basePath() + QStringLiteral("/") + STORAGE_SUBDIR; + QDir().mkpath(d); + return d; +} + +QString RootBuilderNative::backupDir() const +{ + return storageDir() + QStringLiteral("/") + BACKUP_SUBDIR; +} + +QString RootBuilderNative::manifestPath() const +{ + return storageDir() + QStringLiteral("/") + MANIFEST_NAME; +} + +QString RootBuilderNative::settingsPath() const +{ + return storageDir() + QStringLiteral("/") + SETTINGS_NAME; +} + +// ============================================================================ +// Settings (own JSON) +// ============================================================================ + +QJsonObject RootBuilderNative::loadSettings() const +{ + QFile f(settingsPath()); + if (!f.open(QIODevice::ReadOnly)) + return QJsonObject{ + {QStringLiteral("enabled"), false}, + {QStringLiteral("mode"), QStringLiteral("copy")}}; + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(f.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + return QJsonObject{ + {QStringLiteral("enabled"), false}, + {QStringLiteral("mode"), QStringLiteral("copy")}}; + + return doc.object(); +} + +void RootBuilderNative::saveSettings(const QJsonObject& settings) const +{ + QDir().mkpath(storageDir()); + QFile f(settingsPath()); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + f.write(QJsonDocument(settings).toJson(QJsonDocument::Indented)); + } +} + +bool RootBuilderNative::isAutoEnabled() const +{ + return loadSettings().value(QStringLiteral("enabled")).toBool(false); +} + +// ============================================================================ +// Manifest +// ============================================================================ + +QJsonObject RootBuilderNative::loadManifest() const +{ + QFile f(manifestPath()); + if (!f.open(QIODevice::ReadOnly)) + return QJsonObject(); + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(f.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + return QJsonObject(); + + return doc.object(); +} + +void RootBuilderNative::saveManifest(const QJsonObject& manifest) const +{ + QDir().mkpath(storageDir()); + QFile f(manifestPath()); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + f.write(QJsonDocument(manifest).toJson(QJsonDocument::Indented)); + } +} + +void RootBuilderNative::removeManifest() const +{ + QFile::remove(manifestPath()); +} + +// ============================================================================ +// Legacy migration +// ============================================================================ + +void RootBuilderNative::migrateLegacy() const +{ + auto* game = m_organizer->managedGame(); + if (!game) + return; + + QString gameDir = game->gameDirectory().absolutePath(); + QString storage = storageDir(); + + // Migrate manifest + QString oldManifest = gameDir + QStringLiteral("/") + LEGACY_MANIFEST; + if (QFileInfo::exists(oldManifest)) { + QString newPath = storage + QStringLiteral("/") + MANIFEST_NAME; + if (!QFileInfo::exists(newPath)) + QFile::copy(oldManifest, newPath); + forceRemove(oldManifest); + } + + // Migrate backup directory + QString oldBackup = gameDir + QStringLiteral("/") + LEGACY_BACKUP; + QFileInfo oldBackupInfo(oldBackup); + if (oldBackupInfo.isDir()) { + QString newBackup = storage + QStringLiteral("/") + BACKUP_SUBDIR; + if (!QFileInfo(newBackup).isDir()) { + // Copy tree + QDir().mkpath(newBackup); + QDirIterator it(oldBackup, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + QString rel = QDir(oldBackup).relativeFilePath(it.filePath()); + QString dst = newBackup + QStringLiteral("/") + rel; + QDir().mkpath(QFileInfo(dst).absolutePath()); + QFile::copy(it.filePath(), dst); + } + } + // Remove old backup tree + QDir(oldBackup).removeRecursively(); + } +} + +// ============================================================================ +// Third-party conflict detection +// ============================================================================ + +void RootBuilderNative::checkThirdPartyRootBuilder() const +{ + // Locate the plugins directory (where .py/.so plugins live, next to the binary) + QString pluginsDir = QCoreApplication::applicationDirPath() + QStringLiteral("/plugins"); + if (!QDir(pluginsDir).exists()) + return; + + QString disabledDir = + QCoreApplication::applicationDirPath() + QStringLiteral("/DisabledPlugins"); + + QDir dir(pluginsDir); + const auto entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + + for (const auto& entry : entries) { + bool conflict = false; + QString entryName = entry.fileName(); + + if (entry.isDir() && entryName.compare(QStringLiteral("rootbuilder"), + Qt::CaseInsensitive) == 0) { + conflict = true; + } else if (entry.isFile() && + entryName.toLower().startsWith(QStringLiteral("rootbuilder")) && + entryName.toLower().endsWith(QStringLiteral(".py"))) { + // Don't move our own bundled rootbuilder.py + if (entryName == QStringLiteral("rootbuilder.py")) + continue; + conflict = true; + } + + if (conflict) { + QDir().mkpath(disabledDir); + QString dst = disabledDir + QStringLiteral("/") + entryName; + if (QFile::rename(entry.absoluteFilePath(), dst)) { + qInfo("Root Builder: moved incompatible third-party plugin " + "'%s' to DisabledPlugins/.", + qUtf8Printable(entryName)); + } else { + qWarning("Root Builder: failed to move third-party plugin " + "'%s' to DisabledPlugins/.", + qUtf8Printable(entryName)); + } + } + } +} + +// ============================================================================ +// File helpers +// ============================================================================ + +QString RootBuilderNative::findRootDir(const QString& modPath) +{ + QDir dir(modPath); + const auto entries = + dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto& entry : entries) { + if (entry.fileName().compare(QStringLiteral("root"), + Qt::CaseInsensitive) == 0) { + return entry.absoluteFilePath(); + } + } + return QString(); +} + +void RootBuilderNative::ensureReadable(const QString& path) +{ + QFileInfo fi(path); + if (fi.exists() && !fi.isReadable()) { + QFile::setPermissions(path, fi.permissions() | QFileDevice::ReadOwner); + } +} + +void RootBuilderNative::ensureWritable(const QString& path) +{ + QFileInfo fi(path); + if (fi.exists()) { + QFile::setPermissions(path, fi.permissions() | QFileDevice::WriteOwner); + } +} + +void RootBuilderNative::reflinkCopy(const QString& src, const QString& dst) +{ + ensureReadable(src); + + // Try cp --reflink=auto first + QProcess proc; + proc.setProgram(QStringLiteral("cp")); + proc.setArguments({QStringLiteral("--reflink=auto"), QStringLiteral("-f"), + QStringLiteral("--"), src, dst}); + proc.start(); + if (proc.waitForFinished(10000) && proc.exitCode() == 0) + return; + + // Fallback to QFile::copy (remove dst first since QFile::copy won't overwrite) + QFile::remove(dst); + if (QFile::copy(src, dst)) + return; + + qWarning("Root Builder: failed to copy %s -> %s", + qUtf8Printable(src), qUtf8Printable(dst)); +} + +bool RootBuilderNative::forceRemove(const QString& path) +{ + if (QFile::remove(path)) + return true; + + // Fix permissions and retry + ensureWritable(QFileInfo(path).absolutePath()); + ensureWritable(path); + if (QFile::remove(path)) + return true; + + qWarning("Root Builder: could not remove %s", qUtf8Printable(path)); + return false; +} + +void RootBuilderNative::cleanupEmptyDirs(const QString& baseDir, + const QStringList& paths) +{ + std::set<QString> dirsToCheck; + for (const auto& path : paths) { + QString parent = QFileInfo(path).absolutePath(); + while (!parent.isEmpty() && parent != baseDir) { + // Check samefile + QFileInfo parentInfo(parent); + QFileInfo baseInfo(baseDir); + if (parentInfo.absoluteFilePath() == baseInfo.absoluteFilePath()) + break; + dirsToCheck.insert(parent); + parent = QFileInfo(parent).absolutePath(); + } + } + + // Sort by length descending (deepest first) + QStringList sorted(dirsToCheck.begin(), dirsToCheck.end()); + std::sort(sorted.begin(), sorted.end(), + [](const QString& a, const QString& b) { + return a.length() > b.length(); + }); + + for (const auto& d : sorted) { + QDir dir(d); + if (dir.exists() && dir.isEmpty()) + dir.rmdir(QStringLiteral(".")); + } +} + +// ============================================================================ +// Build +// ============================================================================ + +int RootBuilderNative::build() const +{ + auto* game = m_organizer->managedGame(); + if (!game) + return 0; + + QString gameDir = game->gameDirectory().absolutePath(); + QString storage = storageDir(); + auto* modList = m_organizer->modList(); + QString mode = loadSettings().value(QStringLiteral("mode")) + .toString(QStringLiteral("copy")); + + // Clear any previous deployment first + QJsonObject existingManifest = loadManifest(); + if (!existingManifest.isEmpty()) + const_cast<RootBuilderNative*>(this)->clear(); + + QJsonArray deployed; + QJsonObject backups; + QSet<QString> deployedSet; + + QStringList mods = modList->allModsByProfilePriority(); + for (const auto& modName : mods) { + if (!(modList->state(modName) & MOBase::IModList::STATE_ACTIVE)) + continue; + + auto* mod = modList->getMod(modName); + if (!mod) + continue; + if (mod->isSeparator() || mod->isBackup() || mod->isForeign()) + continue; + + QString modPath = mod->absolutePath(); + QString rootDir = findRootDir(modPath); + if (rootDir.isEmpty()) + continue; + + QDirIterator it(rootDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + QString srcFile = it.filePath(); + QString rel = QDir(rootDir).relativeFilePath(srcFile); + QString dst = gameDir + QStringLiteral("/") + rel; + + // Backup existing file if not already deployed by us + if (QFile::exists(dst) && !deployedSet.contains(dst)) { + QString bak = + backupDir() + QStringLiteral("/") + rel; + QDir().mkpath(QFileInfo(bak).absolutePath()); + ensureWritable(dst); + QFile::copy(dst, bak); + backups[dst] = bak; + } + + QDir().mkpath(QFileInfo(dst).absolutePath()); + + // Remove existing file/symlink + if (QFileInfo::exists(dst) || QFileInfo(dst).isSymLink()) + forceRemove(dst); + + // In link mode, .exe and .dll must be copied (Wine resolves + // symlinked exe paths to the target, breaking sibling lookups) + QString ext = QFileInfo(srcFile).suffix().toLower(); + if (mode == QStringLiteral("link") && ext != QStringLiteral("exe") && + ext != QStringLiteral("dll")) { + QFile::link(srcFile, dst); + } else { + reflinkCopy(srcFile, dst); + } + + if (!deployedSet.contains(dst)) { + deployed.append(dst); + deployedSet.insert(dst); + } + } + } + + QJsonObject manifest; + manifest[QStringLiteral("deployed")] = deployed; + manifest[QStringLiteral("backups")] = backups; + saveManifest(manifest); + + return deployed.count(); +} + +// ============================================================================ +// Clear +// ============================================================================ + +int RootBuilderNative::clear() const +{ + auto* game = m_organizer->managedGame(); + if (!game) + return 0; + + QString gameDir = game->gameDirectory().absolutePath(); + QString storage = storageDir(); + + QJsonObject manifest = loadManifest(); + if (manifest.isEmpty()) + return 0; + + int count = 0; + QJsonArray deployedArr = manifest.value(QStringLiteral("deployed")).toArray(); + QJsonObject backupsObj = manifest.value(QStringLiteral("backups")).toObject(); + + QStringList failed; + QStringList cleared; + + // Remove deployed files + for (const auto& val : deployedArr) { + QString path = val.toString(); + if (QFileInfo::exists(path) || QFileInfo(path).isSymLink()) { + if (forceRemove(path)) { + ++count; + cleared.append(path); + } else { + failed.append(path); + } + } else { + cleared.append(path); + } + } + + // Restore backups + for (auto it = backupsObj.begin(); it != backupsObj.end(); ++it) { + QString dst = it.key(); + QString bak = it.value().toString(); + if (QFile::exists(bak)) { + QDir().mkpath(QFileInfo(dst).absolutePath()); + ensureWritable(QFileInfo(dst).absolutePath()); + if (QFileInfo::exists(dst) || QFileInfo(dst).isSymLink()) + forceRemove(dst); + if (!QFile::rename(bak, dst)) { + qWarning("Root Builder: could not restore backup %s -> %s", + qUtf8Printable(bak), qUtf8Printable(dst)); + } + } + } + + // Clean up backup dir + QString bDir = backupDir(); + if (QFileInfo(bDir).isDir()) + QDir(bDir).removeRecursively(); + + if (!failed.isEmpty()) { + // Update manifest to only contain files we couldn't remove + QJsonArray failedArr; + for (const auto& f : failed) + failedArr.append(f); + + QJsonObject retryManifest; + retryManifest[QStringLiteral("deployed")] = failedArr; + retryManifest[QStringLiteral("backups")] = QJsonObject(); + saveManifest(retryManifest); + + qWarning("Root Builder: %d file(s) could not be removed. " + "They will be retried on next clear.", + static_cast<int>(failed.size())); + } else { + removeManifest(); + } + + cleanupEmptyDirs(gameDir, cleared); + return count; +} diff --git a/libs/rootbuilder_native/src/rootbuilder.h b/libs/rootbuilder_native/src/rootbuilder.h new file mode 100644 index 0000000..121bcc1 --- /dev/null +++ b/libs/rootbuilder_native/src/rootbuilder.h @@ -0,0 +1,79 @@ +#ifndef ROOTBUILDER_NATIVE_H +#define ROOTBUILDER_NATIVE_H + +#include <uibase/iplugintool.h> + +#include <QJsonObject> +#include <QString> + +class RootBuilderNative : public MOBase::IPluginTool +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) + Q_PLUGIN_METADATA(IID "com.tannin.ModOrganizer.PluginTool/1.0") + +public: + RootBuilderNative(); + + // IPlugin + bool init(MOBase::IOrganizer* organizer) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + bool enabledByDefault() const override; + + // IPluginTool + QString displayName() const override; + QString tooltip() const override; + QIcon icon() const override; + +public slots: + void display() const override; + +private: + // Storage paths + QString storageDir() const; + QString backupDir() const; + QString manifestPath() const; + QString settingsPath() const; + + // Settings (own JSON, not pluginSetting) + QJsonObject loadSettings() const; + void saveSettings(const QJsonObject& settings) const; + bool isAutoEnabled() const; + + // Manifest + QJsonObject loadManifest() const; + void saveManifest(const QJsonObject& manifest) const; + void removeManifest() const; + + // Legacy migration + void migrateLegacy() const; + + // Third-party conflict detection + void checkThirdPartyRootBuilder() const; + + // Build / Clear + int build() const; + int clear() const; + + // File operations + static QString findRootDir(const QString& modPath); + static void reflinkCopy(const QString& src, const QString& dst); + static void ensureReadable(const QString& path); + static void ensureWritable(const QString& path); + static bool forceRemove(const QString& path); + static void cleanupEmptyDirs(const QString& baseDir, + const QStringList& paths); + + // Hooks + bool onAboutToRun(const QString& executable); + void onFinishedRun(const QString& executable, unsigned int exitCode); + + MOBase::IOrganizer* m_organizer = nullptr; +}; + +#endif // ROOTBUILDER_NATIVE_H |
