From 9ab0178dd0064e62289c28c3fa146861b5f32294 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 15 Oct 2026 01:01:25 +0800 Subject: [PATCH] feat: add orcacloud to homepage (#16402) * feat: add orcacloud to homepage * feat: add OrcaCloud webview pane and session handoff from the app * feat: OrcaCloud pane crash, per-open re-sign-in, and web-session persistence * fix: clang-tidy --- resources/web/homepage/css/home.css | 14 + resources/web/homepage/index.html | 2 +- resources/web/homepage/js/home.js | 28 ++ src/slic3r/GUI/GUI_App.cpp | 157 ++++++++++- src/slic3r/GUI/GUI_App.hpp | 20 +- src/slic3r/GUI/Preferences.cpp | 4 +- src/slic3r/GUI/WebGuideDialog.cpp | 3 +- src/slic3r/GUI/WebViewDialog.cpp | 301 ++++++++++++++++++++- src/slic3r/GUI/WebViewDialog.hpp | 49 ++++ src/slic3r/GUI/Widgets/WebView.cpp | 8 +- src/slic3r/GUI/Widgets/WebView.hpp | 4 + src/slic3r/Utils/Http.cpp | 8 + src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 146 ++++++++++ src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 22 ++ 14 files changed, 746 insertions(+), 20 deletions(-) diff --git a/resources/web/homepage/css/home.css b/resources/web/homepage/css/home.css index 950e330b86..d245285014 100644 --- a/resources/web/homepage/css/home.css +++ b/resources/web/homepage/css/home.css @@ -548,6 +548,20 @@ body overflow-y:auto; } +/*---- Cloud mode: OrcaCloud is a native pane to the right of the app panel, so this page + is reduced to its left nav column (the class is toggled by SetCloudMode in js/home.js). ----*/ +body.cloud-mode #RightBoard +{ + display: none; +} + +body.cloud-mode #LeftBoard +{ + /*---- 262px of column plus its 1px separator is the width the app gives the nav. ----*/ + height: 100%; + overflow-y: auto; +} + #ContentBoard { overflow-y:auto; diff --git a/resources/web/homepage/index.html b/resources/web/homepage/index.html index e75e8302f7..64b85b13ae 100644 --- a/resources/web/homepage/index.html +++ b/resources/web/homepage/index.html @@ -90,7 +90,7 @@
Recent
-
+
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;