From 6d491bead183322c185d0f021fcd6f42e5278a83 Mon Sep 17 00:00:00 2001 From: PGMacDesign Date: Tue, 9 Jun 2026 20:28:12 -0700 Subject: [PATCH 1/4] Persist Snapmaker login across restarts via OS secret store Snapmaker cloud login was not persisted on Linux/AppImage (and elsewhere): every restart forced a fresh OAuth login. Persist the session so it survives restarts, revalidating the stored token on startup. - Store only the bearer token, and only in the OS secret store via wxSecretStore (libsecret / Credential Manager / Keychain) -- never in plaintext config. If no secret service is available the token is not persisted (falls back to the prior re-login behaviour) rather than written insecurely. Guarded with wxUSE_SECRETSTORE so toolchains without it (e.g. MinGW) still compile. - On startup, revalidate the token against the accounts/current endpoint and re-fetch the profile from the server, restoring the session; clear the stored token on failure or explicit logout. No user data is written to disk. Addresses #116, #226. Builds on the approach proposed in #266, hardened to keep the token out of plaintext on disk. --- src/slic3r/GUI/GUI_App.cpp | 140 ++++++++++++++++++++++++ src/slic3r/GUI/GUI_App.hpp | 8 +- src/slic3r/GUI/WebSMUserLoginDialog.cpp | 2 + 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 4f6dc46c54c..0525de708fe 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -70,6 +70,7 @@ #include #include #include +#include #include #include @@ -1189,6 +1190,11 @@ void GUI_App::post_init() hms_query->check_hms_info(); }); + // restore a previously persisted Snapmaker login (revalidates the stored token) + CallAfter([this] { + sm_restore_login_from_config(); + }); + DeviceManager::load_filaments_blacklist_config(); @@ -4299,6 +4305,140 @@ void GUI_App::sm_request_user_logout() } catch (std::exception&) { ; } + sm_clear_login_from_config(); +} + +// --- Snapmaker login persistence --------------------------------------------- +// Goal: stop forcing a fresh OAuth login on every restart (issues #116/#226/#266). +// +// Only the bearer token is persisted, and only to the OS secret store via +// wxSecretStore (libsecret on Linux, Credential Manager on Windows, Keychain on +// macOS) -- never to plaintext config. On startup we revalidate the token +// against accounts/current and re-fetch the profile (id/name/account/icon) from +// the server, so no user data is written to disk. If no secret service is +// available we simply do not persist the token and fall back to the previous +// "log in again" behaviour, rather than writing a credential somewhere insecure. +namespace { +#if wxUSE_SECRETSTORE +const wxString SM_LOGIN_SECRET_SERVICE = "Snapmaker_Orca/login"; + +bool sm_secret_store_save(const std::string& account, const std::string& token) +{ + wxSecretStore store = wxSecretStore::GetDefault(); + wxString errmsg; + if (!store.IsOk(&errmsg)) { + BOOST_LOG_TRIVIAL(warning) << "sm_login: secret store unavailable, token not persisted: " << errmsg.ToStdString(); + return false; + } + const wxString user = account.empty() ? wxString("snapmaker") : wxString::FromUTF8(account.c_str()); + return store.Save(SM_LOGIN_SECRET_SERVICE, user, wxSecretValue(wxString::FromUTF8(token.c_str()))); +} + +std::string sm_secret_store_load() +{ + wxSecretStore store = wxSecretStore::GetDefault(); + wxString errmsg; + if (!store.IsOk(&errmsg)) { + BOOST_LOG_TRIVIAL(warning) << "sm_login: secret store unavailable: " << errmsg.ToStdString(); + return std::string(); + } + wxString user; + wxSecretValue value; + if (!store.Load(SM_LOGIN_SECRET_SERVICE, user, value)) + return std::string(); + return std::string(value.GetAsString(wxConvUTF8).ToUTF8()); +} + +void sm_secret_store_clear() +{ + wxSecretStore store = wxSecretStore::GetDefault(); + if (store.IsOk()) + store.Delete(SM_LOGIN_SECRET_SERVICE); +} +#else // !wxUSE_SECRETSTORE +// No OS secret store on this toolchain (e.g. MinGW, which lacks wincred.h). +// Degrade gracefully: never persist the token, so the user logs in again each +// launch -- but we never write the credential to plaintext. +bool sm_secret_store_save(const std::string&, const std::string&) { return false; } +std::string sm_secret_store_load() { return std::string(); } +void sm_secret_store_clear() {} +#endif // wxUSE_SECRETSTORE +} // namespace + +void GUI_App::sm_save_login_to_config() +{ + const std::string token = m_login_userinfo.get_user_token(); + if (token.empty()) { + sm_clear_login_from_config(); + return; + } + // Only the token is persisted (to the OS secret store). Profile fields are + // re-fetched from the server on restore, so nothing lands in plaintext. + sm_secret_store_save(m_login_userinfo.get_user_account(), token); +} + +void GUI_App::sm_clear_login_from_config() +{ + sm_secret_store_clear(); + // Scrub any fields an older build may have written to plaintext config. + app_config->erase("sm_login", "user_id"); + app_config->erase("sm_login", "user_name"); + app_config->erase("sm_login", "user_account"); + app_config->erase("sm_login", "user_icon_url"); + app_config->erase("sm_login", "token"); + app_config->save(); +} + +void GUI_App::sm_restore_login_from_config() +{ + const std::string token = sm_secret_store_load(); + if (token.empty()) + return; + + const std::string region = app_config->get_country_code(); + const std::string user_info_url = (region == "CN") + ? "https://api.snapmaker.cn/api/common/accounts/current" + : "https://id.snapmaker.com/api/common/accounts/current"; + + auto http = Http::get(user_info_url); + http.header("Authorization", token); + // on_complete fires only for 2xx; an expired/invalid token (401/403) and any + // network failure route to on_error below, which clears the stored session. + http.on_complete([this, token](std::string body, unsigned /*status*/) { + // Parse on this worker thread, then apply all state changes on the + // main thread (SMUserInfo::set_user_login notifies the UI). + std::string user_id, user_name, user_icon_url, user_account; + try { + json response = json::parse(body); + if (response.count("data")) { + json data = response["data"]; + if (data.count("id")) + user_id = std::to_string(data["id"].get()); + if (data.count("nickname")) + user_name = data["nickname"].get(); + if (data.count("icon")) + user_icon_url = data["icon"].get(); + if (data.count("account")) + user_account = data["account"].get(); + } + } catch (std::exception&) { + CallAfter([this]() { sm_clear_login_from_config(); }); + return; + } + CallAfter([this, token, user_id, user_name, user_icon_url, user_account]() { + if (!user_id.empty()) m_login_userinfo.set_user_id(user_id); + if (!user_name.empty()) m_login_userinfo.set_user_name(user_name); + if (!user_icon_url.empty()) m_login_userinfo.set_user_icon_url(user_icon_url); + if (!user_account.empty()) m_login_userinfo.set_user_account(user_account); + m_login_userinfo.set_user_token(token); + m_login_userinfo.set_user_login(true); + sm_save_login_to_config(); + }); + }) + .on_error([this](std::string, std::string, unsigned) { + CallAfter([this]() { sm_clear_login_from_config(); }); + }) + .perform(); } //BBS diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index edaf1b11101..20308074b24 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -595,7 +595,13 @@ class GUI_App : public wxApp void sm_request_login(bool show_user_info = false); void sm_ShowUserLogin(bool show = true); void sm_request_user_logout(); - + // Snapmaker login persistence across restarts. Non-secret profile fields are + // kept in AppConfig's [sm_login] section; the bearer token itself is stored in + // the OS secret store (never in plaintext). See GUI_App.cpp for details. + void sm_save_login_to_config(); + void sm_clear_login_from_config(); + void sm_restore_login_from_config(); + void request_user_logout(); int request_user_unbind(std::string dev_id); std::string handle_web_request(std::string cmd); diff --git a/src/slic3r/GUI/WebSMUserLoginDialog.cpp b/src/slic3r/GUI/WebSMUserLoginDialog.cpp index 9b23e17535b..14013f4fdf2 100644 --- a/src/slic3r/GUI/WebSMUserLoginDialog.cpp +++ b/src/slic3r/GUI/WebSMUserLoginDialog.cpp @@ -221,6 +221,8 @@ void SMUserLogin::OnNavigationRequest(wxWebViewEvent &evt) sentryReportLog(SENTRY_LOG_TRACE, userInfo, BP_LOGIN); wxGetApp().sm_get_userinfo()->set_user_token(token); wxGetApp().sm_get_userinfo()->set_user_login(true); + // Persist the session so the user stays logged in across restarts. + wxGetApp().sm_save_login_to_config(); } }) .on_error([&](std::string body, std::string error, unsigned status) { From e9679e3d7c1381bed33f4e40ef97c9e4d7a2b695 Mon Sep 17 00:00:00 2001 From: PGMacDesign Date: Tue, 9 Jun 2026 22:54:36 -0700 Subject: [PATCH 2/4] Push restored Snapmaker login to web UI on subscribe The persisted session is revalidated asynchronously at startup. That can complete before the Flutter home page subscribes to login-state updates, in which case the one-shot user_login_notify() push reaches an empty subscriber list and is lost -- leaving the UI showing 'logged out' despite a valid, restored token. Make sw_SubscribeUserLoginState push the current login state immediately when the user is already logged in at subscribe time. This closes the race in both orderings: restore-before-subscribe is covered by the immediate push here, and restore-after-subscribe by the existing notify() path. --- src/slic3r/GUI/SSWCP.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/slic3r/GUI/SSWCP.cpp b/src/slic3r/GUI/SSWCP.cpp index 4868afd11fa..b1344becb06 100644 --- a/src/slic3r/GUI/SSWCP.cpp +++ b/src/slic3r/GUI/SSWCP.cpp @@ -4855,6 +4855,24 @@ void SSWCP_UserLogin_Instance::sw_SubscribeUserLoginState() try { std::weak_ptr weak_ptr = shared_from_this(); wxGetApp().m_user_login_subscribers[m_webview] = weak_ptr; + + // A persisted session is restored asynchronously at startup (the stored + // token is revalidated over the network). That can finish before the web + // UI subscribes here, in which case the one-shot notify() push already + // fired and was lost. So if we are already logged in at subscribe time, + // push the current state now; otherwise a later notify() will deliver it. + auto pInfo = wxGetApp().sm_get_userinfo(); + if (pInfo && pInfo->is_user_login()) { + json data; + data["status"] = "online"; + data["nickname"] = pInfo->get_user_name(); + data["icon"] = pInfo->get_user_icon_url(); + data["token"] = pInfo->get_user_token(); + data["userid"] = pInfo->get_user_id(); + data["account"] = pInfo->get_user_account(); + m_res_data = data; + send_to_js(); + } } catch (std::exception& e) { handle_general_fail(); From 3a71cdeee2b512124ddfd434981a03e086f2d9c8 Mon Sep 17 00:00:00 2001 From: Roberto Espinoza Date: Mon, 3 Aug 2026 10:06:02 +0900 Subject: [PATCH 3/4] Validate account responses, harden restore, add a "Stay signed in" toggle Building on #479, this makes the persisted session survive the ways it previously did not: * accounts/current reports auth failures as HTTP 200 with a non-200 body code (110002 authorization_missing, 110003 token_expired, 110004 authentication_failed, per the bundled web UI's error table), so a 2xx status alone proves nothing. Slic3r::sm_parse_account_response reports what a reply contains - success envelope, token refusal, profile fields - and each caller applies its own policy: a restore needs a success envelope with a usable account id, while a fresh login proceeds unless the token was refused outright. Previously an invalid token produced a "signed in" state with an empty profile while the UI still showed Login/Register. The parser and the region-to-host mapping live in slic3r/Utils/SnapmakerAccount, free of any GUI dependency, so they can be covered by tests/slic3rutils. * The stored token is cleared only on those auth codes or HTTP 401/403. Transient failures keep it for the next launch, and an unrecognized response is logged rather than passing silently. * A login epoch guards every asynchronous completion, so a slow restore cannot clobber a manual login or logout that happened meanwhile, and a keyring write that lands late cannot resurrect a session the user ended. Failing to store degrades to "nothing persisted" rather than leaving a previous account's token behind. * Exactly one keyring item exists per platform: Windows and macOS delete before saving under a fixed attribute set. * [sm_login]/has_session records that a token really reached the store, so startup skips the keyring - and any unlock prompt - for users who never signed in. It is a hint only: a reader that finds it disagreeing with the store corrects it, and a marker that outlives its item merely costs the keyring probe that having no marker would cost anyway. * The revalidation request has a 30s timeout and a 64 KiB response cap, and its callbacks are disarmed on exit through a liveness token. * Persistence is opt-out through a "Stay signed in" preference (default on); turning it off also drops whatever is already stored. * The login-state payload sent to the web UI is deduplicated into GUI_App::sm_login_state_json, and the subscribe-time push added by #479 is gated on m_event_id so plain subscribe calls keep their shape. --- src/libslic3r/AppConfig.cpp | 3 + src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/GUI_App.cpp | 298 +++++++++++++------ src/slic3r/GUI/GUI_App.hpp | 23 +- src/slic3r/GUI/Preferences.cpp | 12 + src/slic3r/GUI/SSWCP.cpp | 45 +-- src/slic3r/GUI/WebSMUserLoginDialog.cpp | 41 +-- src/slic3r/Utils/SnapmakerAccount.cpp | 55 ++++ src/slic3r/Utils/SnapmakerAccount.hpp | 41 +++ tests/slic3rutils/slic3rutils_tests_main.cpp | 58 ++++ 10 files changed, 430 insertions(+), 148 deletions(-) create mode 100644 src/slic3r/Utils/SnapmakerAccount.cpp create mode 100644 src/slic3r/Utils/SnapmakerAccount.hpp diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 440ac33387c..f81b1534f6c 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -161,6 +161,9 @@ void AppConfig::set_defaults() if (get("remember_output_path_removable").empty()) set_bool("remember_output_path_removable", true); #endif + if (get("remember_login").empty()) + set_bool("remember_login", true); + if (get("toolkit_size").empty()) set("toolkit_size", "100"); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 6d8a5209a4d..cb8aa5415ac 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -594,6 +594,8 @@ set(SLIC3R_GUI_SOURCES Utils/HexFile.hpp Utils/Http.cpp Utils/Http.hpp + Utils/SnapmakerAccount.cpp + Utils/SnapmakerAccount.hpp Utils/InstanceID.cpp Utils/InstanceID.hpp Utils/json_diff.cpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 0525de708fe..c5413182fab 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -38,6 +38,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -71,6 +74,7 @@ #include #include #include +#include "slic3r/Utils/SnapmakerAccount.hpp" #include #include @@ -2474,6 +2478,15 @@ bool GUI_App::OnInit() int GUI_App::OnExit() { + // Session revalidation: drop the liveness token, then cancel the + // request. Both are best-effort and deliberately so -- Http::cancel() + // only aborts a transfer still in progress, and a callback that already + // holds the token runs to completion. Together they make the window in + // which a callback can touch a tearing-down application very small, + // rather than eliminating it. + m_sm_login_alive.reset(); + if (m_sm_login_http) + m_sm_login_http->cancel(); stop_sync_user_preset(); if (m_device_manager) { @@ -4292,13 +4305,7 @@ void GUI_App::sm_request_user_logout() m_login_userinfo.set_user_login(false); } try { - wxString region = wxString::FromUTF8(app_config->get_country_code()); - std::string url = ""; - if (region == "CN") { - url = "https://api.snapmaker.cn/api/oauth2/revoke"; - } else { - url = "https://id.snapmaker.com/api/oauth2/revoke"; - } + std::string url = sm_account_api_base(app_config->get_country_code()) + "/api/oauth2/revoke"; Http http = Http::post(url); http.form_add("token", m_login_userinfo.get_user_token()).perform(); @@ -4311,134 +4318,238 @@ void GUI_App::sm_request_user_logout() // --- Snapmaker login persistence --------------------------------------------- // Goal: stop forcing a fresh OAuth login on every restart (issues #116/#226/#266). // -// Only the bearer token is persisted, and only to the OS secret store via -// wxSecretStore (libsecret on Linux, Credential Manager on Windows, Keychain on -// macOS) -- never to plaintext config. On startup we revalidate the token -// against accounts/current and re-fetch the profile (id/name/account/icon) from -// the server, so no user data is written to disk. If no secret service is -// available we simply do not persist the token and fall back to the previous -// "log in again" behaviour, rather than writing a credential somewhere insecure. -namespace { +// Only the bearer token is persisted, and only to an OS-protected secret store +// -- never to plaintext config. On startup the token is revalidated against +// accounts/current and the profile is re-fetched from the server, so no user +// data is written to disk. Without a usable secret store the app falls back to +// asking for a login each launch. + + +// Defined per platform below; used by the marker helpers that follow. +static void sm_secret_store_clear(); + +// [sm_login]/has_session records that a token really reached the secret +// store, so startup can skip the keyring - and any unlock prompt - for users +// who never signed in. It is only ever a hint: it is written under the login +// epoch that produced it, so a write describing a session the user has since +// left is dropped, and a reader that finds it disagreeing with the store +// corrects it. A marker that outlives its item only costs one keyring probe +// per launch, which is what having no marker at all would cost. +static void sm_set_session_marker(bool present, unsigned epoch) +{ + if (epoch != wxGetApp().sm_login_epoch()) + return; + auto* config = wxGetApp().app_config; + if (present) + config->set("sm_login", "has_session", true); + else + config->erase("sm_login", "has_session"); + if (config->dirty()) + config->save(); +} + +// Failing to persist must degrade to "nothing is persisted", never to +// "the previous session is persisted": drop both the marker and whatever +// item is in the store, unless a newer session already owns them. +static void sm_discard_persisted_session(unsigned epoch) +{ + if (epoch != wxGetApp().sm_login_epoch()) + return; + sm_secret_store_clear(); + sm_set_session_marker(false, epoch); +} + #if wxUSE_SECRETSTORE -const wxString SM_LOGIN_SECRET_SERVICE = "Snapmaker_Orca/login"; +static const wxString SM_LOGIN_SECRET_SERVICE = "Snapmaker_Orca/login"; -bool sm_secret_store_save(const std::string& account, const std::string& token) +static void sm_secret_store_save(const std::string& token, unsigned epoch) { wxSecretStore store = wxSecretStore::GetDefault(); wxString errmsg; if (!store.IsOk(&errmsg)) { - BOOST_LOG_TRIVIAL(warning) << "sm_login: secret store unavailable, token not persisted: " << errmsg.ToStdString(); - return false; + BOOST_LOG_TRIVIAL(info) << "[sm_login] secret store unavailable, token not persisted: " << errmsg.ToStdString(); + sm_discard_persisted_session(epoch); + return; } - const wxString user = account.empty() ? wxString("snapmaker") : wxString::FromUTF8(account.c_str()); - return store.Save(SM_LOGIN_SECRET_SERVICE, user, wxSecretValue(wxString::FromUTF8(token.c_str()))); + // Store under one fixed user attribute (the account name is not always + // known at save time) and delete first, so exactly one item ever exists + // for this service regardless of which save path wrote it. + store.Delete(SM_LOGIN_SECRET_SERVICE); + if (store.Save(SM_LOGIN_SECRET_SERVICE, "snapmaker", wxSecretValue(wxString::FromUTF8(token.c_str())))) + sm_set_session_marker(true, epoch); + else + sm_discard_persisted_session(epoch); } -std::string sm_secret_store_load() +// Credential Manager and Keychain are local and fast, so this reads +// synchronously and invokes `done` inline; the signature matches the Linux +// path so callers need no #if. See that overload for the contract. +static void sm_secret_store_load(unsigned /*epoch*/, std::function done) { wxSecretStore store = wxSecretStore::GetDefault(); wxString errmsg; if (!store.IsOk(&errmsg)) { - BOOST_LOG_TRIVIAL(warning) << "sm_login: secret store unavailable: " << errmsg.ToStdString(); - return std::string(); + BOOST_LOG_TRIVIAL(info) << "[sm_login] secret store unavailable: " << errmsg.ToStdString(); + done(false, std::string()); + return; } wxString user; wxSecretValue value; - if (!store.Load(SM_LOGIN_SECRET_SERVICE, user, value)) - return std::string(); - return std::string(value.GetAsString(wxConvUTF8).ToUTF8()); + std::string token; + if (store.Load(SM_LOGIN_SECRET_SERVICE, user, value)) + token = std::string(value.GetAsString(wxConvUTF8).ToUTF8()); + done(true, std::move(token)); } -void sm_secret_store_clear() +static void sm_secret_store_clear() { wxSecretStore store = wxSecretStore::GetDefault(); if (store.IsOk()) store.Delete(SM_LOGIN_SECRET_SERVICE); } #else // !wxUSE_SECRETSTORE -// No OS secret store on this toolchain (e.g. MinGW, which lacks wincred.h). -// Degrade gracefully: never persist the token, so the user logs in again each -// launch -- but we never write the credential to plaintext. -bool sm_secret_store_save(const std::string&, const std::string&) { return false; } -std::string sm_secret_store_load() { return std::string(); } -void sm_secret_store_clear() {} +// No OS secret store on this toolchain (e.g. MinGW, which lacks wincred.h): +// never persist the token rather than writing it somewhere insecure. +static void sm_secret_store_save(const std::string&, unsigned) {} +static void sm_secret_store_load(unsigned, std::function done) { done(false, std::string()); } +static void sm_secret_store_clear() {} #endif // wxUSE_SECRETSTORE -} // namespace void GUI_App::sm_save_login_to_config() { + ++m_sm_login_epoch; const std::string token = m_login_userinfo.get_user_token(); if (token.empty()) { sm_clear_login_from_config(); return; } - // Only the token is persisted (to the OS secret store). Profile fields are - // re-fetched from the server on restore, so nothing lands in plaintext. - sm_secret_store_save(m_login_userinfo.get_user_account(), token); + if (!app_config->get_bool("remember_login")) { + // The user opted out of staying signed in. + sm_discard_persisted_session(m_sm_login_epoch); + return; + } + // The [sm_login]/has_session marker is written by the store helper only + // after the token was actually persisted, so startup never queries the + // keyring (which may prompt to unlock) unless there is something to find. + sm_secret_store_save(token, m_sm_login_epoch); } void GUI_App::sm_clear_login_from_config() { + ++m_sm_login_epoch; sm_secret_store_clear(); - // Scrub any fields an older build may have written to plaintext config. - app_config->erase("sm_login", "user_id"); - app_config->erase("sm_login", "user_name"); - app_config->erase("sm_login", "user_account"); - app_config->erase("sm_login", "user_icon_url"); - app_config->erase("sm_login", "token"); - app_config->save(); + app_config->erase("sm_login", "has_session"); + if (app_config->dirty()) + app_config->save(); +} + +// The Snapmaker account API reports failures as HTTP 200 with a non-200 +// "code" in the body, so the transport status alone proves nothing. Fills +// `profile` and returns true only for a full success envelope with a usable +// account id; sets `auth_rejected` only for the known invalid/expired-token +// code, so callers can tell definitive rejection from transient errors. + +void GUI_App::sm_forget_persisted_login() +{ + sm_discard_persisted_session(m_sm_login_epoch); } void GUI_App::sm_restore_login_from_config() { - const std::string token = sm_secret_store_load(); - if (token.empty()) + if (!app_config->get_bool("remember_login")) return; + // Cheap plaintext marker first: never touch the OS keyring (which may + // prompt to unlock) unless a session was actually persisted. (Read via + // get_bool: AppConfig serializes bool-ish values as JSON booleans and + // loads them back as "true"/"false" strings.) + if (!app_config->get_bool("sm_login", "has_session")) + return; + // Snapshot the login epoch before anything asynchronous starts: if the + // user logs in or out while the lookup or the validation is in flight, + // the stale result must not clobber the newer state or its stored token. + const unsigned epoch = m_sm_login_epoch; + sm_secret_store_load(epoch, [epoch](bool store_available, std::string token) { + if (!store_available) + return; // keep the marker and retry on the next launch + if (token.empty()) { + // The store answered and holds nothing: correct the stale marker + // so later launches stop querying the keyring. + sm_set_session_marker(false, epoch); + return; + } + wxGetApp().sm_restore_login_with_token(token, epoch); + }); +} + +void GUI_App::sm_restore_login_with_token(const std::string& token, unsigned epoch) +{ + const std::string user_info_url = sm_account_api_base(app_config->get_country_code()) + "/api/common/accounts/current"; - const std::string region = app_config->get_country_code(); - const std::string user_info_url = (region == "CN") - ? "https://api.snapmaker.cn/api/common/accounts/current" - : "https://id.snapmaker.com/api/common/accounts/current"; + // Callbacks run on a detached worker thread; a weak liveness token lets + // them bail out once the application has started tearing down. + std::weak_ptr alive = m_sm_login_alive; auto http = Http::get(user_info_url); http.header("Authorization", token); - // on_complete fires only for 2xx; an expired/invalid token (401/403) and any - // network failure route to on_error below, which clears the stored session. - http.on_complete([this, token](std::string body, unsigned /*status*/) { - // Parse on this worker thread, then apply all state changes on the - // main thread (SMUserInfo::set_user_login notifies the UI). - std::string user_id, user_name, user_icon_url, user_account; - try { - json response = json::parse(body); - if (response.count("data")) { - json data = response["data"]; - if (data.count("id")) - user_id = std::to_string(data["id"].get()); - if (data.count("nickname")) - user_name = data["nickname"].get(); - if (data.count("icon")) - user_icon_url = data["icon"].get(); - if (data.count("account")) - user_account = data["account"].get(); - } - } catch (std::exception&) { - CallAfter([this]() { sm_clear_login_from_config(); }); + // The profile response is a few hundred bytes; cap it so a hostile or + // misbehaving endpoint cannot make startup buffer an arbitrary body. + http.timeout_max(30) + .size_limit(64 * 1024) + .on_complete([this, token, epoch, alive](std::string body, unsigned /*status*/) { + auto keep_alive = alive.lock(); + if (!keep_alive) + return; + // Parse on this worker thread; apply state on the main thread. + SMAccountProfile profile; + bool auth_rejected = false; + // A restored session must come from a success envelope and carry + // a usable account id; anything less is not a session. + if (!sm_parse_account_response(body, profile, auth_rejected) || profile.id.empty()) { + // Only a definitive rejection clears the stored session; any + // other unexpected envelope is transient and the token is kept + // for the next launch. Log either way: a silent no-op here is + // indistinguishable from the feature not working at all. + if (!auth_rejected) + BOOST_LOG_TRIVIAL(warning) << "[sm_login] unrecognized account response, " + "keeping stored token for the next launch"; + if (auth_rejected) + CallAfter([this, epoch]() { + // Never clear a session this request did not validate: + // bail if anything changed or a login is now active. + if (epoch != m_sm_login_epoch || m_login_userinfo.is_user_login()) + return; + BOOST_LOG_TRIVIAL(warning) << "[sm_login] stored token rejected by server, clearing"; + sm_clear_login_from_config(); + }); return; } - CallAfter([this, token, user_id, user_name, user_icon_url, user_account]() { - if (!user_id.empty()) m_login_userinfo.set_user_id(user_id); - if (!user_name.empty()) m_login_userinfo.set_user_name(user_name); - if (!user_icon_url.empty()) m_login_userinfo.set_user_icon_url(user_icon_url); - if (!user_account.empty()) m_login_userinfo.set_user_account(user_account); + CallAfter([this, token, epoch, profile]() { + if (epoch != m_sm_login_epoch || m_login_userinfo.is_user_login()) + return; + m_login_userinfo.set_user_id(profile.id); + if (!profile.nickname.empty()) m_login_userinfo.set_user_name(profile.nickname); + if (!profile.icon.empty()) m_login_userinfo.set_user_icon_url(profile.icon); + if (!profile.account.empty()) m_login_userinfo.set_user_account(profile.account); m_login_userinfo.set_user_token(token); m_login_userinfo.set_user_login(true); - sm_save_login_to_config(); + // The stored token is already current; no re-save needed. }); }) - .on_error([this](std::string, std::string, unsigned) { - CallAfter([this]() { sm_clear_login_from_config(); }); - }) - .perform(); + .on_error([this, epoch, alive](std::string, std::string, unsigned status) { + auto keep_alive = alive.lock(); + if (!keep_alive) + return; + // Only definitive rejection clears; transient network failures + // keep the token and the next launch retries. + if (status == 401 || status == 403) + CallAfter([this, epoch, status]() { + if (epoch != m_sm_login_epoch || m_login_userinfo.is_user_login()) + return; + BOOST_LOG_TRIVIAL(warning) << "[sm_login] token rejected with HTTP " << status << ", clearing"; + sm_clear_login_from_config(); + }); + }); + m_sm_login_http = http.perform(); } //BBS @@ -7636,21 +7747,26 @@ void GUI_App::start_download(std::string url) } -void GUI_App::SMUserInfo::notify() { +// Single source of truth for the login-state payload sent to the web UI +// (used by notify(), sw_GetUserLoginState and the subscribe-time push). +json GUI_App::sm_login_state_json() +{ json data; - if (m_login) { + if (m_login_userinfo.is_user_login()) { data["status"] = "online"; - data["nickname"] = m_login_user_name; - data["icon"] = m_login_user_icon_url; - data["token"] = m_login_user_token; - data["userid"] = m_login_user_id; - data["account"] = m_login_user_account; + data["nickname"] = m_login_userinfo.get_user_name(); + data["icon"] = m_login_userinfo.get_user_icon_url(); + data["token"] = m_login_userinfo.get_user_token(); + data["userid"] = m_login_userinfo.get_user_id(); + data["account"] = m_login_userinfo.get_user_account(); } else { data["status"] = "offline"; } + return data; +} - wxGetApp().user_login_notify(data); - +void GUI_App::SMUserInfo::notify() { + wxGetApp().user_login_notify(wxGetApp().sm_login_state_json()); } bool is_support_filament(int extruder_id) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 20308074b24..26c320a1df7 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -59,6 +59,7 @@ class Notebook; struct wxLanguageInfo; namespace Slic3r { +class Http; namespace GUI { class UpdateVersionDialog; }; @@ -595,12 +596,19 @@ class GUI_App : public wxApp void sm_request_login(bool show_user_info = false); void sm_ShowUserLogin(bool show = true); void sm_request_user_logout(); - // Snapmaker login persistence across restarts. Non-secret profile fields are - // kept in AppConfig's [sm_login] section; the bearer token itself is stored in - // the OS secret store (never in plaintext). See GUI_App.cpp for details. + // Snapmaker login persistence across restarts. Only the bearer token is + // persisted, to the OS secret store (never to plaintext config); profile + // fields are re-fetched from the server on restore. AppConfig's [sm_login] + // section holds only a non-secret marker used to skip the keyring lookup + // for users who never signed in. See GUI_App.cpp for details. + unsigned sm_login_epoch() const { return m_sm_login_epoch; } void sm_save_login_to_config(); void sm_clear_login_from_config(); void sm_restore_login_from_config(); + // Drops any persisted session (used when the user turns off "Stay signed in"). + void sm_forget_persisted_login(); + void sm_restore_login_with_token(const std::string& token, unsigned epoch); + json sm_login_state_json(); void request_user_logout(); int request_user_unbind(std::string dev_id); @@ -872,6 +880,15 @@ class GUI_App : public wxApp bool m_flutter_web_copy_notified{ false }; std::string m_open_method; SMUserInfo m_login_userinfo; + // Bumped whenever the login session changes (manual login, logout, clear) + // so an in-flight async restore can detect it is stale and become a no-op. + unsigned m_sm_login_epoch = 0; + // The in-flight session-revalidation request; cancelled in OnExit. + std::shared_ptr m_sm_login_http; + // Liveness token for that request's callbacks, which run on a detached + // worker thread. Released in OnExit: a callback that has already started + // sees the weak reference expire and skips touching the application. + std::shared_ptr m_sm_login_alive = std::make_shared(); public: std::unordered_map> m_recent_file_subscribers; diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 780c40e8937..26ce66410d9 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -770,6 +770,11 @@ wxBoxSizer *PreferencesDialog::create_item_checkbox(wxString title, wxWindow *pa if (param == "allow_filament_temp_mixing" && wxGetApp().plater()) wxGetApp().plater()->notify_filament_usage_changed(); + // Turning "Stay signed in" off must drop what is already stored, + // not just stop storing from now on. + if (param == "remember_login" && !checkbox->GetValue()) + wxGetApp().sm_forget_persisted_login(); + if (param == PRIVACY_POLICY_FLAGS) { app_config->set("app", PRIVACY_POLICY_FLAGS, checkbox->GetValue()); @@ -1234,6 +1239,12 @@ wxWindow* PreferencesDialog::create_general_page() #endif 50, "single_instance"); + auto item_remember_login = create_item_checkbox(_L("Stay signed in"), page, + _L("Keep your Snapmaker account signed in between sessions. The access token is stored in the " + "operating system's secret store (Credential Manager, Keychain or the desktop keyring), never in " + "a configuration file. Turning this off signs you out of the stored session."), + 50, "remember_login"); + std::vector DefaultPage = {_L("Home"), _L("Prepare")}; auto item_default_page = create_item_combobox(_L("Default Page"), page, _L("Set the page opened on startup."), "default_page", DefaultPage); @@ -1360,6 +1371,7 @@ wxWindow* PreferencesDialog::create_general_page() sizer_page->Add(item_default_page, 0, wxTOP, FromDIP(3)); sizer_page->Add(item_camera_navigation_style, 0, wxTOP, FromDIP(3)); sizer_page->Add(item_single_instance, 0, wxTOP, FromDIP(3)); + sizer_page->Add(item_remember_login, 0, wxTOP, FromDIP(3)); sizer_page->Add(item_mouse_zoom_settings, 0, wxTOP, FromDIP(3)); sizer_page->Add(item_use_free_camera_settings, 0, wxTOP, FromDIP(3)); sizer_page->Add(swap_pan_rotate, 0, wxTOP, FromDIP(3)); diff --git a/src/slic3r/GUI/SSWCP.cpp b/src/slic3r/GUI/SSWCP.cpp index b1344becb06..92c00f1560a 100644 --- a/src/slic3r/GUI/SSWCP.cpp +++ b/src/slic3r/GUI/SSWCP.cpp @@ -4619,27 +4619,9 @@ void SSWCP_UserLogin_Instance::sw_UserLogout() void SSWCP_UserLogin_Instance::sw_GetUserLoginState() { try { - json data; - auto pInfo = wxGetApp().sm_get_userinfo(); - if (pInfo) { - bool islogin = pInfo->is_user_login(); - if (islogin) { - data["status"] = "online"; - data["nickname"] = pInfo->get_user_name(); - data["icon"] = pInfo->get_user_icon_url(); - data["token"] = pInfo->get_user_token(); - data["userid"] = pInfo->get_user_id(); - data["account"] = pInfo->get_user_account(); - } else { - data["status"] = "offline"; - } - - m_res_data = data; - send_to_js(); - finish_job(); - } else { - handle_general_fail(); - } + m_res_data = wxGetApp().sm_login_state_json(); + send_to_js(); + finish_job(); } catch (std::exception& e) { handle_general_fail(); @@ -4856,21 +4838,12 @@ void SSWCP_UserLogin_Instance::sw_SubscribeUserLoginState() std::weak_ptr weak_ptr = shared_from_this(); wxGetApp().m_user_login_subscribers[m_webview] = weak_ptr; - // A persisted session is restored asynchronously at startup (the stored - // token is revalidated over the network). That can finish before the web - // UI subscribes here, in which case the one-shot notify() push already - // fired and was lost. So if we are already logged in at subscribe time, - // push the current state now; otherwise a later notify() will deliver it. - auto pInfo = wxGetApp().sm_get_userinfo(); - if (pInfo && pInfo->is_user_login()) { - json data; - data["status"] = "online"; - data["nickname"] = pInfo->get_user_name(); - data["icon"] = pInfo->get_user_icon_url(); - data["token"] = pInfo->get_user_token(); - data["userid"] = pInfo->get_user_id(); - data["account"] = pInfo->get_user_account(); - m_res_data = data; + // A persisted session is restored asynchronously at startup and can + // complete before this subscribe, in which case the one-shot notify() + // push was lost -- so push the current state now if already logged in + // (event framing only; a plain call keeps its request/response shape). + if (m_event_id != "" && wxGetApp().sm_get_userinfo()->is_user_login()) { + m_res_data = wxGetApp().sm_login_state_json(); send_to_js(); } } diff --git a/src/slic3r/GUI/WebSMUserLoginDialog.cpp b/src/slic3r/GUI/WebSMUserLoginDialog.cpp index 14013f4fdf2..0a5db6ab82b 100644 --- a/src/slic3r/GUI/WebSMUserLoginDialog.cpp +++ b/src/slic3r/GUI/WebSMUserLoginDialog.cpp @@ -5,6 +5,7 @@ #include "libslic3r/AppConfig.hpp" #include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/Utils/SnapmakerAccount.hpp" #include "common_func/common_func.hpp" #include @@ -199,25 +200,29 @@ void SMUserLogin::OnNavigationRequest(wxWebViewEvent &evt) http.header("Authorization",token); http.on_complete([&](std::string body, unsigned status) { if (status == 200) { - std::string user_id = ""; - json response = json::parse(body); - if (response.count("data")) { - json data = response["data"]; - if (data.count("id")) { - wxGetApp().sm_get_userinfo()->set_user_id(std::to_string(data["id"].get())); - user_id = std::to_string(data["id"].get()); - } - if (data.count("nickname")) { - wxGetApp().sm_get_userinfo()->set_user_name(data["nickname"].get()); - } - if (data.count("icon")) { - wxGetApp().sm_get_userinfo()->set_user_icon_url(data["icon"].get()); - } - if (data.count("account")) { - wxGetApp().sm_get_userinfo()->set_user_account(data["account"].get()); - } + // Same parser as the startup restore path. The OAuth + // token in hand is fresh, so a login proceeds with + // whatever profile fields parse (as before); only an + // outright rejection of that token stops it. + SMAccountProfile profile; + bool auth_rejected = false; + sm_parse_account_response(body, profile, auth_rejected); + if (auth_rejected) { + // Completing the login would leave the app + // "signed in" with a credential the server has + // already refused. Leave the user signed out. + BOOST_LOG_TRIVIAL(warning) << "[sm_login] account API rejected a freshly issued token"; + return; } - string userInfo = BP_LOGIN_USER_ID + std::string(":") + user_id; + if (!profile.id.empty()) + wxGetApp().sm_get_userinfo()->set_user_id(profile.id); + if (!profile.nickname.empty()) + wxGetApp().sm_get_userinfo()->set_user_name(profile.nickname); + if (!profile.icon.empty()) + wxGetApp().sm_get_userinfo()->set_user_icon_url(profile.icon); + if (!profile.account.empty()) + wxGetApp().sm_get_userinfo()->set_user_account(profile.account); + string userInfo = BP_LOGIN_USER_ID + std::string(":") + profile.id; sentryReportLog(SENTRY_LOG_TRACE, userInfo, BP_LOGIN); wxGetApp().sm_get_userinfo()->set_user_token(token); wxGetApp().sm_get_userinfo()->set_user_login(true); diff --git a/src/slic3r/Utils/SnapmakerAccount.cpp b/src/slic3r/Utils/SnapmakerAccount.cpp new file mode 100644 index 00000000000..a04bd8fdff7 --- /dev/null +++ b/src/slic3r/Utils/SnapmakerAccount.cpp @@ -0,0 +1,55 @@ +#include "SnapmakerAccount.hpp" + +#include + +namespace Slic3r { + +using json = nlohmann::json; + +bool sm_parse_account_response(const std::string& body, SMAccountProfile& profile, bool& auth_rejected) +{ + auth_rejected = false; + auto str_or_empty = [](const json& j, const char* key) { + return j.contains(key) && j[key].is_string() ? j[key].get() : std::string(); + }; + try { + json response = json::parse(body); + int code = -1; + if (response.contains("code")) { + const json& jcode = response["code"]; + if (jcode.is_number_integer()) + code = jcode.get(); + else if (jcode.is_string()) { + try { + code = std::stoi(jcode.get()); + } catch (std::exception&) {} + } + } + auth_rejected = code == SM_API_CODE_AUTHORIZATION_MISSING || + code == SM_API_CODE_TOKEN_EXPIRED || + code == SM_API_CODE_AUTHENTICATION_FAILED; + if (!response.contains("data") || !response["data"].is_object()) + return false; + const json& data = response["data"]; + if (data.contains("id")) { + const json& jid = data["id"]; + if (jid.is_number_integer()) + profile.id = std::to_string(jid.get()); + else if (jid.is_string()) + profile.id = jid.get(); + } + profile.nickname = str_or_empty(data, "nickname"); + profile.icon = str_or_empty(data, "icon"); + profile.account = str_or_empty(data, "account"); + return code == SM_API_CODE_OK; + } catch (std::exception&) { + return false; + } +} + +std::string sm_account_api_base(const std::string& country_code) +{ + return country_code == "CN" ? "https://api.snapmaker.cn" : "https://id.snapmaker.com"; +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/SnapmakerAccount.hpp b/src/slic3r/Utils/SnapmakerAccount.hpp new file mode 100644 index 00000000000..43a4463537f --- /dev/null +++ b/src/slic3r/Utils/SnapmakerAccount.hpp @@ -0,0 +1,41 @@ +#ifndef slic3r_SnapmakerAccount_hpp_ +#define slic3r_SnapmakerAccount_hpp_ + +#include + +namespace Slic3r { + +// Error codes of the Snapmaker account API, as enumerated by the bundled web +// UI (resources/web/flutter_web): 110001 login_failed, 110002 +// authorization_missing, 110003 token_expired, 110004 authentication_failed. +// The API reports these with HTTP 200, so the transport status proves nothing +// about whether a token was accepted. +static constexpr int SM_API_CODE_OK = 200; +static constexpr int SM_API_CODE_AUTHORIZATION_MISSING = 110002; +static constexpr int SM_API_CODE_TOKEN_EXPIRED = 110003; +static constexpr int SM_API_CODE_AUTHENTICATION_FAILED = 110004; + +struct SMAccountProfile +{ + std::string id; + std::string nickname; + std::string icon; + std::string account; +}; + +// Parses a reply from the accounts/current endpoint. Returns true when the +// body is a success envelope (code 200 with a data object), filling whichever +// profile fields are present; sets auth_rejected for the codes that mean the +// token itself was refused, so callers can tell a definitive rejection from a +// transient failure. Callers decide what else to require: restoring a stored +// session demands a usable account id, while a fresh login proceeds unless the +// token was refused. "code" and "id" are tolerated as integers or numeric +// strings, since only the id.snapmaker.com envelope has been observed live. +bool sm_parse_account_response(const std::string& body, SMAccountProfile& profile, bool& auth_rejected); + +// Returns the account API base for a country code as stored in AppConfig. +std::string sm_account_api_base(const std::string& country_code); + +} // namespace Slic3r + +#endif // slic3r_SnapmakerAccount_hpp_ diff --git a/tests/slic3rutils/slic3rutils_tests_main.cpp b/tests/slic3rutils/slic3rutils_tests_main.cpp index 06989c5ee38..d6190c04f1c 100644 --- a/tests/slic3rutils/slic3rutils_tests_main.cpp +++ b/tests/slic3rutils/slic3rutils_tests_main.cpp @@ -58,3 +58,61 @@ TEST_CASE("Http basic authentication", "[Http][NotWorking]") { REQUIRE(status == 200); } +#include "slic3r/Utils/SnapmakerAccount.hpp" + +// The Snapmaker account API reports failures as HTTP 200 with a non-200 body +// code, so this envelope check is what stands between an expired token and a +// "signed in" state with no profile (issues #116/#226). The parser reports +// facts - was this a success envelope, was the token refused, which profile +// fields are present - and each caller applies its own policy. +TEST_CASE("Snapmaker account response envelope", "[sm_login]") { + Slic3r::SMAccountProfile p; + bool rejected = false; + + SECTION("success envelope yields the profile") { + REQUIRE(Slic3r::sm_parse_account_response( + R"({"code":200,"data":{"id":105467,"nickname":"n","icon":"i","account":"a"}})", p, rejected)); + CHECK(p.id == "105467"); + CHECK(p.nickname == "n"); + CHECK(p.account == "a"); + CHECK_FALSE(rejected); + } + SECTION("numeric-string code and id are accepted") { + REQUIRE(Slic3r::sm_parse_account_response(R"({"code":"200","data":{"id":"105467"}})", p, rejected)); + CHECK(p.id == "105467"); + } + SECTION("auth failure codes are definitive rejections") { + for (const char* body : {R"({"code":110002})", R"({"code":110003})", R"({"code":110004})"}) { + CHECK_FALSE(Slic3r::sm_parse_account_response(body, p, rejected)); + CHECK(rejected); + } + } + SECTION("other failures are not rejections, so the token is kept") { + for (const char* body : {R"({"code":500,"msg":"maintenance"})", R"({"msg":"weird"})", + "captive portal", ""}) { + CHECK_FALSE(Slic3r::sm_parse_account_response(body, p, rejected)); + CHECK_FALSE(rejected); + } + } + SECTION("a non-success code is not a success envelope even with data") { + CHECK_FALSE(Slic3r::sm_parse_account_response(R"({"code":500,"data":{"id":7}})", p, rejected)); + CHECK_FALSE(rejected); + } + SECTION("success envelope without an id parses; requiring one is the caller's policy") { + REQUIRE(Slic3r::sm_parse_account_response(R"({"code":200,"data":{"nickname":"n"}})", p, rejected)); + CHECK(p.id.empty()); + CHECK(p.nickname == "n"); + } + SECTION("null fields do not throw or abort the parse") { + REQUIRE(Slic3r::sm_parse_account_response( + R"({"code":200,"data":{"id":7,"nickname":null,"icon":null}})", p, rejected)); + CHECK(p.id == "7"); + CHECK(p.nickname.empty()); + } +} + +TEST_CASE("Snapmaker account API base per region", "[sm_login]") { + CHECK(Slic3r::sm_account_api_base("CN") == "https://api.snapmaker.cn"); + CHECK(Slic3r::sm_account_api_base("US") == "https://id.snapmaker.com"); + CHECK(Slic3r::sm_account_api_base("Others") == "https://id.snapmaker.com"); +} From c65104821b0e038e441624a35e72d3a5885c91c8 Mon Sep 17 00:00:00 2001 From: Roberto Espinoza Date: Mon, 3 Aug 2026 10:06:02 +0900 Subject: [PATCH 4/4] Linux: store the login token via libsecret and the Secret portal Use libsecret's asynchronous password API instead of wxSecretStore on Linux. Inside a Flatpak this lets libsecret select its file backend behind the Secret portal (org.freedesktop.portal.Secret): the token lives in an encrypted per-application keyring keyed by a per-app master secret, so the sandbox needs no org.freedesktop.secrets permission at all and cannot read other applications' secrets. The manifest drops that permission accordingly. Unsandboxed builds talk to the ordinary Secret Service, and hosts with neither a portal nor a keyring degrade to signing in each launch. The asynchronous API avoids blocking the UI thread on a D-Bus round trip; Keychain and Credential Manager on the wxSecretStore path are local and stay synchronous, and both present the same callback-shaped interface so the restore path needs no platform branching. In-flight operations are cancelled on exit through a GCancellable. --- .../io.github.Snapmaker.Snapmaker_Orca.yml | 7 + src/slic3r/GUI/GUI_App.cpp | 134 +++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/scripts/flatpak/io.github.Snapmaker.Snapmaker_Orca.yml b/scripts/flatpak/io.github.Snapmaker.Snapmaker_Orca.yml index 5782c3838ae..93cb93ac452 100644 --- a/scripts/flatpak/io.github.Snapmaker.Snapmaker_Orca.yml +++ b/scripts/flatpak/io.github.Snapmaker.Snapmaker_Orca.yml @@ -19,6 +19,13 @@ finish-args: - --talk-name=io.github.Snapmaker.Snapmaker_Orca.InstanceCheck.* - --system-talk-name=org.freedesktop.UDisks2 - --env=SPNAV_SOCKET=/run/spnav.sock + # Note: no keyring permission on purpose. The Snapmaker login token is + # persisted via libsecret, which inside the sandbox automatically uses its + # file backend + the Secret portal (org.freedesktop.portal.Secret), storing + # the token in an encrypted per-app keyring -- so the app needs no + # org.freedesktop.secrets access and cannot read other apps' secrets. Do not + # pin SECRET_BACKEND=file: it is redundant while the portal is present and + # turns the graceful no-portal fallback into a hard failure. build-options: env: diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index c5413182fab..55d03f054a3 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -74,6 +74,9 @@ #include #include #include +#if defined(__linux__) +#include +#endif #include "slic3r/Utils/SnapmakerAccount.hpp" #include #include @@ -2476,8 +2479,18 @@ bool GUI_App::OnInit() } } +#if defined(__linux__) +// Defined with the other sm_login helpers below. +static GCancellable* sm_login_cancellable(); +#endif + int GUI_App::OnExit() { +#if defined(__linux__) + // Abort any in-flight secret-store operation so its completion callback + // cannot fire against the application object during teardown. + g_cancellable_cancel(sm_login_cancellable()); +#endif // Session revalidation: drop the liveness token, then cancel the // request. Both are best-effort and deliberately so -- Http::cancel() // only aborts a transfer still in progress, and a callback that already @@ -4359,7 +4372,124 @@ static void sm_discard_persisted_session(unsigned epoch) sm_set_session_marker(false, epoch); } -#if wxUSE_SECRETSTORE +#if defined(__linux__) +// Storage via libsecret's async password API: inside a Flatpak, libsecret +// automatically uses its file backend + the Secret portal, keeping the token +// in an encrypted per-app keyring with no org.freedesktop.secrets access +// needed; unsandboxed it talks to the normal Secret Service. Async because +// the *_sync variants can block a UI thread on a D-Bus round trip (the +// wxSecretStore branch below stays synchronous: Keychain and Credential +// Manager are local and fast). A fixed schema/attribute set makes each store +// replace the previous item, so exactly one item ever exists. +static const SecretSchema* sm_login_schema() +{ + static const SecretSchema schema = { + "io.github.Snapmaker.Snapmaker_Orca.Login", + SECRET_SCHEMA_NONE, + { + { "service", SECRET_SCHEMA_ATTRIBUTE_STRING }, + { nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING }, + } + }; + return &schema; +} +static const char SM_LOGIN_SERVICE_ATTR[] = "Snapmaker_Orca/login"; + +// Cancelled in OnExit so completion callbacks cannot outlive the app. +static GCancellable* sm_login_cancellable() +{ + static GCancellable* cancellable = g_cancellable_new(); + return cancellable; +} + +static void sm_secret_store_save(const std::string& token, unsigned epoch) +{ + // libsecret copies the label/password/attribute strings during this call, + // so caller-owned temporaries are safe despite the async completion. The + // callback runs on the GLib main loop, which wxGTK iterates. The login + // epoch rides in user_data so a store that completes after a logout + // neither re-creates the session marker nor leaves the item behind. + secret_password_store(sm_login_schema(), SECRET_COLLECTION_DEFAULT, + "Snapmaker_Orca login token", token.c_str(), + sm_login_cancellable(), + [](GObject*, GAsyncResult* res, gpointer user_data) { + GError* error = nullptr; + const unsigned epoch = GPOINTER_TO_UINT(user_data); + if (secret_password_store_finish(res, &error)) { + // If the session moved on while this was in + // flight, the newer login owns the stored + // item (same schema and attributes, so its + // write replaced this one) and its own + // callback owns the marker: nothing to do. + sm_set_session_marker(true, epoch); + } else { + if (!g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + BOOST_LOG_TRIVIAL(info) << "[sm_login] secret store unavailable, token not persisted: " + << (error ? error->message : "unknown error"); + sm_discard_persisted_session(epoch); + } + if (error) + g_error_free(error); + } + }, + GUINT_TO_POINTER(epoch), + "service", SM_LOGIN_SERVICE_ATTR, + nullptr); +} + +// Reads the stored token. `done` is invoked with store_available=false when +// the secret store itself could not be consulted (leave the session marker +// alone and retry next launch), or true with a possibly-empty token. Every +// platform below uses this same shape so callers need no #if. +static void sm_secret_store_load(unsigned epoch, std::function done) +{ + using Ctx = std::pair>; + auto* ctx = new Ctx(epoch, std::move(done)); + secret_password_lookup(sm_login_schema(), sm_login_cancellable(), + [](GObject*, GAsyncResult* res, gpointer user_data) { + std::unique_ptr ctx(static_cast(user_data)); + GError* error = nullptr; + gchar* secret = secret_password_lookup_finish(res, &error); + if (error) { + if (!g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + BOOST_LOG_TRIVIAL(info) << "[sm_login] secret store unavailable: " << error->message; + g_error_free(error); + ctx->second(false, std::string()); + return; + } + std::string token; + if (secret) { + token = secret; + secret_password_free(secret); + } + ctx->second(true, std::move(token)); + }, + ctx, + "service", SM_LOGIN_SERVICE_ATTR, + nullptr); +} + +static void sm_secret_store_clear() +{ + // Best-effort async delete: quitting immediately after logout can leave + // the item behind, which is acceptable -- logout revokes the token + // server-side first, so any residue is inert and replaced on next login. + secret_password_clear(sm_login_schema(), sm_login_cancellable(), + [](GObject*, GAsyncResult* res, gpointer) { + GError* error = nullptr; + // Returns FALSE without error when nothing was stored. + secret_password_clear_finish(res, &error); + if (error) { + if (!g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + BOOST_LOG_TRIVIAL(warning) << "[sm_login] failed to clear stored token: " << error->message; + g_error_free(error); + } + }, + nullptr, + "service", SM_LOGIN_SERVICE_ATTR, + nullptr); +} +#elif wxUSE_SECRETSTORE static const wxString SM_LOGIN_SECRET_SERVICE = "Snapmaker_Orca/login"; static void sm_secret_store_save(const std::string& token, unsigned epoch) @@ -4413,7 +4543,7 @@ static void sm_secret_store_clear() static void sm_secret_store_save(const std::string&, unsigned) {} static void sm_secret_store_load(unsigned, std::function done) { done(false, std::string()); } static void sm_secret_store_clear() {} -#endif // wxUSE_SECRETSTORE +#endif // __linux__ / wxUSE_SECRETSTORE void GUI_App::sm_save_login_to_config() {