diff --git a/CMakeLists.txt b/CMakeLists.txt index b4f3a964d1e..e84192423bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,9 @@ set(OPENVDB_FIND_MODULE_PATH "" CACHE PATH "Path to OpenVDB installation's find set(SLIC3R_GTK "2" CACHE STRING "GTK version to use with wxWidgets on Linux") +# Empty leaves Discord Rich Presence inert. +set(SLIC3R_DISCORD_APP_ID "" CACHE STRING "Discord application id for Rich Presence") + set(IS_CROSS_COMPILE FALSE) set(FLATPAK FALSE CACHE BOOL "Not copy FFMPEG file") diff --git a/bbl/i18n/list.txt b/bbl/i18n/list.txt index 70b220670ca..7d9a45e2815 100644 --- a/bbl/i18n/list.txt +++ b/bbl/i18n/list.txt @@ -143,6 +143,7 @@ src/slic3r/GUI/BedShapeDialog.hpp src/slic3r/GUI/ConfigManipulation.cpp src/slic3r/GUI/DeviceManager.cpp src/slic3r/GUI/DeviceErrorDialog.cpp +src/slic3r/GUI/DiscordPresenceSnapshot.cpp src/slic3r/GUI/ExtraRenderers.cpp src/slic3r/GUI/Field.cpp src/slic3r/GUI/GCodeRenderer/AdvancedRenderer.cpp diff --git a/resources/images/discord/bambu_studio.png b/resources/images/discord/bambu_studio.png new file mode 100644 index 00000000000..6e8e0a2b964 Binary files /dev/null and b/resources/images/discord/bambu_studio.png differ diff --git a/resources/images/discord/state_done.png b/resources/images/discord/state_done.png new file mode 100644 index 00000000000..030aa80de2c Binary files /dev/null and b/resources/images/discord/state_done.png differ diff --git a/resources/images/discord/state_error.png b/resources/images/discord/state_error.png new file mode 100644 index 00000000000..acc879646bb Binary files /dev/null and b/resources/images/discord/state_error.png differ diff --git a/resources/images/discord/state_idle.png b/resources/images/discord/state_idle.png new file mode 100644 index 00000000000..b33118afb88 Binary files /dev/null and b/resources/images/discord/state_idle.png differ diff --git a/resources/images/discord/state_paused.png b/resources/images/discord/state_paused.png new file mode 100644 index 00000000000..04c085c1212 Binary files /dev/null and b/resources/images/discord/state_paused.png differ diff --git a/resources/images/discord/state_printing.png b/resources/images/discord/state_printing.png new file mode 100644 index 00000000000..850c7433aa9 Binary files /dev/null and b/resources/images/discord/state_printing.png differ diff --git a/resources/images/discord/state_slicing.png b/resources/images/discord/state_slicing.png new file mode 100644 index 00000000000..ae90e87f90a Binary files /dev/null and b/resources/images/discord/state_slicing.png differ diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index d24bb201968..faa8de704db 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -132,6 +132,11 @@ void AppConfig::set_defaults() if (get("single_instance").empty()) set_bool("single_instance", false); + // Discord Rich Presence sends activity off the machine, so it is opt-in. + if (get("discord_rich_presence").empty()) + set_bool("discord_rich_presence", false); + if (get("discord_rich_presence_hide_names").empty()) + set_bool("discord_rich_presence_hide_names", false); if (get("import_3mf_as_project").empty()) set_bool("import_3mf_as_project", true); if (get("show_bed_heat_soak_area").empty()) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index cd0d64477b1..91d373016ac 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -392,6 +392,8 @@ set(SLIC3R_GUI_SOURCES GUI/ImageMessageDialog.cpp GUI/DeviceManager.hpp GUI/DeviceManager.cpp + GUI/DiscordPresenceSnapshot.hpp + GUI/DiscordPresenceSnapshot.cpp GUI/DeviceErrorDialog.hpp GUI/DeviceErrorDialog.cpp GUI/UserManager.hpp @@ -605,6 +607,10 @@ set(SLIC3R_GUI_SOURCES Utils/EmbossStyleManager.hpp Utils/Http.cpp Utils/Http.hpp + Utils/DiscordIPC.cpp + Utils/DiscordIPC.hpp + Utils/DiscordPresence.cpp + Utils/DiscordPresence.hpp Utils/FixModelByWin10.cpp Utils/FixModelByWin10.hpp Utils/Bonjour.cpp @@ -841,3 +847,7 @@ endif () # Add a definition so that we can tell we are compiling slic3r. target_compile_definitions(libslic3r_gui PRIVATE SLIC3R_CURRENTLY_COMPILING_GUI_MODULE) + +if (SLIC3R_DISCORD_APP_ID) + target_compile_definitions(libslic3r_gui PRIVATE SLIC3R_DISCORD_APP_ID="${SLIC3R_DISCORD_APP_ID}") +endif () diff --git a/src/slic3r/GUI/DiscordPresenceSnapshot.cpp b/src/slic3r/GUI/DiscordPresenceSnapshot.cpp new file mode 100644 index 00000000000..a3fdf45c0df --- /dev/null +++ b/src/slic3r/GUI/DiscordPresenceSnapshot.cpp @@ -0,0 +1,184 @@ +#include "DiscordPresenceSnapshot.hpp" + +#include + +#include "DeviceManager.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "PartPlate.hpp" +#include "Plater.hpp" +#include "DeviceCore/DevManager.h" + +#include "libslic3r/Model.hpp" + +namespace Slic3r { +namespace GUI { + +using Activity = PresenceSnapshot::Activity; + +static int64_t unix_now() +{ + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +// Elapsed-time origin for states with no more meaningful one of their own. +static int64_t session_start_time() +{ + static const int64_t started = unix_now(); + return started; +} + +// A printer keeps reporting FINISH until the next job starts, so terminal +// states have to expire or the profile sticks on "Print complete" for hours. +static const int64_t TERMINAL_STATUS_LINGER_S = 5 * 60; + +// GUI thread only. +static int64_t seconds_in_status(const std::string &status_key) +{ + static std::string last_key; + static int64_t entered_at = 0; + + const int64_t now = unix_now(); + if (status_key != last_key) { + last_key = status_key; + entered_at = now; + } + return now - entered_at; +} + +static std::string percent_suffix(int percent) +{ + if (percent < 0) + return std::string(); + return " - " + std::to_string(percent) + "%"; +} + +// False when there is no printer worth reporting. +static bool collect_from_printer(PresenceSnapshot &snapshot, bool hide_names) +{ + DeviceManager *dev = wxGetApp().getDeviceManager(); + if (dev == nullptr) + return false; + + MachineObject *obj = dev->get_selected_machine(); + if (obj == nullptr || !obj->is_connected()) + return false; + + snapshot.small_text = obj->get_printer_type_display_str().ToUTF8().data(); + + const std::string job_name = obj->subtask_name; + snapshot.details = (hide_names || job_name.empty()) ? _u8L("A print job") : job_name; + + // Tracked on every call so a RUNNING -> FINISH transition is seen at once. + const int64_t status_age = seconds_in_status(obj->print_status + "|" + job_name); + + const int percent = obj->mc_print_percent; + + if (obj->is_in_printing_pause()) { + snapshot.activity = Activity::Paused; + snapshot.state = _u8L("Paused") + percent_suffix(percent); + return true; + } + + // is_in_printing() also covers PREPARE and on-device SLICING, which can + // each last minutes; "Printing - 0%" would be misleading. + if (obj->print_status == "PREPARE") { + snapshot.activity = Activity::Printing; + snapshot.state = _u8L("Preparing"); + return true; + } + + if (obj->print_status == "SLICING") { + snapshot.activity = Activity::Slicing; + snapshot.state = _u8L("Slicing on the printer"); + return true; + } + + if (obj->is_in_printing()) { + snapshot.activity = Activity::Printing; + snapshot.state = _u8L("Printing") + percent_suffix(percent); + // An absolute end time lets Discord run the countdown itself. + if (obj->mc_left_time > 0) + snapshot.end_time = unix_now() + obj->mc_left_time; + return true; + } + + if (status_age <= TERMINAL_STATUS_LINGER_S) { + if (obj->print_status == "FAILED") { + snapshot.activity = Activity::Failed; + snapshot.state = _u8L("Print failed"); + return true; + } + if (obj->print_status == "FINISH") { + snapshot.activity = Activity::Finished; + snapshot.state = _u8L("Print complete"); + return true; + } + } + + // Connected but idle: local activity is more interesting than "printer is on". + snapshot = PresenceSnapshot(); + return false; +} + +static void collect_from_plater(PresenceSnapshot &snapshot, bool hide_names) +{ + snapshot.start_time = session_start_time(); + + Plater *plater = wxGetApp().plater(); + if (plater == nullptr) { + snapshot.activity = Activity::Idle; + snapshot.state = _u8L("Idle"); + return; + } + + const std::string project = plater->get_project_name().ToUTF8().data(); + const size_t objects = plater->model().objects.size(); + + if (objects == 0) { + snapshot.activity = Activity::Idle; + snapshot.state = _u8L("Idle"); + return; + } + + snapshot.details = (hide_names || project.empty()) ? _u8L("Working on a project") : project; + + // PartPlate holds the percentage the progress bar is driven from: -1 when + // unsliced, 0..100 while slicing, 100 once the result is valid. + PartPlate *plate = plater->get_partplate_list().get_curr_plate(); + if (plate != nullptr && !plate->is_slice_result_valid()) { + const float percent = plate->get_slicing_percent(); + if (percent >= 0.f && percent < 100.f) { + snapshot.activity = Activity::Slicing; + snapshot.state = _u8L("Slicing") + percent_suffix(static_cast(percent)); + return; + } + } + + if (plater->is_preview_shown()) { + snapshot.activity = Activity::Previewing; + snapshot.state = _u8L("Previewing"); + return; + } + + snapshot.activity = Activity::Editing; + snapshot.state = wxString::Format(_L_PLURAL("Editing - %d object", "Editing - %d objects", (int) objects), + (int) objects) + .ToUTF8() + .data(); +} + +PresenceSnapshot collect_presence_snapshot(bool hide_names) +{ + PresenceSnapshot snapshot; + + if (collect_from_printer(snapshot, hide_names)) + return snapshot; + + collect_from_plater(snapshot, hide_names); + return snapshot; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/DiscordPresenceSnapshot.hpp b/src/slic3r/GUI/DiscordPresenceSnapshot.hpp new file mode 100644 index 00000000000..5d2a3c40a4a --- /dev/null +++ b/src/slic3r/GUI/DiscordPresenceSnapshot.hpp @@ -0,0 +1,20 @@ +#ifndef slic3r_GUI_DiscordPresenceSnapshot_hpp_ +#define slic3r_GUI_DiscordPresenceSnapshot_hpp_ + +#include "slic3r/Utils/DiscordPresence.hpp" + +namespace Slic3r { +namespace GUI { + +// Read the current application and printer state and describe it as a snapshot +// ready to publish. Must be called on the GUI thread; safe to call before the +// Plater exists (it reports Idle). +// +// hide_names honours the privacy preference: progress and state are kept, but +// project and print job names are replaced with generic wording. +PresenceSnapshot collect_presence_snapshot(bool hide_names); + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GUI_DiscordPresenceSnapshot_hpp_ diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index a87793f5c2d..09b130393d9 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -77,6 +77,8 @@ #include "GUI.hpp" #include "GUI_Utils.hpp" +#include "DiscordPresenceSnapshot.hpp" +#include "slic3r/Utils/DiscordPresence.hpp" #include "3DScene.hpp" #include "MainFrame.hpp" #include "slic3r/GUI/Widgets/WebView.hpp" @@ -2840,12 +2842,53 @@ bool GUI_App::OnInit() } } +void GUI_App::apply_discord_presence_setting() +{ + if (!app_config) + return; + + const bool enabled = app_config->get_bool("discord_rich_presence"); + + // Opted out and never opted in: do not even construct the publisher, so the + // feature costs nothing at all for users who do not want it. + if (!enabled && !m_discord_presence) + return; + + // Config overrides the compiled-in id, so no rebuild is needed to retarget. + std::string application_id = app_config->get("discord_rich_presence_app_id"); + if (application_id.empty()) + application_id = Slic3r::discord_default_application_id(); + + // The id is fixed for the publisher's lifetime, so a change must replace it. + if (m_discord_presence && m_discord_presence->application_id() != application_id) + m_discord_presence.reset(); + + if (!m_discord_presence) + m_discord_presence.reset( + new Slic3r::DiscordPresence(application_id, std::string("Bambu Studio ") + SLIC3R_VERSION)); + + m_discord_presence->set_enabled(enabled); +} + +void GUI_App::update_discord_presence() +{ + if (!m_discord_presence || !m_discord_presence->is_enabled()) + return; + + const bool hide_names = app_config && app_config->get_bool("discord_rich_presence_hide_names"); + m_discord_presence->update(collect_presence_snapshot(hide_names)); +} + int GUI_App::OnExit() { #ifdef __APPLE__ UnRegisterMacPowerCallBack(); #endif + // Stop before the device manager goes away: the worker formats state that + // was read from it, and its destructor clears the presence from the profile. + m_discord_presence.reset(); + Slic3r::HelioQuery::shutdown_background_requests(); stop_sync_user_preset(); @@ -3605,6 +3648,15 @@ bool GUI_App::on_init_inner() app_config->save(); } + // Discord Rich Presence. This only bounds how often the state is read; + // the publisher does its own change detection and rate limiting, and + // returns immediately when the feature is switched off. + { + static auto s_last_discord_update = std::chrono::steady_clock::time_point{}; + if (Slic3r::debounce_elapsed(s_last_discord_update, std::chrono::seconds(1))) + update_discord_presence(); + } + // BBS //this->obj_manipul()->update_if_dirty(); @@ -3634,6 +3686,8 @@ bool GUI_App::on_init_inner() } }); + apply_discord_presence_setting(); + m_initialized = true; flush_logs(); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 934becb911b..f1e723d67d6 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -80,6 +80,7 @@ class UserManager; class DeviceManager; class NetworkAgent; class TaskManager; +class DiscordPresence; namespace GUI{ @@ -331,6 +332,7 @@ class GUI_App : public wxApp std::unique_ptr m_printhost_job_queue; std::unique_ptr m_other_instance_message_handler; std::unique_ptr m_single_instance_checker; + std::unique_ptr m_discord_presence; std::string m_instance_hash_string; size_t m_instance_hash_int; @@ -403,6 +405,10 @@ class GUI_App : public wxApp bool confirm_mesh_paint_warning(); EAppMode get_app_mode() const { return m_app_mode; } Slic3r::DeviceManager* getDeviceManager() { return m_device_manager; } + // Discord Rich Presence. apply_* reads the preference and starts or stops + // the publisher; update_* pushes the current state and is driven from idle. + void apply_discord_presence_setting(); + void update_discord_presence(); bool is_blocking_printing(MachineObject *obj_ = nullptr); Slic3r::TaskManager* getTaskManager() { return m_task_manager; } wgtFilaManagerStore* fila_manager_store() { return m_fila_manager_store; } diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 614a6485c82..fc21ed3954a 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -947,6 +947,12 @@ wxBoxSizer *PreferencesDialog::create_item_checkbox(wxString title, wxWindow *pa BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " sync_user_preset: " << (sync ? "true" : "false"); } + if (param == "discord_rich_presence") { + // Takes effect immediately: enabling starts publishing, disabling + // clears the presence from the user's profile. + wxGetApp().apply_discord_presence_setting(); + } + #ifdef __WXMSW__ if (param == "associate_3mf") { bool pbool = app_config->get("associate_3mf") == "true" ? true : false; @@ -1486,6 +1492,15 @@ wxWindow *PreferencesDialog::create_general_tab() auto item_beta_version_update = create_item_checkbox(_L("Support beta version update."), scrolled, _L("With this option enabled, you can receive beta version updates."), 50, "enable_beta_version_update"); + auto item_discord_presence = create_item_checkbox(_L("Show current activity on Discord"), scrolled, + _L("Publish what you are working on to your Discord profile: the current project, slicing progress, " + "and print progress when a printer is connected. Nothing is sent when Discord is not running."), + 50, "discord_rich_presence"); + + auto item_discord_hide_names = create_item_checkbox(_L("Hide project and print job names on Discord"), scrolled, + _L("Replace project and print job names with generic wording, while still showing progress and status."), + 50, "discord_rich_presence_hide_names"); + // User Experience Improvement Program + "what data" hyperlink. auto item_priv_policy = create_item_checkbox(_L("Join the User Experience Improvement Program."), scrolled, "", 50, "privacyuse"); auto *hyperlink = new Label(scrolled, wxString::FromUTF8(_CTX_utf8(L_CONTEXT("Learn more", "Preferences"), "Preferences"))); @@ -1517,6 +1532,8 @@ wxWindow *PreferencesDialog::create_general_tab() sizer->Add(wrap_option_row(scrolled, item_fila_manager), flags); sizer->Add(wrap_option_row(scrolled, item_multi_machine), flags); sizer->Add(wrap_option_row(scrolled, item_beta_version_update), flags); + sizer->Add(wrap_option_row(scrolled, item_discord_presence), flags); + sizer->Add(wrap_option_row(scrolled, item_discord_hide_names), flags); sizer->Add(wrap_option_row(scrolled, item_priv_policy), flags); sizer->Add(wrap_option_row(scrolled, item_downloads), flags); scrolled->SetSizer(sizer); diff --git a/src/slic3r/Utils/DiscordIPC.cpp b/src/slic3r/Utils/DiscordIPC.cpp new file mode 100644 index 00000000000..f0f7444b9b5 --- /dev/null +++ b/src/slic3r/Utils/DiscordIPC.cpp @@ -0,0 +1,450 @@ +#include "DiscordIPC.hpp" + +#include +#include +#include + +#include + +#ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + // windows.h would otherwise macro-replace min/max. Not relying on the + // PCH for this, since SLIC3R_PCH can be off. + #define NOMINMAX + #endif + #include +#else + #include + #include + #include + #include + #include + #include + #include +#endif + +namespace Slic3r { + +static const size_t DISCORD_HEADER_BYTES = 8; + +std::vector discord_encode_frame(DiscordOpcode opcode, const std::string &payload) +{ + const uint32_t op = static_cast(opcode); + const uint32_t len = static_cast(payload.size()); + + std::vector frame; + frame.reserve(DISCORD_HEADER_BYTES + payload.size()); + for (int i = 0; i < 4; ++i) + frame.push_back(static_cast((op >> (8 * i)) & 0xFF)); + for (int i = 0; i < 4; ++i) + frame.push_back(static_cast((len >> (8 * i)) & 0xFF)); + frame.insert(frame.end(), payload.begin(), payload.end()); + return frame; +} + +bool discord_decode_header(const char *data, size_t size, DiscordOpcode &opcode, uint32_t &length) +{ + if (data == nullptr || size < DISCORD_HEADER_BYTES) + return false; + + uint32_t op = 0, len = 0; + for (int i = 0; i < 4; ++i) + op |= static_cast(static_cast(data[i])) << (8 * i); + for (int i = 0; i < 4; ++i) + len |= static_cast(static_cast(data[4 + i])) << (8 * i); + + if (op > static_cast(DiscordOpcode::Pong)) + return false; + if (len > DISCORD_MAX_FRAME_BYTES) + return false; + + opcode = static_cast(op); + length = len; + return true; +} + +std::string discord_truncate_utf8(const std::string &text, size_t max_bytes) +{ + if (text.size() <= max_bytes) + return text; + + static const char * ellipsis = "\xE2\x80\xA6"; // U+2026 + static const size_t ellipsis_bytes = 3; + + if (max_bytes <= ellipsis_bytes) + return std::string(); + + size_t cut = max_bytes - ellipsis_bytes; + while (cut > 0 && (static_cast(text[cut]) & 0xC0) == 0x80) + --cut; + + return text.substr(0, cut) + ellipsis; +} + +std::vector discord_candidate_endpoints() +{ + std::vector endpoints; + +#ifdef _WIN32 + for (int i = 0; i < 10; ++i) + endpoints.push_back("\\\\.\\pipe\\discord-ipc-" + std::to_string(i)); +#else + std::vector bases; + for (const char *var : { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }) { + const char *value = std::getenv(var); + if (value != nullptr && *value != '\0') + bases.push_back(value); + } + bases.push_back("/tmp"); + + // Sandboxed Discord builds nest the socket one level deeper. + static const char *subdirs[] = { "", "app/com.discordapp.Discord/", "snap.discord/" }; + + for (std::string base : bases) { + while (!base.empty() && base.back() == '/') + base.pop_back(); + for (const char *subdir : subdirs) + for (int i = 0; i < 10; ++i) + endpoints.push_back(base + "/" + subdir + "discord-ipc-" + std::to_string(i)); + } +#endif + + return endpoints; +} + +#ifdef _WIN32 + +bool DiscordIPC::is_connected() const { return m_open; } + +void DiscordIPC::close() +{ + if (m_open) { + ::CloseHandle(static_cast(m_pipe)); + ::CloseHandle(static_cast(m_event)); + m_pipe = nullptr; + m_event = nullptr; + m_open = false; + } +} + +bool DiscordIPC::connect_endpoint(const std::string &path) +{ + // Overlapped, so a stalled Discord cannot block the worker thread forever. + HANDLE pipe = ::CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr); + if (pipe == INVALID_HANDLE_VALUE) + return false; + + HANDLE event = ::CreateEventA(nullptr, TRUE, FALSE, nullptr); + if (event == nullptr) { + ::CloseHandle(pipe); + return false; + } + + m_pipe = pipe; + m_event = event; + m_open = true; + return true; +} + +// Runs one overlapped operation to completion, cancelling it if the deadline +// passes. Only ever called from the worker thread, so sharing m_event is safe. +static bool await_overlapped(HANDLE pipe, OVERLAPPED &ov, bool pending, DWORD &transferred, ULONGLONG deadline) +{ + if (pending) { + const ULONGLONG now = ::GetTickCount64(); + const DWORD wait_ms = now >= deadline ? 0 : static_cast(deadline - now); + if (::WaitForSingleObject(ov.hEvent, wait_ms) != WAIT_OBJECT_0) { + // The blocking wait lets the cancel settle before ov leaves scope. + ::CancelIoEx(pipe, &ov); + ::GetOverlappedResult(pipe, &ov, &transferred, TRUE); + return false; + } + } + // Authoritative even when the call completed synchronously, which is why + // the byte count is not taken from the WriteFile/ReadFile out parameter. + return ::GetOverlappedResult(pipe, &ov, &transferred, FALSE) != FALSE && transferred > 0; +} + +bool DiscordIPC::write_all(const char *src, size_t count) +{ + const ULONGLONG deadline = ::GetTickCount64() + 2000; + + size_t written = 0; + while (written < count) { + OVERLAPPED ov {}; + ov.hEvent = static_cast(m_event); + ::ResetEvent(ov.hEvent); + + DWORD chunk = 0; + const BOOL ok = ::WriteFile(static_cast(m_pipe), src + written, + static_cast(count - written), &chunk, &ov); + const bool pending = !ok && ::GetLastError() == ERROR_IO_PENDING; + if (!ok && !pending) + return false; + if (!await_overlapped(static_cast(m_pipe), ov, pending, chunk, deadline)) + return false; + + written += chunk; + if (written < count && ::GetTickCount64() >= deadline) + return false; + } + return true; +} + +DiscordIPC::ReadResult DiscordIPC::wait_readable(int timeout_ms) +{ + const ULONGLONG deadline = ::GetTickCount64() + static_cast(std::max(0, timeout_ms)); + for (;;) { + DWORD available = 0; + if (!::PeekNamedPipe(static_cast(m_pipe), nullptr, 0, nullptr, &available, nullptr)) + return ReadResult::Closed; + if (available >= DISCORD_HEADER_BYTES) + return ReadResult::Frame; + if (::GetTickCount64() >= deadline) + return ReadResult::NoData; + ::Sleep(10); + } +} + +bool DiscordIPC::read_exactly(char *dst, size_t count, int timeout_ms) +{ + const ULONGLONG deadline = ::GetTickCount64() + static_cast(std::max(0, timeout_ms)); + size_t got = 0; + while (got < count) { + DWORD available = 0; + if (!::PeekNamedPipe(static_cast(m_pipe), nullptr, 0, nullptr, &available, nullptr)) + return false; + if (available == 0) { + if (::GetTickCount64() >= deadline) + return false; + ::Sleep(10); + continue; + } + OVERLAPPED ov {}; + ov.hEvent = static_cast(m_event); + ::ResetEvent(ov.hEvent); + + DWORD chunk = 0; + const DWORD want = static_cast(std::min(count - got, available)); + const BOOL ok = ::ReadFile(static_cast(m_pipe), dst + got, want, &chunk, &ov); + const bool pending = !ok && ::GetLastError() == ERROR_IO_PENDING; + if (!ok && !pending) + return false; + if (!await_overlapped(static_cast(m_pipe), ov, pending, chunk, deadline)) + return false; + got += chunk; + } + return true; +} + +#else // POSIX + +bool DiscordIPC::is_connected() const { return m_fd >= 0; } + +void DiscordIPC::close() +{ + if (m_fd >= 0) { + ::close(m_fd); + m_fd = -1; + } +} + +bool DiscordIPC::connect_endpoint(const std::string &path) +{ + sockaddr_un addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + if (path.size() >= sizeof(addr.sun_path)) + return false; + std::memcpy(addr.sun_path, path.c_str(), path.size()); + + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return false; + +#ifdef FD_CLOEXEC + // Do not leak the socket into child processes. + const int fd_flags = ::fcntl(fd, F_GETFD, 0); + if (fd_flags >= 0) + ::fcntl(fd, F_SETFD, fd_flags | FD_CLOEXEC); +#endif + + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ::close(fd); + return false; + } + +#ifdef SO_NOSIGPIPE + // Discord quitting mid-write would otherwise raise SIGPIPE and kill the + // process. Linux has no SO_NOSIGPIPE and uses MSG_NOSIGNAL below. + const int nosigpipe = 1; + ::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, sizeof(nosigpipe)); +#endif + + // Non-blocking from here on, so a stalled Discord cannot wedge the worker. + const int fl = ::fcntl(fd, F_GETFL, 0); + if (fl >= 0) + ::fcntl(fd, F_SETFL, fl | O_NONBLOCK); + + m_fd = fd; + return true; +} + +bool DiscordIPC::write_all(const char *src, size_t count) +{ + size_t written = 0; + int budget_ms = 2000; + +#ifdef MSG_NOSIGNAL + const int flags = MSG_NOSIGNAL; +#else + const int flags = 0; +#endif + + while (written < count) { + const ssize_t n = ::send(m_fd, src + written, count - written, flags); + if (n > 0) { + written += static_cast(n); + continue; + } + if (n < 0 && errno == EINTR) + continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + pollfd pfd { m_fd, POLLOUT, 0 }; + const int step = 100; + const int ready = ::poll(&pfd, 1, step); + if (ready < 0 && errno != EINTR) + return false; + budget_ms -= step; + if (budget_ms <= 0) + return false; + continue; + } + return false; + } + return true; +} + +DiscordIPC::ReadResult DiscordIPC::wait_readable(int timeout_ms) +{ + pollfd pfd { m_fd, POLLIN, 0 }; + const int ready = ::poll(&pfd, 1, timeout_ms); + if (ready == 0) + return ReadResult::NoData; + if (ready < 0) + return errno == EINTR ? ReadResult::NoData : ReadResult::Closed; + if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) + return ReadResult::Closed; + return ReadResult::Frame; +} + +bool DiscordIPC::read_exactly(char *dst, size_t count, int timeout_ms) +{ + size_t got = 0; + int budget_ms = std::max(0, timeout_ms); + + while (got < count) { + const ssize_t n = ::read(m_fd, dst + got, count - got); + if (n > 0) { + got += static_cast(n); + continue; + } + if (n == 0) + return false; + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + pollfd pfd { m_fd, POLLIN, 0 }; + const int step = 50; + const int ready = ::poll(&pfd, 1, step); + if (ready < 0 && errno != EINTR) + return false; + budget_ms -= step; + if (budget_ms <= 0) + return false; + continue; + } + return false; + } + return true; +} + +#endif // _WIN32 + +DiscordIPC::~DiscordIPC() { close(); } + +bool DiscordIPC::try_connect() +{ + if (is_connected()) + return true; + + for (const std::string &endpoint : discord_candidate_endpoints()) { + if (connect_endpoint(endpoint)) { + BOOST_LOG_TRIVIAL(info) << "DiscordIPC: connected to " << endpoint; + return true; + } + } + return false; +} + +bool DiscordIPC::write_frame(DiscordOpcode opcode, const std::string &payload) +{ + if (!is_connected()) + return false; + + const std::vector frame = discord_encode_frame(opcode, payload); + if (!write_all(frame.data(), frame.size())) { + BOOST_LOG_TRIVIAL(debug) << "DiscordIPC: write failed, dropping connection"; + close(); + return false; + } + return true; +} + +DiscordIPC::ReadResult DiscordIPC::read_frame(int timeout_ms, DiscordOpcode &opcode, std::string &payload) +{ + if (!is_connected()) + return ReadResult::Closed; + + const ReadResult waited = wait_readable(timeout_ms); + if (waited == ReadResult::NoData) + return ReadResult::NoData; + if (waited == ReadResult::Closed) { + close(); + return ReadResult::Closed; + } + + char header[DISCORD_HEADER_BYTES]; + if (!read_exactly(header, DISCORD_HEADER_BYTES, 1000)) { + close(); + return ReadResult::Closed; + } + + uint32_t length = 0; + if (!discord_decode_header(header, DISCORD_HEADER_BYTES, opcode, length)) { + BOOST_LOG_TRIVIAL(debug) << "DiscordIPC: bad frame header, dropping connection"; + close(); + return ReadResult::Closed; + } + + payload.clear(); + if (length > 0) { + payload.resize(length); + if (!read_exactly(&payload[0], length, 2000)) { + close(); + return ReadResult::Closed; + } + } + + if (opcode == DiscordOpcode::Close) { + close(); + return ReadResult::Closed; + } + return ReadResult::Frame; +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/DiscordIPC.hpp b/src/slic3r/Utils/DiscordIPC.hpp new file mode 100644 index 00000000000..489600e2100 --- /dev/null +++ b/src/slic3r/Utils/DiscordIPC.hpp @@ -0,0 +1,71 @@ +#ifndef slic3r_DiscordIPC_hpp_ +#define slic3r_DiscordIPC_hpp_ + +#include +#include +#include +#include + +namespace Slic3r { + +enum class DiscordOpcode : uint32_t { + Handshake = 0, + Frame = 1, + Close = 2, + Ping = 3, + Pong = 4, +}; + +// A larger declared length means the stream has desynchronised. +static const uint32_t DISCORD_MAX_FRAME_BYTES = 64 * 1024; + +std::vector discord_encode_frame(DiscordOpcode opcode, const std::string &payload); + +bool discord_decode_header(const char *data, size_t size, DiscordOpcode &opcode, uint32_t &length); + +// Discord rejects presence fields that are over-long or not valid UTF-8, and +// project names are frequently multibyte. +std::string discord_truncate_utf8(const std::string &text, size_t max_bytes); + +std::vector discord_candidate_endpoints(); + +// Every failure is a return value rather than an exception: "Discord is not +// running" is the ordinary case, not an error. +class DiscordIPC +{ +public: + enum class ReadResult { NoData, Frame, Closed }; + + DiscordIPC() = default; + ~DiscordIPC(); + + DiscordIPC(const DiscordIPC &) = delete; + DiscordIPC &operator=(const DiscordIPC &) = delete; + + bool try_connect(); + void close(); + bool is_connected() const; + + bool write_frame(DiscordOpcode opcode, const std::string &payload); + + // Closed means the caller must close() and reconnect. + ReadResult read_frame(int timeout_ms, DiscordOpcode &opcode, std::string &payload); + +private: + bool connect_endpoint(const std::string &path); + bool write_all(const char *src, size_t count); + ReadResult wait_readable(int timeout_ms); + bool read_exactly(char *dst, size_t count, int timeout_ms); + +#ifdef _WIN32 + void *m_pipe { nullptr }; // HANDLE + void *m_event { nullptr }; // HANDLE, reused by the overlapped read/write waits + bool m_open { false }; +#else + int m_fd { -1 }; +#endif +}; + +} // namespace Slic3r + +#endif // slic3r_DiscordIPC_hpp_ diff --git a/src/slic3r/Utils/DiscordPresence.cpp b/src/slic3r/Utils/DiscordPresence.cpp new file mode 100644 index 00000000000..ba0bf926862 --- /dev/null +++ b/src/slic3r/Utils/DiscordPresence.cpp @@ -0,0 +1,347 @@ +#include "DiscordPresence.hpp" +#include "DiscordIPC.hpp" + +#include +#include +#include + +#include + +#ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + // See the guard in DiscordIPC.cpp. + #define NOMINMAX + #endif + #include +#else + #include +#endif + +// Supplied by the build; empty leaves the feature inert. +#ifndef SLIC3R_DISCORD_APP_ID + #define SLIC3R_DISCORD_APP_ID "" +#endif + +namespace Slic3r { + +std::string discord_default_application_id() { return std::string(SLIC3R_DISCORD_APP_ID); } + +// Discord silently rejects presence fields longer than this. +static const size_t DISCORD_FIELD_MAX_BYTES = 128; + +// A remaining-time estimate moving by less than this is the same estimate. +static const int64_t END_TIME_TOLERANCE_S = 30; + +static const int64_t RECONNECT_BACKOFF_INITIAL_MS = 15 * 1000; +static const int64_t RECONNECT_BACKOFF_MAX_MS = 5 * 60 * 1000; + +static int64_t steady_now_ms() +{ + using namespace std::chrono; + return duration_cast(steady_clock::now().time_since_epoch()).count(); +} + +static int current_process_id() +{ +#ifdef _WIN32 + return static_cast(::GetCurrentProcessId()); +#else + return static_cast(::getpid()); +#endif +} + +bool PresenceSnapshot::operator==(const PresenceSnapshot &other) const +{ + return activity == other.activity && details == other.details && state == other.state && + small_text == other.small_text && start_time == other.start_time && end_time == other.end_time; +} + +bool PresenceSnapshot::equivalent(const PresenceSnapshot &other) const +{ + if (activity != other.activity || details != other.details || state != other.state || + small_text != other.small_text || start_time != other.start_time) + return false; + + const int64_t delta = end_time > other.end_time ? end_time - other.end_time : other.end_time - end_time; + const bool both_set = end_time != 0 && other.end_time != 0; + return both_set ? delta <= END_TIME_TOLERANCE_S : end_time == other.end_time; +} + +const char *discord_state_asset_key(PresenceSnapshot::Activity activity) +{ + switch (activity) { + case PresenceSnapshot::Activity::Printing: return "state_printing"; + case PresenceSnapshot::Activity::Paused: return "state_paused"; + case PresenceSnapshot::Activity::Failed: return "state_error"; + case PresenceSnapshot::Activity::Finished: return "state_done"; + case PresenceSnapshot::Activity::Slicing: return "state_slicing"; + case PresenceSnapshot::Activity::Idle: + case PresenceSnapshot::Activity::Editing: + case PresenceSnapshot::Activity::Previewing: + default: return "state_idle"; + } +} + +nlohmann::json discord_build_activity(const PresenceSnapshot &snapshot, const std::string &large_text) +{ + nlohmann::json activity = nlohmann::json::object(); + + // Discord renders an empty string as a blank line, so omit instead. + const std::string details = discord_truncate_utf8(snapshot.details, DISCORD_FIELD_MAX_BYTES); + if (!details.empty()) + activity["details"] = details; + + const std::string state = discord_truncate_utf8(snapshot.state, DISCORD_FIELD_MAX_BYTES); + if (!state.empty()) + activity["state"] = state; + + nlohmann::json timestamps = nlohmann::json::object(); + // Discord shows only one timer, so a countdown wins over elapsed. + if (snapshot.end_time > 0) + timestamps["end"] = snapshot.end_time; + else if (snapshot.start_time > 0) + timestamps["start"] = snapshot.start_time; + if (!timestamps.empty()) + activity["timestamps"] = timestamps; + + nlohmann::json assets = nlohmann::json::object(); + assets["large_image"] = "bambu_studio"; + if (!large_text.empty()) + assets["large_text"] = discord_truncate_utf8(large_text, DISCORD_FIELD_MAX_BYTES); + assets["small_image"] = discord_state_asset_key(snapshot.activity); + const std::string small_text = discord_truncate_utf8(snapshot.small_text, DISCORD_FIELD_MAX_BYTES); + if (!small_text.empty()) + assets["small_text"] = small_text; + activity["assets"] = assets; + + activity["instance"] = false; + return activity; +} + +std::string discord_build_set_activity(const nlohmann::json &activity, int pid, uint64_t nonce) +{ + nlohmann::json args = nlohmann::json::object(); + args["pid"] = pid; + args["activity"] = activity; + + nlohmann::json command = nlohmann::json::object(); + command["cmd"] = "SET_ACTIVITY"; + command["nonce"] = "bambu-" + std::to_string(nonce); + command["args"] = args; + return command.dump(); +} + +bool PresenceThrottle::should_send(const PresenceSnapshot &snapshot, int64_t now_ms) const +{ + if (m_has_last && m_last.equivalent(snapshot)) + return false; + if (m_has_last && now_ms - m_last_sent_ms < MIN_SEND_INTERVAL_MS) + return false; + return true; +} + +void PresenceThrottle::record_sent(const PresenceSnapshot &snapshot, int64_t now_ms) +{ + m_last = snapshot; + m_has_last = true; + m_last_sent_ms = now_ms; +} + +void PresenceThrottle::reset() +{ + m_has_last = false; + m_last_sent_ms = 0; + m_last = PresenceSnapshot(); +} + +DiscordPresence::DiscordPresence(std::string application_id, std::string large_text) + : m_application_id(std::move(application_id)), m_large_text(std::move(large_text)) +{} + +DiscordPresence::~DiscordPresence() { stop(); } + +void DiscordPresence::set_enabled(bool enabled) +{ + // No id means no handshake can ever succeed, so never start a worker. + if (enabled && m_application_id.empty()) { + BOOST_LOG_TRIVIAL(info) << "DiscordPresence: no application id configured, staying disabled"; + return; + } + + if (enabled == m_enabled.load()) + return; + + m_enabled.store(enabled); + if (enabled) + start(); + else + stop(); +} + +void DiscordPresence::update(const PresenceSnapshot &snapshot) +{ + if (!m_enabled.load()) + return; + { + std::lock_guard lock(m_mutex); + m_pending = snapshot; + m_pending_valid = true; + } + m_cv.notify_one(); +} + +void DiscordPresence::start() +{ + if (m_thread.joinable()) + return; + m_stop.store(false); + m_thread = boost::thread(&DiscordPresence::worker, this); +} + +void DiscordPresence::stop() +{ + if (!m_thread.joinable()) + return; + m_stop.store(true); + m_cv.notify_all(); + m_thread.join(); +} + +bool DiscordPresence::connect_and_handshake() +{ + if (!m_ipc) + m_ipc.reset(new DiscordIPC()); + + if (!m_ipc->try_connect()) + return false; + + nlohmann::json handshake = nlohmann::json::object(); + handshake["v"] = 1; + handshake["client_id"] = m_application_id; + if (!m_ipc->write_frame(DiscordOpcode::Handshake, handshake.dump())) + return false; + + // Waiting for READY avoids reporting success against a socket that will + // reject our commands, usually because the application id is wrong. m_stop + // is checked so quitting does not block OnExit for the full timeout. + const int64_t deadline = steady_now_ms() + 5000; + while (steady_now_ms() < deadline && !m_stop.load()) { + DiscordOpcode opcode = DiscordOpcode::Frame; + std::string payload; + const DiscordIPC::ReadResult result = m_ipc->read_frame(500, opcode, payload); + if (result == DiscordIPC::ReadResult::Closed) + return false; + if (result == DiscordIPC::ReadResult::NoData) + continue; + + try { + const nlohmann::json parsed = nlohmann::json::parse(payload); + const auto evt = parsed.find("evt"); + if (evt != parsed.end() && evt->is_string() && evt->get() == "READY") { + BOOST_LOG_TRIVIAL(info) << "DiscordPresence: handshake complete"; + return true; + } + } catch (const std::exception &) { + // Not fatal on its own; keep waiting for READY. + } + } + + BOOST_LOG_TRIVIAL(debug) << "DiscordPresence: no READY within timeout"; + m_ipc->close(); + return false; +} + +void DiscordPresence::clear_presence() +{ + if (m_ipc && m_ipc->is_connected() && m_sent_anything) + m_ipc->write_frame(DiscordOpcode::Frame, + discord_build_set_activity(nlohmann::json(nullptr), current_process_id(), ++m_nonce)); +} + +void DiscordPresence::worker() +{ + BOOST_LOG_TRIVIAL(info) << "DiscordPresence: worker started"; + m_throttle.reset(); + m_backoff_ms = 0; + m_next_connect_ms = 0; + m_sent_anything = false; + + // Kept rather than consumed, so a reconnect can republish without waiting + // for the state to change. + PresenceSnapshot latest; + bool have_latest = false; + + while (!m_stop.load()) { + { + std::unique_lock lock(m_mutex); + if (!m_pending_valid && !m_stop.load()) + m_cv.wait_for(lock, std::chrono::seconds(1)); + if (m_pending_valid) { + latest = m_pending; + have_latest = true; + m_pending_valid = false; + } + } + + if (m_stop.load()) + break; + + const int64_t now = steady_now_ms(); + + if (!m_ipc || !m_ipc->is_connected()) { + if (now < m_next_connect_ms) + continue; + if (!connect_and_handshake()) { + m_backoff_ms = m_backoff_ms == 0 ? RECONNECT_BACKOFF_INITIAL_MS + : std::min(m_backoff_ms * 2, RECONNECT_BACKOFF_MAX_MS); + m_next_connect_ms = now + m_backoff_ms; + continue; + } + // A fresh connection shows no presence, so resend unconditionally. + m_backoff_ms = 0; + m_sent_anything = false; + m_throttle.reset(); + } + + // Discord replies to every command; draining stops the buffer filling + // and is how we notice the client going away. + for (;;) { + DiscordOpcode opcode = DiscordOpcode::Frame; + std::string payload; + const DiscordIPC::ReadResult result = m_ipc->read_frame(0, opcode, payload); + if (result == DiscordIPC::ReadResult::Closed) { + BOOST_LOG_TRIVIAL(info) << "DiscordPresence: connection closed by peer"; + break; + } + if (result == DiscordIPC::ReadResult::NoData) + break; + if (opcode == DiscordOpcode::Ping) + m_ipc->write_frame(DiscordOpcode::Pong, payload); + } + + if (!m_ipc->is_connected()) { + m_backoff_ms = RECONNECT_BACKOFF_INITIAL_MS; + m_next_connect_ms = steady_now_ms() + m_backoff_ms; + continue; + } + + if (have_latest && m_throttle.should_send(latest, now)) { + const nlohmann::json activity = discord_build_activity(latest, m_large_text); + if (m_ipc->write_frame(DiscordOpcode::Frame, + discord_build_set_activity(activity, current_process_id(), ++m_nonce))) { + m_throttle.record_sent(latest, now); + m_sent_anything = true; + } + } + } + + clear_presence(); + if (m_ipc) + m_ipc->close(); + BOOST_LOG_TRIVIAL(info) << "DiscordPresence: worker stopped"; +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/DiscordPresence.hpp b/src/slic3r/Utils/DiscordPresence.hpp new file mode 100644 index 00000000000..5113adee26b --- /dev/null +++ b/src/slic3r/Utils/DiscordPresence.hpp @@ -0,0 +1,120 @@ +#ifndef slic3r_DiscordPresence_hpp_ +#define slic3r_DiscordPresence_hpp_ + +#include +#include +#include +#include +#include +#include + +#include + +#include "nlohmann/json.hpp" + +namespace Slic3r { + +class DiscordIPC; + +// Plain data so it can cross to the worker thread by value. The display +// strings are built by the GUI layer, which owns translation and the privacy +// filter; nothing below this point does more than serialise them. +struct PresenceSnapshot +{ + enum class Activity { Idle, Editing, Previewing, Slicing, Printing, Paused, Failed, Finished }; + + Activity activity { Activity::Idle }; + std::string details; // first line, e.g. the project name + std::string state; // second line, e.g. "Printing - 47%" + std::string small_text; // small icon tooltip, e.g. the printer model + int64_t start_time { 0 }; // unix seconds; Discord counts up from here + int64_t end_time { 0 }; // unix seconds; Discord counts down to here + + bool operator==(const PresenceSnapshot &other) const; + bool operator!=(const PresenceSnapshot &other) const { return !(*this == other); } + + // Equal as far as the viewer is concerned. end_time is compared with a + // tolerance, because a remaining-time estimate drifts constantly and + // treating that as a change would push an update every cycle. + bool equivalent(const PresenceSnapshot &other) const; +}; + +// Empty unless the build sets SLIC3R_DISCORD_APP_ID. +std::string discord_default_application_id(); + +// Must match a key uploaded to the Discord application's art assets. +const char *discord_state_asset_key(PresenceSnapshot::Activity activity); + +nlohmann::json discord_build_activity(const PresenceSnapshot &snapshot, const std::string &large_text); + +// A null activity clears the presence. +std::string discord_build_set_activity(const nlohmann::json &activity, int pid, uint64_t nonce); + +// Split out from the worker so the policy can be tested against an explicit +// clock rather than wall time. +class PresenceThrottle +{ +public: + // Discord allows 5 presence updates per 20 seconds; stay comfortably under. + static const int64_t MIN_SEND_INTERVAL_MS = 5000; + + bool should_send(const PresenceSnapshot &snapshot, int64_t now_ms) const; + void record_sent(const PresenceSnapshot &snapshot, int64_t now_ms); + void reset(); + +private: + PresenceSnapshot m_last; + bool m_has_last { false }; + int64_t m_last_sent_ms { 0 }; +}; + +// All socket work happens on the worker thread; update() is the only method +// the GUI thread calls while running. +class DiscordPresence +{ +public: + DiscordPresence(std::string application_id, std::string large_text); + ~DiscordPresence(); + + DiscordPresence(const DiscordPresence &) = delete; + DiscordPresence &operator=(const DiscordPresence &) = delete; + + void set_enabled(bool enabled); + bool is_enabled() const { return m_enabled.load(); } + + const std::string &application_id() const { return m_application_id; } + + // GUI thread. Non-blocking. + void update(const PresenceSnapshot &snapshot); + +private: + void start(); + void stop(); + void worker(); + bool connect_and_handshake(); + void clear_presence(); + + const std::string m_application_id; + const std::string m_large_text; + + std::atomic m_enabled { false }; + std::atomic m_stop { false }; + + boost::thread m_thread; + std::mutex m_mutex; + std::condition_variable m_cv; + PresenceSnapshot m_pending; + bool m_pending_valid { false }; + + // Worker-thread state only. + std::unique_ptr m_ipc; + PresenceThrottle m_throttle; + uint64_t m_nonce { 0 }; + int64_t m_next_connect_ms { 0 }; + int64_t m_backoff_ms { 0 }; + bool m_sent_anything { false }; +}; + +} // namespace Slic3r + +#endif // slic3r_DiscordPresence_hpp_ diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 6bb8f8bda5a..f5de50e153e 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(${_TEST_NAME}_tests helio_retry_policy_tests.cpp helio_support_data_tests.cpp helio_recent_runs_tests.cpp + discord_presence_tests.cpp ) target_link_libraries(${_TEST_NAME}_tests test_common libslic3r_gui libslic3r) diff --git a/tests/slic3rutils/discord_presence_tests.cpp b/tests/slic3rutils/discord_presence_tests.cpp new file mode 100644 index 00000000000..a4b022da3cf --- /dev/null +++ b/tests/slic3rutils/discord_presence_tests.cpp @@ -0,0 +1,263 @@ +#include + +#include +#include + +#include "slic3r/Utils/DiscordIPC.hpp" +#include "slic3r/Utils/DiscordPresence.hpp" + +using namespace Slic3r; + +TEST_CASE("Discord frame header is little-endian regardless of host", "[DiscordIPC]") +{ + const std::vector frame = discord_encode_frame(DiscordOpcode::Frame, "ab"); + + REQUIRE(frame.size() == 8 + 2); + // opcode 1 + CHECK(static_cast(frame[0]) == 0x01); + CHECK(static_cast(frame[1]) == 0x00); + CHECK(static_cast(frame[2]) == 0x00); + CHECK(static_cast(frame[3]) == 0x00); + // length 2 + CHECK(static_cast(frame[4]) == 0x02); + CHECK(static_cast(frame[5]) == 0x00); + CHECK(static_cast(frame[6]) == 0x00); + CHECK(static_cast(frame[7]) == 0x00); + CHECK(frame[8] == 'a'); + CHECK(frame[9] == 'b'); +} + +TEST_CASE("Discord frame header round-trips", "[DiscordIPC]") +{ + const std::string payload(300, 'x'); + const std::vector frame = discord_encode_frame(DiscordOpcode::Handshake, payload); + + DiscordOpcode opcode = DiscordOpcode::Pong; + uint32_t length = 0; + REQUIRE(discord_decode_header(frame.data(), frame.size(), opcode, length)); + CHECK(opcode == DiscordOpcode::Handshake); + CHECK(length == payload.size()); +} + +TEST_CASE("Discord header decoding rejects bad input", "[DiscordIPC]") +{ + DiscordOpcode opcode = DiscordOpcode::Frame; + uint32_t length = 0; + + SECTION("short buffer") { + const char buf[4] = { 0, 0, 0, 0 }; + CHECK_FALSE(discord_decode_header(buf, sizeof(buf), opcode, length)); + } + + SECTION("null buffer") { + CHECK_FALSE(discord_decode_header(nullptr, 8, opcode, length)); + } + + SECTION("unknown opcode") { + const unsigned char buf[8] = { 0x63, 0, 0, 0, 0, 0, 0, 0 }; + CHECK_FALSE(discord_decode_header(reinterpret_cast(buf), sizeof(buf), opcode, length)); + } + + SECTION("implausible length is refused rather than allocated") { + const unsigned char buf[8] = { 0x01, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF }; + CHECK_FALSE(discord_decode_header(reinterpret_cast(buf), sizeof(buf), opcode, length)); + } +} + +TEST_CASE("Endpoint probing covers all ten sockets", "[DiscordIPC]") +{ + const std::vector endpoints = discord_candidate_endpoints(); + REQUIRE_FALSE(endpoints.empty()); + + for (int i = 0; i < 10; ++i) { + const std::string suffix = "discord-ipc-" + std::to_string(i); + bool found = false; + for (const std::string &endpoint : endpoints) + if (endpoint.size() >= suffix.size() && endpoint.compare(endpoint.size() - suffix.size(), suffix.size(), suffix) == 0) { + found = true; + break; + } + CHECK(found); + } +} + +TEST_CASE("UTF-8 truncation never splits a codepoint", "[DiscordIPC]") +{ + SECTION("short input is returned unchanged") { + CHECK(discord_truncate_utf8("hello", 128) == "hello"); + } + + SECTION("multibyte input stays valid UTF-8") { + // 3 bytes per character. + std::string text; + for (int i = 0; i < 100; ++i) + text += "\xE6\xB5\x8B"; + + const std::string cut = discord_truncate_utf8(text, 128); + REQUIRE(cut.size() <= 128); + + size_t i = 0; + while (i < cut.size()) { + const unsigned char c = static_cast(cut[i]); + const size_t len = c < 0x80 ? 1 : (c & 0xE0) == 0xC0 ? 2 : (c & 0xF0) == 0xE0 ? 3 : 4; + REQUIRE(i + len <= cut.size()); + i += len; + } + CHECK(i == cut.size()); + } + + SECTION("truncated output is marked with an ellipsis") { + const std::string cut = discord_truncate_utf8(std::string(200, 'a'), 128); + CHECK(cut.size() <= 128); + CHECK(cut.substr(cut.size() - 3) == "\xE2\x80\xA6"); + } +} + +static PresenceSnapshot printing_snapshot() +{ + PresenceSnapshot snap; + snap.activity = PresenceSnapshot::Activity::Printing; + snap.details = "benchy.3mf"; + snap.state = "Printing - 47%"; + snap.small_text = "P1S"; + snap.end_time = 1756400000; + return snap; +} + +TEST_CASE("Activity payload carries the expected fields", "[DiscordPresence]") +{ + const nlohmann::json activity = discord_build_activity(printing_snapshot(), "Bambu Studio 1.0"); + + CHECK(activity["details"] == "benchy.3mf"); + CHECK(activity["state"] == "Printing - 47%"); + CHECK(activity["timestamps"]["end"] == 1756400000); + CHECK(activity["assets"]["large_image"] == "bambu_studio"); + CHECK(activity["assets"]["large_text"] == "Bambu Studio 1.0"); + CHECK(activity["assets"]["small_image"] == "state_printing"); + CHECK(activity["assets"]["small_text"] == "P1S"); +} + +TEST_CASE("Empty activity fields are omitted rather than sent blank", "[DiscordPresence]") +{ + PresenceSnapshot snap; + snap.activity = PresenceSnapshot::Activity::Idle; + snap.state = "Idle"; + + const nlohmann::json activity = discord_build_activity(snap, "Bambu Studio 1.0"); + + CHECK(activity.find("details") == activity.end()); + CHECK(activity["state"] == "Idle"); + CHECK(activity.find("timestamps") == activity.end()); + CHECK(activity["assets"].find("small_text") == activity["assets"].end()); +} + +TEST_CASE("A countdown takes precedence over an elapsed timer", "[DiscordPresence]") +{ + PresenceSnapshot snap = printing_snapshot(); + snap.start_time = 1756300000; + + const nlohmann::json activity = discord_build_activity(snap, std::string()); + CHECK(activity["timestamps"]["end"] == 1756400000); + CHECK(activity["timestamps"].find("start") == activity["timestamps"].end()); +} + +TEST_CASE("Each activity maps to its own state badge", "[DiscordPresence]") +{ + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Printing)) == "state_printing"); + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Paused)) == "state_paused"); + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Failed)) == "state_error"); + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Finished)) == "state_done"); + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Slicing)) == "state_slicing"); + CHECK(std::string(discord_state_asset_key(PresenceSnapshot::Activity::Editing)) == "state_idle"); +} + +TEST_CASE("Over-long fields are truncated before they reach Discord", "[DiscordPresence]") +{ + PresenceSnapshot snap; + snap.details = std::string(400, 'a'); + snap.state = std::string(400, 'b'); + + const nlohmann::json activity = discord_build_activity(snap, std::string()); + CHECK(activity["details"].get().size() <= 128); + CHECK(activity["state"].get().size() <= 128); +} + +TEST_CASE("SET_ACTIVITY command is well formed", "[DiscordPresence]") +{ + const nlohmann::json activity = discord_build_activity(printing_snapshot(), std::string()); + const nlohmann::json command = nlohmann::json::parse(discord_build_set_activity(activity, 4242, 7)); + + CHECK(command["cmd"] == "SET_ACTIVITY"); + CHECK(command["nonce"] == "bambu-7"); + CHECK(command["args"]["pid"] == 4242); + CHECK(command["args"]["activity"]["details"] == "benchy.3mf"); +} + +TEST_CASE("Clearing the presence sends a null activity", "[DiscordPresence]") +{ + const nlohmann::json command = nlohmann::json::parse(discord_build_set_activity(nlohmann::json(nullptr), 1, 1)); + CHECK(command["args"]["activity"].is_null()); +} + +TEST_CASE("A drifting time estimate is not treated as a change", "[DiscordPresence]") +{ + const PresenceSnapshot a = printing_snapshot(); + + PresenceSnapshot b = a; + b.end_time += 10; + CHECK(a.equivalent(b)); + CHECK(a != b); + + PresenceSnapshot c = a; + c.end_time += 600; + CHECK_FALSE(a.equivalent(c)); +} + +TEST_CASE("Unchanged snapshots are not resent", "[DiscordPresence]") +{ + PresenceThrottle throttle; + const PresenceSnapshot snap = printing_snapshot(); + + REQUIRE(throttle.should_send(snap, 0)); + throttle.record_sent(snap, 0); + + CHECK_FALSE(throttle.should_send(snap, 60'000)); +} + +TEST_CASE("A change inside the rate limit window is held, not dropped", "[DiscordPresence]") +{ + PresenceThrottle throttle; + const PresenceSnapshot first = printing_snapshot(); + throttle.record_sent(first, 0); + + PresenceSnapshot second = first; + second.state = "Printing - 48%"; + + CHECK_FALSE(throttle.should_send(second, 1000)); + CHECK_FALSE(throttle.should_send(second, PresenceThrottle::MIN_SEND_INTERVAL_MS - 1)); + CHECK(throttle.should_send(second, PresenceThrottle::MIN_SEND_INTERVAL_MS)); +} + +TEST_CASE("The first snapshot is always sent", "[DiscordPresence]") +{ + PresenceThrottle throttle; + CHECK(throttle.should_send(printing_snapshot(), 0)); +} + +TEST_CASE("Reset makes the next snapshot send again", "[DiscordPresence]") +{ + PresenceThrottle throttle; + const PresenceSnapshot snap = printing_snapshot(); + throttle.record_sent(snap, 0); + REQUIRE_FALSE(throttle.should_send(snap, 60'000)); + + throttle.reset(); + CHECK(throttle.should_send(snap, 60'000)); +} + +TEST_CASE("Presence stays disabled without an application id", "[DiscordPresence]") +{ + DiscordPresence presence("", "Bambu Studio"); + presence.set_enabled(true); + CHECK_FALSE(presence.is_enabled()); +}