Files
OrcaSlicer/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp
T
HanifKoh e68694dbaf Percent-Encode Local File URLs for Embedded Web Pages (#15961)
* Percent-Encode Local File URLs for Embedded Web Pages

The Home tab, setup wizard, Project tab and other embedded pages were
loaded from file:// URLs built by pasting the resources path into a
string. A '#', '%' or '?' in the install path was then read as a URL
fragment, escape or query, so the pages failed to load, for example a
portable install under D:\#OneDrive showed a directory listing instead
of the setup wizard.

Add file_url_from_path(), built on wxFileSystem::FileNameToURL, and use
it wherever a local page or image URL is built from a path. Queries such
as ?lang= are appended after the path is encoded. The wizard's printer
cover images are passed to the page as file URLs too.

* Encode the Login Error Page URL and Cover More Windows Path Forms

The login dialog's error page was still loaded from a raw resources path;
it now uses file_url_from_path like the other local pages.

The Windows file URL tests now also cover a resources path joined with a
forward-slash relative path, as the callers build them, and a UNC path.

* Build the Flush Dialog Page URLs with the Shared Helper

WipingDialog and NozzleListTable still called
wxFileSystem::FileNameToURL directly. They now go through
file_url_from_path like every other local page, so the URLs are built
in one place.

Adds a test for a resources directory with a '#' in its name, which the
plugin page check did not recognise before.
2026-09-29 22:30:56 +08:00

299 lines
12 KiB
C++

#include "WebViewHostDialog.hpp"
#include "WebView.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Widgets/StateColor.hpp"
#include <nlohmann/json.hpp>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <wx/log.h>
#include <wx/sizer.h>
#include <algorithm>
namespace Slic3r { namespace GUI {
namespace {
// CSS "#rrggbb" for a wxColour (portable accessor used throughout the codebase).
std::string css_color(const wxColour& c) { return c.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); }
// "dark"/"light" for the live app theme — the value of both data-orca-theme and color-scheme.
std::string host_theme_name() { return wxGetApp().dark_mode() ? "dark" : "light"; }
// The host theme "contract": CSS custom properties filled from the LIVE app theme,
// plus color-scheme. Consumed by resources/web/dialog/css/theme.css and by plugin
// content. Variables only — no element styling — so it never fights a page's CSS.
std::string host_theme_vars_css()
{
GUI_App& app = wxGetApp();
const wxColour bg = app.get_window_default_clr();
const wxColour fg = app.get_label_clr_default();
const wxColour muted = app.get_label_clr_sys();
const wxColour border = app.get_highlight_default_clr();
const wxColour accent = StateColor::darkModeColorFor(wxColour("#009688"));
std::string font = app.normal_font().GetFaceName().ToStdString();
// Strip characters that could break out of the CSS value / <style> block.
font.erase(std::remove_if(font.begin(), font.end(), [](char c) {
return c == '\'' || c == '"' || c == '<' || c == '>' || c == '{' || c == '}' || c == ';';
}),
font.end());
std::string s;
s += ":root{";
s += "--orca-bg:" + css_color(bg) + ";";
s += "--orca-fg:" + css_color(fg) + ";";
s += "--orca-muted:" + css_color(muted) + ";";
s += "--orca-border:" + css_color(border) + ";";
s += "--orca-accent:" + css_color(accent) + ";";
s += "--orca-accent-fg:#ffffff;";
s += "--orca-font:" + (font.empty() ? std::string() : "'" + font + "',") +
"system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;";
s += "color-scheme:" + host_theme_name() + ";";
s += "}";
return s;
}
// JS to re-theme an already-loaded document live (no reload): replace the injected
// style's contents and update data-orca-theme. Everything downstream (theme.css
// tokens, plugin element defaults, page layout) re-cascades from these values.
std::string host_theme_apply_js()
{
const std::string vars_literal = nlohmann::json(host_theme_vars_css()).dump();
const std::string theme = host_theme_name();
return "(function(){var css=" + vars_literal + ";var theme=\"" + theme + "\";" + R"JS(
var el=document.getElementById('orca-host-theme-vars');
if(el){el.textContent=css;}
else if(document.head){document.head.insertAdjacentHTML('afterbegin','<style id="orca-host-theme-vars"></style>');var e2=document.getElementById('orca-host-theme-vars');if(e2)e2.textContent=css;}
if(document.documentElement)
document.documentElement.setAttribute('data-orca-theme',theme);
})();)JS";
}
} // namespace
std::string WebViewHostDialog::theme_apply_script() { return host_theme_apply_js(); }
// Document-start user script: injects the contract <style>, stamps data-orca-theme before
// first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for
// host-themed pages. The WebView2 timing guard lives in document_start_injector().
std::string WebViewHostDialog::theme_user_script()
{
const std::string style = "<style id=\"orca-host-theme-vars\">" + host_theme_vars_css() + "</style>";
return document_start_injector(
style, "orca-host-theme-vars", "afterbegin",
"window.__orcaHostThemed=true;var theme=\"" + host_theme_name() + "\";",
"if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);");
}
std::string WebViewHostDialog::element_defaults_user_script()
{
std::string css;
css += "<style id=\"orca-plugin-defaults\">";
css += "html,body{background:var(--orca-bg);color:var(--orca-fg);"
"font-family:var(--orca-font);font-size:13px;}";
css += "body{margin:0;}";
css += "h1,h2,h3,h4,h5,h6{color:var(--orca-fg);font-weight:600;}";
css += "a{color:var(--orca-accent);}";
css += "hr{border:0;border-top:1px solid var(--orca-border);}";
css += "button{font:inherit;color:var(--orca-accent-fg);background:var(--orca-accent);"
"border:1px solid var(--orca-accent);border-radius:4px;padding:5px 14px;cursor:pointer;}";
css += "button:hover{filter:brightness(1.1);}";
css += "button:disabled{opacity:.5;cursor:default;}";
css += "input,select,textarea{font:inherit;color:var(--orca-fg);"
"background:var(--orca-bg);border:1px solid var(--orca-border);"
"border-radius:4px;padding:4px 8px;}";
css += "input:focus,select:focus,textarea:focus{outline:none;border-color:var(--orca-accent);}";
css += "table{border-collapse:collapse;}";
css += "th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--orca-border);}";
css += "th{color:var(--orca-muted);font-weight:600;}";
css += "::-webkit-scrollbar{width:12px;height:12px;}";
css += "::-webkit-scrollbar-thumb{background:var(--orca-border);border-radius:6px;}";
css += "::-webkit-scrollbar-track{background:transparent;}";
css += "</style>";
return document_start_injector(css, "orca-plugin-defaults", "beforeend");
}
std::string WebViewHostDialog::document_start_injector(const std::string& markup,
const char* dom_id,
const char* position,
const std::string& prelude,
const std::string& on_inject)
{
const std::string literal = nlohmann::json(markup).dump();
std::string s;
s += "(function(){";
// wxWebView's AddUserScript runs in child frames too (including cross-origin
// frames on WebView2). Host theme state belongs only to the top-level page.
s += "if(window.top!==window.self)return;";
s += prelude;
s += "var css=" + literal + ";";
s += "function inject(){";
s += "var root=document.head||document.documentElement;if(!root)return false;";
s += "if(!document.getElementById('" + std::string(dom_id) + "'))root.insertAdjacentHTML('" +
std::string(position) + "',css);";
s += on_inject;
s += "return true;}";
s += "if(inject())return;";
s += "var obs=new MutationObserver(function(){if(inject())obs.disconnect();});";
s += "obs.observe(document,{childList:true});})();";
return s;
}
WebViewHostDialog::WebViewHostDialog(wxWindow* parent,
wxWindowID id,
const wxString& title,
const wxPoint& pos,
const wxSize& size,
long style)
: DPIDialog(parent, id, title, pos, size, style)
{
SetBackgroundColour(*wxWHITE);
}
bool WebViewHostDialog::create_webview(const std::string& resource_path,
const wxString& title,
const wxSize& dialog_size,
const wxSize& min_size)
{
SetTitle(title);
SetMinSize(FromDIP(min_size));
const wxString target_url = build_resource_url(resource_path);
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
m_browser = WebView::CreateWebView(this, target_url);
if (m_browser == nullptr) {
wxLogError("Could not init m_browser");
delete topsizer;
return false;
}
SetSizer(topsizer);
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
SetSize(FromDIP(dialog_size));
CenterOnParent();
Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebViewHostDialog::on_script_message_event, this, m_browser->GetId());
// Inject the shared host theme contract BEFORE the first load so the page paints in
// the app theme with no flash, and re-theme live when the app theme toggles.
register_theme_user_scripts();
m_browser->Bind(EVT_WEBVIEW_RECREATED, &WebViewHostDialog::on_webview_recreated, this);
load_url(target_url);
wxGetApp().UpdateDlgDarkUI(this);
return true;
}
wxString WebViewHostDialog::build_resource_url(const std::string& resource_path) const
{
wxString target_url = file_url_from_path(boost::filesystem::path(resources_dir()) / resource_path);
if (append_language_to_url()) {
const wxString lang = wxGetApp().current_language_code_safe();
if (!lang.empty()) {
target_url += wxT("?lang=");
target_url += lang;
}
}
return target_url;
}
void WebViewHostDialog::load_url(const wxString& url)
{
if (!m_browser)
return;
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << " enter, url=" << into_u8(url);
WebView::LoadUrl(m_browser, url);
m_browser->SetFocus();
}
bool WebViewHostDialog::run_script(const wxString& script)
{
if (!m_browser)
return false;
return WebView::RunScript(m_browser, script);
}
void WebViewHostDialog::call_web_handler(const nlohmann::json& payload, const wxString& handler)
{
const wxString payload_text = wxString::FromUTF8(payload.dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore));
const wxString script = handler + wxT("(") + payload_text + wxT(")");
wxGetApp().CallAfter([this, script] { run_script(script); });
}
bool WebViewHostDialog::handle_common_script_command(const nlohmann::json& payload, int close_return_code)
{
const std::string command = payload.value("command", "");
if (command == "close_page") {
if (IsModal())
EndModal(close_return_code);
else
Close();
return true;
}
return false;
}
void WebViewHostDialog::on_dpi_changed(const wxRect&)
{
Refresh();
}
void WebViewHostDialog::on_script_message_parse_error(const wxString& payload, const std::exception& error)
{
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << "; payload=" << into_u8(payload) << "; error=" << error.what();
}
void WebViewHostDialog::on_script_message_event(wxWebViewEvent& event)
{
const wxString payload = event.GetString();
try {
on_script_message(nlohmann::json::parse(payload.utf8_string()));
} catch (const std::exception& e) {
on_script_message_parse_error(payload, e);
}
}
void WebViewHostDialog::register_theme_user_scripts()
{
if (!m_browser)
return;
// Added once, at creation. Deliberately no RemoveAllUserScripts() here: the "wx"
// script message handler is registered separately (AddScriptMessageHandler), but on
// some backends RemoveAllUserScripts() drops it too, which would break
// window.wx.postMessage / HandleStudio. Live re-theme goes through apply_theme_live().
m_browser->AddUserScript(wxString::FromUTF8(theme_user_script()));
add_user_scripts();
}
void WebViewHostDialog::apply_theme_live()
{
if (!m_browser)
return;
// Update the already-loaded document in place (no reload, no flash) by rewriting the
// injected :root variables + data-orca-theme; the whole cascade re-flows from these.
// The document-start script keeps the creation-time theme for any later reload, and
// these dialogs are not reloaded on a theme toggle (see WebView::RecreateAll).
run_script(wxString::FromUTF8(host_theme_apply_js()));
}
void WebViewHostDialog::on_webview_recreated(wxCommandEvent&)
{
// Handled: do NOT Skip(), so WebView::RecreateAll skips the redundant reload.
apply_theme_live();
}
}} // namespace Slic3r::GUI