Compare commits

...
11 changed files with 298 additions and 8 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;
+36
View File
@@ -5419,6 +5419,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()) {
@@ -5835,6 +5849,22 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
}*/
}
wxString GUI_App::cloud_webview_handoff()
{
auto orca = m_agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent()) : nullptr;
if (!orca || !orca->is_user_login())
return {};
// Never block the UI thread on a token refresh: that can take a cross-process lock plus a
// synchronous POST and retry. If the access token is missing or within a minute of expiry,
// skip the handoff for this open — set_cloud_mode then loads the plain cloud page and the next
// open retries. The pane is the app's guest: the app owns the session and re-injects a
// freshly-refreshed copy on each open (Supabase rotates refresh tokens, so a concurrent
// refresh by the SPA can invalidate the app's copy — a known, accepted trade-off).
if (orca->access_token_needs_refresh(std::chrono::seconds(60)))
return {};
return from_u8(orca->build_webview_handoff_url());
}
void GUI_App::on_user_login_handle(wxCommandEvent &evt)
{
if (!m_agent) { return; }
@@ -5890,6 +5920,12 @@ 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. A closed pane is seeded when it is next opened.
if (WebViewPanel* home = WebViewPanel::if_built())
if (home->cloud_mode_active())
home->set_cloud_mode(true, cloud_webview_handoff());
}
+3
View File
@@ -557,6 +557,9 @@ public:
void on_update_machine_list(wxCommandEvent& evt);
void on_user_login(wxCommandEvent &evt);
void on_user_login_handle(wxCommandEvent& evt);
// Returns the fragment handoff URL for the signed-in Orca Cloud session, refreshing the
// token first if needed. Empty when not signed in.
wxString cloud_webview_handoff();
void enable_user_preset_folder(bool enable);
// BBS
+106 -6
View File
@@ -55,6 +55,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 +141,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 +301,98 @@ 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);
// 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));
}
// Consumed on the cloud view so it never reaches the homepage handlers; new windows open in
// the system browser, the same way PrinterWebView treats them.
void WebViewPanel::OnCloudWebViewEvent(wxWebViewEvent& evt)
{
if (evt.GetEventType() == wxEVT_WEBVIEW_NEWWINDOW) {
const wxString url = evt.GetURL();
if (!url.empty())
wxLaunchDefaultBrowser(url);
evt.Veto();
}
}
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::set_cloud_mode(bool on, const wxString& url)
{
if (on) {
if (!m_cloud_browser) {
create_cloud_browser();
if (url.empty()) {
WebView::LoadUrl(m_cloud_browser, wxString(kCloudUrl));
} else {
// Load the handoff directly: WebView::LoadUrl re-serializes through wxURI, which
// could alter the fragment that carries the session.
m_cloud_browser->LoadURL(url);
}
} else if (!url.empty()) {
// Re-inject a freshly-refreshed session whenever the pane is (re)opened.
m_cloud_browser->LoadURL(url);
}
m_cloud_browser->Show();
} else if (m_cloud_browser) {
m_cloud_browser->Hide();
// Supabase scrubs with window.location.hash='', so the token fragment can remain in
// history; drop it when the pane is closed.
m_cloud_browser->ClearHistory();
}
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();
WebView::LoadUrl(m_cloud_browser, kCloudUrl);
m_cloud_browser->Show(cloud_on);
}
apply_cloud_layout(cloud_on);
}
bool WebViewPanel::Show(bool show)
@@ -645,7 +742,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 +788,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 +807,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 +825,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)
+16
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,12 +115,27 @@ public:
void update_mode();
// Shows/hides the native OrcaCloud pane beside this homepage nav column. When the pane is
// created, or when `on` is true with a non-empty `url`, that URL is loaded (the fragment
// carries a Supabase session handoff); `url` empty falls back to the cloud home 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(); }
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);
// 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};
wxButton * m_button_stop;
+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.
@@ -85,6 +85,8 @@ namespace {
constexpr const char* ORCA_DEFAULT_API_URL = "https://api.orcaslicer.com";
constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com";
constexpr const char* ORCA_DEFAULT_CLOUD_URL = "https://cloud.orcaslicer.com";
// Host component of ORCA_DEFAULT_CLOUD_URL. The handoff pins to it (build_webview_handoff_url).
constexpr const char* ORCA_DEFAULT_CLOUD_HOST = "cloud.orcaslicer.com";
// Orca: This is a public key with no secret, used to identify the client application to the backend.
constexpr const char* ORCA_DEFAULT_PUB_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_";
@@ -3018,6 +3020,82 @@ std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& langua
return url;
}
// True when the two strings are equal ignoring ASCII case.
static bool ascii_iequals(const std::string& a, const std::string& b)
{
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(),
[](char x, char y) { return std::tolower(static_cast<unsigned char>(x)) ==
std::tolower(static_cast<unsigned char>(y)); });
}
// 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_webview_handoff_url() const
{
if (cloud_base_url.empty())
return "";
// Pin the handoff to the known cloud origin: https, no userinfo, no port (or :443), and the
// exact host. The OrcaCloud pane hardcodes the same host as kCloudUrl in WebViewDialog.cpp, so
// the handoff must target that host; if the pane ever becomes configurable, both must move
// together. Rejecting anything else avoids handing the refresh token to a plaintext or
// attacker-controlled base URL.
const std::string https_prefix = "https://";
const bool https_scheme = cloud_base_url.size() >= https_prefix.size() &&
ascii_iequals(https_prefix, cloud_base_url.substr(0, https_prefix.size()));
const std::string authority = https_scheme ? cloud_base_url.substr(https_prefix.size()) : std::string();
// host_port = [userinfo@]host[:port], up to the first '/', '?' or '#'.
const std::string host_port = authority.substr(0, authority.find_first_of("/?#"));
const bool has_userinfo = host_port.find('@') != std::string::npos;
const size_t port_sep = host_port.find(':');
const std::string host = host_port.substr(0, port_sep);
const std::string port = port_sep == std::string::npos ? "" : host_port.substr(port_sep + 1);
const bool host_ok = ascii_iequals(host, ORCA_DEFAULT_CLOUD_HOST);
const bool port_ok = port.empty() || port == "443";
if (!https_scheme || has_userinfo || !host_ok || !port_ok) {
BOOST_LOG_TRIVIAL(warning) << "refusing OrcaCloud webview handoff: cloud URL is not the pinned https origin";
return "";
}
std::lock_guard<std::mutex> lock(session_mutex);
if (!session.logged_in || session.access_token.empty())
return "";
// Supabase's implicit flow (detectSessionInUrl) requires all four fragment parameters.
const auto expires_in = std::max<long long>(0,
std::chrono::duration_cast<std::chrono::seconds>(session.expires_at - std::chrono::system_clock::now()).count());
return cloud_base_url + "/#access_token=" + percent_encode_unreserved(session.access_token) +
"&refresh_token=" + percent_encode_unreserved(session.refresh_token) +
"&expires_in=" + std::to_string(expires_in) +
"&token_type=bearer";
}
bool OrcaCloudServiceAgent::access_token_needs_refresh(std::chrono::seconds skew) const
{
std::lock_guard<std::mutex> lock(session_mutex);
return should_refresh_locked(skew);
}
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,11 @@ public:
// ========================================================================
std::string get_cloud_service_host() override;
std::string get_cloud_login_url(const std::string& language = "") override;
// URL that hands the current session to the OrcaCloud SPA via the URL fragment
// (Supabase implicit flow / detectSessionInUrl). Empty when not signed in or no cloud URL.
std::string build_webview_handoff_url() const;
// True when the access token is missing or will expire within `skew` (no network).
bool access_token_needs_refresh(std::chrono::seconds skew) const;
int connect_server() override;
bool is_server_connected() override;
int refresh_connection() override;