Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/flatpak/io.github.Snapmaker.Snapmaker_Orca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/libslic3r/AppConfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
2 changes: 2 additions & 0 deletions src/slic3r/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
418 changes: 402 additions & 16 deletions src/slic3r/GUI/GUI_App.cpp

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion src/slic3r/GUI/GUI_App.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class Notebook;
struct wxLanguageInfo;

namespace Slic3r {
class Http;
namespace GUI {
class UpdateVersionDialog;
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Http> 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<void> m_sm_login_alive = std::make_shared<char>();

public:
std::unordered_map<void*, std::weak_ptr<SSWCP_Instance>> m_recent_file_subscribers;
Expand Down
12 changes: 12 additions & 0 deletions src/slic3r/GUI/Preferences.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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<wxString> 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);

Expand Down Expand Up @@ -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));
Expand Down
33 changes: 12 additions & 21 deletions src/slic3r/GUI/SSWCP.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -4855,6 +4837,15 @@ void SSWCP_UserLogin_Instance::sw_SubscribeUserLoginState()
try {
std::weak_ptr<SSWCP_Instance> 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();
Expand Down
43 changes: 25 additions & 18 deletions src/slic3r/GUI/WebSMUserLoginDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wx/sizer.h>
Expand Down Expand Up @@ -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<int>()));
user_id = std::to_string(data["id"].get<int>());
}
if (data.count("nickname")) {
wxGetApp().sm_get_userinfo()->set_user_name(data["nickname"].get<std::string>());
}
if (data.count("icon")) {
wxGetApp().sm_get_userinfo()->set_user_icon_url(data["icon"].get<std::string>());
}
if (data.count("account")) {
wxGetApp().sm_get_userinfo()->set_user_account(data["account"].get<std::string>());
}
// 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) {
Expand Down
55 changes: 55 additions & 0 deletions src/slic3r/Utils/SnapmakerAccount.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#include "SnapmakerAccount.hpp"

#include <nlohmann/json.hpp>

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>() : 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<int>();
else if (jcode.is_string()) {
try {
code = std::stoi(jcode.get<std::string>());
} 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<long long>());
else if (jid.is_string())
profile.id = jid.get<std::string>();
}
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
41 changes: 41 additions & 0 deletions src/slic3r/Utils/SnapmakerAccount.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#ifndef slic3r_SnapmakerAccount_hpp_
#define slic3r_SnapmakerAccount_hpp_

#include <string>

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_
58 changes: 58 additions & 0 deletions tests/slic3rutils/slic3rutils_tests_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"})",
"<html>captive portal</html>", ""}) {
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");
}