mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 15:03:49 +00:00
* fix: impl refactor * fix: unload/load python module, race conditions, freezes * remove dead code * remove extra hook * remove more dead code * fix gil run script
959 lines
35 KiB
C++
959 lines
35 KiB
C++
#include "PythonFileUtils.hpp"
|
|
|
|
#include "PluginFsUtils.hpp"
|
|
#include "PythonInterpreter.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include "PluginDescriptor.hpp"
|
|
#include "libslic3r/miniz_extension.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
#include <boost/nowide/fstream.hpp>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <sstream>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
#ifdef WIN32
|
|
#include <boost/locale/encoding_utf.hpp>
|
|
#endif
|
|
|
|
namespace Slic3r {
|
|
namespace {
|
|
|
|
bool is_safe_archive_entry_path(const boost::filesystem::path& path)
|
|
{
|
|
if (!is_safe_relative_path(path))
|
|
return false;
|
|
|
|
for (const auto& part : path) {
|
|
const std::string token = part.string();
|
|
if (token.empty() || token == "." || token == "..")
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
std::string decode_zip_entry_extra_path(const std::string& extra, const std::string& fallback)
|
|
{
|
|
const char* p = extra.data();
|
|
const char* e = p + extra.length();
|
|
while (p + 4 <= e) {
|
|
const auto len = static_cast<std::uint16_t>(static_cast<unsigned char>(p[2])) |
|
|
static_cast<std::uint16_t>(static_cast<unsigned char>(p[3]) << 8);
|
|
if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01')
|
|
return std::string(p + 9, p + 4 + len);
|
|
p += 4 + len;
|
|
}
|
|
|
|
return decode_path(fallback.c_str());
|
|
}
|
|
|
|
std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat)
|
|
{
|
|
if (stat.m_is_utf8)
|
|
return stat.m_filename;
|
|
|
|
std::string extra(1024, 0);
|
|
const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size());
|
|
return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename);
|
|
}
|
|
|
|
std::string normalize_zip_entry_name(std::string entry_name)
|
|
{
|
|
std::replace(entry_name.begin(), entry_name.end(), '\\', '/');
|
|
while (!entry_name.empty() && entry_name.back() == '/')
|
|
entry_name.pop_back();
|
|
return entry_name;
|
|
}
|
|
|
|
struct ZipReaderGuard
|
|
{
|
|
mz_zip_archive archive;
|
|
bool opened = false;
|
|
|
|
ZipReaderGuard() { mz_zip_zero_struct(&archive); }
|
|
|
|
~ZipReaderGuard()
|
|
{
|
|
if (opened)
|
|
close_zip_reader(&archive);
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
bool is_valid_plugin_id(const std::string& id)
|
|
{
|
|
if (id.empty())
|
|
return false;
|
|
if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0)
|
|
return false;
|
|
|
|
for (unsigned char ch : id) {
|
|
if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.')
|
|
continue;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
namespace {
|
|
|
|
// RAII helper to free heap-allocated memory from miniz.
|
|
struct MzHeapFree {
|
|
void* ptr = nullptr;
|
|
~MzHeapFree() { if (ptr) std::free(ptr); }
|
|
};
|
|
|
|
// Read a text file from within a zip archive into a string.
|
|
// Returns true on success, false if the file is not found or cannot be read.
|
|
bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error)
|
|
{
|
|
size_t size = 0;
|
|
void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0);
|
|
if (!data) {
|
|
error = std::string("Wheel does not contain ") + filename;
|
|
return false;
|
|
}
|
|
MzHeapFree guard{data};
|
|
out.assign(static_cast<const char*>(data), size);
|
|
return true;
|
|
}
|
|
|
|
// TOML section parsing states.
|
|
enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray };
|
|
|
|
// Strip a quoted string value: "foo" → foo, 'foo' → foo.
|
|
// Returns the unquoted value or the input unchanged if not quoted.
|
|
std::string unquote_toml_string(const std::string& val)
|
|
{
|
|
if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\'')))
|
|
return val.substr(1, val.size() - 2);
|
|
return val;
|
|
}
|
|
|
|
// Split a TOML inline array: ["a", "b"] → {"a", "b"}.
|
|
// Handles trailing commas and single-line format.
|
|
std::vector<std::string> parse_toml_inline_array(const std::string& val)
|
|
{
|
|
std::vector<std::string> result;
|
|
std::string inner = val;
|
|
// Strip outer brackets.
|
|
if (!inner.empty() && inner.front() == '[')
|
|
inner.erase(0, 1);
|
|
if (!inner.empty() && inner.back() == ']')
|
|
inner.pop_back();
|
|
|
|
// Simple split by comma, strip quotes and whitespace.
|
|
std::istringstream ss(inner);
|
|
std::string item;
|
|
while (std::getline(ss, item, ',')) {
|
|
// Trim whitespace.
|
|
size_t s = 0, e = item.size();
|
|
while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s;
|
|
while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e;
|
|
item = item.substr(s, e - s);
|
|
if (!item.empty())
|
|
result.push_back(unquote_toml_string(item));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Parse PEP 723 TOML subset for dependencies, requires-python, and
|
|
// [tool.orcaslicer.plugin] identity fields.
|
|
//
|
|
// requires-python = ">=3.12"
|
|
// dependencies = ["pkg>=1.0", ]
|
|
//
|
|
// [tool.orcaslicer.plugin]
|
|
// id = "my-plugin"
|
|
// name = "My Plugin"
|
|
// description = "Does things."
|
|
// author = "Author"
|
|
// version = "1.0.0"
|
|
//
|
|
// Returns false only on parse errors; missing block is not an error.
|
|
bool parse_pep723_toml(const std::string& toml_content,
|
|
std::vector<std::string>& out_deps,
|
|
std::string& out_requires_python,
|
|
std::string& out_name,
|
|
std::string& out_description,
|
|
std::string& out_author,
|
|
std::string& out_version,
|
|
std::map<std::string, std::string>& out_settings,
|
|
std::string& error)
|
|
{
|
|
out_deps.clear();
|
|
out_requires_python.clear();
|
|
out_name.clear();
|
|
out_description.clear();
|
|
out_author.clear();
|
|
out_version.clear();
|
|
out_settings.clear();
|
|
|
|
TomlSection section = TomlSection::Root;
|
|
|
|
std::istringstream stream(toml_content);
|
|
std::string line;
|
|
|
|
while (std::getline(stream, line)) {
|
|
// Trim leading/trailing whitespace.
|
|
size_t start = 0;
|
|
while (start < line.size() && (line[start] == ' ' || line[start] == '\t'))
|
|
++start;
|
|
size_t end = line.size();
|
|
while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t'))
|
|
--end;
|
|
std::string trimmed = line.substr(start, end - start);
|
|
|
|
if (trimmed.empty() || trimmed[0] == '#')
|
|
continue;
|
|
|
|
// TOML section header.
|
|
if (trimmed[0] == '[') {
|
|
if (trimmed == "[tool.orcaslicer.plugin]") {
|
|
section = TomlSection::OrcaPlugin;
|
|
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
|
|
section = TomlSection::OrcaPluginSettings; // per-plugin params table
|
|
} else {
|
|
section = TomlSection::Root; // Unknown section — skip.
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (section == TomlSection::InDepsArray) {
|
|
if (trimmed == "]") {
|
|
section = TomlSection::Root;
|
|
continue;
|
|
}
|
|
std::string val = trimmed;
|
|
if (!val.empty() && val.back() == ',')
|
|
val.pop_back();
|
|
val = unquote_toml_string(val);
|
|
if (!val.empty())
|
|
out_deps.push_back(val);
|
|
continue;
|
|
}
|
|
|
|
// Look for key = value.
|
|
size_t eq = trimmed.find('=');
|
|
if (eq == std::string::npos)
|
|
continue;
|
|
|
|
std::string key = trimmed.substr(0, eq);
|
|
while (!key.empty() && (key.back() == ' ' || key.back() == '\t'))
|
|
key.pop_back();
|
|
|
|
std::string val = trimmed.substr(eq + 1);
|
|
while (!val.empty() && (val.front() == ' ' || val.front() == '\t'))
|
|
val.erase(0, 1);
|
|
// Trim trailing.
|
|
while (!val.empty() && (val.back() == ' ' || val.back() == '\t'))
|
|
val.pop_back();
|
|
|
|
if (section == TomlSection::Root) {
|
|
if (key == "requires-python") {
|
|
out_requires_python = unquote_toml_string(val);
|
|
} else if (key == "dependencies") {
|
|
if (val == "[") {
|
|
section = TomlSection::InDepsArray;
|
|
} else {
|
|
// Inline array: dependencies = ["a", "b"]
|
|
out_deps = parse_toml_inline_array(val);
|
|
}
|
|
}
|
|
} else if (section == TomlSection::OrcaPlugin) {
|
|
if (key == "name") out_name = unquote_toml_string(val);
|
|
else if (key == "description") out_description = unquote_toml_string(val);
|
|
else if (key == "author") out_author = unquote_toml_string(val);
|
|
else if (key == "version") out_version = unquote_toml_string(val);
|
|
} else if (section == TomlSection::OrcaPluginSettings) {
|
|
// collect every key as a string; the plugin parses (int/float/...) what it needs.
|
|
if (!key.empty())
|
|
out_settings[key] = unquote_toml_string(val);
|
|
}
|
|
}
|
|
|
|
// Check for unclosed arrays.
|
|
if (section == TomlSection::InDepsArray) {
|
|
error = "PEP 723 metadata: unclosed dependencies array";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Normalize a distribution name to a Python import package name.
|
|
// Converts hyphens to underscores and lowercases.
|
|
std::string normalize_package_name(const std::string& name)
|
|
{
|
|
std::string result;
|
|
result.reserve(name.size());
|
|
for (unsigned char ch : name) {
|
|
if (ch == '-' || ch == '.')
|
|
result += '_';
|
|
else
|
|
result += static_cast<char>(std::tolower(ch));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Parse METADATA (RFC 822 style) into a flat multimap.
|
|
// https://packaging.python.org/en/latest/specifications/core-metadata/
|
|
void parse_metadata_rfc822(const std::string& content,
|
|
std::string& out_name,
|
|
std::string& out_version,
|
|
std::string& out_summary,
|
|
std::string& out_author,
|
|
std::string& out_requires_python,
|
|
std::string& out_import_name,
|
|
std::vector<std::string>& out_requires_dist,
|
|
std::string& error)
|
|
{
|
|
std::istringstream stream(content);
|
|
std::string line;
|
|
std::string current_header;
|
|
std::string current_value;
|
|
|
|
auto flush = [&]() {
|
|
if (current_header.empty())
|
|
return;
|
|
std::string lower = current_header;
|
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
|
[](unsigned char c) { return std::tolower(c); });
|
|
|
|
if (lower == "name")
|
|
out_name = current_value;
|
|
else if (lower == "version")
|
|
out_version = current_value;
|
|
else if (lower == "summary")
|
|
out_summary = current_value;
|
|
else if (lower == "author")
|
|
out_author = current_value;
|
|
else if (lower == "requires-python")
|
|
out_requires_python = current_value;
|
|
else if (lower == "import-name")
|
|
out_import_name = current_value;
|
|
else if (lower == "requires-dist")
|
|
out_requires_dist.push_back(current_value);
|
|
|
|
current_header.clear();
|
|
current_value.clear();
|
|
};
|
|
|
|
while (std::getline(stream, line)) {
|
|
// Blank line after headers marks the start of the body.
|
|
if (line.empty() && current_header.empty())
|
|
continue;
|
|
if (line.empty()) {
|
|
flush();
|
|
// Remaining content is the body (description); stop parsing headers.
|
|
break;
|
|
}
|
|
|
|
// Continuation line.
|
|
if (line[0] == ' ' || line[0] == '\t') {
|
|
if (!current_value.empty())
|
|
current_value += '\n';
|
|
size_t pos = line.find_first_not_of(" \t");
|
|
current_value += (pos != std::string::npos) ? line.substr(pos) : "";
|
|
continue;
|
|
}
|
|
|
|
flush();
|
|
|
|
size_t colon = line.find(':');
|
|
if (colon == std::string::npos)
|
|
continue;
|
|
|
|
current_header = line.substr(0, colon);
|
|
size_t val_start = colon + 1;
|
|
while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t'))
|
|
++val_start;
|
|
current_value = line.substr(val_start);
|
|
}
|
|
|
|
flush();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool is_ignored_plugin_directory(const boost::filesystem::path& path)
|
|
{
|
|
const std::string name = path.filename().string();
|
|
return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR;
|
|
}
|
|
|
|
bool is_safe_relative_path(const boost::filesystem::path& path)
|
|
{
|
|
if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name())
|
|
return false;
|
|
|
|
for (const auto& part : path) {
|
|
const std::string token = part.string();
|
|
if (token == "..")
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
|
|
boost::system::error_code ec;
|
|
fs::create_directories(destination, ec);
|
|
if (ec) {
|
|
error = "Failed to create plugin staging directory: " + ec.message();
|
|
return false;
|
|
}
|
|
|
|
ZipReaderGuard reader;
|
|
if (!open_zip_reader(&reader.archive, zip_path.string())) {
|
|
error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
|
|
return false;
|
|
}
|
|
reader.opened = true;
|
|
|
|
std::unordered_set<std::string> extracted_entries;
|
|
const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive);
|
|
mz_zip_archive_file_stat stat;
|
|
for (mz_uint i = 0; i < num_entries; ++i) {
|
|
if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) {
|
|
error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
|
|
return false;
|
|
}
|
|
|
|
std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat));
|
|
if (entry_name.empty())
|
|
continue;
|
|
if (entry_name.find(':') != std::string::npos) {
|
|
error = "Plugin zip entry contains an invalid path: " + entry_name;
|
|
return false;
|
|
}
|
|
|
|
const fs::path relative_path(entry_name);
|
|
if (!is_safe_archive_entry_path(relative_path)) {
|
|
error = "Plugin zip entry escapes the plugin package: " + entry_name;
|
|
return false;
|
|
}
|
|
|
|
const std::string relative_key = relative_path.generic_string();
|
|
if (!extracted_entries.insert(relative_key).second) {
|
|
error = "Plugin zip contains duplicate entry: " + relative_key;
|
|
return false;
|
|
}
|
|
|
|
const fs::path output_path = destination / relative_path;
|
|
if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) {
|
|
fs::create_directories(output_path, ec);
|
|
if (ec) {
|
|
error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message();
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
fs::create_directories(output_path.parent_path(), ec);
|
|
if (ec) {
|
|
error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message();
|
|
return false;
|
|
}
|
|
|
|
if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) {
|
|
error = "Plugin zip file conflicts with an existing directory: " + output_path.string();
|
|
return false;
|
|
}
|
|
|
|
const std::string encoded_output_path = encode_path(output_path.string().c_str());
|
|
mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0);
|
|
#ifdef WIN32
|
|
if (!extracted) {
|
|
const std::wstring wide_output_path = boost::locale::conv::utf_to_utf<wchar_t>(output_path.generic_string());
|
|
extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0);
|
|
}
|
|
#endif
|
|
if (!extracted) {
|
|
error = "Failed to extract plugin zip entry " + relative_key + ": " +
|
|
MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry)
|
|
{
|
|
PluginInstallState state;
|
|
if (!read_install_state(plugin_dir, state))
|
|
return;
|
|
|
|
// The cloud identity and the persisted installed version are read back. plugin_key
|
|
// is always derived by the catalog scan (filename for local, the cloud uuid for
|
|
// cloud), so it is not read from the sidecar. installed_version is the source of
|
|
// truth for a cloud plugin's installed version: it records the version fetched from
|
|
// the cloud at install time, independent of the (possibly stale) manifest/PEP723
|
|
// header that scan_directory parses into entry.version.
|
|
if (!state.installed_version.empty())
|
|
entry.installed_version = state.installed_version;
|
|
if (!state.cloud_uuid.empty())
|
|
entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false};
|
|
|
|
// Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a
|
|
// capability has no existence — and so no state — until it is materialized, at which point the
|
|
// loader seeds the flag onto the capability itself.
|
|
entry.enabled = state.enabled;
|
|
}
|
|
|
|
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
const fs::path sidecar_path = plugin_dir / ".install_state.json";
|
|
|
|
if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path))
|
|
return false;
|
|
|
|
boost::nowide::ifstream f(sidecar_path.string());
|
|
if (!f)
|
|
return false;
|
|
|
|
try {
|
|
nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true);
|
|
if (state.is_discarded() || !state.is_object())
|
|
return false;
|
|
|
|
PluginInstallState parsed;
|
|
if (state.contains("installed_from") && state["installed_from"].is_string())
|
|
parsed.installed_from = state["installed_from"].get<std::string>();
|
|
if (state.contains("installed_version") && state["installed_version"].is_string())
|
|
parsed.installed_version = state["installed_version"].get<std::string>();
|
|
if (state.contains("plugin_name") && state["plugin_name"].is_string())
|
|
parsed.plugin_name = state["plugin_name"].get<std::string>();
|
|
if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string())
|
|
parsed.cloud_uuid = state["cloud_uuid"].get<std::string>();
|
|
if (state.contains("enabled") && state["enabled"].is_boolean())
|
|
parsed.enabled = state["enabled"].get<bool>();
|
|
|
|
// capabilities is a JSON array of single-key objects {<cap_name>: <bool>}.
|
|
if (state.contains("capabilities") && state["capabilities"].is_array()) {
|
|
for (const auto& item : state["capabilities"]) {
|
|
if (!item.is_object())
|
|
continue;
|
|
for (auto it = item.begin(); it != item.end(); ++it) {
|
|
if (it.value().is_boolean())
|
|
parsed.capabilities.emplace_back(it.key(), it.value().get<bool>());
|
|
}
|
|
}
|
|
}
|
|
|
|
out = std::move(parsed);
|
|
return true;
|
|
} catch (...) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
const fs::path sidecar_path = plugin_dir / ".install_state.json";
|
|
|
|
nlohmann::json json;
|
|
json["installed_from"] = state.installed_from;
|
|
json["installed_version"] = state.installed_version;
|
|
json["plugin_name"] = state.plugin_name;
|
|
json["enabled"] = state.enabled;
|
|
if (!state.cloud_uuid.empty())
|
|
json["cloud_uuid"] = state.cloud_uuid;
|
|
|
|
nlohmann::json capabilities = nlohmann::json::array();
|
|
for (const auto& [name, enabled] : state.capabilities)
|
|
capabilities.push_back(nlohmann::json{{name, enabled}});
|
|
json["capabilities"] = std::move(capabilities);
|
|
|
|
boost::nowide::ofstream f(sidecar_path.string());
|
|
if (!f)
|
|
return false;
|
|
|
|
f << json.dump(2);
|
|
return static_cast<bool>(f);
|
|
}
|
|
|
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
|
const std::vector<std::pair<std::string, bool>>& capabilities)
|
|
{
|
|
PluginInstallState state;
|
|
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
|
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud
|
|
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
|
|
// never overwrites the source-of-truth version. Fall back to the manifest version for
|
|
// first-time/local installs where installed_version is not yet populated.
|
|
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
|
|
state.plugin_name = entry.name;
|
|
state.cloud_uuid = entry.cloud_uuid();
|
|
state.enabled = enabled;
|
|
state.capabilities = capabilities;
|
|
return write_install_state(plugin_dir, state);
|
|
}
|
|
|
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry)
|
|
{
|
|
// Install-time writer: the package is not loaded, so its capabilities are not known yet and the
|
|
// sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real
|
|
// per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities)
|
|
// overload.
|
|
return write_install_state(plugin_dir, entry, true, {});
|
|
}
|
|
|
|
bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
|
|
if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) {
|
|
error = "Python plugin file does not exist: " + py_path.string();
|
|
return false;
|
|
}
|
|
|
|
boost::nowide::ifstream f(py_path.string());
|
|
if (!f) {
|
|
error = "Failed to open Python plugin file: " + py_path.string();
|
|
return false;
|
|
}
|
|
|
|
// Scan for PEP 723 inline script metadata block.
|
|
// The block is delimited by:
|
|
// # /// script
|
|
// # <toml content>
|
|
// # ///
|
|
std::string pep723_content;
|
|
bool in_block = false;
|
|
std::string line;
|
|
|
|
while (std::getline(f, line)) {
|
|
// Strip trailing carriage return (Windows line endings).
|
|
if (!line.empty() && line.back() == '\r')
|
|
line.pop_back();
|
|
|
|
if (!in_block) {
|
|
// Look for opening delimiter.
|
|
if (line == "# /// script")
|
|
in_block = true;
|
|
continue;
|
|
}
|
|
|
|
if (line == "# ///") {
|
|
in_block = false;
|
|
continue;
|
|
}
|
|
|
|
// Extract TOML content from the comment line.
|
|
// Lines must start with "# " or "#\t" per PEP 723.
|
|
if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t'))
|
|
pep723_content += line.substr(2) + "\n";
|
|
else if (line == "#")
|
|
pep723_content += "\n";
|
|
// If line doesn't start with "# ", it's still part of the block content
|
|
// but we skip it as it doesn't follow the spec.
|
|
}
|
|
|
|
if (!pep723_content.empty()) {
|
|
std::string pep723_error;
|
|
std::string requires_python;
|
|
std::string pep_name, pep_desc, pep_author, pep_version;
|
|
if (!parse_pep723_toml(pep723_content,
|
|
descriptor.dependencies,
|
|
requires_python,
|
|
pep_name,
|
|
pep_desc,
|
|
pep_author,
|
|
pep_version,
|
|
descriptor.settings,
|
|
pep723_error)) {
|
|
error = "Failed to parse PEP 723 metadata: " + pep723_error;
|
|
return false;
|
|
}
|
|
// requires-python is stored but not validated against the bundled Python here.
|
|
(void) requires_python;
|
|
|
|
// Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section.
|
|
// Cloud metadata overrides these when available; they serve as the local
|
|
// source of truth for side-loaded .py plugins and as fallback values.
|
|
if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name);
|
|
if (!pep_desc.empty()) descriptor.description = pep_desc;
|
|
if (!pep_author.empty()) descriptor.author = pep_author;
|
|
if (!pep_version.empty()) descriptor.version = pep_version;
|
|
}
|
|
|
|
// Validate that required identity fields are present (either from PEP 723 or
|
|
// from cloud metadata already set on the manifest by the caller).
|
|
// Validation is deferred to the install/discovery layer so cloud metadata
|
|
// can fill in gaps.
|
|
return true;
|
|
}
|
|
|
|
bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
|
|
if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) {
|
|
error = "Wheel plugin file does not exist: " + whl_path.string();
|
|
return false;
|
|
}
|
|
|
|
ZipReaderGuard reader;
|
|
if (!open_zip_reader(&reader.archive, whl_path.string())) {
|
|
error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
|
|
return false;
|
|
}
|
|
reader.opened = true;
|
|
|
|
// Find the single .dist-info directory.
|
|
// Scan ALL entries, not just directory entries — some zip writers omit
|
|
// explicit directory entries. normalize_zip_entry_name strips trailing
|
|
// slashes, so we match ".dist-info" as the last path component of a
|
|
// directory entry, and ".dist-info/" embedded in a file path.
|
|
const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive);
|
|
std::string dist_info_dir;
|
|
mz_zip_archive_file_stat stat;
|
|
|
|
for (mz_uint i = 0; i < num_entries; ++i) {
|
|
if (!mz_zip_reader_file_stat(&reader.archive, i, &stat))
|
|
continue;
|
|
|
|
std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat));
|
|
if (entry_name.empty())
|
|
continue;
|
|
|
|
// Find .dist-info as a path component.
|
|
size_t pos = entry_name.find(".dist-info");
|
|
if (pos == std::string::npos)
|
|
continue;
|
|
|
|
std::string candidate;
|
|
if (pos + 10 == entry_name.size()) {
|
|
// Directory entry itself (trailing / stripped by normalize).
|
|
candidate = entry_name + "/";
|
|
} else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') {
|
|
// File inside .dist-info/: name.dist-info/METADATA
|
|
candidate = entry_name.substr(0, pos + 11); // include trailing /
|
|
} else {
|
|
continue; // .dist-info mid-name, not a path component.
|
|
}
|
|
|
|
if (!dist_info_dir.empty() && candidate != dist_info_dir) {
|
|
error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate;
|
|
return false;
|
|
}
|
|
dist_info_dir = candidate;
|
|
}
|
|
|
|
if (dist_info_dir.empty()) {
|
|
error = "Wheel does not contain a .dist-info directory";
|
|
return false;
|
|
}
|
|
|
|
// Read METADATA.
|
|
const std::string metadata_path = dist_info_dir + "METADATA";
|
|
std::string meta_content;
|
|
if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error))
|
|
return false;
|
|
|
|
std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name;
|
|
std::vector<std::string> requires_dist;
|
|
std::string meta_error;
|
|
parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author,
|
|
meta_requires_python, meta_import_name, requires_dist, meta_error);
|
|
|
|
if (meta_name.empty()) {
|
|
error = "Wheel METADATA missing required Name field";
|
|
return false;
|
|
}
|
|
if (meta_version.empty()) {
|
|
error = "Wheel METADATA missing required Version field";
|
|
return false;
|
|
}
|
|
|
|
// Read WHEEL (verify existence and Wheel-Version).
|
|
const std::string wheel_path = dist_info_dir + "WHEEL";
|
|
std::string wheel_content;
|
|
if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error))
|
|
return false;
|
|
// Verify there's at least a Wheel-Version header line.
|
|
if (wheel_content.find("Wheel-Version:") == std::string::npos) {
|
|
error = "Wheel WHEEL file missing Wheel-Version header";
|
|
return false;
|
|
}
|
|
|
|
// Parse and validate wheel platform tags.
|
|
{
|
|
std::vector<std::string> wheel_tags;
|
|
std::istringstream wstream(wheel_content);
|
|
std::string wline;
|
|
while (std::getline(wstream, wline)) {
|
|
while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n'))
|
|
wline.pop_back();
|
|
if (wline.rfind("Tag:", 0) == 0) {
|
|
std::string tag = wline.substr(4);
|
|
size_t s = 0, e = tag.size();
|
|
while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s;
|
|
while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e;
|
|
wheel_tags.push_back(tag.substr(s, e - s));
|
|
}
|
|
}
|
|
|
|
if (!wheel_tags.empty()) {
|
|
bool compatible = false;
|
|
const std::string abi_tag = PythonInterpreter::python_abi_tag();
|
|
for (const auto& tag : wheel_tags) {
|
|
// Pure Python wheel: py3-none-any or cp312-none-any
|
|
if (tag.find("-none-any") != std::string::npos) {
|
|
compatible = true;
|
|
break;
|
|
}
|
|
// Platform-specific: check ABI tag matches.
|
|
if (tag.find(abi_tag) == 0) {
|
|
// Accept if the platform tag matches the current OS.
|
|
#ifdef _WIN32
|
|
if (tag.find("-win") != std::string::npos)
|
|
compatible = true;
|
|
#elif __APPLE__
|
|
if (tag.find("-macosx") != std::string::npos)
|
|
compatible = true;
|
|
#else
|
|
if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos)
|
|
compatible = true;
|
|
#endif
|
|
}
|
|
}
|
|
if (!compatible) {
|
|
error = "Wheel is incompatible with this platform. Tags: ";
|
|
for (size_t i = 0; i < wheel_tags.size(); ++i) {
|
|
if (i > 0) error += ", ";
|
|
error += wheel_tags[i];
|
|
}
|
|
error += "; expected ABI: " + abi_tag;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read RECORD (verify existence).
|
|
const std::string record_path = dist_info_dir + "RECORD";
|
|
std::string record_content;
|
|
if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error))
|
|
return false;
|
|
if (record_content.empty()) {
|
|
error = "Wheel RECORD file is empty";
|
|
return false;
|
|
}
|
|
|
|
// Parse top_level.txt if present.
|
|
std::string top_level;
|
|
const std::string top_level_path = dist_info_dir + "top_level.txt";
|
|
std::string top_level_content;
|
|
if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) {
|
|
// top_level.txt contains one package name per line.
|
|
std::istringstream tl_stream(top_level_content);
|
|
std::string tl_line;
|
|
std::vector<std::string> top_levels;
|
|
while (std::getline(tl_stream, tl_line)) {
|
|
while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n'))
|
|
tl_line.pop_back();
|
|
if (!tl_line.empty())
|
|
top_levels.push_back(tl_line);
|
|
}
|
|
if (top_levels.size() == 1)
|
|
top_level = top_levels[0];
|
|
else if (top_levels.size() > 1) {
|
|
// Ambiguous: multiple top-level packages. Fall through to Name-based fallback.
|
|
}
|
|
// Zero entries: leave top_level empty.
|
|
}
|
|
// If top_level.txt is not found, that's OK — it's optional per the wheel spec.
|
|
|
|
// Determine the entry package in priority order.
|
|
// 1. Cloud/catalog metadata — handled by caller, not here.
|
|
// 2. Core Metadata Import-Name.
|
|
// 3. top_level.txt if unambiguous.
|
|
// 4. Normalized Name as fallback.
|
|
if (!meta_import_name.empty()) {
|
|
descriptor.entry_package = meta_import_name;
|
|
} else if (!top_level.empty()) {
|
|
descriptor.entry_package = top_level;
|
|
} else {
|
|
descriptor.entry_package = normalize_package_name(meta_name);
|
|
}
|
|
|
|
descriptor.dependencies = std::move(requires_dist);
|
|
|
|
// Populate local identity fallbacks from wheel metadata.
|
|
// Cloud metadata will override these when available.
|
|
descriptor.name = sanitize_plugin_name(meta_name);
|
|
descriptor.version = meta_version;
|
|
descriptor.description = meta_summary;
|
|
descriptor.author = meta_author;
|
|
|
|
return true;
|
|
}
|
|
|
|
boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error)
|
|
{
|
|
namespace fs = boost::filesystem;
|
|
|
|
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
|
|
error = "Plugin directory does not exist: " + plugin_dir.string();
|
|
return {};
|
|
}
|
|
|
|
fs::path py_entry;
|
|
fs::path whl_entry;
|
|
|
|
for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) {
|
|
if (is_ignored_plugin_directory(it->path()))
|
|
continue;
|
|
if (!fs::is_regular_file(it->status()))
|
|
continue;
|
|
|
|
const fs::path ext = it->path().extension();
|
|
if (ext == ".py") {
|
|
if (!py_entry.empty()) {
|
|
error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() +
|
|
" and " + it->path().filename().string();
|
|
return {};
|
|
}
|
|
py_entry = it->path();
|
|
} else if (ext == ".whl") {
|
|
if (!whl_entry.empty()) {
|
|
error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() +
|
|
" and " + it->path().filename().string();
|
|
return {};
|
|
}
|
|
whl_entry = it->path();
|
|
}
|
|
}
|
|
|
|
if (!py_entry.empty() && !whl_entry.empty()) {
|
|
error = "Plugin directory contains both .py and .whl entry files";
|
|
return {};
|
|
}
|
|
|
|
if (!py_entry.empty())
|
|
return py_entry;
|
|
|
|
if (!whl_entry.empty())
|
|
return whl_entry;
|
|
|
|
error = "Plugin directory does not contain a .py or .whl entry file";
|
|
return {};
|
|
}
|
|
|
|
} // namespace Slic3r
|