diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 35cf96d171..8890f18808 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -908,7 +908,7 @@ if (UNIX AND NOT APPLE) target_link_libraries(libslic3r_gui ${X11_LIBRARIES} ${webkit2gtk_LIBRARIES}) endif() target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GTK${SLIC3R_GTK}_INCLUDE_DIRS} ${LIBSECRET_INCLUDE_DIRS} ${webkit2gtk_INCLUDE_DIRS}) - target_link_libraries(libslic3r_gui ${GTK${SLIC3R_GTK}_LIBRARIES} fontconfig ${LIBSECRET_LIBRARIES}) + target_link_libraries(libslic3r_gui ${GTK${SLIC3R_GTK}_LIBRARIES} fontconfig ${LIBSECRET_LIBRARIES} ${webkit2gtk_LIBRARIES}) # Propagate GDK backend detection results as compile definitions so that # LinuxDisplayBackend.cpp can include the right GDK headers. diff --git a/src/slic3r/GUI/PluginWebDialog.cpp b/src/slic3r/GUI/PluginWebDialog.cpp index 1808f21ce9..707c7d33f8 100644 --- a/src/slic3r/GUI/PluginWebDialog.cpp +++ b/src/slic3r/GUI/PluginWebDialog.cpp @@ -4,13 +4,27 @@ #include "slic3r/GUI/GUI_App.hpp" #include +#include "slic3r/plugin/PluginManager.hpp" #include +#include #include +#include #include +#ifdef __linux__ +#include + +// Signal names for glib +#define DOWNLOAD_STARTED_SIGNAL "download-started" +#define DOWNLOAD_DESTINATION_SIGNAL "decide-destination" +#define DOWNLOAD_FINISHED_SIGNAL "finished" +#define DOWNLOAD_FAILED_SIGNAL "failed" +#define DOWNLOAD_POLICY_SIGNAL "decide-policy" +#endif + namespace Slic3r { namespace GUI { namespace { @@ -66,10 +80,10 @@ constexpr char ORCA_BRIDGE_JS[] = R"JS( } catch (e) { /* bridge not ready yet */ } } window.orca = { - postMessage: function (d) { send('message', d); }, - submit: function (d) { send('submit', d); }, - close: function () { send('close'); }, - onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); } + postMessage: function (d) { send('message', d); }, + submit: function (d) { send('submit', d); }, + close: function () { send('close'); }, + onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); } }; window.__orcaDispatch = function (payload) { var data = payload ? payload.data : null; @@ -88,19 +102,209 @@ wxString web_base_url() return wxString("file://") + from_u8(dir) + "/"; } +using PluginDownloadCallback = std::function; + +#if defined(__linux__) + +std::string sanitize_plugin_download_filename(const gchar* suggested) +{ + std::string name = suggested ? suggested : ""; + name = boost::filesystem::path(name).filename().string(); + if (name.empty() || name == "." || name == "..") + name = "download"; + return name; +} + +boost::filesystem::path unique_plugin_download_path(const boost::filesystem::path& dir, const std::string& filename) +{ + const boost::filesystem::path base = filename; + const std::string stem = base.stem().string(); + const std::string ext = base.extension().string(); + boost::filesystem::path candidate = dir / filename; + for (int n = 1; boost::filesystem::exists(candidate); ++n) + candidate = dir / (stem + " (" + std::to_string(n) + ")" + ext); + return candidate; +} + +struct PluginDownloadRedirectConfig +{ + wxString target_dir; + PluginDownloadCallback callback; +}; + +constexpr const char* PLUGIN_DOWNLOAD_REDIRECT_DATA_KEY = "orca-plugin-download-redirect-config"; +constexpr const char* PLUGIN_DOWNLOAD_STARTED_KEY = "orca-plugin-download-started-connected"; +constexpr const char* PLUGIN_DOWNLOAD_POLICY_KEY = "orca-plugin-download-policy-connected"; + +struct PluginDownloadSession +{ + wxString target_dir; + PluginDownloadCallback callback; + bool notified{false}; + wxString resolved_filename; + wxString resolved_path; +}; + +void notify_plugin_download(PluginDownloadSession* session, nlohmann::json info) +{ + if (session->notified) + return; + session->notified = true; + if (session->callback) + session->callback(info); +} + +void fail_plugin_download(PluginDownloadSession* session, WebKitDownload* download, const std::string& filename, const std::string& error) +{ + notify_plugin_download(session, + {{"filename", filename}, {"path", ""}, {"mimeType", ""}, {"size", 0}, {"success", false}, {"error", error}}); + webkit_download_cancel(download); +} + +gboolean on_plugin_download_decide_destination(WebKitDownload* download, const gchar* suggested_filename, gpointer user_data) +{ + auto* session = static_cast(user_data); + const std::string safe_name = sanitize_plugin_download_filename(suggested_filename); + if (session->target_dir.empty()) { + fail_plugin_download(session, download, safe_name, "Plugin storage directory is unavailable."); + return TRUE; + } + + namespace fs = boost::filesystem; + const fs::path dir(session->target_dir.utf8_string()); + boost::system::error_code ec; + fs::create_directories(dir, ec); + if (ec || !fs::is_directory(dir, ec)) { + fail_plugin_download(session, download, safe_name, "Could not create the plugin storage directory."); + return TRUE; + } + + const fs::path dest = unique_plugin_download_path(dir, safe_name); + GError* uri_error = nullptr; + gchar* uri = g_filename_to_uri(dest.string().c_str(), nullptr, &uri_error); + if (!uri) { + const std::string error = (uri_error && uri_error->message) ? uri_error->message : + "Could not build a destination path for the download."; + fail_plugin_download(session, download, safe_name, error); + if (uri_error) + g_error_free(uri_error); + return TRUE; + } + + session->resolved_filename = wxString::FromUTF8(dest.filename().string()); + session->resolved_path = wxString::FromUTF8(dest.string()); + webkit_download_set_destination(download, uri); + g_free(uri); + return TRUE; +} + +void on_plugin_download_finished(WebKitDownload* download, gpointer user_data) +{ + auto* session = static_cast(user_data); + WebKitURIResponse* response = webkit_download_get_response(download); + const gchar* mime = response ? webkit_uri_response_get_mime_type(response) : nullptr; + notify_plugin_download(session, {{"filename", std::string(session->resolved_filename.utf8_string())}, + {"path", std::string(session->resolved_path.utf8_string())}, + {"mimeType", mime ? mime : ""}, + {"size", webkit_download_get_received_data_length(download)}, + {"success", true}, + {"error", ""}}); + delete session; +} + +void on_plugin_download_failed(WebKitDownload*, GError* error, gpointer user_data) +{ + auto* session = static_cast(user_data); + notify_plugin_download(session, {{"filename", std::string(session->resolved_filename.utf8_string())}, + {"path", std::string(session->resolved_path.utf8_string())}, + {"mimeType", ""}, + {"size", 0}, + {"success", false}, + {"error", (error && error->message) ? error->message : "Download failed."}}); + delete session; +} + +void on_plugin_download_started(WebKitWebContext*, WebKitDownload* download, gpointer) +{ + WebKitWebView* source_view = webkit_download_get_web_view(download); + if (!source_view) + return; + + auto* config = static_cast(g_object_get_data(G_OBJECT(source_view), PLUGIN_DOWNLOAD_REDIRECT_DATA_KEY)); + if (!config) + return; + + auto* session = new PluginDownloadSession{config->target_dir, config->callback}; + g_signal_connect(download, DOWNLOAD_DESTINATION_SIGNAL, G_CALLBACK(on_plugin_download_decide_destination), session); + g_signal_connect(download, DOWNLOAD_FINISHED_SIGNAL, G_CALLBACK(on_plugin_download_finished), session); + g_signal_connect(download, DOWNLOAD_FAILED_SIGNAL, G_CALLBACK(on_plugin_download_failed), session); +} + +gboolean on_plugin_decide_policy(WebKitWebView* web_view, + WebKitPolicyDecision* decision, + WebKitPolicyDecisionType decision_type, + gpointer) +{ + if (decision_type != WEBKIT_POLICY_DECISION_TYPE_RESPONSE || + !g_object_get_data(G_OBJECT(web_view), PLUGIN_DOWNLOAD_REDIRECT_DATA_KEY)) + return FALSE; + + auto* response = WEBKIT_RESPONSE_POLICY_DECISION(decision); + if (webkit_response_policy_decision_is_mime_type_supported(response)) + return FALSE; + + // Binary responses without Content-Disposition can otherwise navigate the + // page instead of becoming WebKitDownload objects. + webkit_policy_decision_download(decision); + return TRUE; +} + +void enable_plugin_download_redirect(wxWebView* view, wxString target_dir, PluginDownloadCallback callback) +{ + auto* native_view = static_cast(view->GetNativeBackend()); + if (!native_view) + return; + + auto* config = new PluginDownloadRedirectConfig{std::move(target_dir), std::move(callback)}; + g_object_set_data_full(G_OBJECT(native_view), PLUGIN_DOWNLOAD_REDIRECT_DATA_KEY, config, + [](gpointer data) { delete static_cast(data); }); + + WebKitWebContext* context = webkit_web_view_get_context(native_view); + if (!g_object_get_data(G_OBJECT(context), PLUGIN_DOWNLOAD_STARTED_KEY)) { + g_signal_connect(context, DOWNLOAD_STARTED_SIGNAL, G_CALLBACK(on_plugin_download_started), nullptr); + g_object_set_data(G_OBJECT(context), PLUGIN_DOWNLOAD_STARTED_KEY, GUINT_TO_POINTER(1)); + } + if (!g_object_get_data(G_OBJECT(native_view), PLUGIN_DOWNLOAD_POLICY_KEY)) { + g_signal_connect(native_view, DOWNLOAD_POLICY_SIGNAL, G_CALLBACK(on_plugin_decide_policy), nullptr); + g_object_set_data(G_OBJECT(native_view), PLUGIN_DOWNLOAD_POLICY_KEY, GUINT_TO_POINTER(1)); + } +} +#elif defined(__WIN32__) +void enable_plugin_download_redirect(wxWebView*, wxString, PluginDownloadCallback) +{ throw std::runtime_error("Plugin webview download redirection is not implemented on Windows."); } +#elif defined(__WXMAC__) || defined(__WXOSX__) +void enable_plugin_download_redirect(wxWebView*, wxString, PluginDownloadCallback) +{ throw std::runtime_error("Plugin webview download redirection is not implemented on macOS."); } +#endif + } // namespace -PluginWebDialog::PluginWebDialog(wxWindow* parent, - const wxString& title, +PluginWebDialog::PluginWebDialog(wxWindow* parent, + const wxString& title, + const std::string& plugin_key, const std::string& html, - const wxSize& size, - MessageHandler on_message, - SubmitHandler on_submit, - CloseHandler on_close, - CloseHandler on_destroyed, - long wx_style) + const std::string& url, + const wxSize& size, + MessageHandler on_message, + SubmitHandler on_submit, + DownloadHandler on_download_complete, + CloseHandler on_close, + CloseHandler on_destroyed, + long wx_style) : WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style) , m_html(html) + , m_url(url) + , m_plugin_key(plugin_key) , m_on_message(std::move(on_message)) , m_on_submit(std::move(on_submit)) , m_on_close(std::move(on_close)) @@ -122,6 +326,19 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent, // missing/blocked bootstrap resource (e.g. a packaged build) still triggers it. Bind(wxEVT_WEBVIEW_LOADED, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); Bind(wxEVT_WEBVIEW_ERROR, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); + + wxString storage_dir; + try { + storage_dir = wxString::FromUTF8(PluginManager::instance().get_storage_dir(m_plugin_key)); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "PluginWebDialog: get_storage_dir('" << m_plugin_key + << "') failed, blocking downloads for this dialog: " << e.what(); + } + try { + enable_plugin_download_redirect(wv, std::move(storage_dir), std::move(on_download_complete)); + } catch (const std::runtime_error& e) { + BOOST_LOG_TRIVIAL(warning) << "PluginWebDialog: " << e.what(); + } } Bind(wxEVT_CLOSE_WINDOW, &PluginWebDialog::on_close_window, this); } @@ -183,14 +400,18 @@ void PluginWebDialog::load_plugin_content() if (m_content_loaded) return; m_content_loaded = true; - if (wxWebView* wv = browser()) - wv->SetPage(wxString::FromUTF8(m_html), web_base_url()); + if (wxWebView* wv = browser()) { + if (!m_url.empty()) + wv->LoadURL(wxString::FromUTF8(m_url)); + else + wv->SetPage(wxString::FromUTF8(m_html), web_base_url()); + } } void PluginWebDialog::on_script_message(const nlohmann::json& payload) { if (payload.value("channel", std::string()) == "orca") { - const std::string kind = payload.value("kind", std::string()); + const std::string kind = payload.value("kind", std::string()); const nlohmann::json data = payload.contains("data") ? payload["data"] : nlohmann::json(); if (kind == "message") { if (m_on_message) diff --git a/src/slic3r/GUI/PluginWebDialog.hpp b/src/slic3r/GUI/PluginWebDialog.hpp index 05f77f5148..b91c40a176 100644 --- a/src/slic3r/GUI/PluginWebDialog.hpp +++ b/src/slic3r/GUI/PluginWebDialog.hpp @@ -12,8 +12,9 @@ namespace Slic3r { namespace GUI { -// A host-owned webview window that renders plugin-supplied raw HTML and bridges -// messages to/from the page through a small injected `window.orca` API. +// A host-owned webview window that renders plugin-supplied raw HTML or loads a +// plugin-supplied URL and bridges messages to/from the page through a small +// injected `window.orca` API. // // This class is deliberately Python-agnostic: it talks to the plugin layer only // through std::function hooks. Those hooks must NOT capture bare pybind11 @@ -26,18 +27,23 @@ class PluginWebDialog : public Slic3r::GUI::WebViewHostDialog public: using MessageHandler = std::function; using SubmitHandler = std::function; + using DownloadHandler = std::function; using CloseHandler = std::function; - // on_submit fires once for window.orca.submit(). on_close fires only on a - // user/JS-initiated close (while the window is alive). on_destroyed runs from - // the destructor on every path and must touch host-side state only (no Python - // / no derived members). + // on_submit fires once for window.orca.submit(). on_download_complete fires once + // when a plugin download finishes or fails. on_close fires only on a user/JS- + // initiated close (while the window is alive). on_destroyed runs from the + // destructor on every path and must touch host-side state only (no Python / no + // derived members). PluginWebDialog(wxWindow* parent, const wxString& title, + const std::string& plugin_key, const std::string& html, + const std::string& url, const wxSize& size, MessageHandler on_message, SubmitHandler on_submit, + DownloadHandler on_download_complete, CloseHandler on_close, CloseHandler on_destroyed, long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER); @@ -71,6 +77,8 @@ private: void finish(bool submitted, const nlohmann::json& data); std::string m_html; + std::string m_url; + std::string m_plugin_key; bool m_content_loaded{false}; bool m_open{true}; bool m_close_fired{false}; diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 29aa59b253..a8e010ce14 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -8,6 +8,7 @@ #include #include +#include #include namespace Slic3r { @@ -97,6 +98,7 @@ ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_audit_mode(mode); PluginAuditManager::m_scoped_allowed_roots.clear(); + PluginAuditManager::instance().add_scoped_allowed_root(PluginManager::instance().get_storage_dir(plugin_key)); } ScopedPluginAuditContext::~ScopedPluginAuditContext() diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index c098ea3224..f0863c36ac 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -14,6 +15,7 @@ #include #include +#include #include #include @@ -103,6 +105,51 @@ GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit) }; } +GUI::PluginWebDialog::DownloadHandler default_download_handler() +{ + return [](const json& data) { + if (wxTheApp == nullptr || GUI::wxGetApp().plater() == nullptr) + return; + + auto *notifications = GUI::wxGetApp().plater()->get_notification_manager(); + if (notifications == nullptr) + return; + + if (data.value("success", false)) { + const boost::filesystem::path path(data.value("path", std::string())); + if (!path.empty() && !path.parent_path().empty()) { + notifications->push_import_finished_notification( + path.string(), path.parent_path().string(), false); + } + return; + } + + const std::string error = data.value("error", std::string("Download failed.")); + notifications->push_notification( + GUI::NotificationType::CustomNotification, + GUI::NotificationManager::NotificationLevel::WarningNotificationLevel, + std::string("Download failed: ") + error); + }; +} + +GUI::PluginWebDialog::DownloadHandler make_download_adapter(py::object on_download_complete) +{ + CallablePtr holder = make_holder(std::move(on_download_complete)); + if (!holder) + return default_download_handler(); + return [holder](const json& data) { + PythonGILState gil; + if (!gil) + return; + try { + holder->fn(json_to_py(data)); + } catch (py::error_already_set& e) { + BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_download_complete handler raised: " << e.what(); + PyErr_Clear(); + } + }; +} + // -------------------------------------------------------------------------- // Registry of live plugin UI resources. Keyed by an opaque id; tracks the // owning plugin so all of a plugin's UI can be torn down on unload. @@ -277,19 +324,24 @@ struct UiProgressHandle }; py::object ui_create_window(const std::string& html, const std::string& title, int width, int height, - py::object on_message, py::object on_close, long style, py::object on_submit) + py::object on_message, py::object on_close, long style, py::object on_submit, + py::object on_download_complete, const std::string& url) { - auto msg_adapter = make_message_adapter(std::move(on_message)); - auto submit_adapter = make_submit_adapter(std::move(on_submit)); - CallablePtr close_holder = make_holder(std::move(on_close)); - const std::string plugin_key = PluginAuditManager::instance().current_plugin(); - const int w = width > 0 ? width : 820; - const int h = height > 0 ? height : 600; + auto msg_adapter = make_message_adapter(std::move(on_message)); + auto submit_adapter = make_submit_adapter(std::move(on_submit)); + auto download_adapter = make_download_adapter(std::move(on_download_complete)); + CallablePtr close_holder = make_holder(std::move(on_close)); + const std::string plugin_key = PluginAuditManager::instance().current_plugin(); + const int w = width > 0 ? width : 820; + const int h = height > 0 ? height : 600; if ((style & ~WINDOW_MODAL) != 0) throw std::invalid_argument("unsupported orca.host.ui.create_window style flags"); const bool modal = (style & WINDOW_MODAL) != 0; + if (html.empty() == url.empty()) + throw std::invalid_argument("orca.host.ui.create_window requires exactly one of html or url"); + if (wxTheApp == nullptr) throw std::runtime_error("OrcaSlicer application is not initialized"); @@ -313,9 +365,10 @@ py::object ui_create_window(const std::string& html, const std::string& title, i const int new_id = UiRegistry::instance().reserve_id(); UiRegistry::instance().bind(new_id, nullptr, plugin_key); - GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, w, h, + GUI::wxGetApp().CallAfter([new_id, plugin_key, html, url, title, w, h, msg_adapter = std::move(msg_adapter), submit_adapter = std::move(submit_adapter), + download_adapter = std::move(download_adapter), close_holder = std::move(close_holder), modal]() mutable { // Torn down (plugin unload / app shutdown) before the window materialized. if (!UiRegistry::instance().is_open(new_id)) @@ -340,8 +393,9 @@ py::object ui_create_window(const std::string& html, const std::string& title, i // Registry cleanup: GIL-free, runs from the dialog destructor on every path. auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); }; - auto* dlg = new GUI::PluginWebDialog(ui_parent(), wxString::FromUTF8(title), html, + auto* dlg = new GUI::PluginWebDialog(ui_parent(), wxString::FromUTF8(title), plugin_key, html, url, wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter), + std::move(download_adapter), std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE); UiRegistry::instance().bind(new_id, dlg, plugin_key); if (modal) { @@ -487,12 +541,15 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host) "is_open", [](const UiWindowHandle& h) { return UiRegistry::instance().is_open(h.id); }, "Return True while the window is open."); - ui.def("create_window", &ui_create_window, py::arg("html"), py::arg("title") = "OrcaSlicer", py::arg("width") = 820, + ui.def("create_window", &ui_create_window, py::arg("html") = "", py::arg("title") = "OrcaSlicer", py::arg("width") = 820, py::arg("height") = 600, py::arg("on_message") = py::none(), py::arg("on_close") = py::none(), py::arg("style") = WINDOW_MODELESS, py::arg("on_submit") = py::none(), - "Open a persistent HTML window or modal dialog and return a UiWindow. style is WINDOW_MODELESS " - "or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) " - "is called when the page submits; offload heavy work to a thread and push results back with window.post()."); + py::arg("on_download_complete") = py::none(), + py::arg("url") = "", + "Open a persistent HTML window or modal dialog and return a UiWindow. Provide exactly one of html or url. " + "style is WINDOW_MODELESS or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) " + "is called when the page submits; on_download_complete(info) is called on the UI thread when a " + "download finishes or fails; offload heavy work to a thread and push results back with window.post()."); py::class_(ui, "ProgressDialog", "Handle to a native progress dialog.") .def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100,