Files
open-bamboo-networking/tools/plugin_runner/plugin_downloader.cpp
T
Alexey Cluster 304fa24777 Clean up
Signed-off-by: Alexey Cluster <cluster@cluster.wtf>
2026-05-20 16:56:16 +02:00

328 lines
11 KiB
C++

#include "plugin_downloader.hpp"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <regex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <sys/stat.h>
#include <curl/curl.h>
#include <minizip/unzip.h>
#include <nlohmann/json.hpp>
// Implements the plugin discovery + download flow Bambu Studio itself
// uses (PresetUpdater::priv::sync_plugins, src/slic3r/Utils/PresetUpdater.cpp:1165).
//
// 1. GET https://api.bambulab.com/v1/iot-service/api/slicer/resource
// ?slicer/plugins/cloud=<MM.mm.pp>.00
// Response: { "message": "success", "resources": [
// { "type": "slicer/plugins/cloud", "version": "01.10.04.00",
// "url": "https://files.bambulab.com/.../plugins.zip",
// "force_update": false, "description": "" } ]}
// 2. Cache lookup: <cache_dir>/<version>/libbambu_networking.so. If
// present and `force` is false, short-circuit.
// 3. Otherwise GET the `url` into a temp file, then unzip. The .so lives
// inside the archive at one of a couple of paths that have varied
// across releases (e.g. `linux/libbambu_networking.so`, plain
// `libbambu_networking.so`, sometimes nested under a versioned dir).
// We just walk the archive and pick the first entry whose basename
// is `libbambu_networking.so`.
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace obn::plugin_runner {
namespace {
constexpr const char* kApiBase = "https://api.bambulab.com/v1/iot-service/api/slicer/resource";
constexpr const char* kResourceType = "slicer/plugins/cloud";
constexpr const char* kSoBasename = "libbambu_networking.so";
bool valid_abi_prefix(const std::string& s)
{
static const std::regex re("^[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}$");
return std::regex_match(s, re);
}
size_t curl_write_string(void* ptr, size_t size, size_t nmemb, void* userdata)
{
auto* out = static_cast<std::string*>(userdata);
out->append(static_cast<const char*>(ptr), size * nmemb);
return size * nmemb;
}
size_t curl_write_file(void* ptr, size_t size, size_t nmemb, void* userdata)
{
auto* f = static_cast<std::FILE*>(userdata);
return std::fwrite(ptr, size, nmemb, f);
}
struct HttpResult {
bool ok = false;
long status = 0;
std::string body;
std::string error;
};
// Bambu's `/slicer/resource` endpoint switches the returned `url` based on
// X-BBL-OS-Type (e.g. "linux" -> linux_*.zip, "windows" -> win_*.zip,
// macos -> mac_*.zip). Without the header it defaults to Windows, so
// every request from this tool needs the linux marker.
struct curl_slist* studio_headers()
{
struct curl_slist* h = nullptr;
h = curl_slist_append(h, "X-BBL-Client-Type: slicer");
h = curl_slist_append(h, "X-BBL-Client-Name: OpenBambooNetworking");
h = curl_slist_append(h, "X-BBL-OS-Type: linux");
return h;
}
HttpResult http_get_string(const std::string& url, long timeout_s = 30)
{
HttpResult r;
CURL* h = curl_easy_init();
if (!h) {
r.error = "curl_easy_init failed";
return r;
}
struct curl_slist* hdrs = studio_headers();
curl_easy_setopt(h, CURLOPT_URL, url.c_str());
curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, curl_write_string);
curl_easy_setopt(h, CURLOPT_WRITEDATA, &r.body);
curl_easy_setopt(h, CURLOPT_TIMEOUT, timeout_s);
curl_easy_setopt(h, CURLOPT_USERAGENT, "obn-plugin-runner/1");
curl_easy_setopt(h, CURLOPT_HTTPHEADER, hdrs);
char errbuf[CURL_ERROR_SIZE] = {0};
curl_easy_setopt(h, CURLOPT_ERRORBUFFER, errbuf);
CURLcode code = curl_easy_perform(h);
if (code == CURLE_OK) {
curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &r.status);
r.ok = (r.status >= 200 && r.status < 300);
if (!r.ok) r.error = "HTTP " + std::to_string(r.status);
} else {
r.error = errbuf[0] ? errbuf : curl_easy_strerror(code);
}
curl_slist_free_all(hdrs);
curl_easy_cleanup(h);
return r;
}
HttpResult http_get_file(const std::string& url, const std::string& dst_path,
long timeout_s = 120)
{
HttpResult r;
std::FILE* f = std::fopen(dst_path.c_str(), "wb");
if (!f) {
r.error = "cannot open " + dst_path + ": " + std::strerror(errno);
return r;
}
CURL* h = curl_easy_init();
if (!h) {
std::fclose(f);
r.error = "curl_easy_init failed";
return r;
}
struct curl_slist* hdrs = studio_headers();
curl_easy_setopt(h, CURLOPT_URL, url.c_str());
curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, curl_write_file);
curl_easy_setopt(h, CURLOPT_WRITEDATA, f);
curl_easy_setopt(h, CURLOPT_TIMEOUT, timeout_s);
curl_easy_setopt(h, CURLOPT_USERAGENT, "obn-plugin-runner/1");
curl_easy_setopt(h, CURLOPT_HTTPHEADER, hdrs);
char errbuf[CURL_ERROR_SIZE] = {0};
curl_easy_setopt(h, CURLOPT_ERRORBUFFER, errbuf);
CURLcode code = curl_easy_perform(h);
if (code == CURLE_OK) {
curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &r.status);
r.ok = (r.status >= 200 && r.status < 300);
if (!r.ok) r.error = "HTTP " + std::to_string(r.status);
} else {
r.error = errbuf[0] ? errbuf : curl_easy_strerror(code);
}
curl_slist_free_all(hdrs);
curl_easy_cleanup(h);
std::fclose(f);
return r;
}
// Walks `zip_path` and writes the first entry whose basename equals
// `wanted_basename` to `dst_path`. Returns true on success. Doesn't try
// to be clever about case sensitivity, symlinks, or zip64 — the Bambu
// archives are well-formed and small.
bool unzip_extract_basename(const std::string& zip_path,
const std::string& wanted_basename,
const std::string& dst_path,
std::string& err)
{
unzFile uf = unzOpen(zip_path.c_str());
if (!uf) {
err = "unzOpen failed for " + zip_path;
return false;
}
if (unzGoToFirstFile(uf) != UNZ_OK) {
err = "unzGoToFirstFile failed";
unzClose(uf);
return false;
}
bool found = false;
do {
char fname[1024] = {0};
unz_file_info info{};
if (unzGetCurrentFileInfo(uf, &info, fname, sizeof(fname),
nullptr, 0, nullptr, 0) != UNZ_OK) continue;
std::string entry(fname);
std::string base = entry;
auto slash = base.rfind('/');
if (slash != std::string::npos) base = base.substr(slash + 1);
if (base != wanted_basename) continue;
if (unzOpenCurrentFile(uf) != UNZ_OK) {
err = "unzOpenCurrentFile failed for " + entry;
break;
}
std::FILE* f = std::fopen(dst_path.c_str(), "wb");
if (!f) {
err = std::string("cannot open ") + dst_path + ": " + std::strerror(errno);
unzCloseCurrentFile(uf);
break;
}
std::vector<char> buf(64 * 1024);
int n;
while ((n = unzReadCurrentFile(uf, buf.data(),
static_cast<unsigned int>(buf.size()))) > 0) {
if (std::fwrite(buf.data(), 1, static_cast<size_t>(n), f) != static_cast<size_t>(n)) {
err = "short write to " + dst_path;
std::fclose(f);
unzCloseCurrentFile(uf);
unzClose(uf);
return false;
}
}
std::fclose(f);
unzCloseCurrentFile(uf);
if (n < 0) {
err = "unzReadCurrentFile failed for " + entry +
" (code " + std::to_string(n) + ")";
break;
}
// Make sure the extracted .so is executable by dlopen-time loaders.
::chmod(dst_path.c_str(), 0755);
found = true;
break;
} while (unzGoToNextFile(uf) == UNZ_OK);
unzClose(uf);
if (!found && err.empty()) {
err = "no entry named '" + wanted_basename + "' inside " + zip_path;
}
return found;
}
} // namespace
DownloadResult fetch_plugin(const std::string& abi_prefix,
const std::string& cache_dir,
bool force)
{
DownloadResult out;
if (!valid_abi_prefix(abi_prefix)) {
out.error_message = "bad abi prefix '" + abi_prefix +
"', expected MM.mm.pp (e.g. 02.05.03)";
return out;
}
// Studio sends the version inside the query as `MM.mm.pp.00`. The
// suffix is required by the API even though we don't use the
// patch-level component.
std::string using_version = abi_prefix + ".00";
std::string url = std::string(kApiBase) + "?" + kResourceType + "=" + using_version;
HttpResult q = http_get_string(url, 20);
out.http_status = static_cast<int>(q.status);
if (!q.ok) {
out.error_message = "metadata GET failed: " + q.error;
return out;
}
json meta;
try {
meta = json::parse(q.body);
} catch (const std::exception& e) {
out.error_message = std::string("metadata parse failed: ") + e.what();
return out;
}
if (!meta.contains("message") || meta["message"].get<std::string>() != "success") {
out.error_message = "API rejected the query: " + q.body;
return out;
}
if (!meta.contains("resources") || !meta["resources"].is_array() ||
meta["resources"].empty()) {
out.error_message = "API returned no resources for " + using_version;
return out;
}
std::string archive_url, version;
for (const auto& r : meta["resources"]) {
if (!r.contains("type") || r["type"].get<std::string>() != kResourceType) continue;
if (r.contains("version")) version = r["version"].get<std::string>();
if (r.contains("url")) archive_url = r["url"].get<std::string>();
break;
}
if (archive_url.empty() || version.empty()) {
out.error_message = "API response missing url/version: " + q.body;
return out;
}
out.version = version;
// Cache layout: <cache_dir>/<version>/libbambu_networking.so
fs::path target_dir = fs::path(cache_dir) / version;
fs::path target_so = target_dir / kSoBasename;
std::error_code ec;
fs::create_directories(target_dir, ec);
if (ec) {
out.error_message = "cannot create " + target_dir.string() + ": " + ec.message();
return out;
}
if (!force && fs::exists(target_so, ec)) {
out.ok = true;
out.so_path = target_so.string();
return out;
}
fs::path tmp_zip = target_dir / "plugin.zip.partial";
HttpResult dl = http_get_file(archive_url, tmp_zip.string(), 180);
out.http_status = static_cast<int>(dl.status);
if (!dl.ok) {
out.error_message = "archive GET failed (" + archive_url + "): " + dl.error;
fs::remove(tmp_zip, ec);
return out;
}
std::string err;
if (!unzip_extract_basename(tmp_zip.string(), kSoBasename, target_so.string(), err)) {
out.error_message = "unzip failed: " + err;
fs::remove(tmp_zip, ec);
return out;
}
fs::remove(tmp_zip, ec);
out.ok = true;
out.so_path = target_so.string();
return out;
}
} // namespace obn::plugin_runner