Compare commits

...
14 changed files with 746 additions and 20 deletions
+14
View File
@@ -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;
+1 -1
View File
@@ -90,7 +90,7 @@
<div class="BtnIcon "><img class="LeftIcon" src="img/i2.png" /></div>
<div data-i18n="recent">Recent</div>
</div>
<div class="BtnItem BtnShortcut" onClick="OpenUrlInLocalBrowser('https://cloud.orcaslicer.com')">
<div class="BtnItem BtnShortcut" onClick="GotoCloud()">
<div class="BtnIcon "><img class="ShortcutBrandIcon" src="../image/logo.png" /></div>
<div class="ShortcutTextRow">
<div>OrcaCloud</div>
+28
View File
@@ -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;
+149 -8
View File
@@ -41,6 +41,7 @@
#include <boost/filesystem/exception.hpp>
#include <boost/locale/encoding_utf.hpp>
#include <boost/log/detail/native_typeof.hpp>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <cassert>
@@ -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<OrcaCloudServiceAgent> get_orca_cloud_agent(NetworkAgent* agent)
{
return agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(agent->get_cloud_agent()) : nullptr;
}
void start_ping_test()
{
return;
@@ -2272,7 +2280,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;
@@ -5243,7 +5253,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);
@@ -5256,6 +5266,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" +
@@ -5377,7 +5402,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) {
@@ -5392,7 +5417,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) {
@@ -5401,7 +5426,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) {
@@ -5413,6 +5438,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()) {
@@ -5685,7 +5724,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);
@@ -5829,6 +5870,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; }
@@ -5884,6 +6012,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());
}
@@ -7952,8 +8090,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();
}
+19 -1
View File
@@ -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
+2 -2
View File
@@ -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) {
@@ -2479,7 +2479,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
+2 -1
View File
@@ -1,4 +1,5 @@
#include "WebGuideDialog.hpp"
#include "CloudProvider.hpp"
#include "ConfigWizard.hpp"
#include <algorithm>
@@ -753,7 +754,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);
}
}
}
+295 -6
View File
@@ -36,12 +36,17 @@
#include <wx/textdlg.h>
#include <wx/toplevel.h>
#include <wx/url.h>
#include <wx/uri.h>
#include <slic3r/GUI/Widgets/WebView.hpp>
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
#include <wx/webview.h>
#include <wx/utils.h>
#include <wx/window.h>
#ifdef __linux__
#include <webkit2/webkit2.h>
#endif
#include <wx/infobar.h>
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()
{
@@ -134,7 +146,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
@@ -290,12 +306,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<WebKitWebView*>(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)
@@ -645,7 +931,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")) {
@@ -691,7 +977,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();
@@ -710,6 +996,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();
}
@@ -725,7 +1014,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)
+49
View File
@@ -28,6 +28,7 @@
#include "wx/fs_mem.h"
#include "wx/stdpaths.h"
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/tbarbase.h>
#include "wx/textctrl.h"
#include <wx/timer.h>
@@ -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
+7 -1
View File
@@ -418,6 +418,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;
@@ -425,7 +431,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);
}
+4
View File
@@ -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.
+8
View File
@@ -482,6 +482,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<curl_off_t>(0));
}
CURLcode res = ::curl_easy_perform(curl);
+146
View File
@@ -108,6 +108,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";
@@ -3018,6 +3022,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<char>(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<std::mutex> 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":"<code>"};
// the other statuses use {"error":{"code":"<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<std::string>();
if (err.is_object() && err.contains("code") && err["code"].is_string())
return err["code"].get<std::string>();
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<std::string>();
} 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<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)";
@@ -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=<path>[&user_id=<desktop 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=<hash>&state=<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;