Files
OrcaSlicer/src/slic3r/Utils/OrcaCloudServiceAgent.cpp
T
a5413efc37 refactor: printer agent infrastructure changes to work with other printer agents aside from bambu (#15710)
* Add developer flag for printer agents

* Parse user print info on the UI thread to prevent heap corruption (#119)

get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.

Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.

* Prevent loss of user access code on LAN reselect

Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.

* Harden send flow and separate upload failure recovery (#111)

* fix(send): harden FT send path + IP pre-flight UX

* Remove early returns

* Working Moonraker and Qidi printer agent transport (#104)

Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.

* Add support for runtime error status in plugins

Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.

* Resolve duplicate agent ID conflicts

Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.

* fix: checkbox should depend on plugin is_loaded status

* Replace fake-enum printer agent dropdown (#121)

A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.

* Reset device selection on agent swap or unload (#124)

set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.

* Gate agent mode behind use_printer_agents toggle

Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.

* Track BBLPrinterAgentPlugin.py

* Add printer-agent and plugin status tests

Ports the agent lifecycle, duplicate
agent-id, built-in-id clash and
status-resolution tests. The loader
runs on a detached worker thread, so
the lifecycle tests live in their own
executable. Tests install the
production unload-side registry
wiring themselves (no GUI in the
test binary) and register agents
manually so concurrent loads stay
deterministic.

* fix: pin HTTP to prevent connection refusal

Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.

* Bring Moonraker device panel to feature parity

The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.

Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.

Filament sync switches to pull mode so the
agent is queried on demand.

Not compiled or run.

* Stop blocking print on unreported nozzle data

* fix: make Klipper macro lamp control reliable

* Surface Moonraker webcams and gate unrunnable controls

* Keep Bambu AMS dialect out of the agent waist

M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.

Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.

* Fix multi-color filament logic

Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.

* Move Moonraker commands off the UI thread

Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.

Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.

Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.

* Show Snapmaker U1 camera in Device tab

The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.

* fix: start stream when camera URL changes

* fix: stop Qidi slot parse throwing on null

* Keep printer-agent progress in sync

Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.

Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.

* docs: document the printer-agent subsystem

* fix: merge access codes into one

* Reconcile implementation split with PR tip

* feat: abstract remaining gcode commands in devicemanager

* refactor: abstract bambu specific protocol to printer agent

* refactor: push bbl workflows to bbl printer agent

* remove unused

* fix: default impl

* fix callback error

* fix: remove redundant cache

* specify api for getting file transfer url

* revert file transfer abstraction

* fix: ams filament mapping workflow

* feat: update qidi to use subscription based filament sync mode

* fix: resolve stubgen byte header conflict

* fix: ams sync info and periodic ams sync via subscription workflow

* fix: skip filament sync dialog if filamentSyncMode is none

* feat: parse nozzle information for qidi and moonraker printer agents

* fix: extend access code requirements t 0, 8 or more characters.

* remove irrelevant docs

* fix: remove heavy includes from IPrinterAgent

* fix: defer filesystem and camera abstractions

* fix: remote do_fetch_filament_info from tests

* cleanup moonraker and snapmaker printer agents

* fix: access codes regression

* fix: tests

* fix: printer agent switching on preset change

* fix: remove unused variable

* fix: snapmaker U1 SelectMachineDialog blocking print

* fix: merge artifact

* fix: clear up some unrelated changes

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

* feat: generic camera stream support for http snapshot and rtsp

* fix: build & access code UI

* feat: generic camera stream support for http snapshot and rtsp

* fix: build & access code UI

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

* fix: build errors

* feat: camera via webrtc

* fix: build

* fix: cmake

* feat: remove frame assembler and change config to set protocol

* fix: orcaprinteragent refactor

* fix: LAN paths and camera stream

* feat: use ffmpeg to render http camera stream

* fix: make model_id/dev_type optional instead of blocking

* fix: connect via ip dialog

* feat: LAN impl for Orca Printer Agent

* fix: model_id resolution method for non bambu printers

* fix: ffmpeg http camera stream jittering due to incomplete frames

* fix: revert sdcard check

* feat: check printer storage status before sending

* fix: moonraker printer agent hang on printer power cut

* fix: shim layer for any compatibiliity changes

* fix: cloud printers were using the wrong MQTT endpoint

* feat: cloud download via HTTP

* temp: doc for intended change

* fix: warnings

* fix: warnings

* fix: camera auto-play on startup

* fix: split infra from impl

* fix: uninitialized ams state blocking print

* Fixes nullptr deref

* Log first before std::move

* fix: printer agent virutal optional functions

* fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests

* Revert "fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests"

This reverts commit 2f566e3779.

* Guard libdatachannel. Remove unused code

* fix: unit tests & unused variables

* fix(ci): deps build order for datachannel

* Resolve printer agent first before getting cloud printer agent

* fix(ci): set depends openssl

* fix: re-include apply header guarded by ifdef __APPLE__

* fix(ci): add libdatachannel to flatpak manifest

* fix: add internal_developer_mode chekc back to MediaPlayCtrl::load()

* fix: invoke js clearInterval on WebMediaController::stop

* fix: change rtc log level

* fix: inject provider, agent id and generation to get_user_print_info to ensure correct metadata

* fix: revert moonraker specific behavior

* fix: remove stale comment

* fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string

* fix: remove hardcoded ICE servers

* feat: enable https camera stream mode

* fix: move non-mandatory printer agent function stubs to IPrinterAgent

* fix: dedupe compatible printer type check

* fix: stop the correct media controller

* fix: scope get_my_machine_list to printers listed under the current printer agent

* fix: move printer agent plugin tests into test_plugin_lifecycle.cpp

* fix: always build bundled DataChannel dep

* fix: disable unused DataChannel media support

* revert: filament sync work

* fix: wrap command_* with small wrapper

* fix: regression bug, connecting to bambu needs bblp username

* fix: default impl for vendor agnostic gcode commansd

* refactor: media controller playback routing and ownership

* fix: bump libdatachannel ver & update flatpak to use tar instead

* fix: stop flatpak DataChannel build from re-cloning over the sandboxed network

* fix: update windows ffmpeg prebuild

* fix: update printer agent plugin API

* fix: shift camera signaling channel to network agent

* fix: follow external-packages for flatpak libdatachannel deps & add flatpak path to use source_dir

* feat: add printer-agent.md doc to HLSD

* fix: guard DeviceManager command dispatch when no printer agent is bound

* fix: port BBL implementations from #15711

* refactor: connect_printer api and dialog

* fix(tests): make omitted printer agent operations answer like a missing agent

* fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline

* test: cover printer agent default command dispatch

* fix: validate windows FFmpeg avformat library

* feat: extend optional printer model warnings to calibration & ams workflows

* fix: handle malformed printer progress values safely

* fix: clear webview document on stop

* chore: reduce diagnostic logging level to trace

* refactor: centralize printer compatibility checks

* tests: add device manager integration coverage

* tests: cover WebMediaController lifecycle with wxWebView stub

* fix: make integration tests headless

* refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2

* refactor: make printer connection SSL agent-specific

* fix: persist input printer host and port

* fix: use correct device id for Moonraker connections

* fix: make moonraker gcode commands asynchronous

* fix: preserve moonraker device names

* fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent

---------

Co-authored-by: Andrew <159703254+andrewsoonqn@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
Co-authored-by: Lam Wei Lun <weilun.lam@gmail.com>
2026-10-01 00:30:44 +08:00

3633 lines
137 KiB
C++

#include "OrcaCloudServiceAgent.hpp"
#include "Http.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "libslic3r/AppConfig.hpp"
#include <boost/asio.hpp>
#include <boost/beast/core/detail/base64.hpp>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>
#include <exception>
#include <iostream>
#include <libslic3r/Platform.hpp>
#include <memory>
#include <nlohmann/json.hpp>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <optional>
#include <random>
#include <sstream>
#include <string>
#include <utility>
#include <wx/filename.h>
#include <wx/filefn.h>
#include <wx/secretstore.h>
#include <wx/stdpaths.h>
#include <wx/app.h>
#include <wx/utils.h>
#include "slic3r/plugin/PluginDescriptor.hpp"
#if defined(_WIN32)
#include <Windows.h>
#endif
#if !defined(_WIN32)
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#endif
#if defined(__APPLE__)
#include <uuid/uuid.h>
#endif
using json = nlohmann::json;
namespace Slic3r {
namespace {
constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com";
constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com";
constexpr const char* ORCA_DEFAULT_CLOUD_URL = "https://cloud.orcaslicer.com";
// Orca: This is a public key with no secret, used to identify the client application to the backend.
constexpr const char* ORCA_DEFAULT_PUB_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_";
constexpr const char* ORCA_HEALTH_PATH = "/api/v1/health";
constexpr const char* ORCA_SYNC_PULL_PATH = "/api/v1/sync/pull";
constexpr const char* ORCA_SYNC_PUSH_PATH = "/api/v1/sync/push";
constexpr const char* ORCA_SYNC_FORCE_PUSH_PATH = "/api/v1/sync/force-push";
constexpr const char* ORCA_SYNC_DELETE_PATH = "/api/v1/sync/delete";
constexpr const char* ORCA_PROFILES_PATH = "/api/v1/sync/profiles";
constexpr const char* ORCA_SUBSCRIPTIONS_PATH = "/api/v1/subscriptions";
constexpr const char* ORCA_SYNC_STATE_FILE = "sync_state";
constexpr const char* ORCA_SYNC_PROFILE_TABLE = "profiles";
constexpr size_t ORCA_SYNC_MAX_PAYLOAD_SIZE = 1048576; // 1MB size limit
constexpr const char* ORCA_SUBSCRIBED_PLUGINS = "/api/v1/plugins/subscriptions";
constexpr const char* ORCA_UNSUBSCRIBE_PLUGINS = "/api/v1/plugins/subscriptions";
constexpr const char* ORCA_PLUGINS_MINE = "/api/v1/plugins/mine";
constexpr const char* ORCA_PLUGINS_BASE = "/api/v1/plugins";
constexpr const char* ORCA_PLUGIN_DOWNLOAD_URL = "/api/v1/plugins/download";
constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers";
constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login";
constexpr const char* CONFIG_ORCA_API_URL = "orca_api_url";
constexpr const char* CONFIG_ORCA_AUTH_URL = "orca_auth_url";
constexpr const char* CONFIG_ORCA_CLOUD_URL = "orca_cloud_url";
constexpr const char* CONFIG_ORCA_PUB_KEY = "orca_pub_key";
constexpr const char* SECRET_STORE_SERVICE = "OrcaSlicer/Auth";
constexpr const char* SECRET_STORE_USER = "orca_refresh_token";
constexpr std::chrono::seconds TOKEN_REFRESH_SKEW{900}; // 15 minutes
// Cross-process advisory lock serializing refresh-token rotation between Orca instances on
// this machine. The Supabase refresh token rotates on every use, so read -> spend -> write-back
// of the rotated successor must be one critical section across processes; otherwise a second
// instance can re-spend a token a peer already rotated, triggering `refresh_token_already_used`.
// Blocks until acquired (kernel sleep, no CPU spin). It cannot deadlock permanently because the
// refresh POST is bounded (http_post_token uses timeout_max) and both back-ends release
// automatically if the holder dies. Same machine only; cross-device concurrency is still
// governed by the server's reuse-detection grace window.
class InterProcessRefreshTokenLock
{
public:
explicit InterProcessRefreshTokenLock(const std::string& path)
{
if (path.empty()) { m_locked = true; return; } // no path -> proceed unsynchronized
#if defined(_WIN32)
const std::wstring name = L"Local\\OrcaTokenRefresh_" + std::to_wstring(std::hash<std::string>{}(path));
m_handle = ::CreateMutexW(nullptr, FALSE, name.c_str());
if (!m_handle) { m_locked = true; return; } // can't create -> don't block
const DWORD r = ::WaitForSingleObject(m_handle, INFINITE); // blocks, no spin
m_locked = (r == WAIT_OBJECT_0 || r == WAIT_ABANDONED); // ABANDONED: prior holder crashed
#else
m_fd = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600);
if (m_fd == -1) { m_locked = true; return; } // can't open -> don't block
struct flock fl{};
fl.l_type = F_WRLCK; fl.l_whence = SEEK_SET; fl.l_start = 0; fl.l_len = 0;
int rc;
do { rc = ::fcntl(m_fd, F_SETLKW, &fl); } while (rc == -1 && errno == EINTR); // blocks in kernel
m_locked = (rc != -1);
#endif
}
~InterProcessRefreshTokenLock()
{
#if defined(_WIN32)
if (m_handle) { if (m_locked) ::ReleaseMutex(m_handle); ::CloseHandle(m_handle); }
#else
if (m_fd != -1) ::close(m_fd); // closing the fd releases the lock we hold
#endif
}
bool locked() const { return m_locked; }
InterProcessRefreshTokenLock(const InterProcessRefreshTokenLock&) = delete;
InterProcessRefreshTokenLock& operator=(const InterProcessRefreshTokenLock&) = delete;
private:
bool m_locked = false;
#if defined(_WIN32)
HANDLE m_handle = nullptr;
#else
int m_fd = -1;
#endif
};
// Return a JSON field only when it is present as a string. Missing or non-string values normalize to empty.
std::string get_json_string_field(const json& j, const std::string& key)
{
if (j.contains(key) && j[key].is_string()) {
return j[key].get<std::string>();
}
return "";
}
long long get_json_int64_field(const json& j, const std::string& key)
{
if (!j.contains(key))
return 0;
const auto& value = j[key];
if (value.is_number_integer())
return value.get<long long>();
if (value.is_number_unsigned())
return static_cast<long long>(value.get<unsigned long long>());
return 0;
}
// Resolve the human-facing UI label from provider metadata.
std::string resolve_display_name(const std::string& display_name,
const std::string& nickname,
const std::string& full_name,
const std::string& name,
const std::string& username)
{
// Providers and payload shapes do not all use the same display-name field.
// Fallback sequence: display_name -> nickname -> full_name -> name
if (!display_name.empty())
return display_name;
if (!nickname.empty())
return nickname;
if (!full_name.empty())
return full_name;
if (!name.empty())
return name;
return username;
}
std::string base64url_encode(const std::vector<unsigned char>& data)
{
std::string out;
out.resize(boost::beast::detail::base64::encoded_size(data.size()));
out.resize(boost::beast::detail::base64::encode(out.data(), data.data(), data.size()));
std::replace(out.begin(), out.end(), '+', '-');
std::replace(out.begin(), out.end(), '/', '_');
out.erase(std::remove(out.begin(), out.end(), '='), out.end());
return out;
}
bool base64url_decode(const std::string& input, std::vector<unsigned char>& out)
{
std::string padded = input;
while (padded.size() % 4 != 0)
padded.push_back('=');
std::string normalized = padded;
std::replace(normalized.begin(), normalized.end(), '-', '+');
std::replace(normalized.begin(), normalized.end(), '_', '/');
out.resize(boost::beast::detail::base64::decoded_size(normalized.size()));
auto res = boost::beast::detail::base64::decode(out.data(), normalized.data(), normalized.size());
if (!res.second)
return false;
out.resize(res.first);
return true;
}
std::vector<unsigned char> random_bytes(size_t len)
{
std::vector<unsigned char> bytes(len);
if (RAND_bytes(bytes.data(), static_cast<int>(len)) != 1) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<int> dist(0, 255);
for (auto& b : bytes)
b = static_cast<unsigned char>(dist(gen));
}
return bytes;
}
std::string generate_code_verifier()
{
constexpr int PKCE_VERIFIER_BYTES = 32;
auto bytes = random_bytes(PKCE_VERIFIER_BYTES);
return base64url_encode(bytes);
}
std::string generate_state_token()
{
auto bytes = random_bytes(16);
std::stringstream ss;
for (auto b : bytes) {
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(b);
}
return ss.str();
}
std::string sha256_base64url(const std::string& input)
{
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char*>(input.data()), input.size(), hash);
std::vector<unsigned char> hash_vec(hash, hash + sizeof(hash));
return base64url_encode(hash_vec);
}
std::string os_machine_id()
{
// Orca: OS-level identifiers that live outside data_dir, so a copied data_dir
// on another machine yields a different key and the stored refresh
// token silently fails to decrypt (forcing a normal sign-in).
#if defined(__linux__)
std::ifstream f("/etc/machine-id");
std::string id;
if (f.good()) {
std::getline(f, id);
}
if (!id.empty())
return id;
#elif defined(_WIN32)
// HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid is a per-install
// identifier that isn't copied along with the user's data_dir.
HKEY hkey = nullptr;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ | KEY_WOW64_64KEY, &hkey) == ERROR_SUCCESS) {
wchar_t buffer[128] = {0};
DWORD buffer_bytes = sizeof(buffer);
DWORD type = 0;
LONG status = RegQueryValueExW(hkey, L"MachineGuid", nullptr, &type, reinterpret_cast<LPBYTE>(buffer), &buffer_bytes);
RegCloseKey(hkey);
if (status == ERROR_SUCCESS && type == REG_SZ && buffer_bytes > sizeof(wchar_t)) {
const size_t wlen = (buffer_bytes / sizeof(wchar_t)) - 1; // strip trailing NUL
return wxString(buffer, wlen).ToStdString();
}
}
#elif defined(__APPLE__)
// gethostuuid() returns the hardware-tied host UUID (same value as
// "Hardware UUID" in System Information). It persists across OS
// reinstalls and kernel updates. Do NOT use sysctl kern.uuid — that
// is the running kernel image's build UUID (from kernel_uuid_string
// in XNU), which rotates on every macOS/kernel update and would
// sign users out on every OS update.
uuid_t host_id;
struct timespec wait = {0, 0};
if (gethostuuid(host_id, &wait) == 0) {
uuid_string_t str;
uuid_unparse(host_id, str);
return std::string(str);
}
#endif
// Last resort: OS hostname.
return wxGetHostName().ToStdString();
}
std::string get_encryption_key()
{
// Bind the encryption key to both the machine and the OS user. A data_dir
// copied to another machine changes the machine half; a data_dir shared
// between OS users on the same machine changes the user half. Either
// difference makes the stored token fail to decrypt and forces a sign-in.
return os_machine_id() + ":" + wxGetUserId().ToStdString();
}
std::vector<unsigned char> sha256_bytes(const std::string& input)
{
std::vector<unsigned char> out(SHA256_DIGEST_LENGTH, 0);
SHA256(reinterpret_cast<const unsigned char*>(input.data()), input.size(), out.data());
return out;
}
std::string hmac_sha256_hex(const std::string& data, const std::vector<unsigned char>& key)
{
unsigned int len = 0;
unsigned char result[EVP_MAX_MD_SIZE];
if (HMAC(EVP_sha256(), key.data(), static_cast<int>(key.size()), reinterpret_cast<const unsigned char*>(data.data()), data.size(),
result, &len) == nullptr) {
return {};
}
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (unsigned int i = 0; i < len; ++i) {
oss << std::setw(2) << static_cast<int>(result[i]);
}
return oss.str();
}
bool is_port_available(int port)
{
if (port <= 0 || port > 65535)
return false;
using boost::asio::ip::tcp;
boost::asio::io_context ctx;
boost::system::error_code ec;
tcp::acceptor acceptor(ctx);
tcp::endpoint endpoint(tcp::v4(), static_cast<unsigned short>(port));
acceptor.open(endpoint.protocol(), ec);
if (ec)
return false;
acceptor.set_option(tcp::acceptor::reuse_address(true), ec);
if (ec)
return false;
acceptor.bind(endpoint, ec);
if (ec)
return false;
acceptor.close(ec);
return true;
}
int choose_loopback_port()
{
int base_port = auth_constants::LOOPBACK_PORT;
if (const char* env_port = std::getenv("ORCA_LOOPBACK_PORT")) {
try {
int parsed = std::stoi(env_port);
if (parsed > 0 && parsed <= 65535) {
base_port = parsed;
}
} catch (...) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: invalid ORCA_LOOPBACK_PORT value, falling back to default";
}
}
std::vector<int> candidates = {base_port, base_port + 1, base_port + 2};
for (int port : candidates) {
if (is_port_available(port))
return port;
}
return base_port;
}
bool aes256gcm_encrypt(const std::string& plaintext, const std::vector<unsigned char>& key, std::string& out_b64)
{
const int iv_len = 12;
auto iv = random_bytes(iv_len);
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx)
return false;
bool ok = true;
int len = 0;
std::vector<unsigned char> ciphertext(plaintext.size());
std::vector<unsigned char> tag(16);
if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) != 1)
ok = false;
if (ok && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, nullptr) != 1)
ok = false;
if (ok && EVP_EncryptInit_ex(ctx, nullptr, nullptr, key.data(), iv.data()) != 1)
ok = false;
if (ok &&
EVP_EncryptUpdate(ctx, ciphertext.data(), &len, reinterpret_cast<const unsigned char*>(plaintext.data()), plaintext.size()) != 1)
ok = false;
int ciphertext_len = len;
if (ok && EVP_EncryptFinal_ex(ctx, ciphertext.data() + len, &len) != 1)
ok = false;
ciphertext_len += len;
if (ok && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, tag.size(), tag.data()) != 1)
ok = false;
EVP_CIPHER_CTX_free(ctx);
if (!ok)
return false;
ciphertext.resize(ciphertext_len);
std::vector<unsigned char> payload;
payload.reserve(iv.size() + tag.size() + ciphertext.size());
payload.insert(payload.end(), iv.begin(), iv.end());
payload.insert(payload.end(), tag.begin(), tag.end());
payload.insert(payload.end(), ciphertext.begin(), ciphertext.end());
out_b64 = base64url_encode(payload);
return true;
}
bool aes256gcm_decrypt(const std::string& b64_payload, const std::vector<unsigned char>& key, std::string& plaintext)
{
std::vector<unsigned char> payload;
if (!base64url_decode(b64_payload, payload))
return false;
if (payload.size() < 12 + 16)
return false;
const size_t iv_len = 12;
const size_t tag_len = 16;
std::vector<unsigned char> iv(payload.begin(), payload.begin() + iv_len);
std::vector<unsigned char> tag(payload.begin() + iv_len, payload.begin() + iv_len + tag_len);
std::vector<unsigned char> ciphertext(payload.begin() + iv_len + tag_len, payload.end());
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx)
return false;
bool ok = true;
int len = 0;
std::vector<unsigned char> plain(ciphertext.size());
if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) != 1)
ok = false;
if (ok && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, nullptr) != 1)
ok = false;
if (ok && EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), iv.data()) != 1)
ok = false;
if (ok && EVP_DecryptUpdate(ctx, plain.data(), &len, ciphertext.data(), ciphertext.size()) != 1)
ok = false;
int plain_len = len;
if (ok && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, tag.size(), tag.data()) != 1)
ok = false;
if (ok && EVP_DecryptFinal_ex(ctx, plain.data() + len, &len) != 1)
ok = false;
plain_len += len;
EVP_CIPHER_CTX_free(ctx);
if (!ok)
return false;
plain.resize(plain_len);
plaintext.assign(reinterpret_cast<char*>(plain.data()), plain.size());
return true;
}
} // namespace
// ============================================================================
// Constructor / Destructor
// ============================================================================
OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
: log_dir(std::move(log_dir))
, api_base_url(ORCA_DEFAULT_API_URL)
, auth_base_url(ORCA_DEFAULT_AUTH_URL)
, cloud_base_url(ORCA_DEFAULT_CLOUD_URL)
{
auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY;
pkce_bundle.loopback_port = choose_loopback_port();
update_redirect_uri();
regenerate_pkce();
compute_fallback_path();
}
OrcaCloudServiceAgent::~OrcaCloudServiceAgent()
{
if (refresh_thread.joinable()) {
refresh_thread.join();
}
}
std::string OrcaCloudServiceAgent::generate_uuid_for_setting_id(const std::string& name, const std::string& user_id)
{
if (name.empty()) {
return "";
}
// Mix user_id into the hashed input so two different users generating a setting_id
// for an identically-named preset get distinct UUIDs. Without this, the cloud's ID
// space collides across accounts and the second user's create gets HTTP 409 with
// server_profile=null on every sync (the foreign owner's record is not exposed).
static const boost::uuids::uuid orca_namespace =
boost::uuids::string_generator()("f47ac10b-58cc-4372-a567-0e02b2c3d479");
boost::uuids::name_generator_sha1 gen(orca_namespace);
boost::uuids::uuid id = user_id.empty() ? gen(name) : gen(user_id + "/" + name);
return boost::uuids::to_string(id);
}
void OrcaCloudServiceAgent::configure_urls(AppConfig* app_config)
{
if (!app_config)
return;
// Read token storage preference
m_use_encrypted_token_file = app_config->get_bool(SETTING_USE_ENCRYPTED_TOKEN_FILE);
std::string api_url = app_config->get(CONFIG_ORCA_API_URL);
if (!api_url.empty()) {
api_base_url = api_url;
}
std::string auth_url = app_config->get(CONFIG_ORCA_AUTH_URL);
if (!auth_url.empty()) {
auth_base_url = auth_url;
}
std::string cloud_url = app_config->get(CONFIG_ORCA_CLOUD_URL);
if (!cloud_url.empty()) {
cloud_base_url = cloud_url;
}
std::string pub_key = app_config->get(CONFIG_ORCA_PUB_KEY);
if (!pub_key.empty()) {
auth_headers["apikey"] = pub_key;
}
}
void OrcaCloudServiceAgent::set_api_base_url(const std::string& url) { api_base_url = url; }
void OrcaCloudServiceAgent::set_auth_base_url(const std::string& url) { auth_base_url = url; }
void OrcaCloudServiceAgent::set_cloud_base_url(const std::string& url) { cloud_base_url = url; }
void OrcaCloudServiceAgent::set_use_encrypted_token_file(bool use) { m_use_encrypted_token_file = use; }
bool OrcaCloudServiceAgent::get_use_encrypted_token_file() const { return m_use_encrypted_token_file; }
// ============================================================================
// ICloudServiceAgent - Lifecycle Methods
// ============================================================================
int OrcaCloudServiceAgent::init_log() { return BAMBU_NETWORK_SUCCESS; }
int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir)
{
config_dir = cfg_dir;
wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), secret_constants::USER_SECRET_FILENAME);
fallback.MakeAbsolute();
secret_fallback_path = fallback.GetFullPath().ToStdString();
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::set_cert_file(std::string folder, std::string filename)
{
// Not used by OrcaCloudServiceAgent (OAuth doesn't need client certs)
(void) folder;
(void) filename;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::set_country_code(std::string code)
{
country_code = code;
return BAMBU_NETWORK_SUCCESS;
}
/// Decode a saved user session or a refresh token.
///
/// Returns `false` if invalid input, and a re-authentication is required.
///
/// If returns `true`, `out_refresh_token` will contain the user refresh token, and `out_session` can be one of two scenarios:
/// - if `out_session.logged_in` is `true`, then `out_session.refresh_token` and `out_session.user_id` are guaranteed to be present,
/// and a refresh is not necessarily required until you need to make any network call
/// - otherwise if `out_session.logged_in` is `false`, you should do a refresh immediately to get the user information before proceed,
/// otherwise user will be logged out
static bool parse_stored_secret(const std::string& secret, std::string& out_refresh_token, OrcaCloudServiceAgent::SessionInfo& out_session)
{
out_refresh_token.clear();
out_session = OrcaCloudServiceAgent::SessionInfo{};
try {
// Valid secret should be a json object, otherwise it's a plain refresh token
const json secret_json = json::parse(secret, nullptr, false);
if (secret_json.type() != json::value_t::object) {
out_refresh_token = secret;
return true;
}
OrcaCloudServiceAgent::SessionInfo user_session{};
user_session.refresh_token = get_json_string_field(secret_json, "refresh_token");
user_session.user_id = get_json_string_field(secret_json, "user_id");
user_session.user_name = get_json_string_field(secret_json, "username");
user_session.user_nickname = get_json_string_field(secret_json, "nickname");
user_session.logged_in = true;
// User session, must at least contains refresh token and user id
if (user_session.refresh_token.empty() || user_session.user_id.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: secret does not contain valid user session, force re-authentication";
return false;
}
out_refresh_token = user_session.refresh_token;
out_session = std::move(user_session);
return true;
} catch (const std::exception&) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: parse_stored_secret exception, force re-authentication";
return false;
}
}
int OrcaCloudServiceAgent::start()
{
regenerate_pkce();
// Attempt silent sign-in from stored refresh token
std::string stored_secret;
if (load_user_secret(stored_secret) && !stored_secret.empty()) {
// Backward compatibility: if secret it a json, then read it as use session,
// which allows us to refresh it in a background thread to speed up the app startup;
// otherwise it's a plain refresh token, then we force a sync refresh
std::string refresh_token;
SessionInfo stored_session;
if (parse_stored_secret(stored_secret, refresh_token, stored_session)) {
if (stored_session.logged_in) {
// We have a previously saved user session, use it. Skip re-persisting: the secret was
// just loaded from disk, so writing the identical bytes back is wasted startup I/O.
set_user_session(stored_session.access_token, stored_session.user_id, stored_session.user_name,
stored_session.user_nickname, stored_session.user_avatar, stored_session.refresh_token,
/*persist=*/false);
}
refresh_now(refresh_token, "refresh token", stored_session.logged_in);
}
}
return BAMBU_NETWORK_SUCCESS;
}
// ============================================================================
// ICloudServiceAgent - User Session Management
// ============================================================================
bool OrcaCloudServiceAgent::exchange_auth_code(const std::string& auth_code, const std::string& state, std::string& session_payload)
{
const auto expected_state = pkce_bundle.state;
if (expected_state.empty()) {
BOOST_LOG_TRIVIAL(warning) << "[auth] event=code_exchange result=failure reason=no_expected_state";
return false;
}
if (state != expected_state) {
BOOST_LOG_TRIVIAL(warning) << "[auth] event=code_exchange result=failure reason=state_mismatch";
return false;
}
std::string code_verifier;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
code_verifier = pkce_bundle.verifier;
}
// Exchange the Supabase PKCE code with the token endpoint.
// With redirect_to, GoTrue handles Google OAuth internally and redirects to localhost
// with a Supabase-generated auth_code (not a raw Google code). The grant_type goes
// in the URL query string (same pattern as the refresh_token flow).
const std::string token_url = auth_base_url + auth_constants::TOKEN_PATH + "?grant_type=pkce";
json body_j;
body_j["auth_code"] = auth_code;
body_j["code_verifier"] = code_verifier;
std::string response;
unsigned int http_code = 0;
const bool success = http_post_token(body_j.dump(), &response, &http_code, token_url);
if (!success || http_code >= 400) {
BOOST_LOG_TRIVIAL(error) << "[auth] event=code_exchange result=failure http_code=" << http_code << " body=" << response;
return false;
}
session_payload = response;
BOOST_LOG_TRIVIAL(info) << "[auth] event=code_exchange result=success";
return true;
}
int OrcaCloudServiceAgent::change_user(std::string user_info)
{
try {
auto tree = json::parse(user_info);
auto safe_str = [](const json& j, const std::string& key) -> std::string {
if (j.contains(key) && j[key].is_string())
return j[key].get<std::string>();
return "";
};
// Check if this is a WebView login message (PKCE flow completion)
std::string command = safe_str(tree, "command");
if (command == "user_login") {
if (!tree.contains("data") || !tree["data"].is_object()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: WebView login payload missing data field";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
const auto& data = tree["data"];
std::string state = safe_str(data, "state");
// Check for auth code (PKCE authorization code flow)
std::string auth_code = safe_str(data, "code");
if (!auth_code.empty()) {
std::string session_payload;
if (!exchange_auth_code(auth_code, state, session_payload)) {
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
// Recursively process the session payload (contains access_token, user, etc.)
return change_user(session_payload);
}
// Validate PKCE state
const auto expected_state = pkce_bundle.state;
if (!expected_state.empty() && state != expected_state) {
BOOST_LOG_TRIVIAL(warning) << "[auth] event=login result=failure reason=state_mismatch";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
// Direct token flow (tokens already obtained by WebView)
bool success = set_user_session(data);
BOOST_LOG_TRIVIAL(info) << "[auth] event=login result=" << (success ? "success" : "failure") << " source=webview";
return success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
// Orca cloud session payload (default flow)
const json* session_node = nullptr;
if (tree.contains("data") && tree["data"].is_object()) {
const auto& data = tree["data"];
if (data.contains("session") && data["session"].is_object()) {
session_node = &data["session"];
} else if (data.contains("access_token") || data.contains("token")) {
session_node = &data;
}
}
if (!session_node) {
if (tree.contains("session") && tree["session"].is_object()) {
session_node = &tree["session"];
} else if (tree.contains("access_token") || tree.contains("token")) {
session_node = &tree;
}
}
if (session_node) {
return set_user_session(*session_node) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: Username/password login is disabled. Use the Orca cloud PKCE flow.";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: change_user exception - " << e.what();
return BAMBU_NETWORK_ERR_INVALID_RESULT;
}
}
bool OrcaCloudServiceAgent::is_user_login()
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.logged_in;
}
int OrcaCloudServiceAgent::user_logout(bool request)
{
// Send logout request to backend if requested
if (request) {
std::string token;
std::string refresh_copy;
{
std::lock_guard<std::mutex> lock(session_mutex);
token = session.access_token;
refresh_copy = session.refresh_token;
}
if (!token.empty()) {
std::string response;
unsigned int http_code = 0;
json logout_req = json::object();
if (!refresh_copy.empty()) {
logout_req["refresh_token"] = refresh_copy;
}
int result = http_post_auth(auth_constants::LOGOUT_PATH, logout_req.dump(), &response, &http_code) ?
BAMBU_NETWORK_SUCCESS :
BAMBU_NETWORK_ERR_INVALID_HANDLE;
if (result != BAMBU_NETWORK_SUCCESS || http_code >= 400) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: Orca cloud logout request failed - http_code=" << http_code;
}
}
}
clear_session();
return BAMBU_NETWORK_SUCCESS;
}
std::string OrcaCloudServiceAgent::get_user_id()
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.user_id;
}
std::string OrcaCloudServiceAgent::get_user_name()
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.user_name;
}
std::string OrcaCloudServiceAgent::get_user_avatar()
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.user_avatar;
}
std::string OrcaCloudServiceAgent::get_user_nickname()
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.user_nickname;
}
// ============================================================================
// ICloudServiceAgent - Login UI Support
// ============================================================================
std::string OrcaCloudServiceAgent::build_login_cmd()
{
// When already signed in, emit the homepage payload so the web UI
// can flip to the logged-in state without re-opening the login flow.
if (is_user_login()) {
std::string display_name = get_user_nickname();
if (display_name.empty()) {
display_name = "unknown name";
}
json cmd;
cmd["command"] = "orca_userlogin";
cmd["data"]["name"] = display_name;
cmd["data"]["avatar"] = get_user_avatar();
// The unique handle, shown under the display name in the homepage account menu.
cmd["data"]["account"] = get_user_name();
return cmd.dump();
}
update_redirect_uri();
regenerate_pkce();
const auto bundle = pkce();
json tree;
tree["action"] = "login_config";
tree["backend_url"] = auth_base_url;
// Include API key for direct Supabase calls from JavaScript
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
auto it = auth_headers.find("apikey");
if (it != auth_headers.end()) {
tree["apikey"] = it->second;
}
}
tree["pkce"]["code_challenge"] = bundle.challenge;
tree["pkce"]["code_challenge_method"] = "S256";
tree["pkce"]["state"] = bundle.state;
tree["pkce"]["redirect_uri"] = bundle.redirect;
tree["pkce"]["code_verifier"] = bundle.verifier;
tree["pkce"]["loopback_port"] = bundle.loopback_port;
return tree.dump();
}
std::string OrcaCloudServiceAgent::build_logout_cmd() { return json{{"command", "orca_useroffline"}}.dump(); }
std::string OrcaCloudServiceAgent::build_login_info()
{
json tree;
{
std::lock_guard<std::mutex> lock(session_mutex);
tree["user_id"] = session.user_id;
tree["user_name"] = session.user_name;
tree["nickname"] = session.user_nickname;
tree["avatar"] = session.user_avatar;
tree["logged_in"] = session.logged_in;
}
// Do not expose tokens to the WebView
tree["access_token"] = "";
tree["refresh_token"] = "";
tree["backend_url"] = api_base_url;
tree["auth_url"] = auth_base_url;
return tree.dump();
}
// ============================================================================
// ICloudServiceAgent - Token Access
// ============================================================================
std::string OrcaCloudServiceAgent::get_access_token() const
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.access_token;
}
std::string OrcaCloudServiceAgent::get_refresh_token() const
{
std::lock_guard<std::mutex> lock(session_mutex);
return session.refresh_token;
}
bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { return refresh_if_expiring(TOKEN_REFRESH_SKEW, reason); }
// ============================================================================
// ICloudServiceAgent - Server Connectivity
// ============================================================================
int OrcaCloudServiceAgent::connect_server()
{
std::string response;
unsigned int http_code = 0;
int result = http_get(ORCA_HEALTH_PATH, &response, &http_code);
bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300);
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = connected;
}
invoke_server_connected_callback(connected ? 0 : -1, http_code);
return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
bool OrcaCloudServiceAgent::is_server_connected()
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
return is_connected;
}
int OrcaCloudServiceAgent::refresh_connection() { return connect_server(); }
int OrcaCloudServiceAgent::start_subscribe(std::string module)
{
(void) module;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::stop_subscribe(std::string module)
{
(void) module;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::add_subscribe(std::vector<std::string> dev_list)
{
(void) dev_list;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::del_subscribe(std::vector<std::string> dev_list)
{
(void) dev_list;
return BAMBU_NETWORK_SUCCESS;
}
void OrcaCloudServiceAgent::enable_multi_machine(bool enable)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
multi_machine_enabled = enable;
}
// ============================================================================
// ICloudServiceAgent - Settings Synchronization
// ============================================================================
int OrcaCloudServiceAgent::get_user_presets(std::map<std::string, std::map<std::string, std::string>>* user_presets)
{
if (!user_presets)
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
if (!is_user_login()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: Not logged in";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
bool success = false;
std::string error_msg;
int http_code_out = 0;
SyncState saved_state;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
saved_state = sync_state;
sync_state = SyncState{};
}
auto on_success = [&](const SyncPullResponse& resp) {
// Get current user_id for all presets (required by load_user_preset)
std::string current_user_id = get_user_id();
for (const auto& upsert : resp.upserts) {
// Parse JSON content into key-value pairs using helper
std::map<std::string, std::string> value_map;
json_to_map(upsert.content.dump(), value_map);
// Add metadata from top-level sync response if not already in content
// These are required by PresetCollection::load_user_preset
if (value_map.find(BBL_JSON_KEY_SETTING_ID) == value_map.end()) {
value_map[BBL_JSON_KEY_SETTING_ID] = upsert.id;
}
if (value_map.find(BBL_JSON_KEY_USER_ID) == value_map.end()) {
value_map[BBL_JSON_KEY_USER_ID] = current_user_id;
}
if (value_map.find(ORCA_JSON_KEY_UPDATE_TIME) == value_map.end()) {
value_map[ORCA_JSON_KEY_UPDATE_TIME] = std::to_string(upsert.updated_time);
}
// Use preset name from content or fallback to upsert.name or upsert.id
std::string preset_name = upsert.content.value(BBL_JSON_KEY_NAME, "");
if (preset_name.empty()) {
preset_name = upsert.name.empty() ? upsert.id : upsert.name;
}
// Store as: user_presets[preset_name][key] = value
// This matches the format expected by PresetBundle::load_user_presets
(*user_presets)[preset_name] = value_map;
}
if (resp.next_cursor != 0) {
std::lock_guard<std::recursive_mutex> lock(state_mutex);
sync_state.last_sync_timestamp = resp.next_cursor;
save_sync_state();
} else {
std::lock_guard<std::recursive_mutex> lock(state_mutex);
sync_state = saved_state;
}
success = true;
};
auto on_error = [&](int code, const std::string& err) {
http_code_out = code;
error_msg = err;
success = false;
};
sync_pull(on_success, on_error);
if (!success) {
std::lock_guard<std::recursive_mutex> lock(state_mutex);
sync_state = saved_state;
}
return success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
std::string OrcaCloudServiceAgent::request_setting_id(std::string name,
std::map<std::string, std::string>* values_map,
unsigned int* http_code)
{
std::string new_id = generate_uuid_for_setting_id(name, get_user_id());
if (new_id.empty()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: request_setting_id failed - name is empty";
return "";
}
nlohmann::json content;
content["name"] = name;
content["type"] = IOT_PRINT_TYPE_STRING; // Default type
if (values_map && !values_map->empty()) {
for (const auto& pair : *values_map) {
// Skip updated_time - it's metadata, not content
if (pair.first == IOT_JSON_KEY_UPDATED_TIME)
continue;
content[pair.first] = pair.second;
}
}
// Use sync_push to create the profile (no original_updated_time for new profiles per spec)
auto result = sync_push(new_id, name, content, "");
if (http_code)
*http_code = result.http_code;
if (result.success) {
if (values_map && result.new_updated_time != 0) {
(*values_map)[IOT_JSON_KEY_UPDATED_TIME] = std::to_string(result.new_updated_time);
}
return new_id;
}
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: request_setting_id failed - " << result.error_message
<< " - http code: " << result.http_code;
return "";
}
int OrcaCloudServiceAgent::put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force)
{
// Extract original_updated_time for Optimistic Concurrency Control
// If present, server will verify version before update. If absent, treated as insert.
std::string original_updated_time;
if (values_map) {
auto it = values_map->find(IOT_JSON_KEY_UPDATED_TIME);
if (it != values_map->end()) {
original_updated_time = it->second;
}
}
// Build content JSON
nlohmann::json content;
content["name"] = name;
if (values_map && !values_map->empty()) {
for (const auto& pair : *values_map) {
// Skip updated_time - it's used for OCC, not as content
if (pair.first == IOT_JSON_KEY_UPDATED_TIME)
continue;
content[pair.first] = pair.second;
}
}
auto result = sync_push(setting_id, name, content, original_updated_time, force);
if (http_code) *http_code = result.http_code;
if (result.success) {
if (values_map && result.new_updated_time != 0) {
(*values_map)[IOT_JSON_KEY_UPDATED_TIME] = std::to_string(result.new_updated_time);
}
return BAMBU_NETWORK_SUCCESS;
}
if (result.http_code == 409) {
// Conflict - update values_map with server version
if (values_map && result.server_version.updated_time != 0) {
(*values_map)[IOT_JSON_KEY_UPDATED_TIME] = std::to_string(result.server_version.updated_time);
}
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: put_setting conflict - server_updated_time="
<< result.server_version.updated_time;
}
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: put_setting failed - " << result.error_message;
return BAMBU_NETWORK_ERR_PUT_SETTING_FAILED;
}
int OrcaCloudServiceAgent::get_setting_list(std::string bundle_version, ProgressFn pro_fn, WasCancelledFn cancel_fn)
{ return get_setting_list2(bundle_version, nullptr, pro_fn, cancel_fn); }
int OrcaCloudServiceAgent::get_setting_list2(std::string bundle_version, CheckFn chk_fn, ProgressFn pro_fn, WasCancelledFn cancel_fn)
{
(void) bundle_version;
bool success = false;
int error_code = 0;
auto on_success = [&](const SyncPullResponse& resp) {
int total = static_cast<int>(resp.upserts.size() + resp.deletes.size());
int processed = 0;
bool cancelled = false;
for (const auto& upsert : resp.upserts) {
if (cancel_fn && cancel_fn()) {
cancelled = true;
break;
}
if (chk_fn) {
std::map<std::string, std::string> info;
info[IOT_JSON_KEY_SETTING_ID] = upsert.id;
info[IOT_JSON_KEY_UPDATED_TIME] = std::to_string(upsert.updated_time);
if (upsert.content.is_object()) {
for (auto& [key, value] : upsert.content.items()) {
if (value.is_string()) {
info[key] = value.get<std::string>();
} else {
info[key] = value.dump();
}
}
}
if (!info.count(IOT_JSON_KEY_NAME) && !upsert.name.empty()) {
info[IOT_JSON_KEY_NAME] = upsert.name;
}
if (!info.count(IOT_JSON_KEY_TYPE)) {
info[IOT_JSON_KEY_TYPE] = IOT_PRINT_TYPE_STRING;
}
chk_fn(info);
}
if (pro_fn) {
int progress = total > 0 ? (processed * 100 / total) : 100;
pro_fn(progress);
}
processed++;
}
if (!cancelled) {
for (const auto& deleted_id : resp.deletes) {
if (cancel_fn && cancel_fn()) {
cancelled = true;
break;
}
if (chk_fn) {
std::map<std::string, std::string> info;
info[IOT_JSON_KEY_SETTING_ID] = deleted_id;
info["deleted"] = "true";
chk_fn(info);
}
if (pro_fn) {
int progress = total > 0 ? (processed * 100 / total) : 100;
pro_fn(progress);
}
processed++;
}
}
if (!cancelled && resp.next_cursor != 0) {
std::lock_guard<std::recursive_mutex> lock(state_mutex);
sync_state.last_sync_timestamp = resp.next_cursor;
save_sync_state();
}
if (pro_fn) {
pro_fn(100);
}
success = !cancelled;
};
auto on_error = [&](int code, const std::string& err) {
error_code = code;
success = false;
};
sync_pull(on_success, on_error);
return success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
int OrcaCloudServiceAgent::delete_setting(std::string setting_id)
{
std::string path = std::string(ORCA_SYNC_DELETE_PATH) + "?resource=" + ORCA_SYNC_PROFILE_TABLE + "&id=" + setting_id;
std::string response;
unsigned int http_code = 0;
int result = http_delete(path, &response, &http_code);
// Treat 204 as success: the setting is already gone from cloud, which is our goal.
if (http_code == 204) {
return BAMBU_NETWORK_SUCCESS;
}
if (result != BAMBU_NETWORK_SUCCESS || http_code >= 400) {
return BAMBU_NETWORK_ERR_DEL_SETTING_FAILED;
}
return BAMBU_NETWORK_SUCCESS;
}
// ============================================================================
// Sync Protocol Implementation
// ============================================================================
int OrcaCloudServiceAgent::sync_pull(std::function<void(const SyncPullResponse&)> on_success,
std::function<void(int http_code, const std::string& error)> on_error)
{
std::string path = ORCA_SYNC_PULL_PATH;
if (sync_state.last_sync_timestamp != 0) {
path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
}
std::string response;
unsigned int http_code = 0;
int result = http_get(path, &response, &http_code);
// Handle 410 Gone - cursor too old, need full resync
if (http_code == 410) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: sync_pull returned 410 Gone - cursor too old, triggering full resync";
clear_sync_state();
// Retry without cursor
path = ORCA_SYNC_PULL_PATH;
result = http_get(path, &response, &http_code);
}
if (result != BAMBU_NETWORK_SUCCESS || (http_code != 200 && http_code != 304)) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: sync_pull failed - http_code=" << http_code << " - path=" << path;
if (on_error)
on_error(http_code, response);
return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
if (http_code == 304) {
if (on_success) {
SyncPullResponse empty_response;
on_success(empty_response);
}
return BAMBU_NETWORK_SUCCESS;
}
try {
auto json = nlohmann::json::parse(response);
SyncPullResponse resp;
resp.next_cursor = json.value("next_cursor", 0);
if (json.contains("upserts") && json["upserts"].is_array()) {
for (const auto& item : json["upserts"]) {
ProfileUpsert upsert;
upsert.id = item.value("id", "");
upsert.name = item.value("name", "");
upsert.updated_time = item.value(ORCA_JSON_KEY_UPDATE_TIME, 0);
upsert.created_time = item.value(ORCA_JSON_KEY_CREATED_TIME, 0);
if (item.contains("content")) {
upsert.content = item["content"];
}
resp.upserts.push_back(upsert);
}
}
if (json.contains("deletes") && json["deletes"].is_array()) {
for (const auto& item : json["deletes"]) {
resp.deletes.push_back(item.get<std::string>());
}
}
if (on_success)
on_success(resp);
return BAMBU_NETWORK_SUCCESS;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: sync_pull parse error - " << e.what();
if (on_error)
on_error(http_code, e.what());
return BAMBU_NETWORK_ERR_INVALID_RESULT;
}
}
SyncPushResult OrcaCloudServiceAgent::sync_push(const std::string& profile_id,
const std::string& name,
const nlohmann::json& content,
const std::string& original_updated_time,
bool force)
{
SyncPushResult result;
result.success = false;
result.http_code = 0;
result.server_deleted = false;
nlohmann::json body;
body["id"] = profile_id;
body["name"] = name;
body["content"] = content;
if (!original_updated_time.empty()) {
body["original_updated_time"] = original_updated_time;
}
// Validate payload size before upload
std::string body_str = body.dump();
if (body_str.size() > ORCA_SYNC_MAX_PAYLOAD_SIZE) {
result.http_code = 413; // HTTP 413 Payload Too Large
result.success = false;
result.error_message = "Preset content exceeds 1MB size limit (actual: " + std::to_string(body_str.size()) + " bytes)";
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: sync_push payload too large - " << "size=" << body_str.size() << " bytes, "
<< "limit=" << ORCA_SYNC_MAX_PAYLOAD_SIZE << " bytes, " << "profile_id=" << profile_id;
return result;
}
std::string response;
unsigned int http_code = 0;
int http_result = http_post(force ? ORCA_SYNC_FORCE_PUSH_PATH : ORCA_SYNC_PUSH_PATH, body_str, &response, &http_code);
result.http_code = http_code;
if (http_code == 409) {
// Conflict - parse server version
nlohmann::json err_body;
try {
auto json = nlohmann::json::parse(response);
err_body = json;
if (json.is_null()) {
result.server_deleted = true;
} else {
auto& profile_data = json["server_profile"];
result.server_version.id = profile_data.value("id", "");
result.server_version.name = profile_data.value("name", "");
result.server_version.updated_time = profile_data.value(ORCA_JSON_KEY_UPDATE_TIME, 0);
}
} catch (...) {}
// Surface the conflict via the http-error callback with the local preset name injected.
// The raw server body omits the name for tombstone (-3) conflicts (server_profile is null),
// but the GUI needs it to regenerate the deterministic setting_id for a force push.
if (!err_body.is_object())
err_body = nlohmann::json::object();
err_body["name"] = name;
invoke_http_error_callback(409, err_body.dump());
result.error_message = response;
return result;
}
if (http_code != 200) {
result.error_message = response;
return result;
}
// Success
try {
auto json = nlohmann::json::parse(response);
result.new_updated_time = json.value(ORCA_JSON_KEY_UPDATE_TIME, 0);
if (result.new_updated_time != 0) {
result.success = true;
} else {
result.error_message = "Server response missing required updated_time timestamp";
}
} catch (const std::exception& e) {
result.error_message = e.what();
}
return result;
}
// ============================================================================
// Sync State Management
// ============================================================================
void OrcaCloudServiceAgent::load_sync_state()
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
if (sync_state_path.empty())
return;
try {
std::ifstream ifs(sync_state_path);
if (ifs.good()) {
std::string line;
if (std::getline(ifs, line)) {
sync_state.last_sync_timestamp = std::stoll(line);
}
}
} catch (...) {
sync_state.last_sync_timestamp = 0;
}
}
void OrcaCloudServiceAgent::save_sync_state()
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
if (sync_state_path.empty())
return;
try {
std::string tmp_path = sync_state_path + ".tmp";
std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc);
if (ofs.good()) {
ofs << std::to_string(sync_state.last_sync_timestamp);
ofs.close();
boost::filesystem::rename(tmp_path, sync_state_path);
}
} catch (...) {}
}
void OrcaCloudServiceAgent::clear_sync_state()
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
sync_state = SyncState{};
if (!sync_state_path.empty() && boost::filesystem::exists(sync_state_path)) {
boost::filesystem::remove(sync_state_path);
}
}
// ============================================================================
// Auth - PKCE and Session Management
// ============================================================================
void OrcaCloudServiceAgent::set_session_handler(SessionHandler handler) { session_handler = std::move(handler); }
void OrcaCloudServiceAgent::set_on_login_complete_handler(OnLoginCompleteHandler handler)
{ on_login_complete_handler = std::move(handler); }
const OrcaCloudServiceAgent::PkceBundle& OrcaCloudServiceAgent::pkce()
{
if (pkce_bundle.verifier.empty() || pkce_bundle.challenge.empty() || pkce_bundle.state.empty()) {
regenerate_pkce();
}
return pkce_bundle;
}
void OrcaCloudServiceAgent::regenerate_pkce()
{
pkce_bundle.verifier = generate_code_verifier();
pkce_bundle.challenge = sha256_base64url(pkce_bundle.verifier);
pkce_bundle.state = generate_state_token();
if (pkce_bundle.redirect.empty()) {
pkce_bundle.redirect = "http://localhost:" + std::to_string(pkce_bundle.loopback_port) + auth_constants::LOOPBACK_PATH;
}
}
void OrcaCloudServiceAgent::update_redirect_uri()
{
int selected_port = choose_loopback_port();
pkce_bundle.loopback_port = selected_port;
pkce_bundle.redirect = "http://localhost:" + std::to_string(selected_port) + auth_constants::LOOPBACK_PATH;
}
// ============================================================================
// Auth - Token Persistence
// ============================================================================
void OrcaCloudServiceAgent::persist_user_secret(const std::string& secret)
{
if (secret.empty()) {
clear_user_secret();
return;
}
bool stored = false;
if (m_use_encrypted_token_file) {
// Use encrypted file only
auto key = sha256_bytes(get_encryption_key());
if (key.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot derive key for user secret file storage";
return;
}
std::string payload;
if (!aes256gcm_encrypt(secret, key, payload)) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to encrypt user secret for file storage";
return;
}
std::string signed_payload = payload;
if (auto mac = hmac_sha256_hex(payload, key); !mac.empty()) {
signed_payload = "v2:" + mac + ":" + payload;
}
compute_fallback_path();
if (secret_fallback_path.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no user secret storage path available; skipping file persistence";
return;
}
wxFileName path(wxString::FromUTF8(secret_fallback_path.c_str()));
path.MakeAbsolute();
if (!wxFileName::DirExists(path.GetPath())) {
wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL);
}
const std::string tmp_path = secret_fallback_path + ".tmp";
std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc | std::ios::binary);
if (ofs.good()) {
ofs << signed_payload;
ofs.flush();
ofs.close();
if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(secret_fallback_path.c_str()), true)) {
stored = true;
} else {
wxRemoveFile(wxString::FromUTF8(tmp_path.c_str()));
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace user secret file";
}
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open user secret file for write - " << secret_fallback_path;
}
} else {
// Use wxSecretStore only
wxSecretStore store = wxSecretStore::GetDefault();
if (store.IsOk()) {
wxSecretValue secret_value(wxString::FromUTF8(secret.c_str()));
if (store.Save(SECRET_STORE_SERVICE, SECRET_STORE_USER, secret_value)) {
stored = true;
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: System Keychain save failed";
}
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: System Keychain not available";
}
}
(void) stored;
}
bool OrcaCloudServiceAgent::load_user_secret(std::string& out_secret)
{
out_secret.clear();
if (m_use_encrypted_token_file) {
// Load from encrypted file only
compute_fallback_path();
if (wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) {
std::ifstream ifs(secret_fallback_path, std::ios::binary);
std::string payload((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
auto key = sha256_bytes(get_encryption_key());
std::string plain;
if (!key.empty()) {
std::string encoded_payload = payload;
bool integrity_ok = true;
if (payload.rfind("v2:", 0) == 0) {
auto delim = payload.find(':', 3);
if (delim == std::string::npos) {
BOOST_LOG_TRIVIAL(warning) << "payload missing delim ':'.";
integrity_ok = false;
} else {
std::string stored_hmac = payload.substr(3, delim - 3);
std::string lower_stored = stored_hmac;
std::transform(lower_stored.begin(), lower_stored.end(), lower_stored.begin(), ::tolower);
encoded_payload = payload.substr(delim + 1);
std::string computed_hmac = hmac_sha256_hex(encoded_payload, key);
std::transform(computed_hmac.begin(), computed_hmac.end(), computed_hmac.begin(), ::tolower);
if (computed_hmac.empty() || computed_hmac != lower_stored) {
integrity_ok = false;
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: user secret integrity check failed (HMAC mismatch)";
}
}
}
if (integrity_ok && aes256gcm_decrypt(encoded_payload, key, plain) && !plain.empty()) {
out_secret = plain;
// Upgrade legacy payloads to signed format
if (payload.rfind("v2:", 0) != 0) {
persist_user_secret(out_secret);
}
return true;
}
}
}
} else {
wxSecretStore store = wxSecretStore::GetDefault();
if (store.IsOk()) {
wxString username;
wxSecretValue secret;
if (store.Load(SECRET_STORE_SERVICE, username, secret) && secret.IsOk()) {
out_secret.assign(static_cast<const char*>(secret.GetData()), secret.GetSize());
if (!out_secret.empty()) {
return true;
}
}
}
}
return false;
}
void OrcaCloudServiceAgent::clear_user_secret()
{
wxSecretStore store = wxSecretStore::GetDefault();
if (store.IsOk()) {
store.Delete(SECRET_STORE_SERVICE);
}
compute_fallback_path();
if (!secret_fallback_path.empty() && wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) {
wxRemoveFile(wxString::FromUTF8(secret_fallback_path.c_str()));
}
}
// ============================================================================
// Auth - Token Refresh
// ============================================================================
bool OrcaCloudServiceAgent::should_refresh_locked(std::chrono::seconds skew) const
{
if (!session.logged_in)
return false;
if (session.expires_at.time_since_epoch().count() == 0)
return true;
auto now = std::chrono::system_clock::now();
return (session.expires_at - now) <= skew;
}
bool OrcaCloudServiceAgent::decode_jwt_expiry(const std::string& token, std::chrono::system_clock::time_point& out_tp)
{
out_tp = {};
if (token.empty())
return false;
auto first = token.find('.');
auto second = token.find('.', first == std::string::npos ? 0 : first + 1);
if (first == std::string::npos || second == std::string::npos)
return false;
std::string payload_b64 = token.substr(first + 1, second - first - 1);
std::vector<unsigned char> payload_bytes;
if (!base64url_decode(payload_b64, payload_bytes))
return false;
std::string payload_str(payload_bytes.begin(), payload_bytes.end());
try {
auto payload = json::parse(payload_str);
if (payload.contains("exp") && payload["exp"].is_number()) {
out_tp = std::chrono::system_clock::time_point{std::chrono::seconds(payload["exp"].get<long long>())};
return true;
}
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: failed to decode JWT exp - " << e.what();
}
return false;
}
RefreshResult OrcaCloudServiceAgent::refresh_now(const std::string& refresh_token, const std::string& reason, bool async)
{
if (refresh_token.empty()) return RefreshResult::AuthRejected; // nothing to refresh
bool expected = false;
if (!refresh_running.compare_exchange_strong(expected, true)) {
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: refresh already running, skip (reason=" << reason << ")";
// Another refresh is already in flight. Treat as transient so we keep the session
// instead of logging out: that in-flight refresh surfaces its own Success/AuthRejected,
// so a genuine rejection is only deferred to the next request, never lost.
return RefreshResult::Transient;
}
auto worker = [this, refresh_token, reason]() {
RefreshResult r = refresh_session_with_token(refresh_token, reason);
refresh_running.store(false);
return r;
};
if (async) {
if (refresh_thread.joinable()) {
refresh_thread.join();
}
refresh_thread = std::thread([worker]() { worker(); });
// Fire-and-forget: the outcome isn't known yet and no current caller consumes it.
// Return Transient (indeterminate) rather than implying a completed, successful refresh.
return RefreshResult::Transient;
}
return worker();
}
RefreshResult OrcaCloudServiceAgent::refresh_from_storage(const std::string& reason, bool async)
{
(void) async; // currently all callers are calling this function synchronous anyway
// Blocks until we own the lock (kernel sleep, no spin). Cannot deadlock permanently:
// the refresh POST is bounded (http_post_token timeout_max) and the lock auto-releases
// on process death, so no holder can keep it longer than that bound.
InterProcessRefreshTokenLock lock(token_lock_path());
if (!lock.locked()) {
// Lock syscall genuinely failed (rare). Proceed best-effort rather than give up.
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: token refresh lock unavailable, "
"proceeding unsynchronized (reason=" << reason << ")";
}
std::string refresh_token;
std::string user_secret;
// We read the refresh token from the OS keychain as the source of truth
if (load_user_secret(user_secret) && !user_secret.empty()) {
SessionInfo stored_session;
parse_stored_secret(user_secret, refresh_token, stored_session);
}
// We only read from memory if the read from OS keychain fails
if (refresh_token.empty())
refresh_token = get_refresh_token();
if (refresh_token.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no refresh token available for refresh (reason=" << reason << ")";
return RefreshResult::AuthRejected; // no persisted token: nothing to preserve
}
// Synchronous: hold the lock across the network round-trip AND the write-back that
// set_user_session performs, so the rotation is atomic w.r.t. other instances.
return refresh_now(refresh_token, reason, /*async=*/false);
}
bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const std::string& reason)
{
bool needs_refresh = false;
{
std::lock_guard<std::mutex> lock(session_mutex);
needs_refresh = should_refresh_locked(skew);
}
if (!needs_refresh)
return true;
// First attempt. refresh_from_storage blocks on the cross-process lock and reads the
// freshest token from the store, so cross-instance contention is already resolved here.
RefreshResult r = refresh_from_storage(reason, false);
if (r == RefreshResult::Success) return true;
if (r == RefreshResult::AuthRejected) return false; // definitive: retrying the same token can't help
// One retry, only for a transient network failure of the refresh POST (lost response,
// timeout, 429/5xx -> Transient). The retry re-acquires the lock and re-reads the store.
std::this_thread::sleep_for(std::chrono::milliseconds(750));
return refresh_from_storage(reason + "_retry", false) == RefreshResult::Success;
}
// Maps a token-refresh HTTP outcome to a RefreshResult. http_code == 0 means the
// server could not be reached; session_established is only meaningful for a 2xx body.
static RefreshResult classify_refresh_result(unsigned http_code, bool session_established)
{
if (http_code == 0)
return RefreshResult::Transient; // no response: network/transport failure
if (http_code == 400 || http_code == 401 || http_code == 403)
return RefreshResult::AuthRejected; // refresh token rejected
if (http_code >= 400)
return RefreshResult::Transient; // rate-limit (429), server error (5xx) or other 4xx: keep the session
return session_established ? RefreshResult::Success // 2xx with a usable session
: RefreshResult::Transient; // 2xx but unusable body
}
// Best-effort extraction of GoTrue's "error_code" field (e.g. "refresh_token_already_used").
// Returns "" if the body is not a JSON object or lacks the field.
static std::string extract_error_code(const std::string& body)
{
const json j = json::parse(body, nullptr, false);
if (j.is_object())
return get_json_string_field(j, "error_code");
return "";
}
static long long now_epoch_seconds()
{
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::string& refresh_token, const std::string& reason)
{
std::string body = "{\"refresh_token\":\"" + refresh_token + "\"}";
std::string url = auth_base_url + auth_constants::TOKEN_PATH + "?grant_type=refresh_token";
std::string response;
unsigned int http_code = 0;
// http_post_token sets http_code to 0 when the server could not be reached.
http_post_token(body, &response, &http_code, url);
const long long now_s = now_epoch_seconds();
const long long last_ok = last_refresh_success_epoch.load(std::memory_order_relaxed);
const long long since_last_ok = (last_ok == 0) ? -1 : (now_s - last_ok); // -1 = no success yet this process
const long long since_start = now_s - agent_start_epoch;
const std::string log_reason = reason.empty() ? "-" : reason;
bool established = false;
if (http_code >= 200 && http_code < 300) {
if (session_handler) {
established = session_handler(response);
} else {
// No session handler set - parse the token response directly and establish the
// session, so OrcaCloudServiceAgent is self-contained without external setup.
try {
established = set_user_session(json::parse(response));
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: token refresh parse exception - " << e.what();
}
}
if (established) {
last_refresh_success_epoch.store(now_s, std::memory_order_relaxed);
BOOST_LOG_TRIVIAL(info) << "[auth] event=refresh_ok reason=" << log_reason
<< " secs_since_last_success=" << since_last_ok;
}
} else {
// Diagnostics to classify a future refresh_token_already_used incident from logs alone
// - secs_since_last_success large (or -1 = none this process) => stale token beyond the
// reuse-grace window (the ">1h gap" residual).
// - stored_differs=true => another instance/device already rotated the token in the
// shared secret store (the multi-session residual).
const std::string error_code = extract_error_code(response);
// Only meaningful when the server actually rejected us (not a bare transport failure).
bool stored_present = false, stored_differs = false;
if (http_code >= 400) {
std::string stored_secret, stored_token;
SessionInfo ignored;
if (load_user_secret(stored_secret) && !stored_secret.empty()
&& parse_stored_secret(stored_secret, stored_token, ignored) && !stored_token.empty()) {
stored_present = true;
stored_differs = (stored_token != refresh_token);
}
}
std::string user_id_copy;
{
std::lock_guard<std::mutex> lock(session_mutex);
user_id_copy = session.user_id;
}
std::string truncated_response = response.size() > 200 ? response.substr(0, 200) + "..." : response;
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh_rejected http_code=" << http_code
<< " error_code=" << (error_code.empty() ? "-" : error_code)
<< " reason=" << log_reason
<< " stored_present=" << (stored_present ? "true" : "false")
<< " stored_differs=" << (stored_differs ? "true" : "false")
<< " secs_since_last_success=" << since_last_ok
<< " secs_since_start=" << since_start
<< " user_id=" << (user_id_copy.empty() ? "-" : user_id_copy)
<< " response_body=" << truncated_response;
}
return classify_refresh_result(http_code, established);
}
// ============================================================================
// Auth - Session State Helpers
// ============================================================================
bool OrcaCloudServiceAgent::set_user_session(const std::string& token,
const std::string& user_id,
const std::string& username,
const std::string& nickname,
const std::string& avatar,
const std::string& refresh_token,
bool persist)
{
std::chrono::system_clock::time_point exp_tp{};
decode_jwt_expiry(token, exp_tp);
{
std::lock_guard<std::mutex> lock(session_mutex);
session.access_token = token;
session.refresh_token = refresh_token;
session.user_id = user_id;
session.user_name = username;
session.user_nickname = nickname;
session.user_avatar = avatar;
session.expires_at = exp_tp;
session.logged_in = true;
}
if (persist) {
// Store user session on disk to not block use from using
// an already logged in account if internet is not available.
// Don't store access token though, we should always refresh it
// once user is back online.
json sec = json::object();
sec["refresh_token"] = refresh_token;
sec["user_id"] = user_id;
sec["username"] = username;
sec["nickname"] = nickname;
persist_user_secret(sec.dump());
}
// Set per-user sync state path
if (!config_dir.empty() && !user_id.empty()) {
boost::filesystem::path user_dir = boost::filesystem::path(config_dir) / "user" / user_id;
if (!boost::filesystem::exists(user_dir)) {
boost::filesystem::create_directories(user_dir);
}
sync_state_path = (user_dir / ORCA_SYNC_STATE_FILE).string();
load_sync_state();
}
BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: set_user_session - user_id=" << user_id << ", username=" << username;
return true;
}
bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool notify_login)
{
std::string access_token = get_json_string_field(session_json, "access_token");
if (access_token.empty()) {
access_token = get_json_string_field(session_json, "token");
}
std::string refresh_token = get_json_string_field(session_json, "refresh_token");
std::string user_id, username, nickname, avatar;
if (session_json.contains("user") && session_json["user"].is_object()) {
// Nested format (Orca cloud / GoTrue response)
const auto& user = session_json["user"];
user_id = get_json_string_field(user, "id");
if (user.contains("user_metadata") && user["user_metadata"].is_object()) {
const auto& meta = user["user_metadata"];
username = get_json_string_field(meta, "username"); // Orca Cloud's unique username
// Orca Cloud's primary display name field is display_name.
// Fallback to different names from different providers if display_name is not set.
nickname = resolve_display_name(get_json_string_field(meta, "display_name"), get_json_string_field(meta, "nickname"),
get_json_string_field(meta, "full_name"), get_json_string_field(meta, "name"), username);
avatar = get_json_string_field(meta, "avatar_url");
}
} else {
// Flat format (WebView direct token flow)
user_id = get_json_string_field(session_json, "user_id");
username = get_json_string_field(session_json, "username");
nickname = resolve_display_name(get_json_string_field(session_json, "display_name"),
get_json_string_field(session_json, "nickname"), get_json_string_field(session_json, "full_name"),
get_json_string_field(session_json, "name"), username);
avatar = get_json_string_field(session_json, "avatar");
}
if (access_token.empty() || user_id.empty()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: session payload missing access_token or user id";
return false;
}
bool success = set_user_session(access_token, user_id, username, nickname, avatar, refresh_token);
if (success && notify_login && on_login_complete_handler) {
on_login_complete_handler(true, user_id);
}
return success;
}
void OrcaCloudServiceAgent::clear_session()
{
{
std::lock_guard<std::mutex> lock(session_mutex);
session = SessionInfo{};
}
clear_user_secret();
}
// ============================================================================
// HTTP Helpers
// ============================================================================
RefreshResult OrcaCloudServiceAgent::attempt_refresh_after_unauthorized(const std::string& reason)
{
RefreshResult r = refresh_from_storage(reason, false);
if (r != RefreshResult::Transient) return r; // Success or AuthRejected: decided, no retry
// Only a transient (network/server) failure is worth retrying.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
r = refresh_from_storage(reason + "_retry", false);
if (r == RefreshResult::Transient)
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh result=transient source=" << reason << " action=keep_session";
else if (r == RefreshResult::AuthRejected)
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh result=rejected source=" << reason << " action=logout";
return r;
}
std::map<std::string, std::string> OrcaCloudServiceAgent::data_headers()
{
std::scoped_lock lock(state_mutex, headers_mutex);
auto headers = auth_headers;
for (const auto& pair : extra_headers) {
headers[pair.first] = pair.second;
}
return headers;
}
bool OrcaCloudServiceAgent::resolve_unauthorized(HttpResult& res,
const std::function<HttpResult()>& perform, const std::string& reason)
{
if (res.status != 401)
return false;
RefreshResult rr = attempt_refresh_after_unauthorized(reason);
if (rr == RefreshResult::Success) {
res = perform(); // refreshed: retry the original request with the new token
return false;
}
// Transient (no connection / 5xx / 429 / ambiguous): keep the session and token,
// suppress the auth error so the GUI does not log the user out.
// AuthRejected (refresh token genuinely rejected): let the 401 surface -> logout.
return rr == RefreshResult::Transient;
}
int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: GET " << url;
if (!ensure_token_fresh("http_get_" + path))
BOOST_LOG_TRIVIAL(warning) << "ensure_token_fresh returned false";
auto perform = [&]() {
HttpResult result;
try {
auto http = Http::get(url);
http.tls_verify(true);
std::string token = get_access_token();
auto headers = data_headers();
for (const auto& pair : headers) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
http.on_complete([&](std::string body, unsigned resp_status) {
result.success = true;
result.status = resp_status;
result.body = body;
})
.on_error([&](std::string body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
.perform_sync();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_get exception - " << e.what();
}
return result;
};
HttpResult res = perform();
bool suppress = resolve_unauthorized(res, perform, "http_get_" + path);
if (response_body)
*response_body = res.body;
if (http_code)
*http_code = res.status;
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url;
ensure_token_fresh("http_post_" + path);
auto perform = [&]() {
HttpResult result;
try {
auto http = Http::post(url);
http.tls_verify(true);
std::string token = get_access_token();
auto headers = data_headers();
for (const auto& pair : headers) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
http.header("Content-Type", "application/json");
http.set_post_body(body);
http.on_complete([&](std::string resp_body, unsigned resp_status) {
result.success = true;
result.status = resp_status;
result.body = resp_body;
})
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
.perform_sync();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_post exception - " << e.what();
}
return result;
};
HttpResult res = perform();
bool suppress = resolve_unauthorized(res, perform, "http_post_" + path);
if (response_body)
*response_body = res.body;
if (http_code)
*http_code = res.status;
// 409 is a push-only domain conflict; sync_push re-fires the error callback with the
// local preset name injected (the raw server body omits it for tombstone conflicts),
// so skip the generic nameless auto-fire here to avoid a duplicate, nameless event.
if (!suppress && (!res.success || res.status >= 400) && res.status != 409) {
invoke_http_error_callback(res.status, res.body);
}
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: PUT " << url;
ensure_token_fresh("http_put_" + path);
auto perform = [&]() {
HttpResult result;
try {
auto http = Http::put(url);
http.tls_verify(true);
std::string token = get_access_token();
auto headers = data_headers();
for (const auto& pair : headers) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
http.header("Content-Type", "application/json");
http.set_post_body(body);
http.on_complete([&](std::string resp_body, unsigned resp_status) {
result.success = true;
result.status = resp_status;
result.body = resp_body;
})
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
.perform_sync();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_put exception - " << e.what();
}
return result;
};
HttpResult res = perform();
bool suppress = resolve_unauthorized(res, perform, "http_put_" + path);
if (response_body)
*response_body = res.body;
if (http_code)
*http_code = res.status;
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_delete(const std::string& path, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: DELETE " << url;
ensure_token_fresh("http_delete_" + path);
auto perform = [&]() {
HttpResult result;
try {
auto http = Http::del(url);
http.tls_verify(true);
std::string token = get_access_token();
auto headers = data_headers();
for (const auto& pair : headers) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
http.on_complete([&](std::string resp_body, unsigned resp_status) {
result.success = true;
result.status = resp_status;
result.body = resp_body;
})
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
.perform_sync();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_delete exception - " << e.what();
}
return result;
};
HttpResult res = perform();
bool suppress = resolve_unauthorized(res, perform, "http_delete_" + path);
if (response_body)
*response_body = res.body;
if (http_code)
*http_code = res.status;
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
bool OrcaCloudServiceAgent::http_post_token(const std::string& body,
std::string* response_body,
unsigned int* http_code,
const std::string& custom_url)
{
std::map<std::string, std::string> headers_copy;
std::string url;
{
std::lock_guard<std::mutex> lock(headers_mutex);
url = custom_url.empty() ? (auth_base_url + auth_constants::TOKEN_PATH) : custom_url;
headers_copy = extra_headers;
}
// Add auth headers
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
for (const auto& pair : auth_headers) {
headers_copy[pair.first] = pair.second;
}
}
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url;
bool has_apikey = false;
for (const auto& pair : headers_copy) {
if (pair.first == "apikey")
has_apikey = true;
}
if (!has_apikey) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: http_post_token - apikey header MISSING! Token request will likely fail.";
}
try {
auto http = Http::post(url);
http.tls_verify(true);
for (const auto& pair : headers_copy) {
http.header(pair.first, pair.second);
}
http.remove_header("Authorization");
http.remove_header("Content-Type");
http.header("Content-Type", "application/json");
http.set_post_body(body);
bool success = false;
unsigned int status = 0;
std::string resp_body;
http.on_complete([&](std::string body, unsigned resp_status) {
success = true;
status = resp_status;
resp_body = body;
})
.on_error([&](std::string body, std::string error, unsigned resp_status) {
success = false;
status = resp_status; // keep 0 for "no response" so the refresh classifier sees a transport failure
resp_body = body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
// Keep this timeout finite: refresh_from_storage holds a cross-process lock across
// this call, so an unbounded refresh POST would let one instance wedge token refresh
// for every other Orca instance on the machine.
.timeout_max(30)
.perform_sync();
if (response_body)
*response_body = resp_body;
if (http_code)
*http_code = status;
return success;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_post_token exception - " << e.what();
if (http_code)
*http_code = 0;
return false;
}
}
bool OrcaCloudServiceAgent::http_post_auth(const std::string& path,
const std::string& body,
std::string* response_body,
unsigned int* http_code)
{
std::string url = auth_base_url + path + "?scope=local";
std::string token;
std::map<std::string, std::string> headers_copy;
{
std::lock_guard<std::mutex> lock(session_mutex);
token = session.access_token;
}
{
std::lock_guard<std::mutex> lock(headers_mutex);
headers_copy = extra_headers;
}
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
for (const auto& pair : auth_headers) {
headers_copy[pair.first] = pair.second;
}
}
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST (auth) " << url;
try {
auto http = Http::post(url);
http.tls_verify(true);
for (const auto& pair : headers_copy) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
http.remove_header("Content-Type");
http.header("Content-Type", "application/json");
http.set_post_body(body);
bool success = false;
unsigned int status = 0;
std::string resp_body;
http.on_complete([&](std::string body, unsigned resp_status) {
success = true;
status = resp_status;
resp_body = body;
})
.on_error([&](std::string body, std::string error, unsigned resp_status) {
success = false;
status = resp_status == 0 ? 404 : resp_status;
resp_body = body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP (auth) error - " << error;
})
.timeout_max(30)
.perform_sync();
if (response_body)
*response_body = resp_body;
if (http_code)
*http_code = status;
return success;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: http_post_auth exception - " << e.what();
if (http_code)
*http_code = 0;
return false;
}
}
void OrcaCloudServiceAgent::compute_fallback_path()
{
if (!secret_fallback_path.empty())
return;
// wxStandardPaths::GetUserDataDir() resolves the app data directory via
// wxAppConsoleBase::GetAppName(), which dereferences wxTheApp. In headless
// contexts (CLI, unit tests) there is no wxApp, so guard the call to avoid a
// null dereference. The path can still be provided explicitly through
// set_config_dir(); when it is left empty, file persistence is skipped.
if (wxTheApp == nullptr)
return;
wxFileName fallback(wxStandardPaths::Get().GetUserDataDir(), "orca_refresh_token.sec");
fallback.MakeAbsolute();
secret_fallback_path = fallback.GetFullPath().ToStdString();
}
// ============================================================================
// JSON Helpers
// ============================================================================
std::string OrcaCloudServiceAgent::map_to_json(const std::map<std::string, std::string>& map)
{
nlohmann::json j;
for (const auto& pair : map) {
j[pair.first] = pair.second;
}
return j.dump();
}
void OrcaCloudServiceAgent::json_to_map(const std::string& json, std::map<std::string, std::string>& map)
{
try {
auto j = nlohmann::json::parse(json);
for (auto it = j.begin(); it != j.end(); ++it) {
if (it.value().is_string()) {
map[it.key()] = it.value().get<std::string>();
} else {
map[it.key()] = it.value().dump();
}
}
} catch (...) {}
}
// ============================================================================
// Callback Invocation
// ============================================================================
void OrcaCloudServiceAgent::invoke_server_connected_callback(int return_code, int reason_code)
{
AppOnServerConnectedFn callback;
QueueOnMainFn queue_fn;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
callback = on_server_connected_fn;
queue_fn = queue_on_main_fn;
}
if (callback) {
CloudEvent event{ORCA_CLOUD_PROVIDER};
if (queue_fn) {
queue_fn([callback, event, return_code, reason_code]() { callback(event, return_code, reason_code); });
} else {
callback(event, return_code, reason_code);
}
}
}
void OrcaCloudServiceAgent::invoke_http_error_callback(unsigned http_code, const std::string& http_body)
{
AppOnHttpErrorFn callback;
QueueOnMainFn queue_fn;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
callback = on_http_error_fn;
queue_fn = queue_on_main_fn;
}
if (callback) {
CloudEvent event{ORCA_CLOUD_PROVIDER};
if (queue_fn) {
queue_fn([callback, event, http_code, http_body]() { callback(event, http_code, http_body); });
} else {
callback(event, http_code, http_body);
}
}
}
// ============================================================================
// Callback Registration
// ============================================================================
int OrcaCloudServiceAgent::set_on_server_connected_fn(AppOnServerConnectedFn fn)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
on_server_connected_fn = fn;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::set_on_http_error_fn(AppOnHttpErrorFn fn)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
on_http_error_fn = fn;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::set_get_country_code_fn(GetCountryCodeFn fn)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
get_country_code_fn = fn;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::set_queue_on_main_fn(QueueOnMainFn fn)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
queue_on_main_fn = fn;
return BAMBU_NETWORK_SUCCESS;
}
// ============================================================================
// Stub Implementations (Cloud Services, Model Mall, Analytics, Ratings)
// ============================================================================
int OrcaCloudServiceAgent::get_my_message(int type, int after, int limit, unsigned int* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_my_message (stub)";
if (http_code)
*http_code = 200;
if (http_body)
*http_body = "[]";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::check_user_task_report(int* task_id, bool* printable)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: check_user_task_report (stub)";
if (task_id)
*task_id = 0;
if (printable)
*printable = false;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::string* http_body)
{
std::string response;
unsigned int code = 0;
int result = http_get(ORCA_CLOUD_PRINTER, &response, &code);
if (http_code)
*http_code = code;
if (result != 0 || code != 200)
return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
try {
auto resp_json = nlohmann::json::parse(response);
nlohmann::json devices = nlohmann::json::array();
for (const auto& printer : resp_json.value("data", nlohmann::json::array())) {
const std::string role = printer.value("access_role", "");
if (role.empty() || role == "viewer")
continue;
nlohmann::json device;
device["dev_id"] = printer.value("id", "");
device["dev_name"] = printer.value("name", "");
if (printer.contains("model") && printer["model"].is_string())
device["dev_model_name"] = printer["model"].get<std::string>();
bool online = false;
if (printer.contains("status_snapshot") && printer["status_snapshot"].is_object()) {
const auto& status = printer["status_snapshot"].value("status", nlohmann::json::object());
online = status.value("connection", nlohmann::json::object()).value("state", "") == "online";
if (status.contains("job") && status["job"].is_object())
device["task_status"] = status["job"].value("state", "");
}
device["dev_online"] = online;
devices.push_back(std::move(device));
}
if (http_body) {
nlohmann::json out;
out["devices"] = std::move(devices);
*http_body = out.dump();
}
} catch (const std::exception&) {
return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_user_tasks(TaskQueryParams params, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_tasks (stub)";
if (http_body)
*http_body = "[]";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_printer_firmware(std::string dev_id, unsigned* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_printer_firmware (stub)";
if (http_code)
*http_code = 200;
if (http_body)
*http_body = "{}";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_task_plate_index(std::string task_id, int* plate_index)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_task_plate_index (stub)";
if (plate_index)
*plate_index = 0;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_user_info(int* identifier)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_info (stub)";
if (identifier)
*identifier = 0;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_subtask_info(std::string subtask_id, std::string* task_json, unsigned int* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_subtask_info (stub)";
if (task_json)
*task_json = "{}";
if (http_code)
*http_code = 200;
if (http_body)
*http_body = "{}";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_slice_info(std::string project_id, std::string profile_id, int plate_index, std::string* slice_json)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_slice_info (stub)";
if (slice_json)
*slice_json = "{}";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::query_bind_status(std::vector<std::string> query_list, unsigned int* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: query_bind_status (stub)";
if (http_code)
*http_code = 200;
if (http_body)
*http_body = "{}";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::modify_printer_name(std::string dev_id, std::string dev_name)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: modify_printer_name (stub)";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_camera_url(std::string dev_id, std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_camera_url (stub)";
if (callback)
callback("");
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_design_staffpick (stub)";
if (callback)
callback("[]");
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::start_publish(PublishParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, std::string* out)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: start_publish (stub)";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_model_publish_url(std::string* url)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_model_publish_url (stub)";
if (url)
*url = "";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_subtask(BBLModelTask* task, OnGetSubTaskFn getsub_fn)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_subtask (stub)";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_model_mall_home_url(std::string* url)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_model_mall_home_url (stub)";
if (url)
*url = "";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_model_mall_detail_url(std::string* url, std::string id)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_model_mall_detail_url (stub)";
if (url)
*url = "";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_my_profile(std::string token, unsigned int* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_my_profile (stub)";
if (http_code)
*http_code = 200;
if (http_body)
*http_body = "{}";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_my_token(std::string ticket, unsigned int* http_code, std::string* http_body)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_my_token (stub) - Orca cloud uses code-based OAuth, not tickets";
if (http_code)
*http_code = 0;
if (http_body)
*http_body = "";
return -1;
}
int OrcaCloudServiceAgent::track_enable(bool enable)
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
enable_track = enable;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::track_remove_files()
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: track_remove_files (stub)";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::track_event(std::string evt_key, std::string content)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: track_event (stub) - " << evt_key;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::track_header(std::string header)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: track_header (stub)";
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::track_update_property(std::string name, std::string value, std::string type)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: track_update_property (stub) - " << name;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::track_get_property(std::string name, std::string& value, std::string type)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: track_get_property (stub) - " << name;
value = "";
return BAMBU_NETWORK_SUCCESS;
}
bool OrcaCloudServiceAgent::get_track_enable()
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
return enable_track;
}
int OrcaCloudServiceAgent::put_model_mall_rating(
int design_id, int score, std::string content, std::vector<std::string> images, unsigned int& http_code, std::string& http_error)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: put_model_mall_rating (stub)";
http_code = 200;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_oss_config(std::string& config, std::string country_code, unsigned int& http_code, std::string& http_error)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_oss_config (stub)";
config = "{}";
http_code = 200;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::put_rating_picture_oss(
std::string& config, std::string& pic_oss_path, std::string model_id, int profile_id, unsigned int& http_code, std::string& http_error)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: put_rating_picture_oss (stub)";
http_code = 200;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_model_mall_rating_result(int job_id,
std::string& rating_result,
unsigned int& http_code,
std::string& http_error)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_model_mall_rating_result (stub)";
rating_result = "{}";
http_code = 200;
return BAMBU_NETWORK_SUCCESS;
}
std::string OrcaCloudServiceAgent::get_cloud_service_host() { return api_base_url; }
std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& language)
{
std::string url = cloud_base_url + ORCA_CLOUD_LOGIN_PATH;
if (!language.empty()) {
url += "?lang=" + language;
}
return url;
}
int OrcaCloudServiceAgent::get_mw_user_preference(std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)";
if (callback)
callback("{}");
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::get_mw_user_4ulist(int seed, int limit, std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_4ulist (stub)";
if (callback)
callback("[]");
return BAMBU_NETWORK_SUCCESS;
}
std::string OrcaCloudServiceAgent::get_version() { return "OrcaCloudServiceAgent 1.0.0"; }
// ============================================================================
// Bundle Subscription Implementation
// ============================================================================
bool OrcaCloudServiceAgent::unsubscribe_bundle(const std::string& bundle_id)
{
std::string path = std::string(ORCA_SUBSCRIPTIONS_PATH) + "/" + bundle_id;
std::string response;
unsigned int http_code = 0;
int result = http_delete(path, &response, &http_code);
if (http_code >= 400) {
return false;
}
return true;
}
std::string OrcaCloudServiceAgent::get_bundle_url(const std::string& bundle_id) const
{ return cloud_base_url + "/app/bundles/subscribed-bundles/" + bundle_id; }
int OrcaCloudServiceAgent::get_subscribed_bundles(std::vector<std::pair<std::string, std::string>>* bundles,
std::vector<std::string>& notfound,
std::vector<std::string>& unauthorized)
{
if (!bundles)
return -1;
std::string response_body;
unsigned int http_code = 0;
// GET /api/v1/bundles
int result = http_get("/api/v1/subscriptions", &response_body, &http_code);
if (result != 0 || http_code != 200) {
BOOST_LOG_TRIVIAL(error) << "get_subscribed_bundles failed: http_code=" << http_code << ", response=" << response_body;
return result != 0 ? result : http_code;
}
// Parse JSON response
try {
auto json = nlohmann::json::parse(response_body);
if (!json.contains("data") || !json["data"].is_array()) {
BOOST_LOG_TRIVIAL(error) << "get_subscribed_bundles: invalid response format";
return -1;
}
if (json.contains("not_found")) {
// not found warning
for (const auto& not_found : json["not_found"]) {
notfound.push_back(not_found);
}
}
if (json.contains("unauthorised")) {
// populate something to be iterated to show warning notifications
for (const auto& u : json["unauthorised"]) {
unauthorized.push_back(u);
}
}
// if(json.contains("privated") && json["privated"].is_array() && !json["privated"].empty())
// {
// // populate something to be iterated to show warning notifications
// for(const auto& not_found : json["privated"])
// {
// }
// }
for (const auto& bundle_json : json["data"]) {
bundles->push_back(std::make_pair(bundle_json["id"].get<std::string>(), bundle_json["version"].get<std::string>()));
}
BOOST_LOG_TRIVIAL(info) << "get_subscribed_bundles: loaded " << bundles->size() << " bundles";
return 0;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "get_subscribed_bundles: JSON parse error: " << e.what();
return -1;
}
}
int OrcaCloudServiceAgent::get_shared_bundle(const std::string& bundle_id,
std::map<std::string, std::map<std::string, std::string>>* presets,
BundleMetadata* bundle_metadata)
{
if (!presets)
return -1;
std::string response_body;
unsigned int http_code = 0;
// GET /api/v1/bundles/{id}
std::string path = "/api/v1/bundles/" + bundle_id;
int result = http_get(path, &response_body, &http_code);
if (result != 0 || http_code != 200) {
BOOST_LOG_TRIVIAL(error) << "get_shared_bundle failed: bundle_id=" << bundle_id << ", http_code=" << http_code;
return result != 0 ? result : http_code;
}
// Parse JSON response
try {
auto json = nlohmann::json::parse(response_body);
BOOST_LOG_TRIVIAL(info) << "get_shared_bundle: response: " << response_body;
BOOST_LOG_TRIVIAL(info) << "get_shared_bundle: shared_profile: " << json["shared_profiles"];
// Parse the bundle metadata
if (json.contains("id"))
bundle_metadata->id = json["id"].get<std::string>();
if (json.contains("name"))
bundle_metadata->name = json["name"].get<std::string>();
if (json.contains("version"))
bundle_metadata->version = json["version"].get<std::string>();
if (json.contains("description"))
bundle_metadata->description = json["description"].get<std::string>();
if (json.contains("author"))
bundle_metadata->author = json["author"].get<std::string>();
if (json.contains("updated_time"))
bundle_metadata->updated_time = json["updated_time"].get<long long>();
if (!json.contains("shared_profiles") || !json["shared_profiles"].is_array()) {
BOOST_LOG_TRIVIAL(error) << "get_shared_bundle: invalid response format";
return -1;
}
for (auto& preset_object : json["shared_profiles"]) {
BOOST_LOG_TRIVIAL(info) << "shared profile object: " << preset_object;
// Extract preset name and content
std::string preset_name = preset_object.value("name", "");
if (preset_name.empty()) {
BOOST_LOG_TRIVIAL(warning) << "get_shared_bundle: preset has no name, skipping";
continue;
}
// Parse content JSON into key-value pairs using helper (same as get_user_presets)
std::map<std::string, std::string> value_map;
if (preset_object.contains("content") && preset_object["content"].is_object()) {
json_to_map(preset_object["content"].dump(), value_map);
}
// Add metadata fields to match get_user_presets format
// These are required by PresetCollection::load_user_preset
if (preset_object.contains("id") && value_map.find(BBL_JSON_KEY_SETTING_ID) == value_map.end()) {
value_map[BBL_JSON_KEY_SETTING_ID] = preset_object["id"];
}
if (value_map.find(BBL_JSON_KEY_USER_ID) == value_map.end()) {
// Bundle presets don't have a user_id in the traditional sense
// Use bundle_id as a placeholder to indicate source
value_map[BBL_JSON_KEY_USER_ID] = "bundle:" + bundle_id;
}
if (preset_object.contains("updated_time") && value_map.find(ORCA_JSON_KEY_UPDATE_TIME) == value_map.end()) {
value_map[ORCA_JSON_KEY_UPDATE_TIME] = preset_object["updated_time"].dump();
}
if (value_map.find(BBL_JSON_KEY_NAME) == value_map.end()) {
value_map[BBL_JSON_KEY_NAME] = preset_name;
}
// Store as: presets[preset_name][key] = value
// This matches the format expected by PresetBundle::load_user_presets
(*presets)[preset_name] = value_map;
}
BOOST_LOG_TRIVIAL(info) << "get_shared_bundle: loaded " << presets->size() << " presets for bundle_id=" << bundle_id;
return 0;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "get_shared_bundle: JSON parse error: " << e.what();
return -1;
}
}
// Parse the cloud manifest "type" field (single string or array of strings) into a list of raw
// display labels, kept verbatim and NOT mapped to PluginCapabilityType: they are a purely cosmetic
// "compatibility" taxonomy (e.g. "printer-agent") shown like tags, and may not reflect the plugin's
// real capabilities (which are known only once the plugin is loaded). Prefers "type_label", then
// "type"; skips empty labels and de-duplicates.
static std::vector<std::string> parse_cloud_display_types(const nlohmann::json& item)
{
std::vector<std::string> labels;
auto add = [&labels](std::string label) {
if (!label.empty() && std::find(labels.begin(), labels.end(), label) == labels.end())
labels.push_back(std::move(label));
};
for (const char* key : {"type_label", "type"}) {
if (!item.contains(key))
continue;
const auto& field = item[key];
if (field.is_array()) {
for (const auto& el : field)
if (el.is_string())
add(el.get<std::string>());
} else if (field.is_string()) {
add(field.get<std::string>());
}
if (!labels.empty())
break; // "type_label" wins if present; else fall back to "type"
}
return labels;
}
// True when a cloud main_image is a still/animated image the WebView <img> can render
// (PNG/JPEG/GIF/WebP...). Other media (e.g. video) is skipped so no thumbnail is shown.
static bool cloud_media_is_image(const nlohmann::json& main_image)
{
auto lowered = [](std::string value) {
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return value;
};
if (lowered(get_json_string_field(main_image, "mime_type")).rfind("image/", 0) == 0)
return true;
return lowered(get_json_string_field(main_image, "media_type")) == "image";
}
// creator_display_name degrades to the creator's username and then to their raw user id when the
// cloud cannot resolve a profile, so a bare id is dropped rather than shown: the author then falls
// back to the one the plugin's own manifest declares (see apply_plugin_metadata_fallbacks).
static std::string parse_cloud_author(const nlohmann::json& item)
{
std::string author = get_json_string_field(item, "creator_display_name");
if (author.empty() || is_uuid(author))
author = get_json_string_field(item, "creator_username");
return is_uuid(author) ? std::string() : author;
}
int OrcaCloudServiceAgent::fetch_subscribed_manifests_into_descriptors(std::vector<PluginDescriptor>& descriptors,
std::vector<std::string>& not_found,
std::vector<std::string>& unauthorized)
{
std::string response_body;
unsigned int http_code = 0;
const std::string path = std::string(ORCA_SUBSCRIBED_PLUGINS) + "?include=changelog";
int result = http_get(path, &response_body, &http_code);
if (result != 0 || http_code != 200) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: http_code=" << http_code;
return result != 0 ? result : http_code;
}
try {
BOOST_LOG_TRIVIAL(info) << response_body;
auto root = json::parse(response_body);
if (root.contains("not_found") && root["not_found"].is_array()) {
for (const auto& item : root["not_found"])
not_found.push_back(item.get<std::string>());
}
if (root.contains("unauthorised") && root["unauthorised"].is_array()) {
for (const auto& item : root["unauthorised"])
unauthorized.push_back(item.get<std::string>());
}
if (!root.contains("data") || !root["data"].is_array()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid response format, missing data array";
return -1;
}
for (const auto& item : root["data"]) {
PluginDescriptor descriptor;
std::string uuid = get_json_string_field(item, "uuid");
if (uuid.empty())
uuid = get_json_string_field(item, "id");
descriptor.name = sanitize_plugin_name(get_json_string_field(item, "name"));
descriptor.plugin_key = uuid;
// Cloud API "description" is intentionally not parsed: descriptions come only from the
// plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link.
descriptor.author = parse_cloud_author(item);
descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version;
// Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are
// populated only when the plugin is actually loaded. Leave them empty here.
descriptor.display_types = parse_cloud_display_types(item);
descriptor.entry_package = get_json_string_field(item, "entry_package");
descriptor.sharing_token = get_json_string_field(item, "sharing_token");
// main_image.access_url is pre-signed, so the WebView <img> can load it without the
// Bearer token (content_url, an API path, would 401). Images only; display-only.
if (item.contains("main_image") && item["main_image"].is_object() && cloud_media_is_image(item["main_image"]))
descriptor.thumbnail_url = get_json_string_field(item["main_image"], "access_url");
if (item.contains("changelog") && item["changelog"].is_array()) {
for (const auto& changelog_item : item["changelog"]) {
if (!changelog_item.is_object())
continue;
PluginChangelog entry;
entry.changelog_id = get_json_string_field(changelog_item, "id");
entry.plugin_uuid = get_json_string_field(changelog_item, "plugin_id");
if (entry.plugin_uuid.empty())
entry.plugin_uuid = uuid;
entry.version = get_json_string_field(changelog_item, "version");
entry.changelog = get_json_string_field(changelog_item, "changelog");
entry.created_time = get_json_int64_field(changelog_item, "created_time");
descriptor.changelog.push_back(std::move(entry));
}
sort_plugin_changelog(descriptor.changelog);
}
descriptor.cloud = std::make_optional<CloudPluginState>({
uuid,
false,
false,
false,
false
});
descriptors.push_back(std::move(descriptor));
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": loaded " << descriptors.size() << " manifests";
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": JSON parse error: " << e.what();
return -1;
}
return 0;
}
int OrcaCloudServiceAgent::fetch_mine_manifests_into_descriptors(std::vector<PluginDescriptor>& descriptors)
{
constexpr int PAGE_LIMIT = 100;
constexpr int MAX_PAGES = 1000; // safety backstop against a server that never stops paginating
int current_offset = 0;
int page_count = 0;
while (true) {
if (++page_count > MAX_PAGES) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": reached max page count (" << MAX_PAGES
<< "), stopping pagination";
break;
}
const std::string path = std::string(ORCA_PLUGINS_MINE) + "?limit=" + std::to_string(PAGE_LIMIT) +
"&offset=" + std::to_string(current_offset) + "&include=changelog";
std::string response_body;
unsigned int http_code = 0;
int result = http_get(path, &response_body, &http_code);
if (result != 0 || http_code != 200) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: http_code=" << http_code;
return result != 0 ? result : http_code;
}
try {
auto root = json::parse(response_body);
if (!root.contains("data") || !root["data"].is_array()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid response format, missing data array";
return -1;
}
const auto& data = root["data"];
for (const auto& item : data) {
PluginDescriptor descriptor;
std::string uuid = get_json_string_field(item, "uuid");
if (uuid.empty())
uuid = get_json_string_field(item, "id");
descriptor.name = sanitize_plugin_name(get_json_string_field(item, "name"));
descriptor.plugin_key = uuid;
// Cloud API "description" is intentionally not parsed: descriptions come only from the
// plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link.
descriptor.author = parse_cloud_author(item);
descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version;
// Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are
// populated only when the plugin is actually loaded. Leave them empty here.
descriptor.display_types = parse_cloud_display_types(item);
descriptor.entry_package = get_json_string_field(item, "entry_package");
descriptor.sharing_token = get_json_string_field(item, "sharing_token");
// main_image.access_url is pre-signed, so the WebView <img> can load it without the
// Bearer token (content_url, an API path, would 401). Images only; display-only.
if (item.contains("main_image") && item["main_image"].is_object() && cloud_media_is_image(item["main_image"]))
descriptor.thumbnail_url = get_json_string_field(item["main_image"], "access_url");
if (item.contains("changelog") && item["changelog"].is_array()) {
for (const auto& changelog_item : item["changelog"]) {
if (!changelog_item.is_object())
continue;
PluginChangelog entry;
entry.changelog_id = get_json_string_field(changelog_item, "id");
entry.plugin_uuid = get_json_string_field(changelog_item, "plugin_id");
if (entry.plugin_uuid.empty())
entry.plugin_uuid = uuid;
entry.version = get_json_string_field(changelog_item, "version");
entry.changelog = get_json_string_field(changelog_item, "changelog");
entry.created_time = get_json_int64_field(changelog_item, "created_time");
descriptor.changelog.push_back(std::move(entry));
}
sort_plugin_changelog(descriptor.changelog);
}
descriptor.cloud = std::make_optional<CloudPluginState>({
uuid,
false,
false,
false,
true
});
descriptors.push_back(std::move(descriptor));
}
// A short (or empty) page means there is nothing left to page through. This is the
// primary guard against a server that keeps echoing a non-null next_offset forever.
if (data.size() < static_cast<size_t>(PAGE_LIMIT))
break;
// Honor an explicit end-of-pagination signal from the server.
if (!root.contains("next_offset") || root["next_offset"].is_null())
break;
// Require the offset to strictly advance; otherwise a stuck/regressing offset
// would loop forever.
const int next_offset = root["next_offset"].get<int>();
if (next_offset <= current_offset) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": next_offset (" << next_offset
<< ") did not advance past " << current_offset << ", stopping pagination";
break;
}
current_offset = next_offset;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": JSON parse error: " << e.what();
return -1;
}
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": loaded " << descriptors.size() << " manifests";
return 0;
}
int OrcaCloudServiceAgent::get_plugin_download_url(const std::string& uuid,
const std::string& requested_version,
std::vector<PluginDownloadData>& data,
std::vector<PluginDownloadNotFound>& not_found,
std::vector<std::string>& unauthorized)
{
data.clear();
not_found.clear();
unauthorized.clear();
if (uuid.empty())
return -1;
std::string response_body;
unsigned int http_code = 0;
auto api_os_type = []() {
std::string os = platform_os_type();
if (os == "unknown")
return std::string{};
if (os == "macos")
return std::string("macosx");
return os;
};
auto api_architecture = []() {
const std::string arch = platform_architecture();
if (arch == "unknown")
return std::string{};
return arch;
};
const std::string os = api_os_type();
const std::string arch = api_architecture();
std::string path = ORCA_PLUGIN_DOWNLOAD_URL;
if (!os.empty())
path += "?os=" + Http::url_encode(os);
if (!os.empty() && !arch.empty())
path += "&arch=" + Http::url_encode(arch);
json request_item;
request_item["plugin_id"] = uuid;
if (!requested_version.empty())
request_item["version"] = requested_version;
json request_body;
request_body["data"] = json::array({request_item});
int result = http_post(path, request_body.dump(), &response_body, &http_code);
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": " << response_body;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": " << "Status: " << http_code;
if (result != 0 || http_code != 200) {
if (http_code == 404)
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " 404, plugin package for " << os << "-" << arch << " not available.";
else
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: url=" << path << ", http_code=" << http_code;
return result != 0 ? result : http_code;
}
try {
auto root = json::parse(response_body);
if (!root.contains("data") || !root["data"].is_array()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid response format, missing data array";
return -1;
}
for (const auto& item : root["data"]) {
if (!item.is_object())
continue;
PluginDownloadData row;
row.plugin_id = get_json_string_field(item, "plugin_id");
row.download_link = get_json_string_field(item, "download_link");
row.requested_os = get_json_string_field(item, "requested_os");
row.returned_os = get_json_string_field(item, "returned_os");
if (!row.requested_os.empty() || !row.returned_os.empty())
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": artifact fallback requested_os=" << row.requested_os
<< ", returned_os=" << row.returned_os;
data.push_back(std::move(row));
}
if (root.contains("not_found") && root["not_found"].is_array()) {
for (const auto& item : root["not_found"]) {
if (!item.is_object())
continue;
PluginDownloadNotFound row;
row.id = get_json_string_field(item, "id");
row.reason = get_json_string_field(item, "reason");
not_found.push_back(std::move(row));
}
}
if (root.contains("unauthorised") && root["unauthorised"].is_array()) {
for (const auto& item : root["unauthorised"]) {
if (item.is_string())
unauthorized.push_back(item.get<std::string>());
}
}
return 0;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": JSON parse error: " << e.what();
return -1;
}
return -1;
}
std::string OrcaCloudServiceAgent::get_plugin_url(const std::string& sharing_token) const
{ return cloud_base_url + "/p/" + Http::url_encode(sharing_token); }
int OrcaCloudServiceAgent::subscribe_plugin(const std::string& plugin_uuid)
{
if (plugin_uuid.empty())
return -1;
const std::string request_body = json{{"plugin_id", plugin_uuid}}.dump();
std::string response_body;
unsigned int http_code = 0;
const int result = http_post(ORCA_SUBSCRIBED_PLUGINS, request_body, &response_body, &http_code);
// Treat an existing subscription as success: the requested cloud state already exists.
if (http_code == 409)
return 0;
if (result != 0 || (http_code != 200 && http_code != 201)) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: http_code=" << http_code << ", response=" << response_body;
return http_code != 0 ? static_cast<int>(http_code) : result;
}
return 0;
}
int OrcaCloudServiceAgent::unsubscribe_plugins(const std::vector<std::string>& plugin_uuids)
{
if (plugin_uuids.empty())
return 0;
std::string response_body;
unsigned int http_code = 0;
std::string query;
for (const std::string& plugin_uuid : plugin_uuids) {
if (plugin_uuid.empty()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: empty plugin UUID";
return -1;
}
if (!query.empty())
query += ",";
query += Http::url_encode(plugin_uuid);
}
const std::string path = std::string(ORCA_UNSUBSCRIBE_PLUGINS) + "?ids=" + query;
int result = http_delete(path, &response_body, &http_code);
if (result != 0 || (http_code != 200 && http_code != 204)) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: http_code=" << http_code << ", response=" << response_body;
return http_code != 0 ? static_cast<int>(http_code) : result;
}
if (!response_body.empty()) {
try {
auto root = json::parse(response_body);
if (root.contains("not_found") && root["not_found"].is_array() && !root["not_found"].empty()) {
std::string missing;
for (const auto& item : root["not_found"]) {
if (!item.is_string())
continue;
if (!missing.empty())
missing += ",";
missing += item.get<std::string>();
}
if (!missing.empty())
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": plugin UUIDs not found: " << missing;
}
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": response parse error: " << e.what();
}
}
return 0;
}
int OrcaCloudServiceAgent::fetch_plugin_changelogs(const std::vector<std::string>& uuids,
std::unordered_map<std::string, std::vector<PluginChangelog>>& changelog)
{
changelog.clear();
if (uuids.empty())
return 0;
int first_error = 0;
for (const std::string& uuid : uuids) {
if (uuid.empty()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: empty plugin UUID";
if (first_error == 0)
first_error = -1;
continue;
}
const std::string path = std::string(ORCA_PLUGINS_BASE) + "/" + Http::url_encode(uuid) + "/changelog?limit=100";
std::string response_body;
unsigned int http_code = 0;
const int result = http_get(path, &response_body, &http_code);
if (result != 0 || http_code != 200) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: plugin_uuid=" << uuid << ", http_code=" << http_code
<< ", response=" << response_body;
if (first_error == 0)
first_error = http_code != 0 ? static_cast<int>(http_code) : result;
continue;
}
try {
auto root = json::parse(response_body);
if (!root.contains("data") || !root["data"].is_array()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid response format, missing data array for plugin_uuid=" << uuid;
if (first_error == 0)
first_error = -1;
continue;
}
auto& plugin_changelog = changelog[uuid];
plugin_changelog.clear();
for (const auto& item : root["data"]) {
if (!item.is_object())
continue;
PluginChangelog entry;
entry.changelog_id = get_json_string_field(item, "id");
entry.plugin_uuid = get_json_string_field(item, "plugin_id");
if (entry.plugin_uuid.empty())
entry.plugin_uuid = uuid;
entry.version = get_json_string_field(item, "version");
entry.changelog = get_json_string_field(item, "changelog");
entry.created_time = get_json_int64_field(item, "created_time");
plugin_changelog.push_back(std::move(entry));
}
sort_plugin_changelog(plugin_changelog);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": JSON parse error for plugin_uuid=" << uuid << ": " << e.what();
if (first_error == 0)
first_error = -1;
continue;
}
}
return first_error;
}
std::string OrcaCloudServiceAgent::token_lock_path() const
{
if (config_dir.empty())
return {};
wxFileName lock(wxString::FromUTF8(config_dir.c_str()), "orca_refresh_token.lock");
lock.MakeAbsolute();
return lock.GetFullPath().ToStdString();
}
} // namespace Slic3r