From dcee299909d2d9e179cddf193dd02b9a26bb5e92 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Wed, 17 Jun 2026 17:15:09 +0800 Subject: [PATCH] Allow use offline when logged in to Orca Cloud (#14235) * Store user session information along with refresh token, to allow offline use once user is logged in * Don't bother with avatar because we won't see it when offline anyway * Fix offline Sync Presets freezing the UI on repeat clicks Ignore restart_sync_user_preset() while a manual sync's progress dialog is on screen, so a second app-modal dialog can't stack on the first. Offline the dialog blocks on a long, uncancellable HTTP timeout; on macOS the global menu stays live while the window is disabled, so a second click otherwise wedges the app (force-quit only). * Skip redundant user-secret re-write on startup set_user_session() always re-encrypts and writes the secret to disk; on the startup restore path that just rewrites the bytes it was loaded from. Add a persist flag so the restore path skips it. Also drop an unused catch binding and a stray blank line. --------- Co-authored-by: SoftFever --- src/slic3r/GUI/GUI_App.cpp | 20 ++- src/slic3r/GUI/GUI_App.hpp | 1 + src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 147 +++++++++++++++------ src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 11 +- 4 files changed, 134 insertions(+), 45 deletions(-) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 9017d39d5cb..d4000540ff1 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -6785,6 +6785,9 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) // BBS m_user_sync_token.reset(new int(0)); if (with_progress_dlg) { + // Mark a manual progress dialog as active so restart_sync_user_preset() ignores + // repeat triggers while it is on screen (prevents stacking modal dialogs). + m_sync_user_preset_dlg_active = true; auto dlg = new ProgressDialog(_L("Loading"), "", 100, this->mainframe, wxPD_AUTO_HIDE | wxPD_APP_MODAL | wxPD_CAN_ABORT); dlg->Update(0, _L("Loading user preset")); progressFn = [this, dlg](int percent) { @@ -6796,7 +6799,9 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) return is_closing() || dlg->WasCanceled() || t.expired(); }; finishFn = [this, dlg](bool) { - CallAfter([=]{ dlg->Destroy(); }); + // Clear the guard together with destroying the dialog, on the GUI thread, so the + // next manual sync is allowed exactly once this dialog leaves the screen. + CallAfter([=]{ dlg->Destroy(); m_sync_user_preset_dlg_active = false; }); }; } else { @@ -6810,7 +6815,10 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) m_sync_update_thread = Slic3r::create_thread( [this, progressFn, cancelFn, finishFn, t = std::weak_ptr(m_user_sync_token)] { - if (!m_agent) return; + // finishFn tears down the progress dialog (and clears the re-entrancy guard), so it + // must run on every exit path — otherwise an early bail-out would leak the modal + // dialog and leave the guard stuck, blocking all later manual syncs. + if (!m_agent) { finishFn(false); return; } // One-time scan for orphaned .info files left over from offline deletions; queues HTTP DELETEs. scan_orphaned_info_files(); @@ -7070,6 +7078,14 @@ void GUI_App::stop_sync_user_preset() void GUI_App::restart_sync_user_preset() { + // A manual sync's progress dialog is already on screen — ignore repeat triggers so a + // second modal dialog can never stack. This matters most offline: each attempt blocks + // on a long HTTP timeout and can't be cancelled mid-request, and on macOS the global + // menu bar stays clickable even while the dialog disables the main window, so without + // this guard repeated clicks pile up modal dialogs and wedge the UI (force-quit only). + if (m_sync_user_preset_dlg_active) + return; + if (!m_user_sync_token) { // No sync running. If a restart helper is already in flight it will // start the new sync once the old thread is joined — don't race it. diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index f72ba263aac..c749280ed32 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -323,6 +323,7 @@ private: boost::thread m_sync_update_thread; std::shared_ptr m_user_sync_token; std::atomic m_restart_sync_pending {false}; + std::atomic m_sync_user_preset_dlg_active {false}; // a manual "Sync Presets" progress dialog is on screen (see restart_sync_user_preset) std::atomic m_sync_user_presets_now {false}; // request the sync loop to push user presets on its next tick std::atomic m_migration_retry_pending {false}; bool m_is_dark_mode{ false }; diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 215216651ea..f8c2c1ab34e 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -480,7 +480,7 @@ int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir) config_dir = cfg_dir; wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), "orca_refresh_token.sec"); fallback.Normalize(); - refresh_fallback_path = fallback.GetFullPath().ToStdString(); + secret_fallback_path = fallback.GetFullPath().ToStdString(); return BAMBU_NETWORK_SUCCESS; } @@ -498,14 +498,71 @@ int OrcaCloudServiceAgent::set_country_code(std::string code) return BAMBU_NETWORK_SUCCESS; } +/// Decode a saved user session or a refresh token. +/// +/// Returns `false` if invalid input, and a re-authentication is required. +/// +/// If returns `true`, `out_refresh_token` will contain the user refresh token, and `out_session` can be one of two scenarios: +/// - if `out_session.logged_in` is `true`, then `out_session.refresh_token` and `out_session.user_id` are guaranteed to be present, +/// and a refresh is not necessarily required until you need to make any network call +/// - otherwise if `out_session.logged_in` is `false`, you should do a refresh immediately to get the user information before proceed, +/// otherwise user will be logged out +static bool parse_stored_secret(const std::string& secret, std::string& out_refresh_token, OrcaCloudServiceAgent::SessionInfo& out_session) +{ + out_refresh_token.clear(); + out_session = OrcaCloudServiceAgent::SessionInfo{}; + + try { + // Valid secret should be a json object, otherwise it's a plain refresh token + const json secret_json = json::parse(secret, nullptr, false); + if (secret_json.type() != json::value_t::object) { + out_refresh_token = secret; + return true; + } + + OrcaCloudServiceAgent::SessionInfo user_session{}; + user_session.refresh_token = get_json_string_field(secret_json, "refresh_token"); + user_session.user_id = get_json_string_field(secret_json, "user_id"); + user_session.user_name = get_json_string_field(secret_json, "username"); + user_session.user_nickname = get_json_string_field(secret_json, "nickname"); + user_session.logged_in = true; + // User session, must at least contains refresh token and user id + if (user_session.refresh_token.empty() || user_session.user_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: secret does not contain valid user session, force re-authentication"; + return false; + } + + out_refresh_token = user_session.refresh_token; + out_session = std::move(user_session); + return true; + } catch (const std::exception&) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: parse_stored_secret exception, force re-authentication"; + return false; + } +} + int OrcaCloudServiceAgent::start() { regenerate_pkce(); // Attempt silent sign-in from stored refresh token - std::string stored_refresh; - if (load_refresh_token(stored_refresh) && !stored_refresh.empty()) { - refresh_now(stored_refresh, "refresh token", false); + std::string stored_secret; + if (load_user_secret(stored_secret) && !stored_secret.empty()) { + // Backward compatibility: if secret it a json, then read it as use session, + // which allows us to refresh it in a background thread to speed up the app startup; + // otherwise it's a plain refresh token, then we force a sync refresh + std::string refresh_token; + SessionInfo stored_session; + if (parse_stored_secret(stored_secret, refresh_token, stored_session)) { + if (stored_session.logged_in) { + // We have a previously saved user session, use it. Skip re-persisting: the secret was + // just loaded from disk, so writing the identical bytes back is wasted startup I/O. + set_user_session(stored_session.access_token, stored_session.user_id, stored_session.user_name, + stored_session.user_nickname, stored_session.user_avatar, stored_session.refresh_token, + /*persist=*/false); + } + refresh_now(refresh_token, "refresh token", stored_session.logged_in); + } } return BAMBU_NETWORK_SUCCESS; @@ -1388,10 +1445,10 @@ void OrcaCloudServiceAgent::update_redirect_uri() // Auth - Token Persistence // ============================================================================ -void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token) +void OrcaCloudServiceAgent::persist_user_secret(const std::string& secret) { - if (token.empty()) { - clear_refresh_token(); + if (secret.empty()) { + clear_user_secret(); return; } @@ -1401,13 +1458,13 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token) // Use encrypted file only auto key = sha256_bytes(get_encryption_key()); if (key.empty()) { - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot derive key for refresh-token file storage"; + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot derive key for user secret file storage"; return; } std::string payload; - if (!aes256gcm_encrypt(token, key, payload)) { - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to encrypt refresh token for file storage"; + if (!aes256gcm_encrypt(secret, key, payload)) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to encrypt user secret for file storage"; return; } @@ -1417,38 +1474,38 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token) } compute_fallback_path(); - if (refresh_fallback_path.empty()) { - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no refresh-token storage path available; skipping file persistence"; + if (secret_fallback_path.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no user secret storage path available; skipping file persistence"; return; } - wxFileName path(wxString::FromUTF8(refresh_fallback_path.c_str())); + wxFileName path(wxString::FromUTF8(secret_fallback_path.c_str())); path.Normalize(); if (!wxFileName::DirExists(path.GetPath())) { wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL); } - const std::string tmp_path = refresh_fallback_path + ".tmp"; + const std::string tmp_path = secret_fallback_path + ".tmp"; std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc | std::ios::binary); if (ofs.good()) { ofs << signed_payload; ofs.flush(); ofs.close(); - if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(refresh_fallback_path.c_str()), true)) { + if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(secret_fallback_path.c_str()), true)) { stored = true; } else { wxRemoveFile(wxString::FromUTF8(tmp_path.c_str())); - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace refresh-token file"; + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace user secret file"; } } else { - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open refresh-token file for write - " << refresh_fallback_path; + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open user secret file for write - " << secret_fallback_path; } } else { // Use wxSecretStore only wxSecretStore store = wxSecretStore::GetDefault(); if (store.IsOk()) { - wxSecretValue secret(wxString::FromUTF8(token.c_str())); - if (store.Save(SECRET_STORE_SERVICE, SECRET_STORE_USER, secret)) { + wxSecretValue secret_value(wxString::FromUTF8(secret.c_str())); + if (store.Save(SECRET_STORE_SERVICE, SECRET_STORE_USER, secret_value)) { stored = true; } else { BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: System Keychain save failed"; @@ -1461,15 +1518,15 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token) (void) stored; } -bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token) +bool OrcaCloudServiceAgent::load_user_secret(std::string& out_secret) { - out_token.clear(); + out_secret.clear(); if (m_use_encrypted_token_file) { // Load from encrypted file only compute_fallback_path(); - if (wxFileExists(wxString::FromUTF8(refresh_fallback_path.c_str()))) { - std::ifstream ifs(refresh_fallback_path, std::ios::binary); + if (wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) { + std::ifstream ifs(secret_fallback_path, std::ios::binary); std::string payload((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); auto key = sha256_bytes(get_encryption_key()); std::string plain; @@ -1492,16 +1549,16 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token) std::transform(computed_hmac.begin(), computed_hmac.end(), computed_hmac.begin(), ::tolower); if (computed_hmac.empty() || computed_hmac != lower_stored) { integrity_ok = false; - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: refresh token integrity check failed (HMAC mismatch)"; + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: user secret integrity check failed (HMAC mismatch)"; } } } if (integrity_ok && aes256gcm_decrypt(encoded_payload, key, plain) && !plain.empty()) { - out_token = plain; + out_secret = plain; // Upgrade legacy payloads to signed format if (payload.rfind("v2:", 0) != 0) { - persist_refresh_token(out_token); + persist_user_secret(out_secret); } return true; } @@ -1513,8 +1570,8 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token) wxString username; wxSecretValue secret; if (store.Load(SECRET_STORE_SERVICE, username, secret) && secret.IsOk()) { - out_token.assign(static_cast(secret.GetData()), secret.GetSize()); - if (!out_token.empty()) { + out_secret.assign(static_cast(secret.GetData()), secret.GetSize()); + if (!out_secret.empty()) { return true; } } @@ -1524,7 +1581,7 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token) return false; } -void OrcaCloudServiceAgent::clear_refresh_token() +void OrcaCloudServiceAgent::clear_user_secret() { wxSecretStore store = wxSecretStore::GetDefault(); if (store.IsOk()) { @@ -1532,8 +1589,8 @@ void OrcaCloudServiceAgent::clear_refresh_token() } compute_fallback_path(); - if (!refresh_fallback_path.empty() && wxFileExists(wxString::FromUTF8(refresh_fallback_path.c_str()))) { - wxRemoveFile(wxString::FromUTF8(refresh_fallback_path.c_str())); + if (!secret_fallback_path.empty() && wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) { + wxRemoveFile(wxString::FromUTF8(secret_fallback_path.c_str())); } } @@ -1615,7 +1672,11 @@ RefreshResult OrcaCloudServiceAgent::refresh_from_storage(const std::string& rea { std::string refresh_token = get_refresh_token(); if (refresh_token.empty()) { - load_refresh_token(refresh_token); + std::string user_secret; + if (load_user_secret(user_secret) && !user_secret.empty()) { + SessionInfo stored_session; + parse_stored_secret(user_secret, refresh_token, stored_session); + } } if (refresh_token.empty()) { BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no refresh token available for refresh (reason=" << reason << ")"; @@ -1695,7 +1756,8 @@ bool OrcaCloudServiceAgent::set_user_session(const std::string& token, const std::string& username, const std::string& nickname, const std::string& avatar, - const std::string& refresh_token) + const std::string& refresh_token, + bool persist) { std::chrono::system_clock::time_point exp_tp{}; decode_jwt_expiry(token, exp_tp); @@ -1712,8 +1774,17 @@ bool OrcaCloudServiceAgent::set_user_session(const std::string& token, session.logged_in = true; } - if (!refresh_token.empty()) { - persist_refresh_token(refresh_token); + if (persist) { + // Store user session on disk to not block use from using + // an already logged in account if internet is not available. + // Don't store access token though, we should always refresh it + // once user is back online. + json sec = json::object(); + sec["refresh_token"] = refresh_token; + sec["user_id"] = user_id; + sec["username"] = username; + sec["nickname"] = nickname; + persist_user_secret(sec.dump()); } // Set per-user sync state path @@ -1789,7 +1860,7 @@ void OrcaCloudServiceAgent::clear_session() std::lock_guard lock(session_mutex); session = SessionInfo{}; } - clear_refresh_token(); + clear_user_secret(); } // ============================================================================ @@ -2224,7 +2295,7 @@ bool OrcaCloudServiceAgent::http_post_auth(const std::string& path, const std::s void OrcaCloudServiceAgent::compute_fallback_path() { - if (!refresh_fallback_path.empty()) + if (!secret_fallback_path.empty()) return; // wxStandardPaths::GetUserDataDir() resolves the app data directory via // wxAppConsoleBase::GetAppName(), which dereferences wxTheApp. In headless @@ -2235,7 +2306,7 @@ void OrcaCloudServiceAgent::compute_fallback_path() return; wxFileName fallback(wxStandardPaths::Get().GetUserDataDir(), "orca_refresh_token.sec"); fallback.Normalize(); - refresh_fallback_path = fallback.GetFullPath().ToStdString(); + secret_fallback_path = fallback.GetFullPath().ToStdString(); } // ============================================================================ diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 8820425f7fe..871f43163eb 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -279,9 +279,9 @@ public: const PkceBundle& pkce(); void regenerate_pkce(); - void persist_refresh_token(const std::string& token); - bool load_refresh_token(std::string& out_token); - void clear_refresh_token(); + void persist_user_secret(const std::string& secret); + bool load_user_secret(std::string& out_secret); + void clear_user_secret(); // Token refresh helpers bool refresh_if_expiring(std::chrono::seconds skew, const std::string& reason); @@ -295,7 +295,8 @@ public: const std::string& username, const std::string& nickname, const std::string& avatar, - const std::string& refresh_token = ""); + const std::string& refresh_token = "", + bool persist = true); // Accepts either nested Orca cloud / GoTrue session JSON or flat WebView token JSON. bool set_user_session(const nlohmann::json& session_json, bool notify_login = true); void clear_session(); @@ -363,7 +364,7 @@ private: // Member variables - auth state PkceBundle pkce_bundle; - std::string refresh_fallback_path; + std::string secret_fallback_path; SessionHandler session_handler; OnLoginCompleteHandler on_login_complete_handler; SessionInfo session;