Merge branch 'feat/plugin-feature' into feature/plugin-slicing

This commit is contained in:
SoftFever
2026-07-06 01:12:05 +08:00
52 changed files with 6418 additions and 3026 deletions

View File

@@ -2427,18 +2427,19 @@ void PrintConfigDef::init_fff_params()
// xgettext:no-c-format, no-boost-format
def = this->add("adaptive_pressure_advance_overhangs", coBools);
def->label = L("Enable adaptive pressure advance for overhangs (beta)");
def->tooltip = L("Enable adaptive PA for overhangs as well as when flow changes within the same feature. This is an experimental option, "
"as if the PA profile is not set accurately, it will cause uniformity issues on the external surfaces before and after overhangs.\n"
"Not compatible with Prusa printers as they pause to process PA changes, which causes delays and defects.");
def->label = L("Enable adaptive pressure advance within features (beta)");
def->tooltip = L("Enable adaptive PA whenever there are flow changes in a feature, such as line width changes in a corner or overhangs.\n\n"
"Not compatible with Prusa printers as they pause to process PA changes, which causes delays and defects.\n\n"
"This is an experimental option, as if the PA profile is not set accurately, it will cause uniformity issues.");
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBools{ false });
def = this->add("adaptive_pressure_advance_bridges", coFloats);
def->label = L("Pressure advance for bridges");
def->tooltip = L("Pressure advance value for bridges. Set to 0 to disable.\n\n"
"A lower PA value when printing bridges helps reduce the appearance of slight under extrusion immediately after bridges. "
"This is caused by the pressure drop in the nozzle when printing in the air and a lower PA helps counteract this.");
def->label = L("Static pressure advance for bridges");
def->tooltip = L("Static pressure advance value for bridges. Set to 0 to apply the same pressure advance as \n"
"equivalent walls (using adaptive settings if enabled).\n\n"
"A lower PA value when printing bridges helps reduce the appearance of slight under-extrusion immediately after bridges. "
"This is caused by the pressure drop in the nozzle when printing in the air and a lower PA helps counteract this.");
def->max = 2;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloats { 0.0 });

View File

@@ -1,5 +1,4 @@
#include "ExportPresetBundleDialog.hpp"
#include <slic3r/GUI/Widgets/WebView.hpp>
#include "GUI_App.hpp"
#include "ConfigWizard.hpp"
#include "I18N.hpp"
@@ -12,17 +11,14 @@
#include <wx/sizer.h>
#include <libslic3r/PresetBundle.hpp>
#include <wx/string.h>
#include <slic3r/GUI/Widgets/WebView.hpp>
#include <miniz.h>
#include <slic3r/GUI/MsgDialog.hpp>
namespace Slic3r { namespace GUI {
ExportPresetBundleDialog::ExportPresetBundleDialog(
wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style)
: DPIDialog(parent, id, _L("ExportPresetBundle"), pos, size, style)
: WebViewHostDialog(parent, id, _L("ExportPresetBundle"), pos, size, style)
{
SetBackgroundColour(*wxWHITE);
SetMinSize(DESIGN_WINDOW_SIZE);
Init();
wxGetApp().UpdateDlgDarkUI(this);
}
@@ -38,97 +34,36 @@ ExportPresetBundleDialog::~ExportPresetBundleDialog()
}
}
void ExportPresetBundleDialog::LoadUrl(wxString& url)
{
if (!m_browser)
return;
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << " enter, url=" << url.ToStdString();
WebView::LoadUrl(m_browser, url);
m_browser->SetFocus();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " exit";
}
void ExportPresetBundleDialog::on_dpi_changed(const wxRect& suggested_rect) { this->Refresh(); }
void ExportPresetBundleDialog::Init()
{
wxString TargetUrl = from_u8(
(boost::filesystem::path(resources_dir()) / "web/dialog/ExportPresetDialog/index.html").make_preferred().string());
wxString strlang = wxGetApp().current_language_code_safe();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", strlang=%1%") % into_u8(strlang);
if (strlang != "")
TargetUrl = wxString::Format("%s?lang=%s", std::string(TargetUrl.mb_str()), strlang);
TargetUrl = "file://" + TargetUrl;
// Create the webview
m_browser = WebView::CreateWebView(this, TargetUrl);
if (m_browser == nullptr) {
wxLogError("Could not init m_browser");
return;
}
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
SetTitle(_L("Export Preset Bundle"));
SetSizer(topsizer);
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
// Set a more sensible size for web browsing
wxSize pSize = FromDIP(wxSize(820, 660));
SetSize(pSize);
int screenheight = wxSystemSettings::GetMetric(wxSYS_SCREEN_Y, NULL);
int screenwidth = wxSystemSettings::GetMetric(wxSYS_SCREEN_X, NULL);
int MaxY = (screenheight - pSize.y) > 0 ? (screenheight - pSize.y) / 2 : 0;
wxPoint tmpPT((screenwidth - pSize.x) / 2, MaxY);
Move(tmpPT);
Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &ExportPresetBundleDialog::OnScriptMessage, this, m_browser->GetId());
LoadUrl(TargetUrl);
create_webview("web/dialog/ExportPresetDialog/index.html", _L("Export Preset Bundle"),
wxSize(820, 660), wxSize(640, 640));
}
void ExportPresetBundleDialog::RunScript(const wxString& s)
void ExportPresetBundleDialog::on_script_message(const nlohmann::json& j)
{
if (!m_browser)
if (handle_common_script_command(j))
return;
WebView::RunScript(m_browser, s);
}
void ExportPresetBundleDialog::OnScriptMessage(wxWebViewEvent& e)
{
try {
wxString strInput = e.GetString();
BOOST_LOG_TRIVIAL(trace) << "ExportPresetBundleDialog::OnScriptMessage;OnRecv:" << strInput.c_str();
json j = json::parse(strInput.utf8_string());
wxString strCmd = j["command"];
BOOST_LOG_TRIVIAL(trace) << "ExportPresetBundleDialog::OnScriptMessage;Command:" << strCmd;
if (strCmd == "close_page") {
this->EndModal(wxID_CANCEL);
} else if (strCmd == "request_export_preset_profile") {
InitExportData();
OnRequestPresets();
} else if (strCmd == "export_local") {
wxFileDialog dlg(this, _L("Save preset bundle"), "", "export.orca_bundle", "Orca Preset Bundle (*.orca_bundle)|*.orca_bundle",
wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
wxString path;
wxString name;
if (dlg.ShowModal() == wxID_OK) {
path = dlg.GetPath();
wxFileName file_name(path);
name = file_name.GetName();
if (file_name.GetExt().empty()) {
file_name.SetExt("orca_bundle");
path = file_name.GetFullPath();
}
const std::string strCmd = j.value("command", "");
if (strCmd == "request_export_preset_profile") {
InitExportData();
OnRequestPresets();
} else if (strCmd == "export_local") {
wxFileDialog dlg(this, _L("Save preset bundle"), "", "export.orca_bundle",
"Orca Preset Bundle (*.orca_bundle)|*.orca_bundle", wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
wxString path;
wxString name;
if (dlg.ShowModal() == wxID_OK) {
path = dlg.GetPath();
wxFileName file_name(path);
name = file_name.GetName();
if (file_name.GetExt().empty()) {
file_name.SetExt("orca_bundle");
path = file_name.GetFullPath();
}
OnExportData(path, name, j["data"]);
}
} catch (std::exception& e) {
BOOST_LOG_TRIVIAL(trace) << "ExportPresetBundleDialog::OnScriptMessage;Error:" << e.what();
OnExportData(path, name, j.value("data", json()));
}
}
@@ -331,8 +266,7 @@ void ExportPresetBundleDialog::OnRequestPresets()
}
}
wxString strJS = wxString::Format("HandleStudio(%s)", wxString::FromUTF8(res.dump(-1, ' ', false, json::error_handler_t::ignore)));
wxGetApp().CallAfter([this, strJS] { RunScript(strJS); });
call_web_handler(res);
}
void ExportPresetBundleDialog::OnExportData(const wxString& path, const wxString& filename, json data)

View File

@@ -3,6 +3,7 @@
#include "GUI.hpp"
#include "GUI_Utils.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include "libslic3r/AppConfig.hpp"
#include <slic3r/GUI/GUI.hpp>
@@ -11,7 +12,6 @@
#include <wx/language.h>
#include <wx/string.h>
#include <wx/fswatcher.h>
#include <wx/webview.h>
namespace Slic3r { namespace GUI {
@@ -37,7 +37,7 @@ enum ExportCase {
CASE_COUNT,
};
class ExportPresetBundleDialog : public Slic3r::GUI::DPIDialog
class ExportPresetBundleDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
ExportPresetBundleDialog(wxWindow* parent,
@@ -52,16 +52,12 @@ public:
// Utilities
bool seq_top_layer_only_changed() const { return m_seq_top_layer_only_changed; }
bool recreate_GUI() const { return m_recreate_GUI; }
void on_dpi_changed(const wxRect& suggested_rect) override;
void show_export_result(const ExportCase& e);
void Init();
void InitExportData();
// Webview
void LoadUrl(wxString& url);
void OnScriptMessage(wxWebViewEvent& e);
void RunScript(const wxString& s);
void on_script_message(const nlohmann::json& payload) override;
void OnRequestPresets();
void OnExportData(const wxString& path, const wxString& name, json data);
@@ -69,9 +65,6 @@ protected:
bool m_seq_top_layer_only_changed{false};
bool m_recreate_GUI{false};
// Webview
wxWebView* m_browser{nullptr};
// Export Preset
std::unordered_map<std::string, Preset*> m_printer_presets; // first: printer name, second: printer presets have same printer name
std::unordered_map<std::string, std::vector<const Preset*>>

View File

@@ -266,18 +266,6 @@ static void set_config_values(DynamicPrintConfig *config, const std::string &key
}
}
template <typename T, typename OptionType>
static void set_config_values(ModelConfig& config, const std::string &key, T value)
{
auto config_opt = config.get().option<OptionType>(key);
if (config_opt) {
config.set_key_value(key, new OptionType(config_opt->values.size(), value));
}
else {
BOOST_LOG_TRIVIAL(info) << "set_config_values: the key" << key << "is empty.";
}
}
bool Plater::has_illegal_filename_characters(const wxString& wxs_name)
{
std::string name = into_u8(wxs_name);
@@ -12928,9 +12916,9 @@ void Plater::_calib_pa_pattern(const Calib_Params& params)
auto &obj_config = obj->config;
if (speeds.size() > 1)
set_config_values<double, ConfigOptionFloatsNullable>(obj_config, "outer_wall_speed", tspd);
obj_config.set_key_value("outer_wall_speed", new ConfigOptionFloatsNullable(1, tspd));
if (accels.size() > 1)
set_config_values<double, ConfigOptionFloatsNullable>(obj_config, "outer_wall_acceleration", tacc);
obj_config.set_key_value("outer_wall_acceleration", new ConfigOptionFloatsNullable(1, tacc));
auto cur_plate = get_partplate_list().get_plate(plate_idx);
if (!cur_plate) {
@@ -13352,7 +13340,7 @@ void Plater::calib_max_vol_speed(const Calib_Params& params)
set_config_values<double, ConfigOptionFloats>(filament_config, "filament_max_volumetric_speed", 200);
filament_config->set_key_value("slow_down_layer_time", new ConfigOptionFloats{0.0});
printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false});
set_config_values<bool, ConfigOptionBoolsNullable>(obj_cfg, "enable_overhang_speed", false);
obj_cfg.set_key_value("enable_overhang_speed", new ConfigOptionBoolsNullable(1, false));
obj_cfg.set_key_value("wall_loops", new ConfigOptionInt(1));
obj_cfg.set_key_value("alternate_extra_wall", new ConfigOptionBool(false));
obj_cfg.set_key_value("top_shell_layers", new ConfigOptionInt(0));

View File

@@ -10,11 +10,15 @@ PluginProgressDialog::PluginProgressDialog(wxWindow* parent,
int maximum,
int style,
CloseHandler on_destroyed)
: wxProgressDialog(title, message, maximum, parent, style)
: ProgressDialog(title, message, maximum, parent, style)
, m_pulse_message(message)
, m_on_destroyed(std::move(on_destroyed))
{
Bind(wxEVT_CLOSE_WINDOW, &PluginProgressDialog::on_close_window, this);
// The base ProgressDialog already binds wxEVT_CLOSE_WINDOW (its OnClose vetoes
// a non-cancelable dialog and marks a cancelable one Canceled). We deliberately
// don't add a second handler: a user-initiated close surfaces to the plugin as
// update()/pulse() returning false, and programmatic close() calls Destroy()
// directly, so no extra wiring is needed here.
}
PluginProgressDialog::~PluginProgressDialog()
@@ -120,11 +124,6 @@ void PluginProgressDialog::close()
Destroy();
}
void PluginProgressDialog::on_close_window(wxCloseEvent& /*event*/)
{
close();
}
void PluginProgressDialog::on_timer(wxTimerEvent& /*event*/)
{
if (m_open)

View File

@@ -5,17 +5,24 @@
#include <memory>
#include <wx/event.h>
#include <wx/progdlg.h>
#include <wx/string.h>
#include <wx/timer.h>
#include <wx/window.h>
#include "Widgets/ProgressDialog.hpp"
namespace Slic3r { namespace GUI {
// A host-owned progress dialog for Python plugins. This class is deliberately
// Python-agnostic; the plugin layer owns any pybind/GIL concerns and marshals
// all calls to the UI thread.
class PluginProgressDialog : public wxProgressDialog
//
// It derives from OrcaSlicer's own Slic3r::GUI::ProgressDialog (a real wxDialog
// with themable wx children) rather than the native wxProgressDialog: on Windows
// wxProgressDialog is a comctl32 TaskDialog running on a worker thread with no
// recolorable wx surface, so it cannot follow OrcaSlicer's (OS-independent) dark
// theme. The base themes itself via UpdateDlgDarkUI(this) in its Create().
class PluginProgressDialog : public ProgressDialog
{
public:
using CloseHandler = std::function<void()>;
@@ -52,7 +59,6 @@ public:
bool is_open() const { return m_open; }
private:
void on_close_window(wxCloseEvent& event);
void on_timer(wxTimerEvent& event);
bool m_open{true};

View File

@@ -2,7 +2,6 @@
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Widgets/StateColor.hpp"
#include <libslic3r/Utils.hpp>
@@ -16,86 +15,37 @@ namespace Slic3r { namespace GUI {
namespace {
// CSS "#rrggbb" for a wxColour (wxC2S_HTML_SYNTAX is the portable accessor used
// throughout the codebase for color->CSS).
std::string css_color(const wxColour& c) { return c.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); }
// Build a <style> block that matches OrcaSlicer's current theme. Injected at
// document-start (see ctor) so an unstyled plugin page already looks native,
// while plugin CSS still wins:
// * variables live on :root and elements use only low-specificity selectors,
// * nothing is marked !important,
// so any later rule the plugin ships (even an element selector) overrides these.
// Generated in C++ from the live theme — correct for the active light/dark mode
// and accent without the page needing to detect anything.
std::string host_theme_style()
// Low-specificity element defaults (no !important) for UNSTYLED plugin HTML, so a bare
// plugin page looks native while any CSS the plugin ships still wins. Built on the
// --orca-* variables the host injects (see WebViewHostDialog); document-start injected
// AFTER the host contract so the variables are defined (shares the base injector's
// WebView2 timing guard).
std::string plugin_defaults_user_script()
{
GUI_App& app = wxGetApp();
const wxColour bg = app.get_window_default_clr(); // dialog background
const wxColour fg = app.get_label_clr_default(); // primary text
const wxColour muted = app.get_label_clr_sys(); // secondary text
const wxColour border = app.get_highlight_default_clr(); // subtle lines / row hover
const wxColour accent = StateColor::darkModeColorFor(wxColour("#009688")); // ORCA teal
const wxColour accent_fg = *wxWHITE;
const std::string font = app.normal_font().GetFaceName().ToStdString();
std::string s;
s += "<style id=\"orca-host-theme\">";
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:" + css_color(accent_fg) + ";";
// The themed face name first, then a portable system-ui fallback stack.
s += "--orca-font:" + (font.empty() ? std::string() : "'" + font + "',") +
"system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;";
s += "color-scheme:" + std::string(app.dark_mode() ? "dark" : "light") + ";";
s += "}";
// Element defaults — low specificity, no !important.
s += "html,body{background:var(--orca-bg);color:var(--orca-fg);"
"font-family:var(--orca-font);font-size:13px;}";
s += "body{margin:0;}";
s += "h1,h2,h3,h4,h5,h6{color:var(--orca-fg);font-weight:600;}";
s += "a{color:var(--orca-accent);}";
s += "hr{border:0;border-top:1px solid var(--orca-border);}";
s += "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;}";
s += "button:hover{filter:brightness(1.1);}";
s += "button:disabled{opacity:.5;cursor:default;}";
s += "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;}";
s += "input:focus,select:focus,textarea:focus{outline:none;border-color:var(--orca-accent);}";
s += "table{border-collapse:collapse;}";
s += "th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--orca-border);}";
s += "th{color:var(--orca-muted);font-weight:600;}";
// WebKit/Chromium scrollbars themed to the background (no-op on others).
s += "::-webkit-scrollbar{width:12px;height:12px;}";
s += "::-webkit-scrollbar-thumb{background:var(--orca-border);border-radius:6px;}";
s += "::-webkit-scrollbar-track{background:transparent;}";
s += "</style>";
return s;
}
// User script that prepends the host theme into the document at document-start,
// before the plugin's own <style>/scripts run (and before first paint). Works
// whether the plugin page has a <head> or is a bare fragment.
std::string host_theme_user_script()
{
// JSON-encode the style so it is a safe JS string literal regardless of
// quotes/newlines it may contain.
const std::string style_literal = nlohmann::json(host_theme_style()).dump();
std::string js;
js += "(function(){";
js += "if(document.getElementById('orca-host-theme'))return;";
js += "var css=" + style_literal + ";";
js += "var head=document.head||document.documentElement;";
js += "head.insertAdjacentHTML('afterbegin',css);";
js += "})();";
return js;
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 WebViewHostDialog::document_start_injector(css, "orca-plugin-defaults", "beforeend");
}
// Injected into every page at document start (before the plugin's own scripts).
@@ -162,11 +112,8 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
if (wxWebView* wv = browser()) {
wv->SetBackgroundColour(wxGetApp().get_window_default_clr());
// Inject the host theme first so its <style> sits ahead of any plugin
// CSS in the document (later same-specificity rules win), making the
// plugin page match OrcaSlicer's light/dark theme by default.
wv->AddUserScript(wxString::FromUTF8(host_theme_user_script()));
wv->AddUserScript(wxString::FromUTF8(ORCA_BRIDGE_JS));
// Theme contract + plugin defaults + bridge are registered by the base
// create_webview() via add_user_scripts(); nothing to add here.
// Swap in the plugin HTML once the bootstrap page settles. Bind ERROR too so a
// missing/blocked bootstrap resource (e.g. a packaged build) still triggers it.
Bind(wxEVT_WEBVIEW_LOADED, &PluginWebDialog::on_bootstrap_event, this, wv->GetId());
@@ -175,6 +122,14 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
Bind(wxEVT_CLOSE_WINDOW, &PluginWebDialog::on_close_window, this);
}
void PluginWebDialog::add_user_scripts()
{
if (wxWebView* wv = browser()) {
wv->AddUserScript(wxString::FromUTF8(plugin_defaults_user_script()));
wv->AddUserScript(ORCA_BRIDGE_JS);
}
}
PluginWebDialog::~PluginWebDialog()
{
// Runs on every destruction path. Deliberately NOT a wxEVT_DESTROY handler:

View File

@@ -69,6 +69,7 @@ protected:
void on_script_message(const nlohmann::json& payload) override;
// Plugin HTML is loaded as a raw string, not a localized resource URL.
bool append_language_to_url() const override { return false; }
void add_user_scripts() override;
private:
void on_bootstrap_event(wxWebViewEvent& event);

View File

@@ -12,7 +12,6 @@
#include <libslic3r/PresetBundle.hpp>
#include <wx/string.h>
#include "MainFrame.hpp"
#include <slic3r/GUI/Widgets/WebView.hpp>
#include <miniz.h>
#include <OrcaCloudServiceAgent.hpp>
#include <wx/event.h>
@@ -21,11 +20,9 @@ namespace Slic3r { namespace GUI {
PresetBundleDialog::PresetBundleDialog(
wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style)
: DPIDialog(parent, id, _L("PresetBundle"), pos, size, style)
: WebViewHostDialog(parent, id, _L("PresetBundle"), pos, size, style)
{
wxGetApp().preset_bundle->bundles.PauseRead(); // for the entirety of the preset bundle dialog, we want the update thread to yield.
SetBackgroundColour(*wxWHITE);
SetMinSize(DESIGN_WINDOW_SIZE);
create();
wxGetApp().UpdateDlgDarkUI(this);
@@ -186,55 +183,11 @@ void PresetBundleDialog::RefreshBundleMap()
wxGetApp().preset_bundle->bundles.ReadUnlock();
}
void PresetBundleDialog::load_url(wxString& url)
{
if (!m_browser)
return;
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << " enter, url=" << url.ToStdString();
WebView::LoadUrl(m_browser, url);
m_browser->SetFocus();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " exit";
}
void PresetBundleDialog::create()
{
app_config = get_app_config();
wxString TargetUrl = from_u8(
(boost::filesystem::path(resources_dir()) / "web/dialog/PresetBundleDialog/index.html").make_preferred().string());
wxString strlang = wxGetApp().current_language_code_safe();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", strlang=%1%") % into_u8(strlang);
if (strlang != "")
TargetUrl = wxString::Format("%s?lang=%s", std::string(TargetUrl.mb_str()), strlang);
TargetUrl = "file://" + TargetUrl;
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
SetTitle(_L("Preset Bundle"));
m_browser = WebView::CreateWebView(this, TargetUrl);
if (m_browser == nullptr) {
wxLogError("Could not init m_browser");
return;
}
SetSizer(topsizer);
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
// Set a more sensible size for web browsing
wxSize pSize = FromDIP(wxSize(820, 660));
SetSize(pSize);
int screenheight = wxSystemSettings::GetMetric(wxSYS_SCREEN_Y, NULL);
int screenwidth = wxSystemSettings::GetMetric(wxSYS_SCREEN_X, NULL);
int MaxY = (screenheight - pSize.y) > 0 ? (screenheight - pSize.y) / 2 : 0;
wxPoint tmpPT((screenwidth - pSize.x) / 2, MaxY);
Move(tmpPT);
Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PresetBundleDialog::OnScriptMessage, this, m_browser->GetId());
load_url(TargetUrl);
create_webview("web/dialog/PresetBundleDialog/index.html", _L("Preset Bundle"),
wxSize(820, 660), wxSize(640, 640));
}
bool PresetBundleDialog::DeleteBundleById(const wxString& id)
@@ -292,68 +245,41 @@ bool PresetBundleDialog::DeleteBundleById(const wxString& id)
bool PresetBundleDialog::UnsubscribeBundleById(const std::string& id) { return wxGetApp().unsubscribe_bundle(id); }
void PresetBundleDialog::on_dpi_changed(const wxRect& suggested_rect) { this->Refresh(); }
void PresetBundleDialog::RunScript(const wxString& s)
void PresetBundleDialog::on_script_message(const nlohmann::json& j)
{
if (!m_browser)
if (handle_common_script_command(j))
return;
WebView::RunScript(m_browser, s);
}
void PresetBundleDialog::OnScriptMessage(wxWebViewEvent& e)
{
try {
wxString strInput = e.GetString();
BOOST_LOG_TRIVIAL(trace) << "PresetBundleDialog::OnScriptMessage;OnRecv:" << strInput.c_str();
json j = json::parse(strInput.utf8_string());
wxString strCmd = j["command"];
BOOST_LOG_TRIVIAL(trace) << "PresetBundleDialog::OnScriptMessage;Command:" << strCmd;
if (strCmd == "request_bundles") {
ListBundles();
} else if (strCmd == "refresh_bundles") {
// use the thread to check for updates.
m_check_update_pending.store(true, std::memory_order_relaxed);
} else if (strCmd == "update_bundle") {
std::string id = j["bundle_id"];
auto* evt = new wxCommandEvent(EVT_UPDATE_PRESET_BUNDLE);
evt->SetString(wxString::FromUTF8(id));
wxQueueEvent(&wxGetApp(), evt); // dialog -> GUI_App
} else if (strCmd == "set_auto_update") {
bool enabled = j.value("enabled", false);
// Example persistence location. Adjust key name if you already have one.
app_config->set_bool("preset_bundle_auto_update", enabled ? true : false);
app_config->save();
} else if (strCmd == "close_page") {
this->EndModal(wxID_CANCEL);
} else if (strCmd == "export_page") {
wxGetApp().CallAfter([this]() {
ExportPresetBundleDialog dlg(this);
dlg.ShowModal();
});
} else if (strCmd == "top_row_menu_action") {
if (j["action"] == "open_folder") {
std::string id = j["bundle_id"];
OpenFolder(id);
} else if (j["action"] == "delete_bundle") {
std::string id = j["bundle_id"];
DeleteBundle(id);
} else if (j["action"] == "unsubscribe_bundle") {
std::string id = j["bundle_id"];
UnsubscribeBundle(id);
}
} else if (strCmd == "open_bundle_on_cloud") {
std::string bundle_id = j["bundle_id"];
OpenBundleOnCloud(bundle_id);
}
} catch (std::exception& e) {
BOOST_LOG_TRIVIAL(trace) << "PresetBundleDialog::OnScriptMessage;Error:" << e.what();
const std::string strCmd = j.value("command", "");
if (strCmd == "request_bundles") {
ListBundles();
} else if (strCmd == "refresh_bundles") {
m_check_update_pending.store(true, std::memory_order_relaxed);
} else if (strCmd == "update_bundle") {
std::string id = j.value("bundle_id", "");
auto* evt = new wxCommandEvent(EVT_UPDATE_PRESET_BUNDLE);
evt->SetString(wxString::FromUTF8(id));
wxQueueEvent(&wxGetApp(), evt);
} else if (strCmd == "set_auto_update") {
bool enabled = j.value("enabled", false);
app_config->set_bool("preset_bundle_auto_update", enabled ? true : false);
app_config->save();
} else if (strCmd == "export_page") {
wxGetApp().CallAfter([this]() {
ExportPresetBundleDialog dlg(this);
dlg.ShowModal();
});
} else if (strCmd == "top_row_menu_action") {
const std::string action = j.value("action", "");
const std::string id = j.value("bundle_id", "");
if (action == "open_folder")
OpenFolder(id);
else if (action == "delete_bundle")
DeleteBundle(id);
else if (action == "unsubscribe_bundle")
UnsubscribeBundle(id);
} else if (strCmd == "open_bundle_on_cloud") {
OpenBundleOnCloud(j.value("bundle_id", ""));
}
}
@@ -396,8 +322,7 @@ void PresetBundleDialog::ListBundles()
res["data"].push_back(std::move(temp));
}
wxString strJS = wxString::Format("HandleStudio(%s)", wxString::FromUTF8(res.dump(-1, ' ', false, json::error_handler_t::ignore)));
wxGetApp().CallAfter([this, strJS] { RunScript(strJS); });
call_web_handler(res);
}
void PresetBundleDialog::OpenFolder(const std::string& id)

View File

@@ -3,6 +3,7 @@
#include "GUI.hpp"
#include "GUI_Utils.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include "libslic3r/AppConfig.hpp"
#include <boost/thread/detail/thread.hpp>
#include <libslic3r/PresetBundle.hpp>
@@ -15,7 +16,6 @@
#include <wx/language.h>
#include <wx/string.h>
#include <wx/fswatcher.h>
#include <wx/webview.h>
namespace Slic3r { namespace GUI {
#define DESIGN_GRAY900_COLOR wxColour("#363636") // Label color
@@ -28,7 +28,7 @@ namespace Slic3r { namespace GUI {
#define DESIGN_INPUT_SIZE wxSize(FromDIP(120), -1)
#define DESIGN_LEFT_MARGIN 25
#define VERTICAL_GAP_SIZE FromDIP(4)
class PresetBundleDialog : public Slic3r::GUI::DPIDialog
class PresetBundleDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
PresetBundleDialog(wxWindow* parent,
@@ -47,10 +47,8 @@ public:
bool seq_top_layer_only_changed() const { return m_seq_top_layer_only_changed; }
bool recreate_GUI() const { return m_recreate_GUI; }
void on_dpi_changed(const wxRect& suggested_rect) override;
// webview utilities
void load_url(wxString& url);
void ListBundles();
void OpenFolder(const std::string& id);
void DeleteBundle(const std::string& id);
@@ -59,11 +57,8 @@ public:
void OnPresetBundlePage();
// sends command to webview
void RunScript(const wxString& s);
// webview events
void OnScriptMessage(wxWebViewEvent& e);
void on_script_message(const nlohmann::json& payload) override;
void StartDialogWorker();
void StopDialogWorker();
@@ -86,7 +81,6 @@ protected:
bool m_recreate_GUI{false};
// Webview
wxWebView* m_browser{nullptr};
std::unordered_map<std::string, BundleMetadata> bundle_copy;
boost::thread m_dialog_worker_thread;

View File

@@ -231,6 +231,17 @@ bool ProgressDialog::Create(const wxString &title, const wxString &message, int
m_sizer_main->Add(m_gauge, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(28));
}
// Optional elapsed/estimated/remaining time labels, created only when the
// caller opts in via the wxPD_*_TIME style flags (so callers that don't set
// them are unaffected). Update()/Pulse() already refresh these once non-null.
if (HasPDFlag(wxPD_ELAPSED_TIME | wxPD_ESTIMATED_TIME | wxPD_REMAINING_TIME)) {
wxFlexGridSizer *sizer_times = new wxFlexGridSizer(2, FromDIP(2), FromDIP(8));
if (HasPDFlag(wxPD_ELAPSED_TIME)) m_elapsed = CreateLabel(GetElapsedLabel(), sizer_times);
if (HasPDFlag(wxPD_ESTIMATED_TIME)) m_estimated = CreateLabel(GetEstimatedLabel(), sizer_times);
if (HasPDFlag(wxPD_REMAINING_TIME)) m_remaining = CreateLabel(GetRemainingLabel(), sizer_times);
m_sizer_main->Add(sizer_times, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP | wxLEFT | wxRIGHT, FromDIP(12));
}
#ifdef __WXMSW__
//m_block_left = new wxWindow(m_gauge, wxID_ANY, wxPoint(0, 0), wxSize(FromDIP(2), PROGRESSDIALOG_GAUGE_SIZE.y * 2));
//m_block_left->SetBackgroundColour(PROGRESSDIALOG_DEF_BK);
@@ -490,6 +501,14 @@ wxStaticText *ProgressDialog::CreateLabel(const wxString &text, wxSizer *sizer)
wxStaticText *label = new wxStaticText(this, wxID_ANY, text);
wxStaticText *value = new wxStaticText(this, wxID_ANY, wxGetTranslation("unknown"));
// Match the message label's look so the times theme with the rest of the
// dialog: PROGRESSDIALOG_GREY_700 is a key in the dark-mode colour map, so
// UpdateDlgDarkUI() (called at the end of Create()) remaps it in dark mode.
for (wxStaticText *st : {label, value}) {
st->SetFont(::Label::Body_13);
st->SetForegroundColour(PROGRESSDIALOG_GREY_700);
}
// select placement most native or nice on target GUI
#if defined(__WXMSW__) || defined(__WXMAC__) || defined(__WXGTK20__)
// value and time centered in one row

View File

@@ -407,6 +407,12 @@ void WebView::RecreateAll()
for (auto webView : g_webviews) {
webView->SetUserAgent(wxString::Format("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) BBL-Slicer/v%s (%s) BBL-Language/%s",
Slic3r::GUI::wxGetApp().get_bbl_client_version(), dark ? "dark" : "light", language_code.mb_str()));
webView->Reload();
// A host-themed WebViewHostDialog re-themes in place (no reload). If it handles
// the event, skip the reload; legacy pages fall through and reload as before
// (their own dark.css swap re-themes them on reload).
wxCommandEvent evt(EVT_WEBVIEW_RECREATED);
evt.SetEventObject(webView);
if (!webView->GetEventHandler()->ProcessEvent(evt))
webView->Reload();
}
}

View File

@@ -2,6 +2,9 @@
#define slic3r_GUI_WebView_hpp_
#include <wx/webview.h>
#include <wx/event.h>
wxDECLARE_EVENT(EVT_WEBVIEW_RECREATED, wxCommandEvent);
class WebView
{

View File

@@ -3,6 +3,8 @@
#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>
@@ -10,8 +12,104 @@
#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;
}
// 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 host_theme_user_script()
{
const std::string style = "<style id=\"orca-host-theme-vars\">" + host_theme_vars_css() + "</style>";
return WebViewHostDialog::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);");
}
// 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::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(){";
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,
@@ -49,6 +147,11 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path,
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;
@@ -130,4 +233,33 @@ void WebViewHostDialog::on_script_message_event(wxWebViewEvent& event)
}
}
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(host_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

View File

@@ -33,6 +33,20 @@ public:
bool run_script(const wxString& script);
void call_web_handler(const nlohmann::json& payload, const wxString& handler = wxT("HandleStudio"));
// Wraps `markup` (an HTML fragment, usually a <style> block) in a document-start user
// script that inserts it once — guarded by element id `dom_id`, at `position` (an
// insertAdjacentHTML target such as "afterbegin"/"beforeend") — retrying via a
// MutationObserver until a root node exists. On WebView2 a document-start script can run
// before <html> exists (document.head and document.documentElement both null), so a bare
// insert would throw and silently never apply. `prelude` is emitted once before the
// injector (extra var/flag declarations); `on_inject` runs inside inject() after each
// successful insert. Both default to empty.
static std::string document_start_injector(const std::string& markup,
const char* dom_id,
const char* position,
const std::string& prelude = {},
const std::string& on_inject = {});
protected:
wxWebView* browser() const { return m_browser; }
@@ -45,8 +59,24 @@ protected:
virtual void on_script_message_parse_error(const wxString& payload, const std::exception& error);
virtual bool append_language_to_url() const { return true; }
// Registers all document-start user scripts: the shared host theme contract first,
// then subclass scripts from add_user_scripts(). Called ONCE, at creation. Live
// re-theme goes through apply_theme_live() (RunScript), not a re-registration —
// calling this again would append duplicate scripts.
void register_theme_user_scripts();
// Subclasses override to add page-specific document-start user scripts (e.g. the
// plugin bridge / unstyled-content defaults). Called AFTER the theme contract is
// added, by register_theme_user_scripts(). Default: none.
virtual void add_user_scripts() {}
// Pushes the current app theme into the already-loaded document without a reload
// (updates the injected :root variables and the data-orca-theme attribute).
void apply_theme_live();
private:
void on_script_message_event(wxWebViewEvent& event);
void on_webview_recreated(wxCommandEvent& event);
wxWebView* m_browser{nullptr};
};

View File

@@ -45,8 +45,13 @@
#include <Windows.h>
#endif
#if defined(__APPLE__)
#if !defined(_WIN32)
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#endif
#if defined(__APPLE__)
#include <uuid/uuid.h>
#endif
@@ -89,6 +94,60 @@ constexpr const char* SECRET_STORE_SERVICE = "OrcaSlicer/Auth";
constexpr const char* SECRET_STORE_USER = "orca_refresh_token";
constexpr std::chrono::seconds TOKEN_REFRESH_SKEW{900}; // 15 minutes
// Cross-process advisory lock serializing refresh-token rotation between Orca instances on
// this machine. The Supabase refresh token rotates on every use, so read -> spend -> write-back
// of the rotated successor must be one critical section across processes; otherwise a second
// instance can re-spend a token a peer already rotated, triggering `refresh_token_already_used`.
// Blocks until acquired (kernel sleep, no CPU spin). It cannot deadlock permanently because the
// refresh POST is bounded (http_post_token uses timeout_max) and both back-ends release
// automatically if the holder dies. Same machine only; cross-device concurrency is still
// governed by the server's reuse-detection grace window.
class InterProcessRefreshTokenLock
{
public:
explicit InterProcessRefreshTokenLock(const std::string& path)
{
if (path.empty()) { m_locked = true; return; } // no path -> proceed unsynchronized
#if defined(_WIN32)
const std::wstring name = L"Local\\OrcaTokenRefresh_" + std::to_wstring(std::hash<std::string>{}(path));
m_handle = ::CreateMutexW(nullptr, FALSE, name.c_str());
if (!m_handle) { m_locked = true; return; } // can't create -> don't block
const DWORD r = ::WaitForSingleObject(m_handle, INFINITE); // blocks, no spin
m_locked = (r == WAIT_OBJECT_0 || r == WAIT_ABANDONED); // ABANDONED: prior holder crashed
#else
m_fd = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600);
if (m_fd == -1) { m_locked = true; return; } // can't open -> don't block
struct flock fl{};
fl.l_type = F_WRLCK; fl.l_whence = SEEK_SET; fl.l_start = 0; fl.l_len = 0;
int rc;
do { rc = ::fcntl(m_fd, F_SETLKW, &fl); } while (rc == -1 && errno == EINTR); // blocks in kernel
m_locked = (rc != -1);
#endif
}
~InterProcessRefreshTokenLock()
{
#if defined(_WIN32)
if (m_handle) { if (m_locked) ::ReleaseMutex(m_handle); ::CloseHandle(m_handle); }
#else
if (m_fd != -1) ::close(m_fd); // closing the fd releases the lock we hold
#endif
}
bool locked() const { return m_locked; }
InterProcessRefreshTokenLock(const InterProcessRefreshTokenLock&) = delete;
InterProcessRefreshTokenLock& operator=(const InterProcessRefreshTokenLock&) = delete;
private:
bool m_locked = false;
#if defined(_WIN32)
HANDLE m_handle = nullptr;
#else
int m_fd = -1;
#endif
};
// Return a JSON field only when it is present as a string. Missing or non-string values normalize to empty.
std::string get_json_string_field(const json& j, const std::string& key)
{
@@ -1678,8 +1737,7 @@ RefreshResult OrcaCloudServiceAgent::refresh_now(const std::string& refresh_toke
}
auto worker = [this, refresh_token, reason]() {
(void) reason;
RefreshResult r = refresh_session_with_token(refresh_token);
RefreshResult r = refresh_session_with_token(refresh_token, reason);
refresh_running.store(false);
return r;
};
@@ -1699,20 +1757,39 @@ RefreshResult OrcaCloudServiceAgent::refresh_now(const std::string& refresh_toke
RefreshResult OrcaCloudServiceAgent::refresh_from_storage(const std::string& reason, bool async)
{
std::string refresh_token = get_refresh_token();
if (refresh_token.empty()) {
std::string user_secret;
if (load_user_secret(user_secret) && !user_secret.empty()) {
SessionInfo stored_session;
parse_stored_secret(user_secret, refresh_token, stored_session);
}
(void) async; // currently all callers are calling this function synchronous anyway
// Blocks until we own the lock (kernel sleep, no spin). Cannot deadlock permanently:
// the refresh POST is bounded (http_post_token timeout_max) and the lock auto-releases
// on process death, so no holder can keep it longer than that bound.
InterProcessRefreshTokenLock lock(token_lock_path());
if (!lock.locked()) {
// Lock syscall genuinely failed (rare). Proceed best-effort rather than give up.
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: token refresh lock unavailable, "
"proceeding unsynchronized (reason=" << reason << ")";
}
std::string refresh_token;
std::string user_secret;
// We read the refresh token from the OS keychain as the source of truth
if (load_user_secret(user_secret) && !user_secret.empty()) {
SessionInfo stored_session;
parse_stored_secret(user_secret, refresh_token, stored_session);
}
// We only read from memory if the read from OS keychain fails
if (refresh_token.empty())
refresh_token = get_refresh_token();
if (refresh_token.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no refresh token available for refresh (reason=" << reason << ")";
return RefreshResult::AuthRejected; // no persisted token: nothing to preserve
}
return refresh_now(refresh_token, reason, async);
// Synchronous: hold the lock across the network round-trip AND the write-back that
// set_user_session performs, so the rotation is atomic w.r.t. other instances.
return refresh_now(refresh_token, reason, /*async=*/false);
}
bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const std::string& reason)
@@ -1726,8 +1803,14 @@ bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const
if (!needs_refresh)
return true;
if (refresh_from_storage(reason, false) == RefreshResult::Success) return true;
// First attempt. refresh_from_storage blocks on the cross-process lock and reads the
// freshest token from the store, so cross-instance contention is already resolved here.
RefreshResult r = refresh_from_storage(reason, false);
if (r == RefreshResult::Success) return true;
if (r == RefreshResult::AuthRejected) return false; // definitive: retrying the same token can't help
// One retry, only for a transient network failure of the refresh POST (lost response,
// timeout, 429/5xx -> Transient). The retry re-acquires the lock and re-reads the store.
std::this_thread::sleep_for(std::chrono::milliseconds(750));
return refresh_from_storage(reason + "_retry", false) == RefreshResult::Success;
}
@@ -1746,7 +1829,23 @@ static RefreshResult classify_refresh_result(unsigned http_code, bool session_es
: RefreshResult::Transient; // 2xx but unusable body
}
RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::string& refresh_token)
// Best-effort extraction of GoTrue's "error_code" field (e.g. "refresh_token_already_used").
// Returns "" if the body is not a JSON object or lacks the field.
static std::string extract_error_code(const std::string& body)
{
const json j = json::parse(body, nullptr, false);
if (j.is_object())
return get_json_string_field(j, "error_code");
return "";
}
static long long now_epoch_seconds()
{
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::string& refresh_token, const std::string& reason)
{
std::string body = "{\"refresh_token\":\"" + refresh_token + "\"}";
std::string url = auth_base_url + auth_constants::TOKEN_PATH + "?grant_type=refresh_token";
@@ -1755,6 +1854,12 @@ RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::strin
// http_post_token sets http_code to 0 when the server could not be reached.
http_post_token(body, &response, &http_code, url);
const long long now_s = now_epoch_seconds();
const long long last_ok = last_refresh_success_epoch.load(std::memory_order_relaxed);
const long long since_last_ok = (last_ok == 0) ? -1 : (now_s - last_ok); // -1 = no success yet this process
const long long since_start = now_s - agent_start_epoch;
const std::string log_reason = reason.empty() ? "-" : reason;
bool established = false;
if (http_code >= 200 && http_code < 300) {
if (session_handler) {
@@ -1768,10 +1873,47 @@ RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::strin
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: token refresh parse exception - " << e.what();
}
}
if (established) {
last_refresh_success_epoch.store(now_s, std::memory_order_relaxed);
BOOST_LOG_TRIVIAL(info) << "[auth] event=refresh_ok reason=" << log_reason
<< " secs_since_last_success=" << since_last_ok;
}
} else {
// Diagnostics to classify a future refresh_token_already_used incident from logs alone
// - secs_since_last_success large (or -1 = none this process) => stale token beyond the
// reuse-grace window (the ">1h gap" residual).
// - stored_differs=true => another instance/device already rotated the token in the
// shared secret store (the multi-session residual).
const std::string error_code = extract_error_code(response);
// Only meaningful when the server actually rejected us (not a bare transport failure).
bool stored_present = false, stored_differs = false;
if (http_code >= 400) {
std::string stored_secret, stored_token;
SessionInfo ignored;
if (load_user_secret(stored_secret) && !stored_secret.empty()
&& parse_stored_secret(stored_secret, stored_token, ignored) && !stored_token.empty()) {
stored_present = true;
stored_differs = (stored_token != refresh_token);
}
}
std::string user_id_copy;
{
std::lock_guard<std::mutex> lock(session_mutex);
user_id_copy = session.user_id;
}
std::string truncated_response = response.size() > 200 ? response.substr(0, 200) + "..." : response;
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: token refresh failed - http_code=" << http_code
<< ", response_body=" << truncated_response;
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh_rejected http_code=" << http_code
<< " error_code=" << (error_code.empty() ? "-" : error_code)
<< " reason=" << log_reason
<< " stored_present=" << (stored_present ? "true" : "false")
<< " stored_differs=" << (stored_differs ? "true" : "false")
<< " secs_since_last_success=" << since_last_ok
<< " secs_since_start=" << since_start
<< " user_id=" << (user_id_copy.empty() ? "-" : user_id_copy)
<< " response_body=" << truncated_response;
}
return classify_refresh_result(http_code, established);
@@ -2240,6 +2382,9 @@ bool OrcaCloudServiceAgent::http_post_token(const std::string& body,
resp_body = body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
// Keep this timeout finite: refresh_from_storage holds a cross-process lock across
// this call, so an unbounded refresh POST would let one instance wedge token refresh
// for every other Orca instance on the machine.
.timeout_max(30)
.perform_sync();
@@ -3447,4 +3592,13 @@ int OrcaCloudServiceAgent::fetch_plugin_changelogs(const std::vector<std::string
return first_error;
}
std::string OrcaCloudServiceAgent::token_lock_path() const
{
if (config_dir.empty())
return {};
wxFileName lock(wxString::FromUTF8(config_dir.c_str()), "orca_refresh_token.lock");
lock.Normalize();
return lock.GetFullPath().ToStdString();
}
} // namespace Slic3r

View File

@@ -322,7 +322,7 @@ public:
bool refresh_if_expiring(std::chrono::seconds skew, const std::string& reason);
RefreshResult refresh_from_storage(const std::string& reason, bool async = false);
RefreshResult refresh_now(const std::string& refresh_token, const std::string& reason, bool async = false);
RefreshResult refresh_session_with_token(const std::string& refresh_token);
RefreshResult refresh_session_with_token(const std::string& refresh_token, const std::string& reason = "");
// Session state helpers. nickname is the human-facing UI label after provider fallback resolution.
bool set_user_session(const std::string& token,
@@ -385,6 +385,9 @@ private:
std::string map_to_json(const std::map<std::string, std::string>& map);
void json_to_map(const std::string& json, std::map<std::string, std::string>& map);
// Refresh token lock
std::string token_lock_path() const;
// Member variables - configuration
std::string log_dir;
std::string config_dir;
@@ -405,6 +408,12 @@ private:
SessionInfo session;
mutable std::mutex session_mutex;
// Refresh diagnostics (see docs/analysis/refresh_token_already_used.md). Epoch seconds so the
// refresh-failure log can report token staleness without holding a lock or logging any token.
std::atomic<long long> last_refresh_success_epoch{0}; // 0 = no success yet this process
const long long agent_start_epoch{std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()};
// Member variables - connection state
bool is_connected{false};
bool enable_track{false};