Compare commits

..

7 Commits

Author SHA1 Message Date
Ian Chua
1a978e9006 Merge branch 'feat/plugin-storage-api' of https://github.com/OrcaSlicer/OrcaSlicer into feat/plugin-storage-api 2026-07-31 14:17:03 +08:00
Ian Chua
d6965973a6 feat: windows impl 2026-07-31 14:16:54 +08:00
Ian Chua
780b4a14b4 Merge branch 'main' into feat/plugin-storage-api 2026-07-30 14:55:34 +08:00
Ian Chua
40bca911d9 feat: redirect browser downloads to plugin storage 2026-07-30 14:55:06 +08:00
Ian Chua
2e246341d1 move the storage directory outside the actual plugin code folder 2026-07-24 18:57:46 +08:00
Ian Chua
9513300835 Merge branch 'main' into feat/plugin-storage-api 2026-07-24 13:09:39 +08:00
Ian Chua
f7caf0db07 feat(plugin): storage API 2026-07-23 21:04:08 +08:00
13 changed files with 1178 additions and 584 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,11 @@
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:rescap3="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities/3"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:virtualization="http://schemas.microsoft.com/appx/manifest/virtualization/windows10"
IgnorableNamespaces="uap uap3 rescap rescap3 desktop desktop6 virtualization">
IgnorableNamespaces="uap rescap rescap3 desktop6 virtualization">
<Identity Name="@MSIX_IDENTITY_NAME@"
Publisher="@MSIX_PUBLISHER@"
@@ -66,20 +64,6 @@
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="orcaslicer" />
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="prusaslicer" />
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="bambustudio" />
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="cura" />
</uap:Extension>
<uap3:Extension Category="windows.appExecutionAlias" EntryPoint="Windows.FullTrustApplication">
<uap3:AppExecutionAlias>
<desktop:ExecutionAlias Alias="orca-slicer.exe" />
</uap3:AppExecutionAlias>
</uap3:Extension>
</Extensions>
</Application>
</Applications>

View File

@@ -913,7 +913,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.

View File

@@ -4,13 +4,41 @@
#include "slic3r/GUI/GUI_App.hpp"
#include <libslic3r/Utils.hpp>
#include "slic3r/plugin/PluginManager.hpp"
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <wx/event.h>
#ifdef __WIN32__
#include <WebView2.h>
#include <objbase.h>
#ifdef __VISUALC__
#include <wrl/event.h>
using Microsoft::WRL::Callback;
#else
#include <wx/msw/private/comptr.h>
#include <wx/msw/wrl/event.h>
#endif
#endif
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#ifdef __linux__
#include <webkit2/webkit2.h>
// 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 {
@@ -88,19 +116,462 @@ wxString web_base_url()
return wxString("file://") + from_u8(dir) + "/";
}
using PluginDownloadCallback = std::function<void(const nlohmann::json&)>;
#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<PluginDownloadSession*>(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<PluginDownloadSession*>(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<PluginDownloadSession*>(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<PluginDownloadRedirectConfig*>(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<WebKitWebView*>(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<PluginDownloadRedirectConfig*>(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__)
std::wstring sanitize_plugin_download_filename(const wchar_t* suggested)
{
std::wstring name;
try {
name = boost::filesystem::path(suggested ? suggested : L"").filename().wstring();
} catch (...) {
name.clear();
}
for (wchar_t& c : name) {
if (c < 0x20 || c == L'<' || c == L'>' || c == L':' || c == L'"' || c == L'/' || c == L'\\' ||
c == L'|' || c == L'?' || c == L'*')
c = L'_';
}
while (!name.empty() && (name.back() == L' ' || name.back() == L'.'))
name.pop_back();
if (name.empty() || name == L"." || name == L"..")
name = L"download";
// Windows reserves these device names even when an extension is present.
const std::wstring stem = boost::filesystem::path(name).stem().wstring();
const std::wstring upper_stem = [&stem] {
std::wstring value = stem;
for (wchar_t& c : value) {
if (c >= L'a' && c <= L'z')
c -= L'a' - L'A';
}
return value;
}();
if (upper_stem == L"CON" || upper_stem == L"PRN" || upper_stem == L"AUX" || upper_stem == L"NUL" ||
(upper_stem.size() == 4 && (upper_stem.substr(0, 3) == L"COM" || upper_stem.substr(0, 3) == L"LPT") &&
upper_stem[3] >= L'1' && upper_stem[3] <= L'9'))
name.insert(0, L"_");
return name;
}
boost::filesystem::path unique_plugin_download_path(const boost::filesystem::path& dir, const std::wstring& filename)
{
const boost::filesystem::path base(filename);
const std::wstring stem = base.stem().wstring();
const std::wstring ext = base.extension().wstring();
boost::filesystem::path candidate = dir / filename;
for (int n = 1; boost::filesystem::exists(candidate); ++n)
candidate = dir / (stem + L" (" + std::to_wstring(n) + L")" + ext);
return candidate;
}
struct PluginDownloadRedirectConfig
{
wxString target_dir;
PluginDownloadCallback callback;
};
struct PluginDownloadSession
{
wxString target_dir;
PluginDownloadCallback callback;
bool notified{false};
wxString resolved_filename;
wxString resolved_path;
wxString mime_type;
};
void notify_plugin_download(PluginDownloadSession& session, nlohmann::json info)
{
if (session.notified)
return;
session.notified = true;
if (session.callback) {
try {
session.callback(info);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "PluginWebDialog: download callback failed: " << e.what();
} catch (...) {
BOOST_LOG_TRIVIAL(warning) << "PluginWebDialog: download callback failed.";
}
}
}
void fail_plugin_download(PluginDownloadSession& session, const std::string& error)
{
notify_plugin_download(session,
{{"filename", std::string(session.resolved_filename.utf8_string())},
{"path", ""},
{"mimeType", std::string(session.mime_type.utf8_string())},
{"size", 0},
{"success", false},
{"error", error}});
}
HRESULT on_plugin_download_state_changed(const std::shared_ptr<PluginDownloadSession>& session,
ICoreWebView2DownloadOperation* operation)
{
try {
COREWEBVIEW2_DOWNLOAD_STATE state = COREWEBVIEW2_DOWNLOAD_STATE_IN_PROGRESS;
if (!operation || FAILED(operation->get_State(&state)))
return S_OK;
if (state == COREWEBVIEW2_DOWNLOAD_STATE_IN_PROGRESS)
return S_OK;
INT64 bytes_received = 0;
operation->get_BytesReceived(&bytes_received);
LPWSTR result_path = nullptr;
operation->get_ResultFilePath(&result_path);
if (result_path) {
session->resolved_path = wxString(result_path);
CoTaskMemFree(result_path);
}
if (state == COREWEBVIEW2_DOWNLOAD_STATE_COMPLETED) {
notify_plugin_download(*session,
{{"filename", std::string(session->resolved_filename.utf8_string())},
{"path", std::string(session->resolved_path.utf8_string())},
{"mimeType", std::string(session->mime_type.utf8_string())},
{"size", bytes_received},
{"success", true},
{"error", ""}});
} else {
COREWEBVIEW2_DOWNLOAD_INTERRUPT_REASON reason =
COREWEBVIEW2_DOWNLOAD_INTERRUPT_REASON_NONE;
operation->get_InterruptReason(&reason);
fail_plugin_download(*session, "Download interrupted (" + std::to_string(static_cast<int>(reason)) + ").");
}
} catch (const std::exception& e) {
fail_plugin_download(*session, e.what());
} catch (...) {
fail_plugin_download(*session, "Download failed.");
}
return S_OK;
}
void cancel_plugin_download(ICoreWebView2DownloadStartingEventArgs* args, ICoreWebView2DownloadOperation* operation)
{
if (args)
args->put_Cancel(TRUE);
if (args)
args->put_Handled(TRUE);
if (operation)
operation->Cancel();
}
HRESULT on_plugin_download_starting(ICoreWebView2DownloadStartingEventArgs* args,
const std::shared_ptr<PluginDownloadRedirectConfig>& config)
{
ICoreWebView2DownloadOperation* operation = nullptr;
auto session = std::make_shared<PluginDownloadSession>();
session->target_dir = config->target_dir;
session->callback = config->callback;
try {
if (!args || FAILED(args->get_DownloadOperation(&operation)))
throw std::runtime_error("Could not initialize the download operation.");
LPWSTR suggested_path = nullptr;
args->get_ResultFilePath(&suggested_path);
const std::wstring filename = sanitize_plugin_download_filename(suggested_path);
if (suggested_path)
CoTaskMemFree(suggested_path);
session->resolved_filename = wxString(filename);
LPWSTR mime_type = nullptr;
if (SUCCEEDED(operation->get_MimeType(&mime_type)) && mime_type) {
session->mime_type = wxString(mime_type);
CoTaskMemFree(mime_type);
}
if (session->target_dir.empty())
throw std::runtime_error("Plugin storage directory is unavailable.");
namespace fs = boost::filesystem;
const fs::path dir(std::wstring(session->target_dir.wc_str()));
fs::create_directories(dir);
if (!fs::is_directory(dir))
throw std::runtime_error("Could not create the plugin storage directory.");
const fs::path destination = unique_plugin_download_path(dir, filename);
session->resolved_path = wxString(destination.wstring());
if (FAILED(args->put_ResultFilePath(destination.wstring().c_str())))
throw std::runtime_error("Could not set the plugin download destination.");
auto state_handler = Callback<ICoreWebView2StateChangedEventHandler>(
[session](ICoreWebView2DownloadOperation* download, IUnknown*) {
return on_plugin_download_state_changed(session, download);
});
EventRegistrationToken token{};
const HRESULT state_result = operation->add_StateChanged(state_handler.Get(), &token);
if (FAILED(state_result))
throw std::runtime_error("Could not monitor the plugin download.");
} catch (const std::exception& e) {
cancel_plugin_download(args, operation);
fail_plugin_download(*session, e.what());
} catch (...) {
cancel_plugin_download(args, operation);
fail_plugin_download(*session, "Could not redirect the plugin download.");
}
if (operation)
operation->Release();
return S_OK;
}
void attach_plugin_download_redirect(wxWebView* view, const std::shared_ptr<PluginDownloadRedirectConfig>& config)
{
auto* web_view = static_cast<ICoreWebView2*>(view->GetNativeBackend());
if (!web_view)
throw std::runtime_error("WebView2 is not ready for download redirection.");
ICoreWebView2_4* web_view_4 = nullptr;
const HRESULT query_result = web_view->QueryInterface(IID_ICoreWebView2_4,
reinterpret_cast<void**>(&web_view_4));
if (FAILED(query_result) || !web_view_4)
throw std::runtime_error("WebView2 download interception is unavailable.");
auto handler = Callback<ICoreWebView2DownloadStartingEventHandler>(
[config](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) {
try {
return on_plugin_download_starting(args, config);
} catch (...) {
// COM event callbacks must not allow exceptions to escape into WebView2.
if (args)
args->put_Cancel(TRUE);
return E_FAIL;
}
});
EventRegistrationToken token{};
const HRESULT add_result = web_view_4->add_DownloadStarting(handler.Get(), &token);
web_view_4->Release();
if (FAILED(add_result))
throw std::runtime_error("Could not register WebView2 download interception.");
}
void enable_plugin_download_redirect(wxWebView* view, wxString target_dir, PluginDownloadCallback callback)
{
if (!view)
return;
auto config = std::make_shared<PluginDownloadRedirectConfig>(PluginDownloadRedirectConfig{std::move(target_dir), std::move(callback)});
if (view->GetNativeBackend()) {
attach_plugin_download_redirect(view, config);
return;
}
// WebView2 creates its native controller asynchronously. The created event is
// queued by wxWidgets after GetNativeBackend() becomes usable.
view->Bind(wxEVT_WEBVIEW_CREATED, [view, config](wxWebViewEvent&) {
try {
attach_plugin_download_redirect(view, config);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "PluginWebDialog: " << e.what();
}
});
}
#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,
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)
: 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 +593,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,8 +667,12 @@ void PluginWebDialog::load_plugin_content()
if (m_content_loaded)
return;
m_content_loaded = true;
if (wxWebView* wv = browser())
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)

View File

@@ -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<void(const nlohmann::json& data)>;
using SubmitHandler = std::function<void(const nlohmann::json& data)>;
using DownloadHandler = std::function<void(const nlohmann::json& data)>;
using CloseHandler = std::function<void()>;
// 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};

View File

@@ -8,6 +8,7 @@
#include <boost/log/trivial.hpp>
#include <cstdlib>
#include <slic3r/plugin/PluginManager.hpp>
#include <utility>
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()

View File

@@ -632,7 +632,7 @@ void parse_metadata_rfc822(const std::string& content,
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;
return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR || name == PLUGIN_DATA_DIR;
}
bool is_safe_relative_path(const boost::filesystem::path& path)

View File

@@ -12,6 +12,7 @@
#include <vector>
#define PLUGIN_SUBSCRIBED_DIR "_subscribed"
#define PLUGIN_DATA_DIR "plugin_data"
namespace Slic3r {

View File

@@ -486,6 +486,38 @@ bool PluginManager::try_get_plugin_descriptor_for_capability(const std::string&
return false;
}
std::string PluginManager::get_storage_dir(const std::string& plugin_key) const
{
namespace fs = boost::filesystem;
PluginDescriptor descriptor;
if (!try_get_plugin_descriptor(plugin_key, descriptor))
throw std::runtime_error("The current plugin is not registered");
const fs::path base_storage_dir = fs::path(get_orca_plugins_dir()) / PLUGIN_DATA_DIR;
if (!descriptor.is_cloud_plugin()) {
const fs::path local_storage_dir = base_storage_dir / plugin_key;
fs::create_directories(local_storage_dir);
return local_storage_dir.string();
}
auto agent = m_cloud_service.get_cloud_agent();
if (!agent)
throw std::runtime_error("Cloud plugin storage is unavailable before networking is initialized");
const std::string user_id = agent->get_user_id();
if (user_id.empty())
throw std::runtime_error("Cloud plugin storage is unavailable without a logged-in user");
if (!is_valid_plugin_id(plugin_key))
throw std::runtime_error("The current cloud plugin key is not a valid folder name");
const fs::path cloud_storage_dir = base_storage_dir / PLUGIN_SUBSCRIBED_DIR / user_id / plugin_key;
fs::create_directories(cloud_storage_dir);
return cloud_storage_dir.string();
}
// ── Capability instances ────────────────────────────────────────────────────────────────────
std::vector<std::shared_ptr<PluginCapabilityInterface>> PluginManager::get_plugin_capabilities(const std::string& plugin_key,

View File

@@ -138,6 +138,10 @@ public:
bool try_get_plugin_descriptor_for_capability(const std::string& capability_name,
PluginCapabilityType type,
PluginDescriptor& out) const;
// Per-plugin storage directory under orca_plugins/plugin_data, created if missing. Throws
// std::runtime_error if the plugin is unregistered, the key is invalid, or (cloud plugins)
// no user is logged in yet.
std::string get_storage_dir(const std::string& plugin_key) const;
std::vector<std::shared_ptr<PluginCapabilityInterface>> get_plugin_capabilities(
const std::string& plugin_key = "", // "" => all plugins

View File

@@ -1,9 +1,31 @@
#include "PluginHost.hpp"
#include "PluginHostBindings.hpp"
#include "PluginHostUi.hpp"
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <stdexcept>
namespace Slic3r {
namespace host_bindings {
void register_plugin(pybind11::module_& host)
{
auto plugin_host = host.def_submodule("plugin", "Plugin host API");
plugin_host.def(
"storage",
[]() -> std::string {
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
if (plugin_key.empty())
throw std::runtime_error("plugin.storage() must be called from a plugin callback");
return PluginManager::instance().get_storage_dir(plugin_key);
},
"Return the installed folder of the current plugin.");
}
} // namespace host_bindings
void PluginHost::RegisterBindings(pybind11::module_& module)
{
auto host = module.def_submodule("host", "Host application API");
@@ -15,6 +37,7 @@ void PluginHost::RegisterBindings(pybind11::module_& module)
host_bindings::register_presets(host);
host_bindings::register_model(host);
host_bindings::register_app(host);
host_bindings::register_plugin(host);
// UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host);

View File

@@ -12,5 +12,5 @@ void register_presets(pybind11::module_& host); // PluginHostPresets.cpp
void register_model(pybind11::module_& host); // PluginHostModel.cpp
void register_app(pybind11::module_& host); // PluginHostApp.cpp
void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp
void register_plugin(pybind11::module_& host); // PluginHost.cpp
} // namespace Slic3r::host_bindings

View File

@@ -7,6 +7,7 @@
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp>
#include <slic3r/GUI/MsgDialog.hpp>
#include <slic3r/GUI/NotificationManager.hpp>
#include <slic3r/GUI/PluginProgressDialog.hpp>
#include <slic3r/GUI/PluginWebDialog.hpp>
@@ -14,6 +15,7 @@
#include <pybind11/pybind11.h>
#include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp>
#include <wx/app.h>
#include <wx/defs.h>
@@ -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,10 +324,12 @@ 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));
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;
@@ -290,6 +339,9 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
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_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.")
.def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100,