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/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 4f6dc46c54c..55d03f054a3 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 @@ -70,6 +73,11 @@ #include #include #include +#include +#if defined(__linux__) +#include +#endif +#include "slic3r/Utils/SnapmakerAccount.hpp" #include #include @@ -1189,6 +1197,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(); @@ -2466,8 +2479,27 @@ 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 + // 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) { @@ -4286,19 +4318,368 @@ 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(); } 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 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 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) +{ + wxSecretStore store = wxSecretStore::GetDefault(); + wxString errmsg; + if (!store.IsOk(&errmsg)) { + BOOST_LOG_TRIVIAL(info) << "[sm_login] secret store unavailable, token not persisted: " << errmsg.ToStdString(); + sm_discard_persisted_session(epoch); + return; + } + // 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); +} + +// 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(info) << "[sm_login] secret store unavailable: " << errmsg.ToStdString(); + done(false, std::string()); + return; + } + wxString user; + wxSecretValue value; + std::string token; + if (store.Load(SM_LOGIN_SECRET_SERVICE, user, value)) + token = std::string(value.GetAsString(wxConvUTF8).ToUTF8()); + done(true, std::move(token)); +} + +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): +// 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 // __linux__ / wxUSE_SECRETSTORE + +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; + } + 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(); + 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() +{ + 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"; + + // 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); + // 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, 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); + // The stored token is already current; no re-save needed. + }); + }) + .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 @@ -7496,21 +7877,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 edaf1b11101..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,7 +596,20 @@ 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. 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); std::string handle_web_request(std::string cmd); @@ -866,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 4868afd11fa..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(); @@ -4855,6 +4837,15 @@ 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 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(); + } } catch (std::exception& e) { handle_general_fail(); diff --git a/src/slic3r/GUI/WebSMUserLoginDialog.cpp b/src/slic3r/GUI/WebSMUserLoginDialog.cpp index 9b23e17535b..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,28 +200,34 @@ 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); + // 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) { 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"); +}