From 3071d525ee956be5d20211505db3fe69d9ec3350 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Sat, 10 Oct 2026 02:48:57 +0800 Subject: [PATCH] Expose App, GL, Printer and Project Facts to Plugins Plugins that help users file bug reports could only guess these from log lines: the app config is deny-listed, and the project path, a project export and the device list had no API. orca.host gains app_info(), gl_info() and selected_printer(), and Plater gains project_path() and export_3mf_copy(path). The copy export leaves the project's file name, saved state and model unchanged, does not write the signed-in account as the designer, and raises the audit event of open(path, "w") so the plugin gets the same permissions as for writing the file itself. --- src/slic3r/GUI/GUI_App.hpp | 2 + src/slic3r/GUI/OpenGLManager.hpp | 3 + src/slic3r/GUI/Plater.cpp | 62 ++++++++-- src/slic3r/GUI/Plater.hpp | 3 + src/slic3r/plugin/host/PluginHostApp.cpp | 128 ++++++++++++++++++++- tests/slic3rutils/test_plugin_host_api.cpp | 13 ++- 6 files changed, 198 insertions(+), 13 deletions(-) diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 8b9ad1bae7..c0ae02545a 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -396,6 +396,8 @@ public: Slic3r::TaskManager* getTaskManager() { return m_task_manager; } HMSQuery* get_hms_query() { return hms_query; } NetworkAgent* getAgent() { return m_agent; } + // Version that wrote the app config before this run; empty when there was no app config. + const boost::optional& last_config_version() const { return m_last_config_version; } // Reconcile the live printer agent with the stored preset selection. void switch_printer_agent(); diff --git a/src/slic3r/GUI/OpenGLManager.hpp b/src/slic3r/GUI/OpenGLManager.hpp index ebd44170cb..9e82123205 100644 --- a/src/slic3r/GUI/OpenGLManager.hpp +++ b/src/slic3r/GUI/OpenGLManager.hpp @@ -47,6 +47,9 @@ public: const std::string& get_vendor() const; const std::string& get_renderer() const; + // False until a GL context has been made current and queried; the getters below detect + // on first use, which needs a current context. + bool is_detected() const { return m_detected; } bool is_core_profile() const { return m_core_profile; } bool is_mesa() const; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 51a0c85909..db3d0f28bd 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -19318,6 +19318,38 @@ void Plater::send_gcode_finish(wxString name) auto out_str = GUI::format(_L("The file %s has been sent to the printer's storage space and can be viewed on the printer."), name); p->notification_manager->push_exporting_finished_notification(out_str, "", false); } +namespace { + +// export_3mf() assigns archive paths to previously unsaved SVGs. Puts them back, so an export that +// is not a project save does not change what a later project save writes. +class SvgArchivePathsRestorer +{ +public: + explicit SvgArchivePathsRestorer(Model& model) + { + for (ModelObject* object : model.objects) + for (ModelVolume* volume : object->volumes) + if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) { + std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf; + m_paths.emplace_back(path_in_3mf, *path_in_3mf); + } + } + ~SvgArchivePathsRestorer() { restore(); } + SvgArchivePathsRestorer(const SvgArchivePathsRestorer&) = delete; + SvgArchivePathsRestorer& operator=(const SvgArchivePathsRestorer&) = delete; + + void restore() + { + for (const auto& [path_in_3mf, previous_path] : m_paths) + *path_in_3mf = previous_path; + } + +private: + std::vector> m_paths; +}; + +} // namespace + void Plater::export_core_3mf() { wxString path = p->get_export_file(FT_3MF); @@ -19326,6 +19358,23 @@ void Plater::export_core_3mf() export_3mf(path_u8, SaveStrategy::Silence); } +bool Plater::export_3mf_copy(const boost::filesystem::path& output_path) +{ + Model& model = p->model; + SvgArchivePathsRestorer svg_paths(model); + + // With no design info, export_3mf() writes the signed-in account's user id as the designer; an + // empty one keeps it out. export_3mf() also drops design info without a designer name afterwards. + const std::shared_ptr design_info = model.design_info; + if (design_info == nullptr) + model.design_info = std::make_shared(); + ScopeGuard restore_design_info([&model, design_info]() { model.design_info = design_info; }); + + // The project save's layout, plus Silence so the project file name stays as it is. Unlike a save + // it never adds FullPathSources: a copy is for sharing, and those are paths on this machine. + return export_3mf(output_path, SaveStrategy::SplitModel | SaveStrategy::ShareMesh | SaveStrategy::Silence) == 0; +} + // Export the current project as a "published" 3MF: a pure export that never touches the // project's file name, dirty state, backup path or title, and attaches the published metadata // to the model only for the duration of the export (a later Save Project is a normal 3MF). @@ -19375,19 +19424,10 @@ int Plater::export_published_3mf(const std::vector& published_keys, std::string(); const std::string prev_payload = had_payload ? model.model_info->metadata_items.at(ORCA_PUBLISHED_CONFIG_TAG) : std::string(); - // export_3mf() assigns archive paths to previously unsaved SVGs. Preserve those fields too, - // otherwise a publish changes what a later normal project save writes. - std::vector> previous_svg_paths; - for (ModelObject* object : model.objects) - for (ModelVolume* volume : object->volumes) - if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) { - std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf; - previous_svg_paths.emplace_back(path_in_3mf, *path_in_3mf); - } + SvgArchivePathsRestorer svg_paths(model); auto restore_temporary_state = [&]() { - for (const auto& [path_in_3mf, previous_path] : previous_svg_paths) - *path_in_3mf = previous_path; + svg_paths.restore(); if (!had_model_info) { model.model_info = nullptr; diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 46192794c6..db84c17deb 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -565,6 +565,9 @@ public: void export_gcode_3mf(bool export_all = false); void send_gcode_finish(wxString name); void export_core_3mf(); + // Write the project to output_path as a copy: the project's file name, dirty state and model stay + // as they are, and the signed-in account is not added as the designer. False when the write fails. + bool export_3mf_copy(const boost::filesystem::path& output_path); // Export a "published" 3MF embedding the author-selected settings in the file metadata; a // pure export that leaves the in-memory project untouched. int export_published_3mf(const std::vector& published_keys, const std::vector& material_keys); diff --git a/src/slic3r/plugin/host/PluginHostApp.cpp b/src/slic3r/plugin/host/PluginHostApp.cpp index 668e99c7ff..3b5c19ec68 100644 --- a/src/slic3r/plugin/host/PluginHostApp.cpp +++ b/src/slic3r/plugin/host/PluginHostApp.cpp @@ -1,16 +1,36 @@ #include "PluginHostBindings.hpp" +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include +#include + +#include +#include +#include #include #include #include +#include +#include #include +#include namespace py = pybind11; @@ -41,6 +61,94 @@ PresetBundle* current_preset_bundle() return preset_bundle; } +GUI::GUI_App& current_app() +{ + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + return GUI::wxGetApp(); +} + +// The plater and the device list are only safe to touch from the UI thread, and the 3MF export +// renders thumbnails, which needs the UI thread's GL context. +void require_main_thread(const char* function_name) +{ + if (!wxIsMainThread()) + throw std::runtime_error(std::string(function_name) + "() must be called from the UI thread"); +} + +void export_3mf_copy(GUI::Plater& plater, const std::string& path) +{ + require_main_thread("export_3mf_copy"); + if (!boost::iends_with(path, ".3mf")) + throw py::value_error("export_3mf_copy() needs a path ending in .3mf"); + // The thumbnails are rendered by the 3D view, which may not exist yet while plugins load. + if (!GUI::OpenGLManager::get_gl_info().is_detected()) + throw std::runtime_error("export_3mf_copy() needs the 3D view, which is not ready yet"); + // The file is written from C++, so raise the audit event Python's open(path, "w") raises: the + // plugin gets the same deny list, permissions and prompt as for writing the file itself. + if (PySys_Audit("open", "ssi", path.c_str(), "w", 0) < 0) + throw py::error_already_set(); + if (!plater.export_3mf_copy(GUI::into_path(GUI::from_u8(path)))) + throw std::runtime_error("Failed to write " + path); +} + +py::object gl_info() +{ + const GUI::OpenGLManager::GLInfo& info = GUI::OpenGLManager::get_gl_info(); + if (!info.is_detected()) + return py::none(); + py::dict out; + out["vendor"] = info.get_vendor(); + out["renderer"] = info.get_renderer(); + out["version"] = info.get_version(); + out["glsl_version"] = info.get_glsl_version(); + out["core_profile"] = info.is_core_profile(); + return std::move(out); +} + +py::dict app_info() +{ + GUI::GUI_App& app = current_app(); + // The app config has no lock of its own; the UI thread is where it is written. + require_main_thread("app_info"); + py::dict out; + out["version"] = SoftFever_VERSION; + out["build"] = GUI::build_commit_label; + out["mode"] = app.is_editor() ? "editor" : "gcode viewer"; + out["language"] = GUI::into_u8(app.current_language_code_safe()); + // The app config file is deny-listed for plugins; these are the parts a bug report needs. + const boost::optional& config_version = app.last_config_version(); + out["app_config_version"] = config_version && config_version->valid() ? config_version->to_string_sf() : std::string(); + out["stealth_mode"] = app.app_config != nullptr && app.app_config->get_stealth_mode(); + out["is_signed_in"] = app.is_user_login(ORCA_CLOUD_PROVIDER); + out["is_bambu_signed_in"] = app.is_user_login(BBL_CLOUD_PROVIDER); + out["network_plugin_version"] = NetworkAgent::is_network_module_loaded() ? NetworkAgent::get_version() : std::string(); + const NetworkLibraryLoadError load_error = NetworkAgent::get_load_error(); + out["network_plugin_error"] = load_error.has_error ? load_error.message : std::string(); + return out; +} + +py::object selected_printer() +{ + GUI::GUI_App& app = current_app(); + require_main_thread("selected_printer"); + DeviceManager* devices = app.getDeviceManager(); + MachineObject* machine = devices != nullptr ? devices->get_selected_machine() : nullptr; + if (machine == nullptr) + return py::none(); + // Agent, model, link and firmware only: no name, serial, address or access code. Other agents + // fill the model and firmware with placeholders, so those come from Bambu printers only. + const bool bambu = machine->printer_agent_id == BBL_PRINTER_AGENT_ID; + py::dict out; + out["agent"] = machine->printer_agent_id; + out["model_id"] = bambu ? machine->printer_type : std::string(); + out["connection"] = machine->connection_type(); + out["online"] = machine->is_online(); + out["connected"] = machine->is_connected(); + out["firmware"] = bambu ? machine->get_ota_version() : std::string(); + return std::move(out); +} + } // namespace // Access to the live GUI application: the Plater and the module-level @@ -52,7 +160,15 @@ void host_bindings::register_app(py::module_& host) .def("model", static_cast(&GUI::Plater::model), py::return_value_policy::reference_internal) .def("is_project_dirty", &GUI::Plater::is_project_dirty) .def("is_presets_dirty", &GUI::Plater::is_presets_dirty) - .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture); + .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture) + // Path the project was last opened from or saved to; empty while it has never been saved. + .def("project_path", [](GUI::Plater& plater) { + require_main_thread("project_path"); + return GUI::into_u8(plater.get_project_filename(".3mf")); + }) + .def("export_3mf_copy", &export_3mf_copy, py::arg("path"), + "Write the current project, unsaved changes included, to path as a 3MF copy. The project's " + "file name and saved state are not changed, and the signed-in account is not written as the designer."); host.def("plater", ¤t_plater, py::return_value_policy::reference); host.def("model", []() -> Model& { @@ -68,6 +184,16 @@ void host_bindings::register_app(py::module_& host) throw std::runtime_error("OrcaSlicer application is not initialized"); return GUI::into_u8(GUI::wxGetApp().current_language_code_safe()); }); + host.def("app_info", &app_info, + "The running app: version, build, mode, language, app_config_version (the version that wrote the app config " + "before this run, empty when there was none), stealth_mode, is_signed_in (Orca Cloud), is_bambu_signed_in (Bambu Cloud), network_plugin_version (empty " + "when the Bambu network plugin is not loaded) and network_plugin_error (empty unless it failed to load)."); + host.def("gl_info", &gl_info, + "OpenGL vendor, renderer, version, glsl_version and core_profile of the 3D view; None before it has been created."); + host.def("selected_printer", &selected_printer, + "Agent, model_id, connection ('lan' or 'cloud'), online, connected and firmware of the printer selected in the " + "device list; model_id and firmware are empty for non-Bambu agents. None when none is selected. Print hosts " + "configured in the printer preset (OctoPrint and the like) are not in the device list."); } } // namespace Slic3r diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 8255840ac6..0841a130d7 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -133,7 +133,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi { py::object host = import_orca_module().attr("host"); - for (const char* function_name : { "preset_bundle", "plater", "model" }) { + for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) { CAPTURE(function_name); try { host.attr(function_name)(); @@ -145,6 +145,17 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi } } +TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]") +{ + py::object host = import_orca_module().attr("host"); + + CHECK(host.attr("gl_info")().is_none()); + + py::object plater_type = host.attr("Plater"); + CHECK(has_attr(plater_type, "project_path")); + CHECK(has_attr(plater_type, "export_3mf_copy")); +} + TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]") { py::object host = import_orca_module().attr("host");