diff options
Diffstat (limited to 'libs/plugin_python/src/proxy')
| -rw-r--r-- | libs/plugin_python/src/proxy/CMakeLists.txt | 118 | ||||
| -rw-r--r-- | libs/plugin_python/src/proxy/build_pythoncore.py | 15 | ||||
| -rw-r--r-- | libs/plugin_python/src/proxy/proxypython.cpp | 368 | ||||
| -rw-r--r-- | libs/plugin_python/src/proxy/proxypython.h | 80 |
4 files changed, 581 insertions, 0 deletions
diff --git a/libs/plugin_python/src/proxy/CMakeLists.txt b/libs/plugin_python/src/proxy/CMakeLists.txt new file mode 100644 index 0000000..ed212a3 --- /dev/null +++ b/libs/plugin_python/src/proxy/CMakeLists.txt @@ -0,0 +1,118 @@ +cmake_minimum_required(VERSION 3.16) + +if(NOT TARGET mo2::uibase) + find_package(mo2-uibase CONFIG REQUIRED) +endif() + +set(PLUGIN_NAME "plugin_python") + +add_library(proxy SHARED proxypython.cpp proxypython.h) +mo2_configure_plugin(proxy + NO_SOURCES + WARNINGS 4 + EXTERNAL_WARNINGS 4 + TRANSLATIONS OFF + EXTRA_TRANSLATIONS + ${CMAKE_CURRENT_SOURCE_DIR}/../runner + ${CMAKE_CURRENT_SOURCE_DIR}/../mobase + ${CMAKE_CURRENT_SOURCE_DIR}/../pybind11-qt) +mo2_default_source_group() +target_link_libraries(proxy PRIVATE runner mo2::uibase) +if(NOT WIN32) + target_compile_definitions(proxy PRIVATE + MO2_PYTHON_PURELIB_DIR="${Python_PURELIB_DIR}" + MO2_PYTHON_PLATLIB_DIR="${Python_PLATLIB_DIR}") +endif() +set_target_properties(proxy PROPERTIES OUTPUT_NAME ${PLUGIN_NAME}) +mo2_install_plugin(proxy FOLDER) + +set(PLUGIN_PYTHON_DIR bin/plugins/${PLUGIN_NAME}) + +# install runner +if(WIN32) + target_link_options(proxy PRIVATE "/DELAYLOAD:runner.dll") + install(FILES $<TARGET_FILE:runner> DESTINATION ${PLUGIN_PYTHON_DIR}/dlls) +endif() + +# translations (custom location) +mo2_add_translations(proxy + TS_FILE ${CMAKE_CURRENT_SOURCE_DIR}/../${PLUGIN_NAME}_en.ts + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../runner + ${CMAKE_CURRENT_SOURCE_DIR}/../mobase + ${CMAKE_CURRENT_SOURCE_DIR}/../pybind11-qt) + +# install DLLs files needed +set(DLL_DIRS ${PLUGIN_PYTHON_DIR}/dlls) +if(WIN32) + file(GLOB dlls_to_install + ${Python_HOME}/dlls/libffi*.dll + ${Python_HOME}/dlls/sqlite*.dll + ${Python_HOME}/dlls/libssl*.dll + ${Python_HOME}/dlls/libcrypto*.dll + ${Python_HOME}/python${Python_VERSION_MAJOR}*.dll) + install(FILES ${dlls_to_install} DESTINATION ${DLL_DIRS}) +endif() + +# install Python .pyd files +set(PYLIB_DIR ${PLUGIN_PYTHON_DIR}/libs) +if(WIN32) + file(GLOB libs_to_install ${Python_DLL_DIR}/*.pyd) + install(FILES ${libs_to_install} DESTINATION ${PYLIB_DIR}) +endif() + +# generate + install standard library +if(WIN32) + set(pythoncore_zip "${CMAKE_CURRENT_BINARY_DIR}/pythoncore.zip") + add_custom_command( + TARGET proxy POST_BUILD + COMMAND ${Python_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/build_pythoncore.py" + ${pythoncore_zip} + ) + install(FILES ${pythoncore_zip} DESTINATION ${PYLIB_DIR}) +endif() + +# install mobase +install(TARGETS mobase DESTINATION ${PYLIB_DIR}) + +# install PyQt6 +if(WIN32) + install( + DIRECTORY ${CMAKE_BINARY_DIR}/pylibs/PyQt${MO2_QT_VERSION_MAJOR} + DESTINATION ${PYLIB_DIR} + PATTERN "*.pyd" + PATTERN "*.pyi" + PATTERN "__pycache__" EXCLUDE + PATTERN "bindings" EXCLUDE + PATTERN "lupdate" EXCLUDE + PATTERN "Qt6" EXCLUDE + PATTERN "uic" EXCLUDE + ) +endif() + +if(NOT WIN32) + # Build-tree runtime layout expected by proxypython.cpp. + add_custom_command(TARGET proxy POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/dlls" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/libs" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/dlls" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/libs" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:proxy>" + "${CMAKE_BINARY_DIR}/src/src/plugins/$<TARGET_FILE_NAME:proxy>" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:runner>" + "${CMAKE_BINARY_DIR}/src/src/plugins/dlls/$<TARGET_FILE_NAME:runner>" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:runner>" + "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/dlls/$<TARGET_FILE_NAME:runner>" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:mobase>" + "${CMAKE_BINARY_DIR}/src/src/plugins/libs/$<TARGET_FILE_NAME:mobase>" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:mobase>" + "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/libs/$<TARGET_FILE_NAME:mobase>") +endif() diff --git a/libs/plugin_python/src/proxy/build_pythoncore.py b/libs/plugin_python/src/proxy/build_pythoncore.py new file mode 100644 index 0000000..01c42f9 --- /dev/null +++ b/libs/plugin_python/src/proxy/build_pythoncore.py @@ -0,0 +1,15 @@ +import sys +import sysconfig +import zipfile +from pathlib import Path + +_EXCLUDE_MODULES = ["ensurepip", "idlelib", "test", "tkinter", "turtle_demo", "venv"] + +libdir = Path(sysconfig.get_path("stdlib")) +assert libdir.exists() + +with zipfile.PyZipFile(sys.argv[1], optimize=2, mode="w") as fp: + fp.writepy(libdir) # pyright: ignore[reportArgumentType] + for path in libdir.iterdir(): + if path.is_dir() and path.name not in _EXCLUDE_MODULES: + fp.writepy(path) # pyright: ignore[reportArgumentType] diff --git a/libs/plugin_python/src/proxy/proxypython.cpp b/libs/plugin_python/src/proxy/proxypython.cpp new file mode 100644 index 0000000..af897db --- /dev/null +++ b/libs/plugin_python/src/proxy/proxypython.cpp @@ -0,0 +1,368 @@ +/* +Copyright (C) 2022 Sebastian Herbord & MO2 Team. All rights reserved. + +This file is part of python proxy plugin for MO + +python proxy plugin is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Python proxy plugin is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with python proxy plugin. If not, see <http://www.gnu.org/licenses/>. +*/ +#include "proxypython.h" + +#include <filesystem> +#include <cerrno> +#include <cstring> +#include <cstdlib> + +#ifdef _WIN32 +#include <Windows.h> +#else +#include <dlfcn.h> +#endif + +#include <QCoreApplication> +#include <QDirIterator> +#include <QMessageBox> +#include <QWidget> +#include <QtPlugin> + +#include <uibase/log.h> +#include <uibase/utility.h> +#include <uibase/versioninfo.h> + +namespace fs = std::filesystem; +using namespace MOBase; + +// retrieve the path to the folder containing the proxy DLL +fs::path getPluginFolder() +{ +#ifdef _WIN32 + wchar_t path[MAX_PATH]; + HMODULE hm = NULL; + + if (GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)&getPluginFolder, &hm) == 0) { + return {}; + } + if (GetModuleFileName(hm, path, sizeof(path)) == 0) { + return {}; + } + + return fs::path(path).parent_path(); +#else + Dl_info info; + if (dladdr((void*)&getPluginFolder, &info) == 0 || info.dli_fname == nullptr) { + return {}; + } + return fs::path(info.dli_fname).parent_path(); +#endif +} + +ProxyPython::ProxyPython() + : m_MOInfo{nullptr}, m_RunnerLib{nullptr}, m_Runner{nullptr}, + m_LoadFailure(FailureType::NONE) +{ +} + +bool ProxyPython::init(IOrganizer* moInfo) +{ + m_MOInfo = moInfo; + + if (m_MOInfo && !m_MOInfo->isPluginEnabled(this)) { + return false; + } + + if (QCoreApplication::applicationDirPath().contains(';')) { + m_LoadFailure = FailureType::SEMICOLON; + return true; + } + + const auto pluginFolder = getPluginFolder(); +#ifdef _WIN32 + const auto pluginDataRoot = + fs::exists(pluginFolder / "plugin_python") ? (pluginFolder / "plugin_python") + : pluginFolder; +#else + // Linux portable installs can contain a legacy Windows "plugin_python" + // payload (pythoncore.zip/.pyd) that is incompatible with our bundled + // Linux runtime. Always use the plugin root on Linux. + const auto pluginDataRoot = pluginFolder; +#endif + + if (pluginFolder.empty()) { +#ifdef _WIN32 + DWORD error = ::GetLastError(); + m_LoadFailure = FailureType::DLL_NOT_FOUND; + log::error("failed to resolve Python proxy directory ({}): {}", error, + qUtf8Printable(windowsErrorString(::GetLastError()))); +#else + m_LoadFailure = FailureType::DLL_NOT_FOUND; + log::error("failed to resolve Python proxy directory: {}", + std::strerror(errno)); +#endif + return false; + } + + if (m_MOInfo && m_MOInfo->persistent(name(), "tryInit", false).toBool()) { + m_LoadFailure = FailureType::INITIALIZATION; + if (QMessageBox::question( + parentWidget(), tr("Python Initialization failed"), + tr("On a previous start the Python Plugin failed to initialize.\n" + "Do you want to try initializing python again (at the risk of " + "another crash)?\n " + "Suggestion: Select \"no\", and click the warning sign for further " + "help.Afterwards you have to re-enable the python plugin."), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) == QMessageBox::No) { + // we force enabled here (note: this is a persistent settings since MO2 2.4 + // or something), plugin + // usually should not handle enabled/disabled themselves but this is a base + // plugin so... + m_MOInfo->setPersistent(name(), "enabled", false, true); + return true; + } + } + + if (m_MOInfo) { + m_MOInfo->setPersistent(name(), "tryInit", true); + } + + // load the pythonrunner library, this is done in multiple steps: + // + // 1. we set the dlls/ subfolder (from the plugin) as the DLL directory so Windows + // will look for DLLs in it, this is required to find the Python and libffi DLL, but + // also the runner DLL + // + const auto dllPaths = pluginDataRoot / "dlls"; +#ifdef _WIN32 + if (SetDllDirectoryW(dllPaths.c_str()) == 0) { + DWORD error = ::GetLastError(); + m_LoadFailure = FailureType::DLL_NOT_FOUND; + log::error("failed to add python DLL directory ({}): {}", error, + qUtf8Printable(windowsErrorString(::GetLastError()))); + return false; + } +#endif + + // 2. we create the Python runner, we do not need to use ::LinkLibrary and + // ::GetProcAddress because we use delayed load for the runner DLL (see the + // CMakeLists.txt) + // + m_Runner = mo2::python::createPythonRunner(); + + if (m_Runner) { + const auto libpath = pluginDataRoot / "libs"; +#ifdef _WIN32 + const std::vector<fs::path> paths{ + libpath / "pythoncore.zip", libpath, + std::filesystem::path{IOrganizer::getPluginDataPath().toStdWString()}}; + m_Runner->initialize(paths); +#else + // On Linux, rely on the unpacked stdlib from PYTHONHOME (AppRun sets + // MO2_PYTHON_DIR/PYTHONHOME). Do not prepend pythoncore.zip here: + // forcing zipimport can fail when zlib is unavailable in embedded mode. + std::vector<fs::path> paths{ + libpath, + std::filesystem::path{IOrganizer::getPluginDataPath().toStdWString()}}; + + // Allow portable/AppImage builds to ship Python packages next to the app. + // MO2_PYTHON_DIR (set by AppRun) points to the writable python/ dir + // next to the AppImage; fall back to <exe_dir>/python. + fs::path pythonDir; + const char* envPy = std::getenv("MO2_PYTHON_DIR"); + if (envPy && envPy[0] != '\0') { + pythonDir = fs::path(envPy); + } else { + pythonDir = fs::path(QCoreApplication::applicationDirPath().toStdWString()) / "python"; + } + paths.emplace_back(pythonDir / "site-packages"); + paths.emplace_back(pythonDir); + +#ifdef MO2_PYTHON_PURELIB_DIR + if (std::strlen(MO2_PYTHON_PURELIB_DIR) > 0) { + paths.emplace_back(MO2_PYTHON_PURELIB_DIR); + } +#endif +#ifdef MO2_PYTHON_PLATLIB_DIR + if (std::strlen(MO2_PYTHON_PLATLIB_DIR) > 0 && + std::strcmp(MO2_PYTHON_PLATLIB_DIR, MO2_PYTHON_PURELIB_DIR) != 0) { + paths.emplace_back(MO2_PYTHON_PLATLIB_DIR); + } +#endif + m_Runner->initialize(paths); +#endif + } + + if (m_MOInfo) { + m_MOInfo->setPersistent(name(), "tryInit", false); + } + + // reset DLL directory +#ifdef _WIN32 + SetDllDirectoryW(NULL); +#endif + + if (!m_Runner || !m_Runner->isInitialized()) { + m_LoadFailure = FailureType::INITIALIZATION; + } + else { + m_Runner->addDllSearchPath(pluginDataRoot / "dlls"); + } + + return true; +} + +QString ProxyPython::name() const +{ + return "Python Proxy"; +} + +QString ProxyPython::localizedName() const +{ + return tr("Python Proxy"); +} + +QString ProxyPython::author() const +{ + return "AnyOldName3, Holt59, Silarn, Tannin"; +} + +QString ProxyPython::description() const +{ + return tr("Proxy Plugin to allow plugins written in python to be loaded"); +} + +VersionInfo ProxyPython::version() const +{ + return VersionInfo(3, 0, 0, VersionInfo::RELEASE_FINAL); +} + +QList<PluginSetting> ProxyPython::settings() const +{ + return {}; +} + +QStringList ProxyPython::pluginList(const QDir& pluginPath) const +{ + QDir dir(pluginPath); + dir.setFilter(dir.filter() | QDir::NoDotAndDotDot); + QDirIterator iter(dir); + + // Note: We put python script (.py) and directory names, not the __init__.py + // files in those since it is easier for the runner to import them. + QStringList result; + while (iter.hasNext()) { + QString name = iter.next(); + QFileInfo info = iter.fileInfo(); + const QString baseName = info.fileName(); + + if (info.isFile() && name.endsWith(".py")) { + // Compatibility shims and disabled plugins staged on Linux; they are not + // loaded as MO2 plugins. + if (baseName == "winreg.py" || baseName == "lzokay.py" || + baseName == "FNISPatches.py" || baseName == "FNISTool.py" || + baseName == "FNISToolReset.py") { + continue; + } + result.append(name); + } + else if (info.isDir() && QDir(info.absoluteFilePath()).exists("__init__.py")) { + result.append(name); + } + } + + return result; +} + +QList<QObject*> ProxyPython::load(const QString& identifier) +{ + if (!m_Runner) { + return {}; + } + return m_Runner->load(identifier); +} + +void ProxyPython::unload(const QString& identifier) +{ + if (m_Runner) { + return m_Runner->unload(identifier); + } +} + +std::vector<unsigned int> ProxyPython::activeProblems() const +{ + auto failure = m_LoadFailure; + + // don't know how this could happen but wth + if (m_Runner && !m_Runner->isInitialized()) { + failure = FailureType::INITIALIZATION; + } + + if (failure != FailureType::NONE) { + return {static_cast<std::underlying_type_t<FailureType>>(failure)}; + } + + return {}; +} + +QString ProxyPython::shortDescription(unsigned int key) const +{ + switch (static_cast<FailureType>(key)) { + case FailureType::SEMICOLON: + return tr("ModOrganizer path contains a semicolon"); + case FailureType::DLL_NOT_FOUND: + return tr("Python DLL not found"); + case FailureType::INVALID_DLL: + return tr("Invalid Python DLL"); + case FailureType::INITIALIZATION: + return tr("Initializing Python failed"); + default: + return tr("invalid problem key %1").arg(key); + } +} + +QString ProxyPython::fullDescription(unsigned int key) const +{ + switch (static_cast<FailureType>(key)) { + case FailureType::SEMICOLON: + return tr("The path to Mod Organizer (%1) contains a semicolon.<br>" + "While this is legal on NTFS drives, many applications do not " + "handle it correctly.<br>" + "Unfortunately MO depends on libraries that seem to fall into that " + "group.<br>" + "As a result the python plugin cannot be loaded, and the only " + "solution we can offer is to remove the semicolon or move MO to a " + "path without a semicolon.") + .arg(QCoreApplication::applicationDirPath()); + case FailureType::DLL_NOT_FOUND: + return tr("The Python plugin DLL was not found, maybe your antivirus deleted " + "it. Re-installing MO2 might fix the problem."); + case FailureType::INVALID_DLL: + return tr( + "The Python plugin DLL is invalid, maybe your antivirus is blocking it. " + "Re-installing MO2 and adding exclusions for it to your AV might fix the " + "problem."); + case FailureType::INITIALIZATION: + return tr("The initialization of the Python plugin DLL failed, unfortunately " + "without any details."); + default: + return tr("invalid problem key %1").arg(key); + } +} + +bool ProxyPython::hasGuidedFix(unsigned int) const +{ + return false; +} + +void ProxyPython::startGuidedFix(unsigned int) const {} diff --git a/libs/plugin_python/src/proxy/proxypython.h b/libs/plugin_python/src/proxy/proxypython.h new file mode 100644 index 0000000..e824e2a --- /dev/null +++ b/libs/plugin_python/src/proxy/proxypython.h @@ -0,0 +1,80 @@ +/* +Copyright (C) 2022 Sebastian Herbord & MO2 Team. All rights reserved. + +This file is part of python proxy plugin for MO + +python proxy plugin is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Python proxy plugin is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with python proxy plugin. If not, see <http://www.gnu.org/licenses/>. +*/ + +#ifndef PROXYPYTHON_H +#define PROXYPYTHON_H + +#include <map> +#include <memory> + +#include <uibase/iplugindiagnose.h> +#include <uibase/ipluginproxy.h> + +#include <pythonrunner.h> + +class ProxyPython : public QObject, + public MOBase::IPluginProxy, + public MOBase::IPluginDiagnose { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginProxy MOBase::IPluginDiagnose) + Q_PLUGIN_METADATA(IID "org.mo2.ProxyPython") + +public: + ProxyPython(); + + virtual bool init(MOBase::IOrganizer* moInfo); + virtual QString name() const override; + virtual QString localizedName() const override; + virtual QString author() const override; + virtual QString description() const override; + virtual MOBase::VersionInfo version() const override; + virtual QList<MOBase::PluginSetting> settings() const override; + + QStringList pluginList(const QDir& pluginPath) const override; + QList<QObject*> load(const QString& identifier) override; + void unload(const QString& identifier) override; + +public: // IPluginDiagnose + virtual std::vector<unsigned int> activeProblems() const override; + virtual QString shortDescription(unsigned int key) const override; + virtual QString fullDescription(unsigned int key) const override; + virtual bool hasGuidedFix(unsigned int key) const override; + virtual void startGuidedFix(unsigned int key) const override; + +private: + MOBase::IOrganizer* m_MOInfo; +#ifdef _WIN32 + HMODULE m_RunnerLib; +#else + void* m_RunnerLib; +#endif + std::unique_ptr<mo2::python::IPythonRunner> m_Runner; + + enum class FailureType : unsigned int { + NONE = 0, + SEMICOLON = 1, + DLL_NOT_FOUND = 2, + INVALID_DLL = 3, + INITIALIZATION = 4 + }; + + FailureType m_LoadFailure; +}; + +#endif // PROXYPYTHON_H |
