OrcaCloud
diff --git a/resources/web/homepage/js/home.js b/resources/web/homepage/js/home.js
index 3077dd5beb..188a9dded2 100644
--- a/resources/web/homepage/js/home.js
+++ b/resources/web/homepage/js/home.js
@@ -145,8 +145,36 @@ function HandleStudio( pVal )
}
}
+/*---- OrcaCloud opens as a native pane beside this page, so the shell only switches its
+ own cloud-mode state and asks the app to show or hide that pane (WebViewPanel::set_cloud_mode). ----*/
+var m_CloudMode=false;
+
+function SetCloudMode( bOn )
+{
+ if( m_CloudMode==bOn )
+ return;
+
+ m_CloudMode=bOn;
+ $("body").toggleClass("cloud-mode", bOn);
+}
+
+function GotoCloud()
+{
+ $(".BtnItem").removeClass("BtnItemSelected");
+ $(".BtnShortcut").addClass("BtnItemSelected");
+ SetCloudMode(true);
+ SendSimpleCommand("homepage_open_cloud");
+}
+
function GotoMenu( strMenu )
{
+ /*---- Any board in the right region means leaving the cloud pane. ----*/
+ if( m_CloudMode )
+ {
+ SetCloudMode(false);
+ SendSimpleCommand("homepage_close_cloud");
+ }
+
let MenuList=$(".BtnItem");
let nAll=MenuList.length;
diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp
index fa8bc811c3..5351ae4904 100644
--- a/src/slic3r/GUI/GUI_App.cpp
+++ b/src/slic3r/GUI/GUI_App.cpp
@@ -41,6 +41,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -325,6 +326,13 @@ namespace GUI {
class MainFrame;
+// The OrcaCloud agent behind the shared NetworkAgent, or null when networking/OrcaCloud is not
+// available (e.g. stealth mode or the network plugin still loading).
+static std::shared_ptr get_orca_cloud_agent(NetworkAgent* agent)
+{
+ return agent ? std::dynamic_pointer_cast(agent->get_cloud_agent()) : nullptr;
+}
+
void start_ping_test()
{
return;
@@ -2262,7 +2270,9 @@ void GUI_App::init_networking_callbacks()
if (return_code == 5) {
GUI::wxGetApp().CallAfter([this, provider = event.provider] {
BOOST_LOG_TRIVIAL(info) << "logout: login expired";
- this->request_user_logout(provider);
+ // Involuntary: the desktop lost its session on its own. Leave the webview's
+ // own session alone (guide §8).
+ this->request_user_logout(provider, /*user_initiated=*/false);
MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK);
if (msg_dlg.ShowModal() == wxOK) {
return;
@@ -5238,7 +5248,7 @@ void GUI_App::post_logout_to_webview(const std::string& provider)
}
}
-void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/)
+void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/, bool user_initiated)
{
if (m_agent && m_agent->is_user_login(provider)) {
m_agent->user_logout(true, provider);
@@ -5251,6 +5261,21 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO
}
if (provider == ORCA_CLOUD_PROVIDER) {
+ // Invalidate any handoff still in flight, so a token minted for the signed-out
+ // account is never loaded into the pane.
+ ++m_cloud_handoff_gen;
+ m_cloud_handoff_state.clear();
+
+ // An explicit user sign-out also ends the webview's own cloud session (guide §8).
+ // A session the desktop lost on its own must NOT do this: a 2FA user would otherwise
+ // loop through the code prompt on every desktop re-login.
+ if (user_initiated) {
+ if (auto orca = get_orca_cloud_agent(m_agent)) {
+ if (WebViewPanel* home = WebViewPanel::if_built())
+ home->cloud_signed_out(from_u8(orca->build_web_session_signout_url()));
+ }
+ }
+
/* delete old user settings */
bool transfer_preset_changes = false;
wxString header = _L("Some presets are modified.") + "\n" +
@@ -5372,7 +5397,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_logout";
- request_user_logout();
+ request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("get_orca_login_info") == 0) {
@@ -5387,7 +5412,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_bambu_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_bambu_logout";
- request_user_logout(BBL_CLOUD_PROVIDER);
+ request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("homepage_orca_login_or_register") == 0) {
@@ -5396,7 +5421,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_orca_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_orca_logout";
- request_user_logout(ORCA_CLOUD_PROVIDER);
+ request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("homepage_modeldepot") == 0) {
@@ -5408,6 +5433,20 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_openproject") == 0) {
this->request_open_project({});
}
+ else if (command_str.compare("homepage_open_cloud") == 0) {
+ // Deferred: set_cloud_mode() may create a second native webview, which must
+ // not run inside this webview's script-message callback.
+ CallAfter([this]() {
+ if (WebViewPanel* home = WebViewPanel::if_built())
+ home->set_cloud_mode(true, cloud_webview_handoff());
+ });
+ }
+ else if (command_str.compare("homepage_close_cloud") == 0) {
+ CallAfter([]() {
+ if (WebViewPanel* home = WebViewPanel::if_built())
+ home->set_cloud_mode(false);
+ });
+ }
else if (command_str.compare("get_recent_projects") == 0) {
if (mainframe) {
if (WebViewPanel* home = WebViewPanel::if_built()) {
@@ -5680,7 +5719,9 @@ void GUI_App::on_http_error(wxCommandEvent &evt)
if (!provider.empty() && m_agent->is_user_login(provider)) {
if (std::chrono::steady_clock::now() - m_last_401_error_time > 30s) {
BOOST_LOG_TRIVIAL(warning) << "logout: http error 401.";
- this->request_user_logout(provider);
+ // Involuntary: a 401 means the desktop session was rejected. Leave the
+ // webview's own session alone (guide §8).
+ this->request_user_logout(provider, /*user_initiated=*/false);
if (!m_show_http_error_msgdlg) {
MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK);
@@ -5824,6 +5865,93 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
}*/
}
+wxString GUI_App::cloud_webview_handoff()
+{
+ // The cloud pane loads the Start URL, not a token: the page checks the webview's own session
+ // and asks for a handoff itself (guide §4). Signed out, user_id is omitted and the page will
+ // post request_web_session, which is what opens the desktop login dialog.
+ auto orca = get_orca_cloud_agent(m_agent);
+ if (!orca)
+ return {};
+ return from_u8(orca->build_web_session_start_url());
+}
+
+void GUI_App::request_cloud_web_session(const std::string& state)
+{
+ if (state.empty())
+ return;
+
+ // Only the newest request counts: a newer state supersedes any mint or login still in flight.
+ m_cloud_handoff_state = state;
+ const uint64_t gen = ++m_cloud_handoff_gen;
+
+ auto orca = get_orca_cloud_agent(m_agent);
+ if (!orca || !orca->is_user_login()) {
+ // Desktop signed out: open the desktop login dialog and stop here. On success
+ // on_user_login_handle reloads the Start URL, which makes the page request again; on
+ // cancel nothing happens (the guide's "on cancel, do nothing"). The newest state is
+ // already stored, so a repeat request while the dialog is open is dropped rather than
+ // stacking another dialog.
+ if (!login_dlg || !login_dlg->IsShown())
+ request_login(true, ORCA_CLOUD_PROVIDER);
+ return;
+ }
+
+ mint_cloud_web_session(state, gen);
+}
+
+void GUI_App::mint_cloud_web_session(const std::string& state, uint64_t gen)
+{
+ auto orca = get_orca_cloud_agent(m_agent);
+ if (!orca)
+ return;
+
+ // The endpoint call blocks on network I/O (and possibly a cross-process token refresh), so
+ // it must not run on the UI thread. Hand off to a worker and marshal the token back.
+ boost::thread([this, orca, state, gen]() {
+ std::string token_hash;
+ std::string error_code;
+ unsigned int http_code = 0;
+ orca->request_web_session_handoff(token_hash, error_code, http_code);
+
+ if (is_closing())
+ return;
+
+ if (token_hash.empty()) {
+ // No token: 401 after retry, 409/429/503 or a transport error. The page owns its own
+ // "Sign-in link expired"/"Try again" UI; the desktop stays silent. Never log the body
+ // (it may echo account data), only the status/error code.
+ BOOST_LOG_TRIVIAL(warning) << "cloud web session handoff not minted - http_code=" << http_code
+ << " error_code=" << error_code;
+ return;
+ }
+
+ GUI::wxGetApp().CallAfter([this, state, gen, token_hash]() {
+ on_cloud_web_session_minted(state, gen, token_hash);
+ });
+ }).detach();
+}
+
+void GUI_App::on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash)
+{
+ if (is_closing())
+ return;
+ // Drop a result for a superseded request (a newer request_web_session, login or sign-out).
+ if (gen != m_cloud_handoff_gen || state != m_cloud_handoff_state)
+ return;
+ if (token_hash.empty())
+ return;
+
+ auto orca = get_orca_cloud_agent(m_agent);
+ if (!orca)
+ return;
+
+ // Load the Complete URL in the SAME webview that posted request_web_session, because the
+ // state lives in that webview's session storage (guide §3/§7).
+ if (WebViewPanel* home = WebViewPanel::if_built())
+ home->load_cloud_url(from_u8(orca->build_web_session_complete_url(token_hash, state)));
+}
+
void GUI_App::on_user_login_handle(wxCommandEvent &evt)
{
if (!m_agent) { return; }
@@ -5879,6 +6007,16 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt)
if (app_config->get("sync_user_preset") == "true") {
start_sync_user_preset();
}
+
+ // Re-seed the embedded OrcaCloud pane if it is already open, so it picks up the new session
+ // without waiting for the next open. On a fresh login or account switch this invalidates any
+ // in-flight handoff for the previous account and loads the Start URL with the new user_id, so
+ // the page drops the old account and re-requests (guide §8).
+ ++m_cloud_handoff_gen;
+ m_cloud_handoff_state.clear();
+ if (WebViewPanel* home = WebViewPanel::if_built())
+ if (home->cloud_mode_active())
+ home->set_cloud_mode(true, cloud_webview_handoff());
}
@@ -8012,8 +8150,11 @@ void GUI_App::on_stealth_mode_enter()
{
stop_sync_user_preset();
BOOST_LOG_TRIVIAL(info) << "logout: on_stealth_mode_enter";
- request_user_logout(ORCA_CLOUD_PROVIDER);
- request_user_logout(BBL_CLOUD_PROVIDER);
+ // Stealth mode forces a desktop logout, but the user did not choose "Sign out": guide §8 ties
+ // ?signout=1 to the explicit desktop sign-out only. Leave the cloud webview's own session
+ // alone, so it is re-checked (and re-seeded with the right account) on the next open.
+ request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/false);
+ request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/false);
if (WebViewPanel* home = WebViewPanel::if_built()) {
home->SendCloudProvidersInfo();
}
diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp
index 0dcdc8e36e..a40c4398d1 100644
--- a/src/slic3r/GUI/GUI_App.hpp
+++ b/src/slic3r/GUI/GUI_App.hpp
@@ -361,6 +361,11 @@ private:
bool m_side_popup_status{false};
bool m_show_http_error_msgdlg{false};
std::chrono::steady_clock::time_point m_last_401_error_time;
+ // OrcaCloud webview session handoff: the newest request_web_session state from the cloud pane
+ // and a generation counter so a superseded mint/login result is dropped (guide §5, "only the
+ // newest request counts").
+ std::string m_cloud_handoff_state;
+ uint64_t m_cloud_handoff_gen { 0 };
bool m_show_error_msgdlg{false};
wxString m_info_dialog_content;
HttpServer m_http_server;
@@ -540,7 +545,11 @@ public:
void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER);
void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER);
- void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER);
+ // `user_initiated` distinguishes a user sign-out from the desktop losing its session on its
+ // own (refresh failed, 401). Only an explicit sign-out also ends the cloud webview's own
+ // session; an involuntary one leaves the webview alone (guide §8). Defaults to the safe
+ // direction: a new caller that forgets the flag will not silently sign the webview out.
+ void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER, bool user_initiated = false);
void post_logout_to_webview(const std::string& provider);
int request_user_unbind(std::string dev_id, const std::string& provider = ORCA_CLOUD_PROVIDER);
std::string handle_web_request(std::string cmd);
@@ -556,6 +565,15 @@ public:
void on_update_machine_list(wxCommandEvent& evt);
void on_user_login(wxCommandEvent &evt);
void on_user_login_handle(wxCommandEvent& evt);
+ // OrcaCloud webview session handoff (guide §5/§7). cloud_webview_handoff builds the cloud
+ // pane's Start URL (with the desktop user_id while signed in). request_cloud_web_session is
+ // called from the pane when the page asks for a session; it opens the desktop login dialog if
+ // signed out, otherwise mints a one-time token on a worker thread and loads the Complete URL
+ // in the same webview.
+ wxString cloud_webview_handoff();
+ void request_cloud_web_session(const std::string& state);
+ void mint_cloud_web_session(const std::string& state, uint64_t gen);
+ void on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash);
void enable_user_preset_folder(bool enable);
// BBS
diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp
index 27da3efce8..cca6bebe0b 100644
--- a/src/slic3r/GUI/Preferences.cpp
+++ b/src/slic3r/GUI/Preferences.cpp
@@ -645,7 +645,7 @@ wxBoxSizer *PreferencesDialog::create_item_region_combobox(wxString title, wxStr
combobox->SetSelection(current_region);
return;
} else {
- wxGetApp().request_user_logout();
+ wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
config->set("region", region.ToStdString());
auto area = config->get_country_code();
if (agent) {
@@ -2481,7 +2481,7 @@ wxBoxSizer* PreferencesDialog::create_debug_page()
AppConfig* config = GUI::wxGetApp().app_config;
std::string country_code = config->get_country_code();
if (agent) {
- wxGetApp().request_user_logout();
+ wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
agent->set_country_code(country_code);
}
ConfirmBeforeSendDialog confirm_dlg(this, wxID_ANY, _L("Warning"), ConfirmBeforeSendDialog::VisibleButtons::ONLY_CONFIRM); // ORCA VisibleButtons instead ButtonStyle
diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp
index 0c799c9d47..8336b28607 100644
--- a/src/slic3r/GUI/WebGuideDialog.cpp
+++ b/src/slic3r/GUI/WebGuideDialog.cpp
@@ -1,4 +1,5 @@
#include "WebGuideDialog.hpp"
+#include "CloudProvider.hpp"
#include "ConfigWizard.hpp"
#include
@@ -755,7 +756,7 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt)
if (wxGetApp().is_user_login()) {
BOOST_LOG_TRIVIAL(info) << "logout: user_logout on user_guide_finish";
// agent->user_logout();
- wxGetApp().request_user_logout();
+ wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
}
}
}
diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp
index 104c42520c..fe7bd0fdfb 100644
--- a/src/slic3r/GUI/WebViewDialog.cpp
+++ b/src/slic3r/GUI/WebViewDialog.cpp
@@ -36,12 +36,17 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+
+#ifdef __linux__
+#include
+#endif
#include
namespace pt = boost::property_tree;
@@ -55,6 +60,13 @@ namespace GUI {
#define LOGIN_INFO_UPDATE_TIMER_ID 10002
+// OrcaCloud is a native pane of its own: the site refuses to be framed
+// (x-frame-options: DENY / CSP frame-ancestors 'none').
+static const char *const kCloudUrl = "https://cloud.orcaslicer.com";
+// #LeftBoard in resources/web/homepage/css/home.css is a 262px column plus a 1px right
+// border; the homepage keeps that width as the nav column while the cloud pane is open.
+static const int kLeftNavWidth = 263;
+
// The home page's strings (resources/web/homepage), keyed by their data-i18n and T() ids.
static json home_page_strings()
{
@@ -136,7 +148,11 @@ WebViewPanel::WebViewPanel(wxWindow *parent)
m_reset_on_show = WebView::NeedsRecreateOnShow();
SetSizer(topsizer);
- topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
+ // The homepage shell is the left nav column; when OrcaCloud opens, its page lives in a
+ // second native pane beside it (the site refuses to be framed).
+ m_content_sizer = new wxBoxSizer(wxHORIZONTAL);
+ m_content_sizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
+ topsizer->Add(m_content_sizer, wxSizerFlags().Expand().Proportion(1));
// Log backend information
/* m_browser->GetUserAgent() may lead crash
@@ -292,12 +308,282 @@ void WebViewPanel::create_browser()
m_browser->Hide();
}
+// The cloud page gets its own native view, created on first use and kept alive afterwards
+// so its session and scroll position survive hiding.
+void WebViewPanel::create_cloud_browser()
+{
+ m_cloud_browser = WebView::CreateWebView(this, wxEmptyString);
+
+#ifdef __linux__
+ // The pane's web session is a cookie (guide §3), and WebKitGTK keeps cookies only in memory
+ // unless a persistent store is set up, so without this the sign-in is lost on every restart
+ // (and on every reset_browser). Each wxWebView has its own context, so give the cloud pane its
+ // own store instead of sharing PrinterWebView's. Mirrors PrinterWebView::create_browser.
+ if (auto* wv = static_cast(m_cloud_browser->GetNativeBackend())) {
+ const auto cookies_path = boost::filesystem::path(data_dir() + "/cache/cloud_cookies.db");
+ auto* cookie_manager = webkit_web_context_get_cookie_manager(webkit_web_view_get_context(wv));
+ webkit_cookie_manager_set_persistent_storage(cookie_manager, cookies_path.c_str(),
+ WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
+ }
+#endif
+
+ // Webview events are command events, so they would otherwise propagate up to the
+ // panel's handlers, which drive the homepage view. Keep the cloud pane's events here.
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATING, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_TITLE_CHANGED, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &WebViewPanel::OnCloudWebViewEvent, this);
+ m_cloud_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebViewPanel::OnCloudWebViewEvent, this);
+
+ m_cloud_browser->Hide();
+ if (m_content_sizer)
+ m_content_sizer->Add(m_cloud_browser, wxSizerFlags().Expand().Proportion(1));
+}
+
+// The scheme://host[:port] origin of an http(s) URL, lower-cased and with the default port
+// dropped, or empty for any other scheme or a malformed URL. Comparing origins (not a string
+// prefix) is what keeps a lookalike host such as "https://cloud.orcaslicer.com.evil.com" out.
+static wxString http_origin_of(const wxString& url)
+{
+ const wxURI uri(url);
+ if (!uri.HasScheme() || !uri.HasServer())
+ return {};
+ const wxString scheme = uri.GetScheme().Lower();
+ if (scheme != "http" && scheme != "https")
+ return {};
+ wxString origin = scheme + "://" + uri.GetServer().Lower();
+ const wxString port = uri.GetPort();
+ if (!port.IsEmpty() && port != (scheme == "https" ? "443" : "80"))
+ origin += ":" + port;
+ return origin;
+}
+
+// Whether a NAVIGATING event will replace the pane's top-level document rather than load a
+// subframe. Edge and macOS report the target frame; WebKitGTK does not (IsTargetMainFrame() is
+// always false there), so a user-gesture navigation stands in: a page's own embedded frames load
+// without a gesture and stay put, while a user following a link to another site does not.
+static bool cloud_navigation_is_top_level(const wxWebViewEvent& evt)
+{
+#ifdef __WXGTK__
+ return evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER;
+#else
+ return evt.IsTargetMainFrame();
+#endif
+}
+
+// Sends an off-site navigation to the system browser instead of embedding it. Deferred: this runs
+// inside a native webview navigation callback, so launching must not re-enter it.
+static void open_cloud_url_externally(const wxString& url)
+{
+ if (url.empty())
+ return;
+ BOOST_LOG_TRIVIAL(info) << "cloud pane: opening externally " << WebView::RedactUrl(url).ToUTF8().data();
+ wxGetApp().CallAfter([url] { wxLaunchDefaultBrowser(url); });
+}
+
+// Consumed on the cloud view so it never reaches the homepage handlers. {web} pages stay in the
+// pane; every other site (OAuth providers included) opens in the system browser (guide §3).
+void WebViewPanel::OnCloudWebViewEvent(wxWebViewEvent& evt)
+{
+ const wxEventType event_type = evt.GetEventType();
+ if (event_type == wxEVT_WEBVIEW_NAVIGATING) {
+ const wxString url = evt.GetURL();
+ // Guide §3: keep every {web} page in the pane and open other sites (OAuth providers
+ // included) in the system browser. Only top-level navigations are diverted, and
+ // should_divert_cloud_url() excludes same-origin URLs and internal schemes.
+ if (should_divert_cloud_url(url) && cloud_navigation_is_top_level(evt)) {
+ evt.Veto();
+ open_cloud_url_externally(url);
+ }
+ } else if (event_type == wxEVT_WEBVIEW_NEWWINDOW) {
+ const wxString url = evt.GetURL();
+ if (!url.empty()) {
+ if (is_cloud_origin(url))
+ load_cloud_url(url);
+ else
+ open_cloud_url_externally(url);
+ }
+ evt.Veto();
+ } else if (event_type == wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED) {
+ on_cloud_script_message(evt.GetString().utf8_string());
+ } else if (event_type == wxEVT_WEBVIEW_LOADED || event_type == wxEVT_WEBVIEW_ERROR) {
+ // A Start URL deferred behind a sign-out (see load_cloud_after_signout).
+ if (!m_cloud_after_signout_url.IsEmpty()) {
+ const wxString next = std::exchange(m_cloud_after_signout_url, wxString());
+ load_cloud_url(next);
+ }
+ }
+}
+
+// The page asks for a web session by posting JSON through window.wx; only the newest request
+// counts (tracked in GUI_App). Nothing here touches the UI directly: the desktop login dialog and
+// the network mint must not run on the webview's message-dispatch stack.
+void WebViewPanel::on_cloud_script_message(const std::string& body)
+{
+ const json msg = json::parse(body, nullptr, false);
+ if (msg.is_discarded() || !msg.is_object() || msg.value("command", std::string()) != "request_web_session")
+ return;
+
+ std::string state;
+ if (msg.contains("data") && msg["data"].is_object())
+ state = msg["data"].value("state", std::string());
+ if (state.empty())
+ return;
+
+ wxGetApp().CallAfter([state = std::move(state)] { wxGetApp().request_cloud_web_session(state); });
+}
+
+void WebViewPanel::apply_cloud_layout(bool cloud_on)
+{
+ if (!m_content_sizer)
+ return;
+
+ // Cloud mode pins the homepage to the nav column width; otherwise it fills the panel.
+ if (m_browser) {
+ if (wxSizerItem* item = m_content_sizer->GetItem(m_browser))
+ item->SetProportion(cloud_on ? 0 : 1);
+ // kLeftNavWidth is in DIP: the web view's CSS px are DIP, so scale to physical pixels.
+ m_browser->SetMinSize(cloud_on ? m_browser->FromDIP(wxSize(kLeftNavWidth, -1)) : wxDefaultSize);
+ }
+ Layout();
+}
+
+void WebViewPanel::load_cloud_url(const wxString& url)
+{
+ if (url.empty())
+ return;
+ // Track the origin of whatever the pane is told to load (Start URL, Complete URL, sign-out or
+ // the plain cloud fallback), so navigation policy has the right origin from the first load on.
+ const wxString origin = http_origin_of(url);
+ if (!origin.IsEmpty())
+ m_cloud_base_url = origin;
+ if (!m_cloud_browser)
+ return;
+ // Raw LoadURL: WebView::LoadUrl re-serialises through wxURI, which must not alter the
+ // fragment that carries the one-time handoff token. Log only the redacted URL.
+ BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(url).ToUTF8().data();
+ m_cloud_browser->LoadURL(url);
+}
+
+bool WebViewPanel::is_cloud_origin(const wxString& url) const
+{
+ const wxString origin = http_origin_of(url);
+ return !origin.IsEmpty() && !m_cloud_base_url.IsEmpty() && origin == m_cloud_base_url;
+}
+
+bool WebViewPanel::should_divert_cloud_url(const wxString& url) const
+{
+ const wxString origin = http_origin_of(url);
+ // Unknown cloud origin or a non-web scheme: never divert (the pane must keep working).
+ if (origin.IsEmpty() || m_cloud_base_url.IsEmpty())
+ return false;
+ return origin != m_cloud_base_url;
+}
+
+// Explicit sign-out ends the pane's own web session. That session is a cookie JS cannot clear
+// (HttpOnly, guide §3/§8) and ClearHistory() is a no-op on WebKitGTK, so drop the pane's stored
+// data natively. wx 3.3.2 ignores the type mask on every backend (any non-zero mask clears
+// everything) and needs an invalid `since` to delete cookies, so the default "all" call is used.
+void WebViewPanel::clear_cloud_storage()
+{
+ if (m_cloud_browser)
+ m_cloud_browser->ClearBrowsingData(wxWEBVIEW_BROWSING_DATA_ALL);
+}
+
+void WebViewPanel::load_cloud_after_signout(const wxString& url)
+{
+ if (m_cloud_pending_signout && !m_cloud_signout_url.IsEmpty()) {
+ // The pane did not exist when the user signed out, so its persisted session must be
+ // cleared before the sign-out page runs and `url` loads once that navigation finishes
+ // (see OnCloudWebViewEvent).
+ m_cloud_pending_signout = false;
+ m_cloud_after_signout_url = url;
+ clear_cloud_storage();
+ load_cloud_url(m_cloud_signout_url);
+ return;
+ }
+ load_cloud_url(url);
+}
+
+void WebViewPanel::schedule_cloud_load(const wxString& url)
+{
+ if (!m_cloud_browser) {
+ load_cloud_after_signout(url);
+ return;
+ }
+ // CreateWebView queues the "wx" handler add on the view (macOS/Linux); queue the load behind
+ // it so the page's first scripts already see the bridge (guide §3).
+ m_cloud_browser->CallAfter([this, url]() { load_cloud_after_signout(url); });
+}
+
+void WebViewPanel::cloud_signed_out(const wxString& signout_url)
+{
+ if (signout_url.empty())
+ return;
+ m_cloud_signout_url = signout_url;
+ // The next Start URL must re-seed the pane (it now reflects the signed-out state), so forget
+ // the previous seed regardless of which branch below runs.
+ m_cloud_seed_url.clear();
+ if (m_cloud_browser) {
+ m_cloud_pending_signout = false;
+ // Run the sign-out page (ends the server-side session), then clear the pane's stored data.
+ load_cloud_url(signout_url);
+ clear_cloud_storage();
+ } else {
+ // Pane never opened this run: clear the persisted session on its first open.
+ m_cloud_pending_signout = true;
+ }
+}
+
+void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
+{
+ if (on) {
+ // load_cloud_url records the origin from whatever it loads, including the plain cloud
+ // fallback used when `url` is empty.
+ if (!m_cloud_browser) {
+ create_cloud_browser();
+ m_cloud_seed_url = url;
+ schedule_cloud_load(url.empty() ? wxString(kCloudUrl) : url);
+ } else if (!url.empty() && url != m_cloud_seed_url) {
+ // Re-seed only when the Start URL actually changed — a login, an account switch or a
+ // sign-out changes it; an ordinary re-open does not (guide §8). Reloading an unchanged
+ // Start URL re-runs the page's auth check on every open, which flashes "Signing you
+ // in…" and re-requests a session (guide §10: mint when the page asks, not per open).
+ // The pane is kept alive, so an unchanged open just shows the page it already holds.
+ m_cloud_seed_url = url;
+ load_cloud_after_signout(url);
+ }
+ m_cloud_browser->Show();
+ } else if (m_cloud_browser) {
+ // Closing keeps the pane alive with its session, so re-opening does not have to sign in
+ // again (guide §10). Nothing is cleared here.
+ m_cloud_browser->Hide();
+ }
+ apply_cloud_layout(on && m_cloud_browser != nullptr);
+}
+
void WebViewPanel::reset_browser()
{
+ // Rebuild both views the same way, keeping the cloud pane's open/closed state.
+ const bool cloud_on = m_cloud_browser && m_cloud_browser->IsShown();
+
m_browser->Destroy(); // also removes it from the sizer
create_browser();
- GetSizer()->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
- Layout();
+ m_content_sizer->Insert(size_t(0), m_browser, wxSizerFlags().Expand().Proportion(1));
+
+ if (m_cloud_browser) {
+ m_cloud_browser->Destroy();
+ m_cloud_browser = nullptr;
+ create_cloud_browser();
+ schedule_cloud_load(wxString(kCloudUrl));
+ m_cloud_browser->Show(cloud_on);
+ // The rebuilt pane holds no seed, so the next open loads the Start URL again.
+ m_cloud_seed_url.clear();
+ }
+
+ apply_cloud_layout(cloud_on);
}
bool WebViewPanel::Show(bool show)
@@ -647,7 +933,7 @@ void WebViewPanel::update_mode()
*/
void WebViewPanel::OnNavigationRequest(wxWebViewEvent& evt)
{
- BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
+ BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
const wxString &url = evt.GetURL();
if (url.StartsWith("File://") || url.StartsWith("file://")) {
if (!url.Contains("/web/homepage/index.html")) {
@@ -693,7 +979,7 @@ void WebViewPanel::OnNavigationComplete(wxWebViewEvent& evt)
{
m_browser->Show();
Layout();
- BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
+ BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
if (wxGetApp().get_mode() == comDevelop)
wxLogMessage("%s", "Navigation complete; url='" + evt.GetURL() + "'");
UpdateState();
@@ -712,6 +998,9 @@ void WebViewPanel::OnDocumentLoaded(wxWebViewEvent& evt)
if (wxGetApp().get_mode() == comDevelop)
wxLogMessage("%s", "Document loaded; url='" + evt.GetURL() + "'");
}
+ // A reloaded homepage loses its cloud-mode class; restore it if the pane is open.
+ if (m_cloud_browser && m_cloud_browser->IsShown())
+ RunScript("SetCloudMode(true)");
UpdateState();
SendCloudProvidersInfo();
}
@@ -727,7 +1016,7 @@ void WebViewPanel::OnTitleChanged(wxWebViewEvent &evt)
*/
void WebViewPanel::OnNewWindow(wxWebViewEvent& evt)
{
- BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
+ BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
wxString flag = " (other)";
if (evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER)
diff --git a/src/slic3r/GUI/WebViewDialog.hpp b/src/slic3r/GUI/WebViewDialog.hpp
index 8a4ecbf35d..07f719331a 100644
--- a/src/slic3r/GUI/WebViewDialog.hpp
+++ b/src/slic3r/GUI/WebViewDialog.hpp
@@ -28,6 +28,7 @@
#include "wx/fs_mem.h"
#include "wx/stdpaths.h"
#include
+#include
#include
#include "wx/textctrl.h"
#include
@@ -114,14 +115,62 @@ public:
void update_mode();
+ // Shows/hides the native OrcaCloud pane beside this homepage nav column. `url` is the
+ // cloud Start URL ({web}/auth/handoff?next=…&user_id=…), which the page uses to decide
+ // whether it already has a session; empty falls back to the plain cloud page.
+ void set_cloud_mode(bool on, const wxString& url = wxString());
+
+ // True while the OrcaCloud pane exists and is visible.
+ bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); }
+
+ // Loads `url` in the cloud pane — the webview that owns the session handoff. Raw LoadURL (no
+ // wxURI re-serialisation) so the Complete URL's #fragment stays intact; never logged.
+ void load_cloud_url(const wxString& url);
+
+ // Clears the cloud pane's own session on an explicit desktop sign-out. If the pane has not
+ // been created yet, the sign-out is remembered and applied when it is first opened.
+ void cloud_signed_out(const wxString& signout_url);
+
bool Show(bool show = true) override;
private:
void create_browser();
+ void create_cloud_browser();
void reset_browser();
+ // Consumes the cloud pane's webview events so they stay off the homepage handlers.
+ void OnCloudWebViewEvent(wxWebViewEvent& evt);
+ // True when `url` has the same scheme/host/port as the cloud origin ({web}).
+ bool is_cloud_origin(const wxString& url) const;
+ // True when a navigation to `url` must leave the pane: an http(s) URL on another origin than
+ // the (known) cloud origin. False for non-web schemes and while the origin is unknown.
+ bool should_divert_cloud_url(const wxString& url) const;
+ // Parses a request_web_session message and defers it to GUI_App.
+ void on_cloud_script_message(const std::string& body);
+ // Loads `url`, first clearing a lazily-required sign-out (see cloud_signed_out).
+ void load_cloud_after_signout(const wxString& url);
+ // First load of a freshly created pane: deferred so CreateWebView's queued "wx" handler add
+ // runs first and the page sees window.wx at load time (guide §3).
+ void schedule_cloud_load(const wxString& url);
+ // Clears the pane's stored web data (its cookie-backed session) on an explicit sign-out.
+ void clear_cloud_storage();
+ // Re-applies the content sizer's proportions and min sizes for the cloud state.
+ void apply_cloud_layout(bool cloud_on);
wxWebView* m_browser;
+ wxWebView* m_cloud_browser{nullptr};
+ wxBoxSizer* m_content_sizer{nullptr};
wxString m_home_url;
bool m_reset_on_show{false};
+ // {web} origin (scheme://host[:port]) of the loaded pane. Set from every load, including the
+ // plain cloud fallback, so navigation policy can keep {web} pages in the pane and send other
+ // sites to the system browser.
+ wxString m_cloud_base_url;
+ // Start URL the pane was last seeded with. An open whose URL matches this is an ordinary
+ // re-open (nothing changed), so it only shows the pane instead of reloading it (guide §10).
+ wxString m_cloud_seed_url;
+ // Sign-out clear the pane must apply once it exists / next loads.
+ wxString m_cloud_signout_url;
+ wxString m_cloud_after_signout_url;
+ bool m_cloud_pending_signout{false};
wxButton * m_button_stop;
wxTextCtrl *m_url;
#if !BBL_RELEASE_TO_PUBLIC
diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp
index b8c54aace1..7af3ba4f1d 100644
--- a/src/slic3r/GUI/Widgets/WebView.cpp
+++ b/src/slic3r/GUI/Widgets/WebView.cpp
@@ -444,6 +444,12 @@ bool WebView::DownloadAndInstallWebViewRuntime()
return DownloadAndInstallWV2RT() == 0;
}
#endif
+wxString WebView::RedactUrl(const wxString& url)
+{
+ const size_t cut = url.find_first_of(wxString("?#"));
+ return cut == wxString::npos ? url : url.Left(cut);
+}
+
void WebView::LoadUrl(wxWebView * webView, wxString const &url)
{
auto url2 = url;
@@ -451,7 +457,7 @@ void WebView::LoadUrl(wxWebView * webView, wxString const &url)
url2.Replace("\\", "/");
#endif
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
- BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << url2.ToUTF8();
+ BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << RedactUrl(url2).ToUTF8();
webView->LoadURL(url2);
}
diff --git a/src/slic3r/GUI/Widgets/WebView.hpp b/src/slic3r/GUI/Widgets/WebView.hpp
index cdd885f9bd..bef8a4a101 100644
--- a/src/slic3r/GUI/Widgets/WebView.hpp
+++ b/src/slic3r/GUI/Widgets/WebView.hpp
@@ -21,6 +21,10 @@ public:
#endif
static void LoadUrl(wxWebView * webView, wxString const &url);
+ // Returns the URL truncated at the first '?' or '#', so a query/fragment carrying secrets
+ // (e.g. a session-handoff token) is never written to a log.
+ static wxString RedactUrl(const wxString& url);
+
static bool RunScript(wxWebView * webView, wxString const & msg);
// Marks "wx" as registered so CreateWebView's deferred add skips the duplicate.
diff --git a/src/slic3r/Utils/Http.cpp b/src/slic3r/Utils/Http.cpp
index 1db14c6686..c09c22fe3f 100644
--- a/src/slic3r/Utils/Http.cpp
+++ b/src/slic3r/Utils/Http.cpp
@@ -485,6 +485,14 @@ void Http::priv::http_perform()
if (!postfields.empty()) {
::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str());
::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, postfields.size());
+ } else if (method == "POST" && form == nullptr && mime == nullptr) {
+ // A POST with no body. Without CURLOPT_POSTFIELDS, curl takes the request body from
+ // the read callback, but form_file_read_cb only has a usable user pointer for a PUT
+ // (set_put_body) or a streamed multipart part (form_add_file); for an empty POST it
+ // dereferences libcurl's default READDATA as a form_file and crashes. Point curl at a
+ // zero-length body so the read callback is never used.
+ ::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str());
+ ::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(0));
}
CURLcode res = ::curl_easy_perform(curl);
diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp
index e299a2f16d..f5f7e30d1d 100644
--- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp
+++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp
@@ -109,6 +109,10 @@ constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers";
constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login";
+// Gateway endpoint that mints a one-time web-session handoff token for the signed-in desktop
+// user. The cloud pane reloads the returned token into the webview (see build_web_session_*).
+constexpr const char* ORCA_WEB_SESSION_HANDOFF_PATH = "/api/v1/account/web-session-handoff";
+
constexpr const char* CONFIG_ORCA_API_URL = "orca_api_url";
constexpr const char* CONFIG_ORCA_AUTH_URL = "orca_auth_url";
constexpr const char* CONFIG_ORCA_CLOUD_URL = "orca_cloud_url";
@@ -3019,6 +3023,148 @@ std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& langua
return url;
}
+// Percent-encodes every byte outside the RFC 3986 unreserved set (A-Za-z0-9-._~), so token values
+// survive fragment parsing intact. Deliberately tiny: no URL-encoder dependency.
+static std::string percent_encode_unreserved(const std::string& s)
+{
+ static const char* const hex = "0123456789ABCDEF";
+ std::string out;
+ out.reserve(s.size());
+ for (unsigned char c : s) {
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
+ c == '-' || c == '.' || c == '_' || c == '~') {
+ out.push_back(static_cast(c));
+ } else {
+ out.push_back('%');
+ out.push_back(hex[c >> 4]);
+ out.push_back(hex[c & 0x0F]);
+ }
+ }
+ return out;
+}
+
+std::string OrcaCloudServiceAgent::build_web_session_start_url(const std::string& next) const
+{
+ if (cloud_base_url.empty())
+ return "";
+
+ // The page replaces a `next` that is not a same-site path with /app/profiles, so pass a
+ // same-site path (the guide's default) and let it validate.
+ std::string url = cloud_base_url + "/auth/handoff?next=" +
+ percent_encode_unreserved(next.empty() ? "/app/profiles" : next);
+
+ std::lock_guard lock(session_mutex);
+ if (session.logged_in && !session.user_id.empty())
+ url += "&user_id=" + percent_encode_unreserved(session.user_id);
+ return url;
+}
+
+std::string OrcaCloudServiceAgent::build_web_session_complete_url(const std::string& token_hash,
+ const std::string& state) const
+{
+ if (cloud_base_url.empty() || token_hash.empty() || state.empty())
+ return "";
+
+ // Fragment (not query) so the one-time token never reaches a server or a log.
+ return cloud_base_url + "/auth/handoff#token_hash=" + percent_encode_unreserved(token_hash) +
+ "&state=" + percent_encode_unreserved(state);
+}
+
+std::string OrcaCloudServiceAgent::build_web_session_signout_url() const
+{
+ if (cloud_base_url.empty())
+ return "";
+ return cloud_base_url + "/auth/handoff?signout=1";
+}
+
+// Extracts the machine-readable code from a handoff error body. 401 uses {"error":""};
+// the other statuses use {"error":{"code":"","message":"..."}}.
+static std::string handoff_error_code(const std::string& body)
+{
+ const json j = json::parse(body, nullptr, false);
+ if (j.is_discarded() || !j.is_object() || !j.contains("error"))
+ return "";
+ const auto& err = j["error"];
+ if (err.is_string())
+ return err.get();
+ if (err.is_object() && err.contains("code") && err["code"].is_string())
+ return err["code"].get();
+ return "";
+}
+
+int OrcaCloudServiceAgent::request_web_session_handoff(std::string& token_hash, std::string& error_code,
+ unsigned int& http_code)
+{
+ token_hash.clear();
+ error_code.clear();
+ http_code = 0;
+
+ const std::string url = api_base_url + ORCA_WEB_SESSION_HANDOFF_PATH;
+ BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << ORCA_WEB_SESSION_HANDOFF_PATH;
+
+ // Refresh the desktop token the usual way before minting (newest access token).
+ ensure_token_fresh("web_session_handoff");
+
+ auto perform = [&]() {
+ HttpResult result;
+ try {
+ auto http = Http::post(url);
+ http.tls_verify(true);
+
+ const std::string token = get_access_token();
+
+ auto headers = data_headers();
+ for (const auto& pair : headers) {
+ http.header(pair.first, pair.second);
+ }
+ if (!token.empty()) {
+ http.header("Authorization", "Bearer " + token);
+ }
+
+ // No request body: the endpoint identifies the user by the bearer token alone.
+ http.on_complete([&](std::string resp_body, unsigned resp_status) {
+ result.success = true;
+ result.status = resp_status;
+ result.body = resp_body;
+ })
+ .on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
+ result.success = false;
+ result.status = resp_status == 0 ? 404 : resp_status;
+ result.body = resp_body;
+ BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff HTTP error - " << error;
+ })
+ .timeout_max(30)
+ .perform_sync();
+
+ } catch (const std::exception& e) {
+ BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff exception - " << e.what();
+ }
+ return result;
+ };
+
+ HttpResult res = perform();
+ // Reuse the shared 401 policy (refresh once, then retry), exactly like http_get/http_post:
+ // `suppress` is true for a transient refresh failure (keep the session and token, no logout)
+ // and false when the refresh token was rejected — so a 401 that remains after the refresh
+ // surfaces through the generic HTTP-error callback and drives the normal desktop logout.
+ // Every other handoff status (409/429/503/transport) stays silent here, so the page can
+ // offer "Try again" instead of the desktop showing a generic cloud error.
+ const bool suppress = resolve_unauthorized(res, perform, "web_session_handoff");
+ if (!suppress && res.status == 401)
+ invoke_http_error_callback(res.status, res.body);
+
+ http_code = res.status;
+ if (res.status == 200) {
+ const json j = json::parse(res.body, nullptr, false);
+ if (!j.is_discarded() && j.is_object() && j.contains("token_hash") && j["token_hash"].is_string())
+ token_hash = j["token_hash"].get();
+ } else {
+ error_code = handoff_error_code(res.body);
+ }
+
+ return (res.success && res.status == 200) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
+}
+
int OrcaCloudServiceAgent::get_mw_user_preference(std::function callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)";
diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp
index ae9e1f3213..524f24ced7 100644
--- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp
+++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp
@@ -203,6 +203,28 @@ public:
// ========================================================================
std::string get_cloud_service_host() override;
std::string get_cloud_login_url(const std::string& language = "") override;
+
+ // ========================================================================
+ // OrcaCloud webview session handoff (OrcaCloud "Webview Session Handoff" guide)
+ // ========================================================================
+ // The cloud pane never receives the desktop's access/refresh tokens. Instead the page posts
+ // request_web_session, the desktop mints a single-use token through the gateway and loads the
+ // Complete URL; the site then signs itself in. These builders return "" when no cloud URL is
+ // configured.
+ //
+ // Start URL: {web}/auth/handoff?next=[&user_id=]. `next` defaults to
+ // /app/profiles; user_id is included only while signed in.
+ std::string build_web_session_start_url(const std::string& next = std::string()) const;
+ // Complete URL: {web}/auth/handoff#token_hash=&state=. Both values URL-encoded.
+ std::string build_web_session_complete_url(const std::string& token_hash, const std::string& state) const;
+ // Clears only the webview's own session; loaded on an explicit desktop sign-out.
+ std::string build_web_session_signout_url() const;
+ // POST {api}/api/v1/account/web-session-handoff with the desktop access token (no body),
+ // refreshing once on 401 through the shared policy. On 200 writes the single-use `token_hash`;
+ // otherwise writes the server's error code (e.g. WEB_SESSION_HANDOFF_UNAVAILABLE) into
+ // `error_code`. Blocking: call from a worker thread. Never logs the token.
+ int request_web_session_handoff(std::string& token_hash, std::string& error_code, unsigned int& http_code);
+
int connect_server() override;
bool is_server_connected() override;
int refresh_connection() override;