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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,12 @@ if(MOONBASE_BUILD_TESTS)
tests/client_tests.cpp
tests/fingerprint_tests.cpp
tests/header_smoke_tests.cpp
tests/inventory_tests.cpp
tests/licensing_tests.cpp
tests/process_dedup_tests.cpp
tests/store_tests.cpp
tests/validator_tests.cpp
tests/version_tests.cpp
tests/live_tests.cpp)
target_link_libraries(moonbase_tests PRIVATE moonbase::licensing doctest::doctest)
moonbase_apply_sanitizer(moonbase_tests)
Expand Down
Binary file added assets/moonbase-juce-update.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ The screens:
and a Deactivate action (server-side `revoke_activation`, with a local-forget fallback
for offline or trial licenses).
- **Trial expired** — a locked "trial has ended" screen with Unlock / Activate offline.
- **Update available.** Shown when a validated license reports a released version
(the `current_release_version` / `p:rel` claim) newer than the app version. Loads the
release notes from the inventory endpoints and downloads the new installer for the
current platform in-app, with progress. "Skip this update" dismisses it (recorded so
the quiet startup path won't re-prompt for that version; a newer release still does).
Controlled by `config.enableUpdatePrompt` / `config.applicationVersion` /
`config.downloadDirectory` / `config.autoPresentUpdate`.

All transitions use JUCE 8's animation API (`juce::Animator` / `ValueAnimatorBuilder`
/ `Easings`, driven by a `VBlankAnimatorUpdater`): cross-fade + fade-up between
Expand Down Expand Up @@ -225,6 +232,11 @@ It is invoked on the message thread. A missing or malformed `endpoint` / `produc
`publicKey` does not throw out of construction; the component shows an Error state and the
reason is reported through this sink (and `DBG` in debug builds).

**Escape hatch:** on the license details screen, double-clicking the green "Active" pill
reveals the configured license folder (where `license.mb` and `license.state.json` live) in
the OS file browser. It's an undocumented gesture (no pointer cursor) for support /
debugging, e.g. to inspect or clear persisted state.

## Refreshing entitlements

After a user buys something mid-session (a sub-product, an upgrade), re-validate the
Expand Down Expand Up @@ -261,6 +273,65 @@ The SDK never polls on a timer; it validates on launch (`start()`) and whenever
stays usable offline until `onlineGracePeriod` elapses since its last successful online
validation.

## App updates

A validated license carries the product's current released version in its claims
(`license.licensed_product.current_release_version`, the JWT `p:rel` claim). On launch
and after every re-validation, the controller compares it (via `moonbase::update_available`,
a small SemVer compare in `moonbase/version.hpp`) against the running app version:

```cpp
config.applicationVersion = "2.3.1"; // or rely on JucePlugin_VersionString
config.enableUpdatePrompt = true; // default; set false to never prompt
config.downloadDirectory = {}; // default: the user's Downloads folder
```

When the released version is newer, the **Update available** screen shows instead of the
Details / Trial screen (it never interrupts the locked Expired screen). It then:

1. Fetches the release notes (plain text) from
`GET /api/customer/inventory/products/{productId}?version={released}`.
2. On "Download", resolves an authenticated, short-lived installer URL from
`GET /api/customer/inventory/products/{productId}/download/{platform}/latest?redirect=false`
and downloads it into `config.downloadDirectory` with progress, then reveals it.

**Download gating.** The product response reports the release access-control level
(`downloadsNeedsUser` / `downloadsNeedsOwnership`). `moonbase::can_download(license, info)`
applies it: a full (non-trial) license owns the product and can download; a trial cannot
download from an owners-only product (the default). When the license isn't entitled, the
screen swaps the Download button for an **Unlock full version** CTA and a short note, so
the user never hits a 403 (`updateInfo().canDownload`).

Both inventory requests authenticate with the license token via the
`Authorization: LicenseToken <jwt>` scheme (`moonbase::inventory_client`). Observe
`controller().updateInfo()` for the phase, versions, notes, progress, and `canDownload`.

**When it appears.** With `config.autoPresentUpdate` (default on), the module presents the
update screen automatically when the plugin **opens** with an available, non-skipped update
(the controller routes there on startup / re-validation and the component appears the
overlay). Opening the license view explicitly (e.g. the host's "License" button) never
auto-shows it — closing the update overlay returns the resting screen to the license view,
so re-opening lands on the license screen. The license view also keeps a clickable
**"Update available"** badge next to the "Active" pill (whenever `updateAvailable()` is
true) that opens it on demand; `ActivationComponent::presentUpdateIfAvailable()` is the
explicit host hook.

**"Skip this update".** Dismissing records the version in the `ignoredUpdates` list (so it
won't auto-present again for that version; a newer release still does). Where it goes
depends on how the screen was entered (tracked via `updateCameFromLicenseView()`): from the
license-view badge → back to the license view; auto-presented on open → it just closes the
overlay (the host's `onClose`).

**What is and isn't cached between sessions.** The released *version* is part of the
license token, so it is read from the persisted `license.mb` on every launch (no network
needed) and refreshed on the next successful online validation. The release *notes* and
the installer URL are **not** cached: `fetchUpdateInfo()` re-fetches the notes each time
the screen is shown, and the presigned installer URL is resolved fresh on each "Download"
click (it expires after ~15 minutes). The skip list lives in a small JSON state file
beside the license (`<license>.state.json`); that file (`ActivationState`) is the general
place for client state that should survive restarts, so future cached/remembered values
live there too.

## Telemetry / analytics

Off by default. One flag attaches JUCE system + host metadata to every activation and
Expand Down
2 changes: 1 addition & 1 deletion examples/juce-native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ juce_add_gui_app(MoonbaseActivationNative
PRODUCT_NAME "Moonbase Activation"
COMPANY_NAME "Moonbase"
BUNDLE_ID "sh.moonbase.activation.native"
VERSION "1.0.0")
VERSION "0.0.1")

target_sources(MoonbaseActivationNative PRIVATE Main.cpp)

Expand Down
254 changes: 254 additions & 0 deletions include/moonbase/inventory.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
#pragma once

// Client for the backend "inventory" endpoints (/api/customer/inventory/...),
// used to fetch a release's notes and an authenticated installer download URL
// for the running platform. Authenticates with the license token via the
// custom "Authorization: LicenseToken <jwt>" scheme. Mirrors license_client and
// reuses its request helpers (detail::append_query / default_headers /
// throw_for_problem).

#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <utility>

#include <nlohmann/json.hpp>

#include "moonbase/client.hpp"
#include "moonbase/detail/url.hpp"
#include "moonbase/errors.hpp"
#include "moonbase/http.hpp"
#include "moonbase/types.hpp"

namespace moonbase {

// The plain-text release notes plus enough to know an installer exists and who
// may download it (the product's release access-control level).
struct release_info {
std::string version; // the queried release version (echoed back by the server)
std::string description; // release notes (plain text); empty when none
bool has_downloads = false;
bool downloads_need_user = false; // download requires an authenticated customer
bool downloads_need_ownership = false; // download requires owning / subscribing to the product
};

// A resolved, ready-to-fetch installer URL. `url` is a short-lived presigned
// link (the backend expires it after ~15 minutes). `filename` is a best-effort
// suggestion parsed from the URL; callers should fall back to their own name
// when it is empty.
struct download_target {
std::string url;
std::string filename;
};

// Whether `license` may download the installer for a release with the given
// access flags (from release_info). Mirrors the backend's release access control:
// a full (non-trial) license owns or subscribes to the product, a trial does not;
// an authenticated customer has a real user id, while an anonymous trial carries
// the nil GUID.
inline bool can_download(const license& lic, const release_info& info)
{
const bool owns = !lic.trial;
const bool has_user = !lic.issued_to.id.empty()
&& lic.issued_to.id != "00000000-0000-0000-0000-000000000000";
if (info.downloads_need_ownership && !owns) {
return false;
}
if (info.downloads_need_user && !has_user) {
return false;
}
return true;
}

namespace detail {

inline std::string inventory_product_path(const licensing_options& options)
{
return trim_trailing_slashes(options.endpoint) +
"/api/customer/inventory/products/" +
url_encode(options.product_id);
}

inline std::string inventory_latest_download_path(const licensing_options& options,
const std::string& platform_name)
{
return inventory_product_path(options) + "/download/" + url_encode(platform_name) + "/latest";
}

// Decode percent-escapes (and '+' as space) so a filename embedded in a
// content-disposition query parameter is readable.
inline std::string url_decode(std::string_view value)
{
std::string out;
out.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
const char ch = value[i];
if (ch == '%' && i + 2 < value.size()) {
const auto hex = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
const int hi = hex(value[i + 1]);
const int lo = hex(value[i + 2]);
if (hi >= 0 && lo >= 0) {
out.push_back(static_cast<char>((hi << 4) | lo));
i += 2;
continue;
}
}
out.push_back(ch == '+' ? ' ' : ch);
}
return out;
}

// Best-effort installer filename from a presigned URL's content-disposition
// (S3 puts it in the response-content-disposition query param). Returns "" when
// not found.
inline std::string filename_from_download_url(const std::string& url)
{
const auto decoded = url_decode(url);
const auto at = decoded.find("filename");
if (at == std::string::npos) {
return {};
}
auto rest = std::string_view(decoded).substr(at + 8); // past "filename"
if (!rest.empty() && rest.front() == '*') {
rest.remove_prefix(1); // RFC 5987 "filename*="
}
const auto eq = rest.find('=');
if (eq == std::string_view::npos) {
return {};
}
rest.remove_prefix(eq + 1);
// Drop an RFC 5987 charset prefix like "UTF-8''".
if (const auto quote = rest.find("''"); quote != std::string_view::npos && quote < 12) {
rest.remove_prefix(quote + 2);
}
std::string name;
bool quoted = false;
for (const char ch : rest) {
if (ch == '"') {
if (quoted) break;
quoted = true;
continue;
}
if (!quoted && (ch == ';' || ch == '&' || ch == ' ')) {
break;
}
name.push_back(ch);
}
return name;
}

inline std::map<std::string, std::string> inventory_headers(const licensing_options& options,
std::string_view license_token)
{
auto headers = default_headers(options);
if (!license_token.empty()) {
headers["Authorization"] = "LicenseToken " + std::string(license_token);
}
return headers;
}

} // namespace detail

class inventory_client {
public:
inventory_client(licensing_options options, std::shared_ptr<http_transport> transport)
: options_(std::move(options)), transport_(std::move(transport))
{
if (!transport_) {
throw configuration_error("An HTTP transport is required");
}
}

// GET /api/customer/inventory/products/{id}?version=<version>: release notes
// for the given version (empty version asks for the current release).
[[nodiscard]] release_info get_release(std::string_view version,
std::string_view license_token) const
{
std::map<std::string, std::string> query{{"includeManifests", "false"}};
if (!version.empty()) {
query["version"] = std::string(version);
}
const auto url = detail::append_query(detail::inventory_product_path(options_), query);

http_request request;
request.method = "GET";
request.url = url;
request.headers = detail::inventory_headers(options_, license_token);
request.connect_timeout = options_.http_connect_timeout;
request.request_timeout = options_.http_request_timeout;

const auto response = transport_->send(request);
if (response.status_code < 200 || response.status_code >= 300) {
detail::throw_for_problem(response.status_code, response.body);
}

try {
const auto json = nlohmann::json::parse(response.body);
release_info info;
if (json.contains("version") && json.at("version").is_string()) {
info.version = json.at("version").get<std::string>();
} else if (json.contains("currentVersion") && json.at("currentVersion").is_string()) {
info.version = json.at("currentVersion").get<std::string>();
}
if (json.contains("releaseDescription") && json.at("releaseDescription").is_string()) {
info.description = json.at("releaseDescription").get<std::string>();
}
info.has_downloads = json.contains("downloads") && json.at("downloads").is_array()
&& !json.at("downloads").empty();
info.downloads_need_user = json.value("downloadsNeedsUser", false);
info.downloads_need_ownership = json.value("downloadsNeedsOwnership", false);
return info;
} catch (const std::exception& ex) {
throw api_error(static_cast<int>(response.status_code),
std::string("Could not parse product response: ") + ex.what());
}
}

// GET /api/customer/inventory/products/{id}/download/{platform}/latest?redirect=false
// resolves the latest installer for the platform to a presigned URL.
// `platform_name` is the backend Platform enum name (e.g. "Mac", "Windows").
// Throws (404) when no installer exists for the platform.
[[nodiscard]] download_target get_download_url(std::string_view platform_name,
std::string_view license_token) const
{
const auto url = detail::append_query(
detail::inventory_latest_download_path(options_, std::string(platform_name)),
{{"redirect", "false"}});

http_request request;
request.method = "GET";
request.url = url;
request.headers = detail::inventory_headers(options_, license_token);
request.connect_timeout = options_.http_connect_timeout;
request.request_timeout = options_.http_request_timeout;

const auto response = transport_->send(request);
if (response.status_code < 200 || response.status_code >= 300) {
detail::throw_for_problem(response.status_code, response.body);
}

try {
const auto json = nlohmann::json::parse(response.body);
download_target target;
target.url = json.at("location").get<std::string>();
target.filename = detail::filename_from_download_url(target.url);
return target;
} catch (const std::exception& ex) {
throw api_error(static_cast<int>(response.status_code),
std::string("Could not parse download response: ") + ex.what());
}
}

private:
licensing_options options_;
std::shared_ptr<http_transport> transport_;
};

} // namespace moonbase
2 changes: 2 additions & 0 deletions include/moonbase/moonbase.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
#ifndef MOONBASE_DISABLE_CURL_TRANSPORT
#include "moonbase/http_curl.hpp"
#endif
#include "moonbase/inventory.hpp"
#include "moonbase/licensing.hpp"
#include "moonbase/store.hpp"
#include "moonbase/types.hpp"
#include "moonbase/validator.hpp"
#include "moonbase/version.hpp"
Loading
Loading