From 755698f1adceef55f52c8e44c9746537885c9bf7 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Thu, 12 Mar 2026 07:08:09 -0500 Subject: Replace Python plugins with native C++ .so, remove bundled Python (~95MB) - Add native plugins: form43_checker, script_extender_checker, preview_dds, basic_games (75 game defs via IPluginProxy pattern with Steam/GOG detection) - Remove portable Python from Dockerfile and build-inner.sh - Add Python settings tab with venv support (system python3 + PyQt6) - Skip loading plugin_python.so when Python disabled (default: off) - Update pythonrunner.cpp to use venv at ~/.local/share/fluorine/python-venv/ - Preserve real file permissions in FUSE VFS (fixes native executable +x bits) - Add FUSE_SET_ATTR_MODE handler so chmod works through VFS - Fix desktop shortcut Exec= to use fluorine-manager launcher, not bare binary Co-Authored-By: Claude Opus 4.6 --- src/src/CMakeLists.txt | 2 +- src/src/envshortcut.cpp | 8 ++ src/src/plugincontainer.cpp | 12 +++ src/src/settingsdialog.cpp | 31 +++--- src/src/settingsdialog.ui | 161 ++++++++++++++++++++++++++++++ src/src/settingsdialogpython.cpp | 206 +++++++++++++++++++++++++++++++++++++++ src/src/settingsdialogpython.h | 29 ++++++ src/src/vfs/mo2filesystem.cpp | 85 ++++++++++++---- src/src/vfs/mo2filesystem.h | 1 + 9 files changed, 503 insertions(+), 32 deletions(-) create mode 100644 src/src/settingsdialogpython.cpp create mode 100644 src/src/settingsdialogpython.h (limited to 'src') diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index d7a7642..30dd8b5 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -121,7 +121,7 @@ target_compile_definitions(organizer PRIVATE if(NOT WIN32) option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD - "Stage shipped Python plugin payload into build plugins/ for Linux runs" ON) + "Stage shipped Python plugin payload into build plugins/ for Linux runs" OFF) option(MO2_STAGE_INSTALLER_WIZARD "Stage installer_wizard python plugin payload (requires additional wizard package)" OFF) set(MO2_7Z_SO_PATH "" CACHE FILEPATH diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp index e78e904..5b6ff8e 100644 --- a/src/src/envshortcut.cpp +++ b/src/src/envshortcut.cpp @@ -377,6 +377,14 @@ static QString appImageOrBinary() if (!appImage.isEmpty() && QFile::exists(appImage)) { return appImage; } + // Prefer the fluorine-manager launcher script over the bare ModOrganizer-core + // binary — the launcher sets up bundled library paths, Qt plugin paths, etc. + // Without it, shortcuts fail on systems that don't have all deps in PATH. + const QString appDir = QCoreApplication::applicationDirPath(); + const QString launcher = appDir + "/fluorine-manager"; + if (QFile::exists(launcher)) { + return QFileInfo(launcher).absoluteFilePath(); + } return QFileInfo(qApp->applicationFilePath()).absoluteFilePath(); } diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp index e215c0a..a170c99 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1304,6 +1305,17 @@ void PluginContainer::loadPlugins() } } + // Skip the Python proxy plugin unless the user has enabled Python support + // in Settings > Python. Native C++ replacements handle all default plugins. + if (iter.fileName() == "libplugin_python.so" || + iter.fileName() == "plugin_python.dll") { + if (!QSettings().value("fluorine/python_enabled", false).toBool()) { + log::debug("plugin \"{}\" skipped (Python plugins disabled in settings)", + iter.fileName()); + continue; + } + } + if (loadCheck.isOpen()) { loadCheck.write(iter.fileName().toUtf8()); loadCheck.write("\n"); diff --git a/src/src/settingsdialog.cpp b/src/src/settingsdialog.cpp index 919bf4e..757c6f8 100644 --- a/src/src/settingsdialog.cpp +++ b/src/src/settingsdialog.cpp @@ -21,12 +21,13 @@ along with Mod Organizer. If not, see . #include "settingsdialogdiagnostics.h" #include "settingsdialoggeneral.h" #include "settingsdialogmodlist.h" -#include "settingsdialognexus.h" -#include "settingsdialogpaths.h" -#include "settingsdialogplugins.h" -#include "settingsdialogproton.h" -#include "settingsdialogtheme.h" -#include "settingsdialogworkarounds.h" +#include "settingsdialognexus.h" +#include "settingsdialogpaths.h" +#include "settingsdialogplugins.h" +#include "settingsdialogproton.h" +#include "settingsdialogpython.h" +#include "settingsdialogtheme.h" +#include "settingsdialogworkarounds.h" #include "ui_settingsdialog.h" using namespace MOBase; @@ -46,14 +47,16 @@ SettingsDialog::SettingsDialog(PluginContainer* pluginContainer, Settings& setti m_tabs.push_back(std::unique_ptr(new PathsSettingsTab(settings, *this))); m_tabs.push_back( std::unique_ptr(new DiagnosticsSettingsTab(settings, *this))); - m_tabs.push_back(std::unique_ptr(new NexusSettingsTab(settings, *this))); - m_tabs.push_back(std::unique_ptr( - new PluginsSettingsTab(settings, m_pluginContainer, *this))); - m_tabs.push_back( - std::unique_ptr(new ProtonSettingsTab(settings, *this))); - m_tabs.push_back( - std::unique_ptr(new WorkaroundsSettingsTab(settings, *this))); -} + m_tabs.push_back(std::unique_ptr(new NexusSettingsTab(settings, *this))); + m_tabs.push_back(std::unique_ptr( + new PluginsSettingsTab(settings, m_pluginContainer, *this))); + m_tabs.push_back( + std::unique_ptr(new ProtonSettingsTab(settings, *this))); + m_tabs.push_back( + std::unique_ptr(new PythonSettingsTab(settings, *this))); + m_tabs.push_back( + std::unique_ptr(new WorkaroundsSettingsTab(settings, *this))); +} PluginContainer* SettingsDialog::pluginContainer() { diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui index 646b9b6..2089c06 100644 --- a/src/src/settingsdialog.ui +++ b/src/src/settingsdialog.ui @@ -1897,6 +1897,167 @@ If you disable this feature, MO will only display official DLCs this way. Please + + + Python + + + + + + Python Plugin Support + + + + + + Enable Python Plugins: + + + + + + + Load .py plugins (requires Python 3 and PyQt6) + + + + + + + Python Path: + + + + + + + true + + + Auto-detected + + + + + + + Detect + + + + + + + Version: + + + + + + + Not detected + + + + + + + + + + Virtual Environment + + + + + + Venv Location: + + + + + + + true + + + + + + + Status: + + + + + + + Not created + + + + + + + Create Virtual Environment + + + Creates a Python virtual environment and installs PyQt6. Required for loading .py plugins. + + + + + + + Delete Venv + + + + + + + Open Folder + + + + + + + true + + + false + + + 150 + + + + monospace + 9 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + Workarounds diff --git a/src/src/settingsdialogpython.cpp b/src/src/settingsdialogpython.cpp new file mode 100644 index 0000000..ebad6eb --- /dev/null +++ b/src/src/settingsdialogpython.cpp @@ -0,0 +1,206 @@ +#include "settingsdialogpython.h" + +#include "fluorinepaths.h" +#include "ui_settingsdialog.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +PythonSettingsTab::PythonSettingsTab(Settings& s, SettingsDialog& d) + : QObject(&d), SettingsTab(s, d) +{ + m_venvPath = fluorineDataDir() + "/python-venv"; + ui->venvPathEdit->setText(m_venvPath); + + // Load saved state + bool pythonEnabled = QSettings().value("fluorine/python_enabled", false).toBool(); + ui->pythonEnableCheck->setChecked(pythonEnabled); + + detectPython(); + refreshVenvState(); + + QObject::connect(ui->pythonRefreshButton, &QPushButton::clicked, this, + &PythonSettingsTab::detectPython); + QObject::connect(ui->createVenvButton, &QPushButton::clicked, this, + &PythonSettingsTab::onCreateVenv); + QObject::connect(ui->deleteVenvButton, &QPushButton::clicked, this, + &PythonSettingsTab::onDeleteVenv); + QObject::connect(ui->openVenvFolderButton, &QPushButton::clicked, this, + &PythonSettingsTab::onOpenVenvFolder); +} + +void PythonSettingsTab::update() +{ + QSettings().setValue("fluorine/python_enabled", + ui->pythonEnableCheck->isChecked()); +} + +void PythonSettingsTab::detectPython() +{ + m_pythonPath = QStandardPaths::findExecutable("python3"); + if (m_pythonPath.isEmpty()) { + m_pythonPath = QStandardPaths::findExecutable("python"); + } + + if (m_pythonPath.isEmpty()) { + ui->pythonPathEdit->setText(""); + ui->pythonVersionValue->setText("Python 3 not found in PATH"); + return; + } + + ui->pythonPathEdit->setText(m_pythonPath); + + // Get version + QProcess proc; + proc.start(m_pythonPath, {"--version"}); + proc.waitForFinished(5000); + + if (proc.exitCode() == 0) { + QString version = QString::fromUtf8(proc.readAllStandardOutput()).trimmed(); + if (version.isEmpty()) { + version = QString::fromUtf8(proc.readAllStandardError()).trimmed(); + } + ui->pythonVersionValue->setText(version); + } else { + ui->pythonVersionValue->setText("Error detecting version"); + } +} + +void PythonSettingsTab::refreshVenvState() +{ + QDir venvDir(m_venvPath); + bool exists = venvDir.exists() && venvDir.exists("bin/python3"); + + if (exists) { + // Check if PyQt6 is installed + QProcess proc; + proc.start(m_venvPath + "/bin/python3", + {"-c", "import PyQt6; print(PyQt6.__file__)"}); + proc.waitForFinished(5000); + + if (proc.exitCode() == 0) { + ui->venvStatusValue->setText("Active (PyQt6 installed)"); + } else { + ui->venvStatusValue->setText("Active (PyQt6 NOT installed)"); + } + } else { + ui->venvStatusValue->setText("Not created"); + } + + ui->deleteVenvButton->setEnabled(exists); + ui->openVenvFolderButton->setEnabled(exists); +} + +void PythonSettingsTab::onCreateVenv() +{ + if (m_pythonPath.isEmpty()) { + QMessageBox::warning(parentWidget(), tr("No Python"), + tr("Python 3 was not found in PATH.\n\n" + "Install Python 3 using your package manager:\n" + " Arch: pacman -S python\n" + " Ubuntu: apt install python3 python3-venv\n" + " Fedora: dnf install python3")); + return; + } + + ui->pythonLogOutput->setVisible(true); + ui->pythonLogOutput->clear(); + ui->venvStatusValue->setText("Creating virtual environment..."); + + // Create venv + { + QProcess proc; + proc.start(m_pythonPath, {"-m", "venv", m_venvPath}); + proc.waitForFinished(30000); + + QString output = QString::fromUtf8(proc.readAllStandardOutput()); + QString errors = QString::fromUtf8(proc.readAllStandardError()); + if (!output.isEmpty()) + ui->pythonLogOutput->append(output); + if (!errors.isEmpty()) + ui->pythonLogOutput->append(errors); + + if (proc.exitCode() != 0) { + ui->venvStatusValue->setText("Failed to create venv"); + ui->pythonLogOutput->append("venv creation failed with exit code " + + QString::number(proc.exitCode())); + return; + } + } + + ui->pythonLogOutput->append("Virtual environment created. Installing PyQt6..."); + ui->venvStatusValue->setText("Installing PyQt6..."); + + // Install PyQt6 + { + QProcess proc; + proc.start(m_venvPath + "/bin/pip", + {"install", "--upgrade", "pip", "PyQt6"}); + proc.waitForFinished(120000); // PyQt6 is large, give it time + + QString output = QString::fromUtf8(proc.readAllStandardOutput()); + QString errors = QString::fromUtf8(proc.readAllStandardError()); + if (!output.isEmpty()) + ui->pythonLogOutput->append(output); + if (!errors.isEmpty()) + ui->pythonLogOutput->append(errors); + + if (proc.exitCode() != 0) { + ui->venvStatusValue->setText("PyQt6 installation failed"); + ui->pythonLogOutput->append("pip install failed with exit code " + + QString::number(proc.exitCode())); + refreshVenvState(); + return; + } + } + + ui->pythonLogOutput->append("PyQt6 installed successfully."); + MOBase::log::info("Python venv created at {}", m_venvPath); + + refreshVenvState(); +} + +void PythonSettingsTab::onDeleteVenv() +{ + QDir venvDir(m_venvPath); + if (!venvDir.exists()) { + refreshVenvState(); + return; + } + + auto result = QMessageBox::question( + parentWidget(), tr("Delete Virtual Environment"), + tr("Delete the Python virtual environment at:\n%1\n\n" + "This will disable .py plugin loading until recreated.") + .arg(m_venvPath)); + + if (result != QMessageBox::Yes) + return; + + if (venvDir.removeRecursively()) { + ui->pythonLogOutput->setVisible(false); + MOBase::log::info("Python venv deleted at {}", m_venvPath); + } else { + QMessageBox::warning(parentWidget(), tr("Error"), + tr("Failed to delete the virtual environment.")); + } + + refreshVenvState(); +} + +void PythonSettingsTab::onOpenVenvFolder() +{ + QDir venvDir(m_venvPath); + if (venvDir.exists()) { + MOBase::shell::Explore(venvDir); + } +} diff --git a/src/src/settingsdialogpython.h b/src/src/settingsdialogpython.h new file mode 100644 index 0000000..187d04b --- /dev/null +++ b/src/src/settingsdialogpython.h @@ -0,0 +1,29 @@ +#ifndef SETTINGSDIALOGPYTHON_H +#define SETTINGSDIALOGPYTHON_H + +#include + +#include "settings.h" +#include "settingsdialog.h" + +class PythonSettingsTab : public QObject, public SettingsTab +{ + Q_OBJECT + +public: + PythonSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update() override; + +private: + void detectPython(); + void refreshVenvState(); + void onCreateVenv(); + void onDeleteVenv(); + void onOpenVenvFolder(); + + QString m_pythonPath; + QString m_venvPath; +}; + +#endif // SETTINGSDIALOGPYTHON_H diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 2b1ef5f..22116f0 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -28,7 +28,8 @@ constexpr double ATTR_CACHE_SECONDS = 86400.0; void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid); void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, uint64_t size, - const std::chrono::system_clock::time_point& mtime); + const std::chrono::system_clock::time_point& mtime, + const std::string& real_path = {}); void maybeLogCounters(Mo2FsContext* ctx) { @@ -209,6 +210,7 @@ struct ChildSnapshot bool is_dir = false; uint64_t size = 0; std::chrono::system_clock::time_point mtime{}; + std::string real_path; }; std::vector listChildrenSnapshot( @@ -229,8 +231,9 @@ std::vector listChildrenSnapshot( snap.name = name; snap.is_dir = child->is_directory; if (!child->is_directory) { - snap.size = child->file_info.size; - snap.mtime = child->file_info.mtime; + snap.size = child->file_info.size; + snap.mtime = child->file_info.mtime; + snap.real_path = child->file_info.real_path; } out.push_back(std::move(snap)); } @@ -256,7 +259,8 @@ std::vector buildDirEntries( const std::string childPath = joinPath(path, child.name); entries.push_back( Mo2FsContext::DirEntry{ctx->inodes->getOrCreate(childPath), child.name, - child.is_dir, child.size, child.mtime}); + child.is_dir, child.size, child.mtime, + child.real_path}); } return entries; @@ -329,8 +333,20 @@ std::vector buildReaddirBlob( for (const auto& entry : entries) { struct stat st; std::memset(&st, 0, sizeof(st)); - st.st_ino = entry.ino; - st.st_mode = entry.is_dir ? (S_IFDIR | 0755) : (S_IFREG | 0644); + st.st_ino = entry.ino; + if (entry.is_dir) { + st.st_mode = S_IFDIR | 0755; + } else { + // Preserve executable bits from the real file on disk. + mode_t mode = 0644; + if (!entry.real_path.empty()) { + struct stat real_st; + if (::stat(entry.real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st.st_mode = S_IFREG | mode; + } const size_t entSize = fuse_add_direntry(req, nullptr, 0, entry.name.c_str(), &st, 0); @@ -362,7 +378,7 @@ std::vector buildReaddirPlusBlob( fillStatForDir(&e.attr, entry.ino, ctx->uid, ctx->gid); } else { fillStatForFile(&e.attr, entry.ino, ctx->uid, ctx->gid, entry.size, - entry.mtime); + entry.mtime, entry.real_path); } const size_t entSize = @@ -444,16 +460,27 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid) void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, uint64_t size, - const std::chrono::system_clock::time_point& mtime) + const std::chrono::system_clock::time_point& mtime, + const std::string& real_path = {}) { std::memset(st, 0, sizeof(struct stat)); st->st_ino = ino; - st->st_mode = S_IFREG | 0644; st->st_nlink = 1; st->st_uid = uid; st->st_gid = gid; st->st_size = static_cast(size); + // Preserve executable bits from the real file on disk so native Linux + // executables added as mods can actually be launched through the VFS. + mode_t mode = 0644; + if (!real_path.empty()) { + struct stat real_st; + if (::stat(real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st->st_mode = S_IFREG | mode; + const auto secs = std::chrono::duration_cast( mtime.time_since_epoch()); st->st_mtim.tv_sec = secs.count(); @@ -473,7 +500,8 @@ void replyEntryFromSnapshot(fuse_req_t req, const Mo2FsContext* ctx, fuse_ino_t if (snap.is_directory) { fillStatForDir(&e.attr, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&e.attr, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&e.attr, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } fuse_reply_entry(req, &e); @@ -683,7 +711,8 @@ void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* /*fi*/) if (snap.is_directory) { fillStatForDir(&st, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } { @@ -800,8 +829,19 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, for (size_t i = static_cast(off); i < entries->size(); ++i) { struct stat st; std::memset(&st, 0, sizeof(st)); - st.st_ino = (*entries)[i].ino; - st.st_mode = (*entries)[i].is_dir ? (S_IFDIR | 0755) : (S_IFREG | 0644); + st.st_ino = (*entries)[i].ino; + if ((*entries)[i].is_dir) { + st.st_mode = S_IFDIR | 0755; + } else { + mode_t mode = 0644; + if (!(*entries)[i].real_path.empty()) { + struct stat real_st; + if (::stat((*entries)[i].real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st.st_mode = S_IFREG | mode; + } const size_t ent = fuse_add_direntry(req, buf.data() + used, size - used, (*entries)[i].name.c_str(), &st, @@ -1164,7 +1204,7 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, fuse_reply_write(req, static_cast(written)); } -void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mode*/, +void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode, struct fuse_file_info* fi) { Mo2FsContext* ctx = getContext(req); @@ -1196,7 +1236,8 @@ void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mo std::error_code ec; fs::create_directories(fs::path(realPath).parent_path(), ec); // Create the file - int tmpFd = open(realPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + int tmpFd = open(realPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, + mode != 0 ? (mode & 07777) : 0644); if (tmpFd < 0) { // Fall back to staging trackedMod.clear(); @@ -1259,7 +1300,8 @@ void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mo e.ino = newIno; e.attr_timeout = TTL_SECONDS; e.entry_timeout = TTL_SECONDS; - fillStatForFile(&e.attr, newIno, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&e.attr, newIno, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); fuse_reply_create(req, &e, fi); } @@ -1447,6 +1489,14 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, updateFileNode(ctx, path, target, targetIsTracked ? "Mod" : "Staging"); } + // Handle chmod — propagate permission changes to the real file on disk. + if ((to_set & FUSE_SET_ATTR_MODE) != 0 && attr != nullptr) { + const auto snap = snapshotForPath(ctx, path); + if (snap.found && !snap.is_directory && !snap.real_path.empty()) { + ::chmod(snap.real_path.c_str(), attr->st_mode & 07777); + } + } + // Handle explicit timestamp changes (utimensat / Wine SetFileTime) if ((to_set & (FUSE_SET_ATTR_MTIME | FUSE_SET_ATTR_MTIME_NOW | FUSE_SET_ATTR_ATIME | FUSE_SET_ATTR_ATIME_NOW)) != 0 && @@ -1523,7 +1573,8 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, if (snap.is_directory) { fillStatForDir(&st, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } fuse_reply_attr(req, &st, TTL_SECONDS); } diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index 3877bbb..40e0ed7 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -51,6 +51,7 @@ struct Mo2FsContext bool is_dir = false; uint64_t size = 0; std::chrono::system_clock::time_point mtime{}; + std::string real_path; }; struct OpenDir { -- cgit v1.3.1