diff --git a/CMakeLists.txt b/CMakeLists.txt index ce8b085..15cbb61 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/assets/moonbase-juce-update.png b/assets/moonbase-juce-update.png new file mode 100644 index 0000000..0767146 Binary files /dev/null and b/assets/moonbase-juce-update.png differ diff --git a/docs/juce-module.md b/docs/juce-module.md index 04ac1d7..a2b70a3 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -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 @@ -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 @@ -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 ` 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 (`.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 diff --git a/examples/juce-native/CMakeLists.txt b/examples/juce-native/CMakeLists.txt index 15894bf..72df2f0 100644 --- a/examples/juce-native/CMakeLists.txt +++ b/examples/juce-native/CMakeLists.txt @@ -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) diff --git a/include/moonbase/inventory.hpp b/include/moonbase/inventory.hpp new file mode 100644 index 0000000..e65a282 --- /dev/null +++ b/include/moonbase/inventory.hpp @@ -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 " scheme. Mirrors license_client and +// reuses its request helpers (detail::append_query / default_headers / +// throw_for_problem). + +#include +#include +#include +#include +#include +#include + +#include + +#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((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 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 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=: 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 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(); + } else if (json.contains("currentVersion") && json.at("currentVersion").is_string()) { + info.version = json.at("currentVersion").get(); + } + if (json.contains("releaseDescription") && json.at("releaseDescription").is_string()) { + info.description = json.at("releaseDescription").get(); + } + 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(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(); + target.filename = detail::filename_from_download_url(target.url); + return target; + } catch (const std::exception& ex) { + throw api_error(static_cast(response.status_code), + std::string("Could not parse download response: ") + ex.what()); + } + } + +private: + licensing_options options_; + std::shared_ptr transport_; +}; + +} // namespace moonbase diff --git a/include/moonbase/moonbase.hpp b/include/moonbase/moonbase.hpp index 54999f7..f46a6c4 100644 --- a/include/moonbase/moonbase.hpp +++ b/include/moonbase/moonbase.hpp @@ -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" diff --git a/include/moonbase/version.hpp b/include/moonbase/version.hpp new file mode 100644 index 0000000..ef17650 --- /dev/null +++ b/include/moonbase/version.hpp @@ -0,0 +1,179 @@ +#pragma once + +// Minimal Semantic Versioning compare, just enough to decide whether the +// backend's "currently released version" (the license's p:rel claim) is newer +// than the running app's version. Header-only and dependency-free so it can be +// unit-tested in isolation. + +#include +#include +#include +#include +#include + +namespace moonbase { + +struct semver { + long long major = 0; + long long minor = 0; + long long patch = 0; + std::string prerelease; // dot-joined identifiers; empty for a normal release +}; + +namespace detail { + +// Whole-string unsigned integer (no sign, no overflow guard beyond long long; +// version numbers are tiny). Returns nullopt for an empty / non-digit run. +inline std::optional parse_uint(std::string_view text) +{ + if (text.empty()) { + return std::nullopt; + } + long long value = 0; + for (const char ch : text) { + if (!std::isdigit(static_cast(ch))) { + return std::nullopt; + } + value = value * 10 + (ch - '0'); + } + return value; +} + +inline std::vector split(std::string_view text, char delim) +{ + std::vector parts; + std::size_t start = 0; + while (true) { + const auto pos = text.find(delim, start); + if (pos == std::string_view::npos) { + parts.push_back(text.substr(start)); + break; + } + parts.push_back(text.substr(start, pos - start)); + start = pos + 1; + } + return parts; +} + +} // namespace detail + +// Tolerant parse: accepts a leading "v"/"V", a missing minor/patch ("2.4" -> +// 2.4.0), a "-prerelease" suffix and a "+build" suffix (build metadata is +// ignored per SemVer). Returns nullopt when the numeric core is malformed. +inline std::optional parse_semver(std::string_view input) +{ + std::size_t i = 0; + while (i < input.size() && std::isspace(static_cast(input[i]))) { + ++i; + } + std::size_t end = input.size(); + while (end > i && std::isspace(static_cast(input[end - 1]))) { + --end; + } + auto text = input.substr(i, end - i); + if (!text.empty() && (text.front() == 'v' || text.front() == 'V')) { + text.remove_prefix(1); + } + if (text.empty()) { + return std::nullopt; + } + + // Strip build metadata first (+...), then split the prerelease (-...). + if (const auto plus = text.find('+'); plus != std::string_view::npos) { + text = text.substr(0, plus); + } + std::string_view core = text; + std::string_view pre; + if (const auto dash = text.find('-'); dash != std::string_view::npos) { + core = text.substr(0, dash); + pre = text.substr(dash + 1); + } + + const auto parts = detail::split(core, '.'); + if (parts.empty() || parts.size() > 3) { + return std::nullopt; + } + + semver result; + const auto major = detail::parse_uint(parts[0]); + if (!major) { + return std::nullopt; + } + result.major = *major; + if (parts.size() > 1) { + const auto minor = detail::parse_uint(parts[1]); + if (!minor) { + return std::nullopt; + } + result.minor = *minor; + } + if (parts.size() > 2) { + const auto patch = detail::parse_uint(parts[2]); + if (!patch) { + return std::nullopt; + } + result.patch = *patch; + } + result.prerelease = std::string(pre); + return result; +} + +// SemVer §11 precedence: -1 if a < b, 0 if equal, 1 if a > b. +inline int compare(const semver& a, const semver& b) +{ + if (a.major != b.major) { + return a.major < b.major ? -1 : 1; + } + if (a.minor != b.minor) { + return a.minor < b.minor ? -1 : 1; + } + if (a.patch != b.patch) { + return a.patch < b.patch ? -1 : 1; + } + + // A release outranks any prerelease of the same core version. + if (a.prerelease.empty() != b.prerelease.empty()) { + return a.prerelease.empty() ? 1 : -1; + } + if (a.prerelease.empty()) { + return 0; + } + + const auto lhs = detail::split(a.prerelease, '.'); + const auto rhs = detail::split(b.prerelease, '.'); + const auto count = lhs.size() < rhs.size() ? lhs.size() : rhs.size(); + for (std::size_t i = 0; i < count; ++i) { + const auto ln = detail::parse_uint(lhs[i]); + const auto rn = detail::parse_uint(rhs[i]); + if (ln && rn) { + if (*ln != *rn) { + return *ln < *rn ? -1 : 1; + } + } else if (ln) { + return -1; // numeric identifiers have lower precedence than alphanumeric + } else if (rn) { + return 1; + } else if (lhs[i] != rhs[i]) { + return lhs[i] < rhs[i] ? -1 : 1; + } + } + if (lhs.size() != rhs.size()) { + return lhs.size() < rhs.size() ? -1 : 1; // more identifiers wins + } + return 0; +} + +// True when `latest` is a strictly newer version than `current`. Returns false +// when either string fails to parse, so a malformed value never prompts an +// update. +inline bool update_available(std::string_view current, std::string_view latest) +{ + const auto a = parse_semver(current); + const auto b = parse_semver(latest); + if (!a || !b) { + return false; + } + return compare(*b, *a) > 0; +} + +} // namespace moonbase diff --git a/modules/moonbase_licensing/README.md b/modules/moonbase_licensing/README.md index f2928f4..e602902 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -19,9 +19,15 @@ Add the module, fill in three fields, show one component. you want that bridge instead, see [`examples/juce/`](../../examples/juce/).) - **Built-in UI.** A configurable, themeable `ActivationComponent` (and one-call `ActivationDialog`) covering every state — welcome, activating, success, offline, - trial, trial expired, license details — with JUCE 8 animated transitions and drag-and-drop for - offline license files. Designed to sit as a modal over your plugin and lock it - until activated. + trial, trial expired, license details, and update-available — with JUCE 8 animated + transitions and drag-and-drop for offline license files. Designed to sit as a modal + over your plugin and lock it until activated. +- **In-app updates.** When the user's license entitles them to a newer release than the + installed app, an "Update available" screen shows the release notes and downloads the + new installer for their platform in-app, with progress. It appears automatically when + the plugin opens, stays reachable from an "Update available" badge on the license + screen, and "Skip this update" is remembered until a newer version ships. Opt out with + `config.enableUpdatePrompt = false`. - **Zero third-party dependencies.** HTTP over `juce::WebInputStream` (no CURL), JSON via a bundled `nlohmann/json`, and RS256 verification via OS-native crypto: Security.framework (macOS/iOS), CNG/bcrypt (Windows), system libcrypto (Linux). @@ -32,6 +38,12 @@ Add the module, fill in three fields, show one component. Requires **JUCE 8** (8.0.4+) and C++17. Supports macOS, Windows, and Linux. +

+ Update available screen: an 'Update available' pill, a 'Solstice 1.0.0 is ready' heading, a 'What's new' changelog card, a Download button, and a 'Skip this update' link. +

+

The in-app update notification: release notes and a one-click installer download.

+ ## Add it to your project Add this repository (as a git submodule, say) and point your build at the module folder. @@ -99,6 +111,11 @@ richer gating, and `onActivationChanged` fires whenever it changes. - **Refresh entitlements** — `controller().refreshLicense()` re-validates online so a freshly purchased sub-product/upgrade loads without a restart (async, silent, with an optional completion callback). +- **App updates.** Set `config.applicationVersion` (or rely on `JucePlugin_VersionString`) + and the update screen surfaces whenever the user's license entitles them to a newer + release. It pops automatically on open (`config.autoPresentUpdate`), downloads installers + into `config.downloadDirectory` (defaults to Downloads), and is fully optional + (`config.enableUpdatePrompt = false`). - **Persistence** — the validated license is stored at `userApplicationDataDirectory///license.mb` by default (override with `config.licenseFile`). diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index 6214934..23cf805 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -88,6 +88,21 @@ struct ActivationConfig bool overlayBackdrop = false; // dim the host behind the panel (modal over a plugin) instead of a full opaque backdrop int trialLengthDays = 14; // trial length shown on the Trial / Expired screens (trials are granted by the backend, not started from the UI) + // When a validated license reports a newer released version than + // applicationVersion (the p:rel claim), show the "Update available" screen and + // offer an in-app download of the new installer. Set false to never prompt. + bool enableUpdatePrompt = true; + + // Automatically present the update screen when the plugin opens with an + // available (non-skipped) update. Set false to surface updates only via the + // "Update available" badge in the license view (no automatic pop on open). + bool autoPresentUpdate = true; + + // Where the downloaded installer is saved. Defaults to the user's Downloads + // folder (falling back to the temp directory) when left empty; see + // resolvedDownloadDirectory(). + juce::File downloadDirectory; + // Optional override for the device name shown on the activation screen (the // " · " chip). When empty, the OS hostname is used // (juce::SystemStats::getComputerName, via the default fingerprint provider). @@ -151,6 +166,19 @@ struct ActivationConfig return {}; #endif } + // The running app version compared against the license's released-version + // claim. Mirrors the resolution in toLicensingOptions(): explicit config, then + // the JUCE plugin macro. Empty when neither is set (no update prompt then). + [[nodiscard]] juce::String resolvedApplicationVersion() const + { + if (applicationVersion.isNotEmpty()) + return applicationVersion; + #if defined (JucePlugin_VersionString) + return JucePlugin_VersionString; + #else + return {}; + #endif + } //== Resolved UI copy (override-or-default) ================================ [[nodiscard]] juce::String brandAccountName() const @@ -222,6 +250,18 @@ struct ActivationConfig .getChildFile("license.mb"); } + [[nodiscard]] juce::File resolvedDownloadDirectory() const + { + if (downloadDirectory != juce::File()) + return downloadDirectory; + + auto downloads = juce::File::getSpecialLocation(juce::File::userHomeDirectory) + .getChildFile("Downloads"); + if (downloads.isDirectory()) + return downloads; + return juce::File::getSpecialLocation(juce::File::tempDirectory); + } + [[nodiscard]] moonbase::licensing_options toLicensingOptions() const { moonbase::licensing_options options; diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 52bb59a..cbc6677 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -45,6 +45,9 @@ ActivationController::ActivationController(ActivationConfig config) std::filesystem::path(file.getFullPathName().toStdString())); auto fingerprint = std::make_shared(); auto transport = std::make_shared(); + // A second transport for the inventory (update) calls, so an update download + // URL fetch and a license validation can't block on each other's stream. + auto inventoryTransport = std::make_shared(); try { @@ -60,10 +63,17 @@ ActivationController::ActivationController(ActivationConfig config) return; } - cancelInFlight_ = [transport] { transport->cancel(); }; + inventory_.emplace(config_.toLicensingOptions(), inventoryTransport); + + cancelInFlight_ = [transport, inventoryTransport] + { + transport->cancel(); + inventoryTransport->cancel(); + }; setDeviceLabel(config_.deviceName.isNotEmpty() ? config_.deviceName : juce::String(fingerprint->device_name())); + state_.emplace(stateFilePath()); } ActivationController::ActivationController(ActivationConfig config, @@ -75,11 +85,14 @@ ActivationController::ActivationController(ActivationConfig config, { jassert(licensing_ != nullptr); setDeviceLabel(std::move(deviceName)); + state_.emplace(stateFilePath()); } ActivationController::~ActivationController() { stopTimer(); + ++updateGeneration_; // drop any queued update-flow continuations + updateDownload_.reset(); // cancels + joins the installer download thread // Unblock any in-flight request, then wait for the workers to finish, so no // detached thread keeps running module code after we (and possibly the // plugin binary) are gone. cancelInFlight_ makes the drain near-instant. @@ -751,7 +764,20 @@ void ActivationController::applyLicense(std::optional value) setLicense(std::move(value)); expiredTrial_.reset(); statusMessage_.clear(); - setScreen(screenForCurrentLicense()); + + // A validated license that outranks the running app routes to the update + // screen first (unless dismissed this session); "Remind me later" then falls + // through to the normal Details / Trial screen. + const auto dest = screenForCurrentLicense(); + if ((dest == Screen::Details || dest == Screen::Trial) && config_.autoPresentUpdate + && updateAvailable() && ! updateDismissedForCurrentLicense()) + { + updateFromLicenseView_ = false; // auto-routed, not from the badge + beginUpdateFlow(dest); + return; + } + + setScreen(dest); } void ActivationController::showTrialExpired(moonbase::license expired) @@ -764,6 +790,302 @@ void ActivationController::showTrialExpired(moonbase::license expired) setScreen(Screen::Expired); } +//============================================================================== +// App update flow +bool ActivationController::updateAvailable() const +{ + if (! config_.enableUpdatePrompt || ! license_) + return false; + const auto& released = license_->licensed_product.current_release_version; + if (! released) + return false; + return moonbase::update_available(config_.resolvedApplicationVersion().toStdString(), *released); +} + +juce::File ActivationController::stateFilePath() const +{ + // A small JSON state file beside the license. Named after the license file so + // it stays unique to this product/store. + const auto license = config_.resolvedLicenseFile(); + return license.getSiblingFile(license.getFileNameWithoutExtension() + ".state.json"); +} + +bool ActivationController::updateDismissedForCurrentLicense() const +{ + if (! state_ || ! license_) + return false; + const auto& released = license_->licensed_product.current_release_version; + if (! released) + return false; + return state_->isUpdateIgnored(juce::String(*released)); +} + +void ActivationController::beginUpdateFlow(Screen /*destination*/) +{ + updateInfo_ = {}; + updateInfo_.phase = UpdateInfo::Phase::Loading; + updateInfo_.currentVersion = config_.resolvedApplicationVersion(); + if (license_ && license_->licensed_product.current_release_version) + updateInfo_.newVersion = juce::String(*license_->licensed_product.current_release_version); + + setScreen(Screen::UpdateAvailable); + fetchUpdateInfo(); +} + +void ActivationController::fetchUpdateInfo() +{ + if (! inventory_ || ! license_) + { + // No way to fetch (test seam): show the screen with whatever we have. + updateInfo_.phase = UpdateInfo::Phase::Ready; + sendChangeMessage(); + return; + } + + const auto generation = ++updateGeneration_; + juce::WeakReference safe(this); + auto inventory = *inventory_; + auto token = license_->token; + auto version = updateInfo_.newVersion.toStdString(); + + threadPool_.addJob([safe, generation, inventory, token, version]() mutable + { + std::optional info; + juce::String diag; + try + { + info = inventory.get_release(version, token); + } + catch (const std::exception& ex) + { + diag = juce::String("fetchUpdateInfo failed: ") + describeError(ex); + } + + juce::MessageManager::callAsync([safe, generation, info, diag]() mutable + { + auto* self = safe.get(); + if (self == nullptr || generation != self->updateGeneration_.load()) + return; + if (info) + { + self->updateInfo_.releaseNotes = juce::String::fromUTF8(info->description.c_str()); + self->updateInfo_.canDownload = self->licenseCanDownload(*info); + self->updateInfo_.error.clear(); + } + else + { + self->updateInfo_.error = "Couldn't load the release details."; + self->emitDiagnostic(diag); + } + self->updateInfo_.phase = UpdateInfo::Phase::Ready; + self->sendChangeMessage(); + }); + }); +} + +bool ActivationController::licenseCanDownload(const moonbase::release_info& info) const +{ + return license_ && moonbase::can_download(*license_, info); +} + +void ActivationController::startUpdateDownload() +{ + if (! inventory_ || ! license_ || screen_ != Screen::UpdateAvailable) + return; + if (updateInfo_.phase == UpdateInfo::Phase::Downloading) + return; + + updateInfo_.phase = UpdateInfo::Phase::Downloading; + updateInfo_.progress = 0.0; + updateInfo_.error.clear(); + sendChangeMessage(); + + const auto generation = ++updateGeneration_; + juce::WeakReference safe(this); + auto inventory = *inventory_; + auto token = license_->token; + const auto platformName = moonbase::to_string(moonbase::current_platform()); + + threadPool_.addJob([safe, generation, inventory, token, platformName]() mutable + { + std::optional target; + juce::String diag; + try + { + target = inventory.get_download_url(platformName, token); + } + catch (const std::exception& ex) + { + diag = juce::String("startUpdateDownload failed: ") + describeError(ex); + } + + juce::MessageManager::callAsync([safe, generation, target, diag]() mutable + { + auto* self = safe.get(); + if (self == nullptr || generation != self->updateGeneration_.load()) + return; + if (target) + { + self->beginFileDownload(*target); + } + else + { + self->emitDiagnostic(diag); + self->failUpdateDownload("Couldn't reach Moonbase to download the update. Try again when online."); + } + }); + }); +} + +void ActivationController::beginFileDownload(const moonbase::download_target& target) +{ + auto dir = config_.resolvedDownloadDirectory(); + dir.createDirectory(); + + const auto suggested = target.filename.empty() + ? defaultInstallerName() + : juce::File::createLegalFileName(juce::String(target.filename)); + updateFile_ = dir.getNonexistentChildFile(suggested.upToLastOccurrenceOf(".", false, false), + suggested.fromLastOccurrenceOf(".", true, false)); + + downloadGeneration_.store(updateGeneration_.load()); + const auto options = juce::URL::DownloadTaskOptions().withListener(this); + updateDownload_ = juce::URL(juce::String(target.url)).downloadToFile(updateFile_, options); + if (updateDownload_ == nullptr) + failUpdateDownload("Couldn't start the download."); +} + +void ActivationController::failUpdateDownload(const juce::String& message) +{ + updateInfo_.phase = UpdateInfo::Phase::Ready; + updateInfo_.progress = 0.0; + updateInfo_.error = message; + sendChangeMessage(); +} + +juce::String ActivationController::defaultInstallerName() const +{ +#if JUCE_MAC + const char* ext = ".dmg"; +#elif JUCE_WINDOWS + const char* ext = ".exe"; +#else + const char* ext = ".zip"; +#endif + auto base = config_.resolvedProductName(); + if (updateInfo_.newVersion.isNotEmpty()) + base << "-" << updateInfo_.newVersion; + return juce::File::createLegalFileName(base + ext); +} + +void ActivationController::progress(juce::URL::DownloadTask*, juce::int64 bytesDownloaded, + juce::int64 totalLength) +{ + const double frac = totalLength > 0 + ? juce::jlimit(0.0, 1.0, (double) bytesDownloaded / (double) totalLength) + : 0.0; + const auto generation = downloadGeneration_.load(); + juce::WeakReference safe(this); + juce::MessageManager::callAsync([safe, generation, frac] + { + auto* self = safe.get(); + if (self == nullptr || generation != self->updateGeneration_.load()) + return; + if (self->updateInfo_.phase != UpdateInfo::Phase::Downloading) + return; + self->updateInfo_.progress = frac; + self->sendChangeMessage(); + }); +} + +void ActivationController::finished(juce::URL::DownloadTask*, bool success) +{ + const auto generation = downloadGeneration_.load(); + juce::WeakReference safe(this); + juce::MessageManager::callAsync([safe, generation, success] + { + auto* self = safe.get(); + if (self == nullptr || generation != self->updateGeneration_.load()) + return; + if (success) + { + self->updateInfo_.phase = UpdateInfo::Phase::Done; + self->updateInfo_.progress = 1.0; + self->updateInfo_.error.clear(); + self->updateFile_.revealToUser(); + self->sendChangeMessage(); + } + else + { + self->failUpdateDownload("The download didn't finish. Try again."); + } + }); +} + +void ActivationController::showUpdate(bool fromLicenseView) +{ + if (! updateAvailable()) + return; + updateFromLicenseView_ = fromLicenseView; + beginUpdateFlow(screenForCurrentLicense()); +} + +void ActivationController::revealUpdateDownload() +{ + if (updateFile_ != juce::File() && updateFile_.existsAsFile()) + { + updateFile_.revealToUser(); + return; + } + + // The downloaded installer is gone (moved, deleted, or cleaned up). Drop back + // to the ready state so the button offers to download it again. + updateFile_ = juce::File(); + updateInfo_.phase = UpdateInfo::Phase::Ready; + updateInfo_.progress = 0.0; + sendChangeMessage(); +} + +void ActivationController::dismissUpdate() +{ + // Remember the dismissed version so we don't prompt for it again next session + // (a newer release still prompts). Persisted in the JSON state file. + if (state_) + state_->ignoreUpdate(updateInfo_.newVersion); + + ++updateGeneration_; // drop any in-flight fetch / download continuations + updateDownload_.reset(); // cancel an active file download + setScreen(screenForCurrentLicense()); +} + +void ActivationController::setPreviewUpdate(UpdateInfo::Phase phase, moonbase::license license, + juce::String releaseNotes, double progressValue, + juce::String error, bool canDownload) +{ + ++generation_; + ++updateGeneration_; + stopTimer(); + pollInFlight_ = false; + busy_ = false; + + setLicense(std::move(license)); + expiredTrial_.reset(); + statusMessage_.clear(); + + updateInfo_ = {}; + updateInfo_.phase = phase; + updateInfo_.currentVersion = config_.resolvedApplicationVersion(); + if (license_ && license_->licensed_product.current_release_version) + updateInfo_.newVersion = juce::String(*license_->licensed_product.current_release_version); + updateInfo_.releaseNotes = std::move(releaseNotes); + updateInfo_.progress = progressValue; + updateInfo_.error = std::move(error); + updateInfo_.canDownload = canDownload; + + screen_ = Screen::UpdateAvailable; + sendSynchronousChangeMessage(); +} + juce::String ActivationController::shortPlatformName() { #if JUCE_MAC diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index dc9b42f..fd68ed2 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -21,24 +21,53 @@ #include #include "ActivationConfig.h" +#include "ActivationState.h" namespace moonbase::juce_integration { class ActivationController : private juce::Timer, + private juce::URL::DownloadTaskListener, public juce::ChangeBroadcaster { public: enum class Screen { - Loading, // validating any stored license at startup - Welcome, // not activated — choose online / trial / offline - BrowserWait, // browser activation in progress, polling - Success, // just activated - Offline, // offline request/response file flow - Trial, // a valid trial license is loaded - Expired, // a trial license that has ended (plugin locked; see expiredTrial()) - Details, // a valid full license is loaded - Error // an operation failed; statusMessage() has detail + Loading, // validating any stored license at startup + Welcome, // not activated — choose online / trial / offline + BrowserWait, // browser activation in progress, polling + Success, // just activated + Offline, // offline request/response file flow + Trial, // a valid trial license is loaded + Expired, // a trial license that has ended (plugin locked; see expiredTrial()) + Details, // a valid full license is loaded + UpdateAvailable,// a valid license, but a newer app version has been released + Error // an operation failed; statusMessage() has detail + }; + + // State backing the "Update available" screen. The version strings are known + // immediately (from the license claim + app version); releaseNotes and the + // download URL are fetched from the inventory endpoints, so the view tracks a + // small phase machine while that happens. + struct UpdateInfo + { + enum class Phase + { + Loading, // fetching release notes + Ready, // notes loaded; download not started (error set if the fetch failed) + Downloading,// installer download in progress (see progress) + Done // installer downloaded + revealed + }; + + Phase phase = Phase::Loading; + juce::String currentVersion; // the running app version + juce::String newVersion; // the released version from the license claim + juce::String releaseNotes; // plain text; empty until loaded + juce::String error; // non-empty when a fetch/download failed + double progress = 0.0; // 0..1 while Downloading + // Whether this license may download the installer, derived from the + // product's release access-control level. False e.g. for a trial when the + // product restricts downloads to owners. Known once notes have loaded. + bool canDownload = true; }; explicit ActivationController(ActivationConfig config); @@ -74,6 +103,35 @@ class ActivationController : private juce::Timer, // licenses. The optional callback runs on the message thread with the outcome. void refreshLicense(bool force = true, std::function onComplete = {}); + //== App update flow ======================================================= + // True when a valid license is loaded, enableUpdatePrompt is set, and the + // license's current_release_version claim is a higher semver than the running + // app version. Pure (no network), safe to call any time. + [[nodiscard]] bool updateAvailable() const; + + // Start the in-app installer download for the released version (resolves an + // authenticated URL from the inventory endpoint, then downloads with progress). + // No-op unless the UpdateAvailable screen is showing. Observe updateInfo(). + void startUpdateDownload(); + + // Re-open the update screen on demand. fromLicenseView marks that the user got + // here from the license view's "Update available" badge (vs an automatic + // open/focus presentation), which controls where "Skip this update" returns to. + // Re-fetches the release notes. No-op when no update is available. + void showUpdate(bool fromLicenseView = false); + + // True when the update screen was opened from the license view (the badge), so + // dismissing should return there rather than close an auto-presented overlay. + [[nodiscard]] bool updateCameFromLicenseView() const noexcept { return updateFromLicenseView_; } + + // Re-reveal the already-downloaded installer in the OS file browser (the + // "Done" state of the update flow). + void revealUpdateDownload(); + + // "Remind me later": leave the update screen for the normal Details / Trial + // screen and don't prompt again this session. + void dismissUpdate(); + //== Offline activation ==================================================== bool saveOfflineRequest(const juce::File& destination); // writes the device token void setOfflineResponse(const juce::File& responseFile); @@ -104,6 +162,16 @@ class ActivationController : private juce::Timer, // nullopt to unpin and fall back to the system clock. void setPreviewClock(std::optional now); + // Force the UpdateAvailable screen with synthetic update state (no network), + // for previews + snapshot tests. newVersion is read from the license's + // current_release_version; currentVersion from the config's app version. + void setPreviewUpdate(UpdateInfo::Phase phase, + moonbase::license license, + juce::String releaseNotes = {}, + double progress = 0.0, + juce::String error = {}, + bool canDownload = true); + //== Accessors ============================================================= [[nodiscard]] Screen screen() const noexcept { return screen_; } [[nodiscard]] const std::optional& license() const noexcept { return license_; } @@ -118,6 +186,7 @@ class ActivationController : private juce::Timer, [[nodiscard]] juce::String offlineError() const { return offlineError_; } [[nodiscard]] juce::String deviceLabel() const { return deviceLabel_; } [[nodiscard]] const ActivationConfig& config() const noexcept { return config_; } + [[nodiscard]] const UpdateInfo& updateInfo() const noexcept { return updateInfo_; } [[nodiscard]] bool isBusy() const noexcept { return busy_; } [[nodiscard]] bool offlineRequestSaved() const noexcept { return offlineRequestSaved_; } @@ -127,6 +196,25 @@ class ActivationController : private juce::Timer, private: void timerCallback() override; + // juce::URL::DownloadTaskListener: both arrive on a background thread and + // marshal to the message thread (gated by downloadGeneration_). + void finished(juce::URL::DownloadTask* task, bool success) override; + void progress(juce::URL::DownloadTask* task, juce::int64 bytesDownloaded, + juce::int64 totalLength) override; + + void beginUpdateFlow(Screen destination); // sets UpdateInfo + fetches notes + void fetchUpdateInfo(); + // Whether the current license may download the installer, given the product's + // release access-control flags (needs-user / needs-ownership). + [[nodiscard]] bool licenseCanDownload(const moonbase::release_info& info) const; + void beginFileDownload(const moonbase::download_target& target); + void failUpdateDownload(const juce::String& message); + [[nodiscard]] juce::String defaultInstallerName() const; + // True when the loaded license's released version is in the persisted + // ignored-updates list (a newer version still prompts). + [[nodiscard]] bool updateDismissedForCurrentLicense() const; + [[nodiscard]] juce::File stateFilePath() const; + void setScreen(Screen newScreen, const juce::String& message = {}); void setLicense(std::optional value); // updates license_ + licensedFlag() void applyLicense(std::optional value); @@ -143,6 +231,9 @@ class ActivationController : private juce::Timer, ActivationConfig config_; std::shared_ptr licensing_; + // Built only on the real path (primary constructor); empty when the controller + // was handed a ready-made licensing (test seam). Guards the update flow. + std::optional inventory_; // Network/file work runs here instead of detached threads, so the destructor // can drain workers (after cancelInFlight_ unblocks any in-flight request); @@ -171,6 +262,21 @@ class ActivationController : private juce::Timer, // and no-op if it has moved on by the time they run on the message thread. std::atomic generation_{0}; + //== Update flow =========================================================== + UpdateInfo updateInfo_; + // True when the update screen was entered from the license-view badge (vs an + // automatic open/focus presentation); controls dismissal navigation. + bool updateFromLicenseView_ = false; + // Persisted client state (ignored update versions, and room to grow). Built + // once the config is valid; empty only on a misconfigured controller. + std::optional state_; + // Separate generation for the update flow's async work (notes fetch + file + // download) so a license re-validation can't cancel an in-flight download. + std::atomic updateGeneration_{0}; + std::atomic downloadGeneration_{0}; + std::unique_ptr updateDownload_; + juce::File updateFile_; + JUCE_DECLARE_WEAK_REFERENCEABLE(ActivationController) JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ActivationController) }; diff --git a/modules/moonbase_licensing/juce/ActivationState.h b/modules/moonbase_licensing/juce/ActivationState.h new file mode 100644 index 0000000..5cc14ef --- /dev/null +++ b/modules/moonbase_licensing/juce/ActivationState.h @@ -0,0 +1,85 @@ +#pragma once + +// A tiny JSON-backed store for client-side state that should survive restarts +// (currently: which app-update versions the user chose to ignore). It lives next +// to the license file and is intentionally generic so future state can be added +// without new files. Unknown keys are preserved on write, so a field written by a +// newer build is not clobbered by an older one. + +#include + +namespace moonbase::juce_integration { + +class ActivationState +{ +public: + explicit ActivationState(juce::File file) : file_(std::move(file)) { load(); } + + //== Ignored app updates =================================================== + [[nodiscard]] juce::StringArray ignoredUpdates() const + { + juce::StringArray out; + if (auto* arr = root_.getProperty(kIgnoredUpdates, {}).getArray()) + for (const auto& v : *arr) + out.add(v.toString()); + return out; + } + + [[nodiscard]] bool isUpdateIgnored(const juce::String& version) const + { + return version.isNotEmpty() && ignoredUpdates().contains(version); + } + + void ignoreUpdate(const juce::String& version) + { + if (version.isEmpty()) + return; + load(); // read-modify-write so a concurrent instance's entries survive + if (isUpdateIgnored(version)) + return; + juce::Array arr; + if (auto* existing = root_.getProperty(kIgnoredUpdates, {}).getArray()) + arr = *existing; + arr.add(version); + setProperty(kIgnoredUpdates, arr); + } + + //== Generic escape hatch for future state ================================= + [[nodiscard]] juce::var get(const juce::Identifier& key) const + { + return root_.getProperty(key, {}); + } + + void set(const juce::Identifier& key, const juce::var& value) { setProperty(key, value); } + +private: + static inline const juce::Identifier kIgnoredUpdates { "ignoredUpdates" }; + + void load() + { + root_ = juce::var(new juce::DynamicObject()); + if (file_.existsAsFile()) + if (auto parsed = juce::JSON::parse(file_.loadFileAsString()); + parsed.getDynamicObject() != nullptr) + root_ = parsed; + } + + void setProperty(const juce::Identifier& key, const juce::var& value) + { + if (root_.getDynamicObject() == nullptr) + root_ = juce::var(new juce::DynamicObject()); + root_.getDynamicObject()->setProperty(key, value); + save(); + } + + void save() const + { + file_.getParentDirectory().createDirectory(); + file_.replaceWithText(juce::JSON::toString(root_)); // pretty, unknown keys kept + } + + juce::File file_; + juce::var root_; +}; + +} // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp index fc904a9..cd2c204 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp @@ -273,6 +273,46 @@ class LinkButton : public juce::Button Justification justification; }; +//============================================================================== +// A compact, clickable accent pill with a download-tray icon, used as the +// "Update available" badge in the license view. +class UpdatePillButton : public juce::Button +{ +public: + explicit UpdatePillButton(ActivationLookAndFeel& l) : juce::Button("Update available"), lnf(l) + { + setMouseCursor(juce::MouseCursor::PointingHandCursor); + } + + [[nodiscard]] int preferredWidth() const + { + const float tw = juce::GlyphArrangement::getStringWidth(lnf.heading(11.0f), getButtonText()); + return (int) std::ceil(leftPad + iconSz + gap + tw + rightPad); + } + + void paintButton(Graphics& g, bool over, bool /*down*/) override + { + auto r = getLocalBounds().toFloat(); + const float radius = r.getHeight() * 0.5f; + g.setColour(lnf.accent.withAlpha(over ? 0.26f : 0.16f)); + g.fillRoundedRectangle(r, radius); + g.setColour(lnf.accent.withAlpha(0.45f)); + g.drawRoundedRectangle(r.reduced(0.5f), radius, 1.0f); + if (auto ic = icons::fromStroke(icons::downloadTray, lnf.accent, 1.9f)) + ic->drawWithin(g, { r.getX() + leftPad, r.getCentreY() - iconSz * 0.5f, iconSz, iconSz }, + juce::RectanglePlacement::centred, 1.0f); + g.setColour(lnf.palette.textPrimary); + g.setFont(lnf.heading(11.0f)); + g.drawText(getButtonText(), + r.withTrimmedLeft(leftPad + iconSz + gap).withTrimmedRight(rightPad * 0.5f), + Justification::centredLeft); + } + +private: + static constexpr float leftPad = 10.0f, iconSz = 13.0f, gap = 6.0f, rightPad = 12.0f; + ActivationLookAndFeel& lnf; +}; + //============================================================================== // Offline response-file target: click to browse, or drag a file onto it. class DropZone : public juce::Component, @@ -1372,6 +1412,12 @@ class DetailsView : public ScreenView "Deactivate this device"); deactivate->onClick = [this] { controller.deactivate(); }; addAndMakeVisible(*deactivate); + + // Shown (in refresh) only when an update is available but was dismissed; + // re-opens the update screen. + updateBadge = std::make_unique(l); + updateBadge->onClick = [this] { controller.showUpdate(/*fromLicenseView*/ true); }; + addChildComponent(*updateBadge); } void refresh() override @@ -1382,6 +1428,7 @@ class DetailsView : public ScreenView deactivate->setBusyImmediate(controller.isBusy()); else deactivate->setBusy(controller.isBusy()); + resized(); // positions + toggles the update badge (hidden when there's no room) repaint(); } @@ -1393,6 +1440,20 @@ class DetailsView : public ScreenView deactivate->setBusy(false); } + // Hidden escape hatch: double-clicking the "Active" pill (no pointer cursor, + // so it isn't advertised) reveals the configured license folder. Handy for + // support / debugging where the license + state files live. + void mouseDoubleClick(const juce::MouseEvent& e) override + { + if (! activePillBounds().contains(e.getPosition())) + return; + const auto file = controller.config().resolvedLicenseFile(); + if (file.existsAsFile()) + file.revealToUser(); // select license.mb in the folder + else + file.getParentDirectory().revealToUser(); // just open the folder + } + void paint(Graphics& g) override { auto r = getLocalBounds(); @@ -1415,7 +1476,7 @@ class DetailsView : public ScreenView auto headerRow = r.removeFromTop(40); drawBrand(g, lnf, controller.config().logo.get(), headerRow, controller.config().resolvedProductName(), controller.config().resolvedManufacturerName(), 34.0f, 15.0f); - drawActivePill(g, headerRow); + drawActivePill(g); r.removeFromTop(22); // The busy state is shown by the inline spinner in the deactivate button, @@ -1475,7 +1536,16 @@ class DetailsView : public ScreenView // Always position it (visibility is driven by refresh() on state change). auto content = getLocalBounds().removeFromBottom(58).reduced(16, 12); const int bw = juce::jmin(180, content.getWidth() / 2); + + // Drop to a short label when the button is too narrow for the full text. + const float fullW = juce::GlyphArrangement::getStringWidth(lnf.heading(14.0f), + "Deactivate this device"); + deactivate->setButtonText((float) bw >= fullW + 24.0f ? juce::String("Deactivate this device") + : juce::String("Deactivate")); + deactivate->setBounds(content.removeFromRight(bw).withSizeKeepingCentre(bw, 32)); + + layoutUpdateBadge(); } private: @@ -1551,16 +1621,66 @@ class DetailsView : public ScreenView } } - void drawActivePill(Graphics& g, Rectangle headerRow) + [[nodiscard]] float activePillWidth() const + { + // Mirrors drawActivePill: leftPad + dot + gap + text + rightPad. + const float tw = juce::GlyphArrangement::getStringWidth(lnf.heading(11.0f), "Active"); + return 11.0f + 6.0f + 7.0f + tw + 12.0f; + } + + // Bounds of the "Active" pill in this view's coordinates (single source of + // truth for both drawActivePill and the double-click escape hatch). + [[nodiscard]] Rectangle activePillBounds() const + { + const auto headerRow = getLocalBounds().removeFromTop(40); + const int pw = (int) std::ceil(activePillWidth()); + return { headerRow.getRight() - headerRightInset - pw, headerRow.getCentreY() - 12, pw, 24 }; + } + + // Right edge of the brand lockup (logo + gap + the wider of the two text lines), + // mirroring the drawBrand(..., logoPx=34, titlePx=15) call in paint(). + [[nodiscard]] int brandRight(Rectangle headerRow) const + { + const auto& cfg = controller.config(); + const float textW = juce::jmax( + juce::GlyphArrangement::getStringWidth(lnf.heading(15.0f), cfg.resolvedProductName()), + juce::GlyphArrangement::getStringWidth(lnf.body(11.5f), cfg.resolvedManufacturerName())); + return headerRow.getX() + 34 + 13 + (int) std::ceil(textW); + } + + // Place the "Update available" badge just left of the "Active" pill. Shown only + // when an update exists AND it fits without overlapping the brand on the left or + // the pill / close button on the right; hidden otherwise (e.g. a narrow window). + void layoutUpdateBadge() + { + if (! controller.updateAvailable()) + { + updateBadge->setVisible(false); + return; + } + + const auto headerRow = getLocalBounds().removeFromTop(40); + const int badgeW = updateBadge->preferredWidth(); + const int activeLeft = headerRow.getRight() - headerRightInset - (int) std::ceil(activePillWidth()); + const int badgeX = activeLeft - 8 - badgeW; + + if (badgeX < brandRight(headerRow) + 12) + { + updateBadge->setVisible(false); // no room: hide rather than overlap + return; + } + + updateBadge->setBounds(badgeX, headerRow.getCentreY() - 12, badgeW, 24); + updateBadge->setVisible(true); + } + + void drawActivePill(Graphics& g) { const juce::String label = "Active"; auto font = lnf.heading(11.0f); const float tw = juce::GlyphArrangement::getStringWidth(font, label); - const float leftPad = 11.0f, dotD = 6.0f, gap = 7.0f, rightPad = 12.0f; - const float pw = leftPad + dotD + gap + tw + rightPad; - auto pb = Rectangle(0, 0, pw, 24.0f) - .withCentre({ (float) (headerRow.getRight() - headerRightInset) - pw * 0.5f, - (float) headerRow.getCentreY() }); + const float leftPad = 11.0f, dotD = 6.0f, gap = 7.0f; + const auto pb = activePillBounds().toFloat(); g.setColour(lnf.palette.successFill); g.fillRoundedRectangle(pb, 12.0f); g.setColour(lnf.palette.successBorder); @@ -1597,6 +1717,334 @@ class DetailsView : public ScreenView } std::unique_ptr deactivate; + std::unique_ptr updateBadge; +}; + +//============================================================================== +// Update available: a valid license, but the backend reports a newer released +// version (the p:rel claim outranks the app version). Shows release notes and an +// in-app installer download with progress; "Remind me later" dismisses it. +class UpdateNotesList : public juce::Component +{ +public: + using Phase = ActivationController::UpdateInfo::Phase; + static constexpr int skeletonStep = 18; // skeleton bar pitch while loading + + UpdateNotesList(ActivationController& c, ActivationLookAndFeel& l) : controller(c), lnf(l) + { + // Intercept mouse events (do not pass them through) so wheel scrolls are + // forwarded to the enclosing Viewport, matching TrialFeatureList. + } + + [[nodiscard]] juce::String notes() const + { + return controller.updateInfo().releaseNotes.trim(); + } + + // Release notes rendered verbatim as plain text (newlines preserved, long + // lines word-wrapped). No bullet/markdown parsing. + [[nodiscard]] juce::AttributedString attributed() const + { + juce::AttributedString as; + as.append(notes(), lnf.body(13.0f), lnf.palette.textBody); + as.setJustification(Justification::topLeft); + return as; + } + + // Height the notes need at a given width, so the viewport can decide whether + // to scroll. Drives UpdateAvailableView::resized(). + [[nodiscard]] int measureHeight(int width) const + { + if (controller.updateInfo().phase == Phase::Loading) + return 3 * skeletonStep; + if (notes().isEmpty()) + return 20; + juce::TextLayout tl; + tl.createLayout(attributed(), (float) juce::jmax(1, width)); + return (int) std::ceil(tl.getHeight()) + 2; + } + + void paint(Graphics& g) override + { + if (controller.updateInfo().phase == Phase::Loading) + { + // Skeleton bars while the notes load. + const float widths[] = { 1.0f, 0.82f, 0.55f }; + for (int i = 0; i < 3; ++i) + { + auto bar = Rectangle(0, i * skeletonStep, + (int) ((float) getWidth() * widths[i]), 11); + g.setColour(Colour(0x10ffffff)); + g.fillRoundedRectangle(bar.toFloat(), 5.5f); + } + return; + } + + if (notes().isEmpty()) + { + g.setColour(lnf.palette.textSecondary); + g.setFont(lnf.body(13.0f)); + g.drawText("No release notes for this version.", getLocalBounds(), + Justification::topLeft); + return; + } + + attributed().draw(g, getLocalBounds().toFloat()); + } + +private: + ActivationController& controller; + ActivationLookAndFeel& lnf; +}; + +class UpdateAvailableView : public ScreenView +{ +public: + using Phase = ActivationController::UpdateInfo::Phase; + + UpdateAvailableView(ActivationController& c, ActivationLookAndFeel& l) : ScreenView(c, l) + { + download = std::make_unique(l, StyledButton::Style::accent, "Download", + icons::fromStroke(icons::downloadTray, + juce::Colours::white, 1.8f)); + download->onClick = [this] + { + if (controller.updateInfo().phase == Phase::Done) + controller.revealUpdateDownload(); + else + controller.startUpdateDownload(); + }; + addAndMakeVisible(*download); + + // Shown instead of Download when this license can't fetch the installer + // (e.g. a trial when the product restricts downloads to owners). + unlock = std::make_unique(l, StyledButton::Style::accent, "Unlock full version", + icons::fromStroke(icons::lock, juce::Colours::white, 1.8f)); + unlock->onClick = [this] { controller.beginOnlineActivation(); }; + addChildComponent(*unlock); + + skip = std::make_unique(l, "Skip this update", l.palette.textSecondary); + skip->onClick = [this] + { + // Record the skip and route the screen back to the license view. If the + // update view was auto-presented (on open / focus) rather than reached + // from the license-view badge, also close the overlay so we just dismiss + // the view instead of revealing the license screen. + const bool fromLicenseView = controller.updateCameFromLicenseView(); + controller.dismissUpdate(); + if (! fromLicenseView && onCloseRequested) + onCloseRequested(); + }; + addAndMakeVisible(*skip); + + notesList = std::make_unique(c, l); + notesViewport.setViewedComponent(notesList.get(), false); + notesViewport.setScrollBarsShown(true, false); + notesViewport.setScrollBarThickness(11); + auto& scrollbar = notesViewport.getVerticalScrollBar(); + scrollbar.setColour(juce::ScrollBar::thumbColourId, Colour(0x80ffffff)); + scrollbar.setColour(juce::ScrollBar::trackColourId, Colour(0x1affffff)); + addAndMakeVisible(notesViewport); + } + + void refresh() override + { + const auto& info = controller.updateInfo(); + const bool reduce = controller.config().reduceMotion; + const bool busy = info.phase == Phase::Loading || info.phase == Phase::Downloading; + + // Once notes have loaded, swap the Download button for an Unlock CTA when + // this license isn't entitled to the installer. + const bool gated = info.phase != Phase::Loading && ! info.canDownload; + download->setVisible(! gated); + unlock->setVisible(gated); + + switch (info.phase) + { + case Phase::Loading: download->setButtonText("Checking for updates"); break; + case Phase::Downloading: download->setButtonText("Downloading"); break; + case Phase::Done: download->setButtonText("Reveal installer"); break; + case Phase::Ready: + default: + download->setButtonText(info.newVersion.isNotEmpty() + ? "Download " + info.newVersion + : juce::String("Download update")); + break; + } + if (reduce) download->setBusyImmediate(busy); + else download->setBusy(busy); + + resized(); // notes height depends on phase (skeleton vs lines) + repaint(); + } + + void visibilityChanged() override + { + if (! isVisible()) + download->setBusy(false); + } + + void paint(Graphics& g) override + { + const auto& cfg = controller.config(); + const auto& info = controller.updateInfo(); + const auto l = computeLayout(); + + drawBrand(g, lnf, cfg.logo.get(), l.header, cfg.resolvedProductName(), + cfg.resolvedManufacturerName(), 34.0f, 15.0f); + + drawUpdatePill(g, l.pill); + + g.setColour(lnf.palette.textPrimary); + g.setFont(lnf.heading(24.0f)); + const auto headingText = (cfg.resolvedProductName() + + (info.newVersion.isNotEmpty() ? " " + info.newVersion : juce::String()) + + " is ready"); + g.drawFittedText(headingText, l.heading, Justification::topLeft, 1); + + // "What's new" card frame + label (the notes viewport sits inside it). + g.setColour(Colour(0x06ffffff)); + g.fillRoundedRectangle(l.card.toFloat(), 12.0f); + g.setColour(lnf.palette.panelBorder); + g.drawRoundedRectangle(l.card.toFloat().reduced(0.5f), 12.0f, 1.0f); + g.setColour(lnf.palette.textSecondary); + g.setFont(lnf.heading(11.0f)); + g.drawText("WHAT'S NEW", l.card.reduced(16, 0).withTop(l.card.getY() + 14).withHeight(14), + Justification::topLeft); + + drawStatusRow(g, l.status, info); + } + + void resized() override + { + const auto l = computeLayout(); + download->setBounds(l.button); + unlock->setBounds(l.button); + skip->setBounds(l.skip); + + const auto area = l.notes; + notesViewport.setBounds(area); + const int fullWidth = area.getWidth(); + if (notesList->measureHeight(fullWidth) > area.getHeight()) + { + // Overflow: leave a gutter for the scrollbar and re-measure at the + // narrower width so wrapping is correct. + const int w = juce::jmax(0, fullWidth - 13); + notesList->setSize(w, notesList->measureHeight(w)); + } + else + { + // Fits: fill the area so the text sits top-aligned, no scrollbar. + notesList->setSize(fullWidth, area.getHeight()); + } + } + +private: + struct Layout { Rectangle header, pill, heading, card, notes, status, button, skip; }; + + [[nodiscard]] Layout computeLayout() const + { + const auto& info = controller.updateInfo(); + // The status row (progress bar / error / access note) only exists while + // downloading, after a failure, or when downloads are gated; collapsing it + // otherwise gives the notes card more room. + const bool gated = ! info.canDownload && info.phase != Phase::Loading; + const bool showStatus = info.phase == Phase::Downloading || info.error.isNotEmpty() || gated; + + auto r = getLocalBounds(); + + Layout l; + l.skip = r.removeFromBottom(20); + r.removeFromBottom(10); + if (showStatus) + { + l.status = r.removeFromBottom(18); + r.removeFromBottom(10); + } + l.button = r.removeFromBottom(46); + r.removeFromBottom(14); + + l.header = r.removeFromTop(40); + r.removeFromTop(14); + l.pill = r.removeFromTop(24); + r.removeFromTop(10); + l.heading = r.removeFromTop(30); + r.removeFromTop(14); + l.card = r; // the notes card takes all remaining height + + auto inner = l.card.reduced(16, 12); + inner.removeFromTop(18); // "WHAT'S NEW" label + gap + l.notes = inner; + return l; + } + + void drawUpdatePill(Graphics& g, Rectangle slot) const + { + const juce::String text = "Update available"; + auto pf = lnf.heading(11.5f); + const float iconSz = 14.0f, lpad = 11.0f, gap = 7.0f, rpad = 12.0f; + const float tw = juce::GlyphArrangement::getStringWidth(pf, text); + const float pw = lpad + iconSz + gap + tw + rpad; + auto pb = Rectangle(0, 0, pw, 24.0f) + .withCentre({ (float) slot.getX() + pw * 0.5f, (float) slot.getCentreY() }); + g.setColour(lnf.accent.withAlpha(0.16f)); + g.fillRoundedRectangle(pb, 12.0f); + g.setColour(lnf.accent.withAlpha(0.40f)); + g.drawRoundedRectangle(pb, 12.0f, 1.0f); + if (auto ic = icons::fromStroke(icons::downloadTray, lnf.accent, 1.9f)) + ic->drawWithin(g, { pb.getX() + lpad, pb.getCentreY() - iconSz * 0.5f, iconSz, iconSz }, + juce::RectanglePlacement::centred, 1.0f); + g.setColour(lnf.palette.textPrimary); + g.setFont(pf); + g.drawText(text, Rectangle(pb.getX() + lpad + iconSz + gap, pb.getY(), tw + 2.0f, + pb.getHeight()), + Justification::centredLeft); + } + + void drawStatusRow(Graphics& g, Rectangle row, const ActivationController::UpdateInfo& info) const + { + if (info.phase == Phase::Downloading) + { + auto pct = row.removeFromRight(44); + row.removeFromRight(10); + auto bar = row.withSizeKeepingCentre(row.getWidth(), 6); + g.setColour(Colour(0x12ffffff)); + g.fillRoundedRectangle(bar.toFloat(), 3.0f); + auto fill = bar.toFloat().withWidth((float) bar.getWidth() + * (float) juce::jlimit(0.0, 1.0, info.progress)); + g.setColour(lnf.accent); + g.fillRoundedRectangle(fill, 3.0f); + g.setColour(lnf.palette.textSecondary); + g.setFont(lnf.body(12.0f)); + g.drawText(juce::String(juce::roundToInt(info.progress * 100.0)) + "%", pct, + Justification::centredRight); + } + else if (info.error.isNotEmpty()) + { + if (auto warn = icons::fromStroke(icons::warning, lnf.palette.error, 1.8f)) + warn->drawWithin(g, row.removeFromLeft(20).toFloat().withSizeKeepingCentre(15, 15), + juce::RectanglePlacement::centred, 1.0f); + g.setColour(lnf.palette.error); + g.setFont(lnf.body(12.5f)); + g.drawFittedText(info.error, row, Justification::centredLeft, 2, 1.0f); + } + else if (! info.canDownload) + { + const auto& lic = controller.license(); + const juce::String note = (lic && lic->trial) + ? "This update is included with a full license." + : juce::String("Sign in to download this update."); + g.setColour(lnf.palette.textSecondary); + g.setFont(lnf.body(12.5f)); + g.drawFittedText(note, row, Justification::centredLeft, 2, 1.0f); + } + } + + juce::Viewport notesViewport; + std::unique_ptr notesList; + std::unique_ptr download; + std::unique_ptr unlock; + std::unique_ptr skip; }; //============================================================================== @@ -1638,6 +2086,7 @@ struct ActivationComponent::Impl : public juce::ChangeListener, trial = std::make_unique(controller, lnf); expired = std::make_unique(controller, lnf); details = std::make_unique(controller, lnf); + updateView = std::make_unique(controller, lnf); for (auto* v : views()) { @@ -1671,11 +2120,21 @@ struct ActivationComponent::Impl : public juce::ChangeListener, controller.removeChangeListener(this); } + // Explicitly present the update screen now (host hook). Routes to the update + // screen as an auto-presentation; the overlay then appears via the change + // callback. No-op when no update is available. + void presentUpdate() + { + if (controller.updateAvailable()) + controller.showUpdate(/*fromLicenseView*/ false); + } + void timerCallback() override { updater.update(); } std::vector views() { - return { welcome.get(), browser.get(), success.get(), offline.get(), trial.get(), expired.get(), details.get() }; + return { welcome.get(), browser.get(), success.get(), offline.get(), trial.get(), + expired.get(), details.get(), updateView.get() }; } ScreenView* viewFor(ActivationController::Screen s) @@ -1687,11 +2146,12 @@ struct ActivationComponent::Impl : public juce::ChangeListener, case S::BrowserWait: return browser.get(); case S::Success: return success.get(); case S::Offline: return offline.get(); - case S::Trial: return trial.get(); - case S::Expired: return expired.get(); - case S::Details: return details.get(); - case S::Error: return welcome.get(); - case S::Loading: default: return nullptr; + case S::Trial: return trial.get(); + case S::Expired: return expired.get(); + case S::Details: return details.get(); + case S::UpdateAvailable: return updateView.get(); + case S::Error: return welcome.get(); + case S::Loading: default: return nullptr; } } @@ -1721,6 +2181,13 @@ struct ActivationComponent::Impl : public juce::ChangeListener, } layoutActive(); startTransition(dir); + + // Auto-present the overlay when the controller routes to the update + // screen on its own (startup / re-validation), so the update shows on + // open. Not when reached from the license-view badge (already visible). + if (screen == ActivationController::Screen::UpdateAvailable + && ! controller.updateCameFromLicenseView()) + appear(); } else if (active != nullptr) { @@ -1825,6 +2292,12 @@ struct ActivationComponent::Impl : public juce::ChangeListener, void dismiss() { + // Closing the overlay while the update screen is up returns the resting + // screen to the license view, so re-opening (e.g. the host's License + // button) never lands back on the update screen. + if (controller.screen() == ActivationController::Screen::UpdateAvailable) + controller.showDetails(); + if (controller.config().reduceMotion || ! appearAnim) { owner.setVisible(false); @@ -1867,7 +2340,8 @@ struct ActivationComponent::Impl : public juce::ChangeListener, { case S::Loading: case S::Welcome: case S::Error: return 0; case S::Offline: case S::BrowserWait: return 1; - case S::Trial: case S::Success: case S::Details: case S::Expired: return 2; + case S::Trial: case S::Success: case S::Details: case S::Expired: + case S::UpdateAvailable: return 2; } return 0; } @@ -1996,6 +2470,11 @@ struct ActivationComponent::Impl : public juce::ChangeListener, const int inset = canClose ? (closeSize + 10) : 0; for (auto* v : views()) v->headerRightInset = inset; + + // The inset just changed; re-lay the active view so any inset-dependent + // header content (e.g. the Details update badge) repositions to match. + if (active != nullptr) + active->resized(); } void layoutActive() @@ -2070,6 +2549,7 @@ struct ActivationComponent::Impl : public juce::ChangeListener, std::unique_ptr trial; std::unique_ptr expired; std::unique_ptr details; + std::unique_ptr updateView; ScreenView* active = nullptr; ScreenView* outgoing = nullptr; ActivationController::Screen lastScreen = ActivationController::Screen::Loading; @@ -2111,6 +2591,8 @@ ActivationController& ActivationComponent::controller() { return impl->controlle void ActivationComponent::appear() { impl->appear(); } +void ActivationComponent::presentUpdateIfAvailable() { impl->presentUpdate(); } + void ActivationComponent::dismiss() { impl->dismiss(); } void ActivationComponent::paint(Graphics& g) { impl->paintChrome(g); } diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.h b/modules/moonbase_licensing/juce/ui/ActivationComponent.h index c79878c..387b68c 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.h +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.h @@ -51,6 +51,11 @@ class ActivationComponent : public juce::Component void appear(); void dismiss(); + // Present the update screen now if an update is available (ignores a previous + // skip). The module already auto-presents on open when config.autoPresentUpdate + // is set; this is exposed so a host can trigger it explicitly too. No-op otherwise. + void presentUpdateIfAvailable(); + // Preferred window size for this design. static constexpr int defaultWidth = 660; static constexpr int defaultHeight = 600; diff --git a/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.cpp b/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.cpp index e67fd60..c341203 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.cpp +++ b/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.cpp @@ -89,5 +89,8 @@ const char* const monitor = "m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"; const char* const check = "M4.5 12.75l6 6 9-13.5"; const char* const cross = "M6 18 18 6M6 6l12 12"; +const char* const downloadTray = + "M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12" + "m4.5 4.5V3"; } // namespace moonbase::juce_integration::icons diff --git a/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.h b/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.h index 1f0856e..9322fd2 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.h +++ b/modules/moonbase_licensing/juce/ui/ActivationLookAndFeel.h @@ -108,6 +108,7 @@ extern const char* const externalLink; extern const char* const monitor; extern const char* const check; extern const char* const cross; +extern const char* const downloadTray; // arrow into a tray (update / download) } // namespace icons diff --git a/modules/moonbase_licensing/moonbase/inventory.hpp b/modules/moonbase_licensing/moonbase/inventory.hpp new file mode 100644 index 0000000..e65a282 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/inventory.hpp @@ -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 " scheme. Mirrors license_client and +// reuses its request helpers (detail::append_query / default_headers / +// throw_for_problem). + +#include +#include +#include +#include +#include +#include + +#include + +#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((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 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 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=: 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 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(); + } else if (json.contains("currentVersion") && json.at("currentVersion").is_string()) { + info.version = json.at("currentVersion").get(); + } + if (json.contains("releaseDescription") && json.at("releaseDescription").is_string()) { + info.description = json.at("releaseDescription").get(); + } + 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(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(); + target.filename = detail::filename_from_download_url(target.url); + return target; + } catch (const std::exception& ex) { + throw api_error(static_cast(response.status_code), + std::string("Could not parse download response: ") + ex.what()); + } + } + +private: + licensing_options options_; + std::shared_ptr transport_; +}; + +} // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/moonbase.hpp b/modules/moonbase_licensing/moonbase/moonbase.hpp index 54999f7..f46a6c4 100644 --- a/modules/moonbase_licensing/moonbase/moonbase.hpp +++ b/modules/moonbase_licensing/moonbase/moonbase.hpp @@ -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" diff --git a/modules/moonbase_licensing/moonbase/version.hpp b/modules/moonbase_licensing/moonbase/version.hpp new file mode 100644 index 0000000..ef17650 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/version.hpp @@ -0,0 +1,179 @@ +#pragma once + +// Minimal Semantic Versioning compare, just enough to decide whether the +// backend's "currently released version" (the license's p:rel claim) is newer +// than the running app's version. Header-only and dependency-free so it can be +// unit-tested in isolation. + +#include +#include +#include +#include +#include + +namespace moonbase { + +struct semver { + long long major = 0; + long long minor = 0; + long long patch = 0; + std::string prerelease; // dot-joined identifiers; empty for a normal release +}; + +namespace detail { + +// Whole-string unsigned integer (no sign, no overflow guard beyond long long; +// version numbers are tiny). Returns nullopt for an empty / non-digit run. +inline std::optional parse_uint(std::string_view text) +{ + if (text.empty()) { + return std::nullopt; + } + long long value = 0; + for (const char ch : text) { + if (!std::isdigit(static_cast(ch))) { + return std::nullopt; + } + value = value * 10 + (ch - '0'); + } + return value; +} + +inline std::vector split(std::string_view text, char delim) +{ + std::vector parts; + std::size_t start = 0; + while (true) { + const auto pos = text.find(delim, start); + if (pos == std::string_view::npos) { + parts.push_back(text.substr(start)); + break; + } + parts.push_back(text.substr(start, pos - start)); + start = pos + 1; + } + return parts; +} + +} // namespace detail + +// Tolerant parse: accepts a leading "v"/"V", a missing minor/patch ("2.4" -> +// 2.4.0), a "-prerelease" suffix and a "+build" suffix (build metadata is +// ignored per SemVer). Returns nullopt when the numeric core is malformed. +inline std::optional parse_semver(std::string_view input) +{ + std::size_t i = 0; + while (i < input.size() && std::isspace(static_cast(input[i]))) { + ++i; + } + std::size_t end = input.size(); + while (end > i && std::isspace(static_cast(input[end - 1]))) { + --end; + } + auto text = input.substr(i, end - i); + if (!text.empty() && (text.front() == 'v' || text.front() == 'V')) { + text.remove_prefix(1); + } + if (text.empty()) { + return std::nullopt; + } + + // Strip build metadata first (+...), then split the prerelease (-...). + if (const auto plus = text.find('+'); plus != std::string_view::npos) { + text = text.substr(0, plus); + } + std::string_view core = text; + std::string_view pre; + if (const auto dash = text.find('-'); dash != std::string_view::npos) { + core = text.substr(0, dash); + pre = text.substr(dash + 1); + } + + const auto parts = detail::split(core, '.'); + if (parts.empty() || parts.size() > 3) { + return std::nullopt; + } + + semver result; + const auto major = detail::parse_uint(parts[0]); + if (!major) { + return std::nullopt; + } + result.major = *major; + if (parts.size() > 1) { + const auto minor = detail::parse_uint(parts[1]); + if (!minor) { + return std::nullopt; + } + result.minor = *minor; + } + if (parts.size() > 2) { + const auto patch = detail::parse_uint(parts[2]); + if (!patch) { + return std::nullopt; + } + result.patch = *patch; + } + result.prerelease = std::string(pre); + return result; +} + +// SemVer §11 precedence: -1 if a < b, 0 if equal, 1 if a > b. +inline int compare(const semver& a, const semver& b) +{ + if (a.major != b.major) { + return a.major < b.major ? -1 : 1; + } + if (a.minor != b.minor) { + return a.minor < b.minor ? -1 : 1; + } + if (a.patch != b.patch) { + return a.patch < b.patch ? -1 : 1; + } + + // A release outranks any prerelease of the same core version. + if (a.prerelease.empty() != b.prerelease.empty()) { + return a.prerelease.empty() ? 1 : -1; + } + if (a.prerelease.empty()) { + return 0; + } + + const auto lhs = detail::split(a.prerelease, '.'); + const auto rhs = detail::split(b.prerelease, '.'); + const auto count = lhs.size() < rhs.size() ? lhs.size() : rhs.size(); + for (std::size_t i = 0; i < count; ++i) { + const auto ln = detail::parse_uint(lhs[i]); + const auto rn = detail::parse_uint(rhs[i]); + if (ln && rn) { + if (*ln != *rn) { + return *ln < *rn ? -1 : 1; + } + } else if (ln) { + return -1; // numeric identifiers have lower precedence than alphanumeric + } else if (rn) { + return 1; + } else if (lhs[i] != rhs[i]) { + return lhs[i] < rhs[i] ? -1 : 1; + } + } + if (lhs.size() != rhs.size()) { + return lhs.size() < rhs.size() ? -1 : 1; // more identifiers wins + } + return 0; +} + +// True when `latest` is a strictly newer version than `current`. Returns false +// when either string fails to parse, so a malformed value never prompts an +// update. +inline bool update_available(std::string_view current, std::string_view latest) +{ + const auto a = parse_semver(current); + const auto b = parse_semver(latest); + if (!a || !b) { + return false; + } + return compare(*b, *a) > 0; +} + +} // namespace moonbase diff --git a/tests/inventory_tests.cpp b/tests/inventory_tests.cpp new file mode 100644 index 0000000..d4cd3b4 --- /dev/null +++ b/tests/inventory_tests.cpp @@ -0,0 +1,154 @@ +#include + +#include +#include +#include +#include + +#include "moonbase/errors.hpp" +#include "moonbase/inventory.hpp" + +#include "test_helpers.hpp" + +using namespace moonbase; + +namespace { + +licensing_options make_options() +{ + licensing_options options; + options.endpoint = "https://demo.moonbase.sh/"; + options.product_id = "demo-app"; + options.target_platform = platform::mac; + options.application_version = "2.3.1"; + options.client_info = "moonbase-juce/9.9 (JUCE v8; TestOS)"; + options.http_connect_timeout = std::chrono::milliseconds{1234}; + options.http_request_timeout = std::chrono::milliseconds{5678}; + return options; +} + +} // namespace + +TEST_CASE("get_release queries the product endpoint with the license token") +{ + auto transport = std::make_shared(std::deque{ + http_response{200, {}, + R"({"id":"demo-app","name":"Solstice","version":"2.4.0",)" + R"("releaseDescription":"Line one\nLine two",)" + R"("downloads":[{"name":"Solstice-2.4.0.dmg","platform":"Mac"}]})"}, + }); + inventory_client client(make_options(), transport); + + const auto info = client.get_release("2.4.0", "the-token"); + CHECK(info.version == "2.4.0"); + CHECK(info.description == "Line one\nLine two"); + CHECK(info.has_downloads); + + REQUIRE(transport->requests.size() == 1); + const auto& request = transport->requests.front(); + CHECK(request.method == "GET"); + CHECK(request.url.find("https://demo.moonbase.sh/api/customer/inventory/products/demo-app?") == 0); + CHECK(request.url.find("version=2.4.0") != std::string::npos); + CHECK(request.url.find("includeManifests=false") != std::string::npos); + CHECK(request.headers.at("Authorization") == "LicenseToken the-token"); + CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); + CHECK(request.connect_timeout == std::chrono::milliseconds{1234}); + CHECK(request.request_timeout == std::chrono::milliseconds{5678}); +} + +TEST_CASE("get_release parses the download access-control flags") +{ + auto transport = std::make_shared(std::deque{ + http_response{200, {}, + R"({"id":"demo-app","name":"Solstice","version":"2.4.0",)" + R"("downloadsNeedsUser":true,"downloadsNeedsOwnership":true,)" + R"("downloads":[{"name":"Solstice-2.4.0.dmg","platform":"Mac"}]})"}, + }); + inventory_client client(make_options(), transport); + + const auto info = client.get_release("2.4.0", "tok"); + CHECK(info.downloads_need_user); + CHECK(info.downloads_need_ownership); +} + +TEST_CASE("can_download enforces the access-control flags against the license") +{ + const auto make_license = [](bool trial, std::string userId) { + license lic; + lic.trial = trial; + lic.issued_to.id = std::move(userId); + return lic; + }; + const auto full = make_license(false, "user-1"); + const auto trial_owned = make_license(true, "user-1"); + const auto trial_anon = make_license(true, "00000000-0000-0000-0000-000000000000"); + + release_info anon; // AllowAnonymous + release_info customers; customers.downloads_need_user = true; // AllowCustomers + release_info owners; owners.downloads_need_user = true; // AllowOwners + owners.downloads_need_ownership = true; + + // Anonymous access: everyone can download. + CHECK(can_download(full, anon)); + CHECK(can_download(trial_owned, anon)); + CHECK(can_download(trial_anon, anon)); + + // Needs a user: a full license and an owned trial pass; an anonymous trial doesn't. + CHECK(can_download(full, customers)); + CHECK(can_download(trial_owned, customers)); + CHECK_FALSE(can_download(trial_anon, customers)); + + // Needs ownership: only the full (non-trial) license passes. + CHECK(can_download(full, owners)); + CHECK_FALSE(can_download(trial_owned, owners)); + CHECK_FALSE(can_download(trial_anon, owners)); +} + +TEST_CASE("get_release tolerates a release with no notes or downloads") +{ + auto transport = std::make_shared(std::deque{ + http_response{200, {}, R"({"id":"demo-app","name":"Solstice","currentVersion":"2.4.0"})"}, + }); + inventory_client client(make_options(), transport); + + const auto info = client.get_release("", "tok"); + CHECK(info.version == "2.4.0"); // falls back to currentVersion + CHECK(info.description.empty()); + CHECK_FALSE(info.has_downloads); + + // An empty version omits the query param. + CHECK(transport->requests.front().url.find("version=") == std::string::npos); +} + +TEST_CASE("get_download_url resolves the latest installer to a presigned URL + filename") +{ + auto transport = std::make_shared(std::deque{ + http_response{200, {}, + R"({"location":"https://s3.example.com/bucket/key?X-Amz-Signature=abc)" + R"(&response-content-disposition=attachment%3B%20filename%3D%22Solstice-2.4.0.dmg%22"})"}, + }); + inventory_client client(make_options(), transport); + + const auto target = client.get_download_url("Mac", "the-token"); + CHECK(target.url.find("s3.example.com") != std::string::npos); + CHECK(target.filename == "Solstice-2.4.0.dmg"); + + REQUIRE(transport->requests.size() == 1); + const auto& request = transport->requests.front(); + CHECK(request.method == "GET"); + CHECK(request.url.find( + "https://demo.moonbase.sh/api/customer/inventory/products/demo-app/download/Mac/latest?") + == 0); + CHECK(request.url.find("redirect=false") != std::string::npos); + CHECK(request.headers.at("Authorization") == "LicenseToken the-token"); +} + +TEST_CASE("get_download_url throws when there is no installer for the platform") +{ + auto transport = std::make_shared(std::deque{ + http_response{404, {}, R"({"title":"No download for the given platform found"})"}, + }); + inventory_client client(make_options(), transport); + + CHECK_THROWS_AS(std::ignore = client.get_download_url("Linux", "tok"), moonbase::api_error); +} diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 30fce68..fa74cd7 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -74,6 +74,8 @@ struct controller_fixture config.accountId = "tenant-1"; config.productName = "Solstice"; config.manufacturerName = "Helio Audio"; + config.licenseFile = licenseFile; // keep sibling state (e.g. the .state.json file) + // in the temp dir, not real app data } ~controller_fixture() @@ -253,6 +255,247 @@ TEST_CASE("showDetails on a trial stays on the trial view, not Details") CHECK(controller.screen() == Screen::Trial); } +//============================================================================== +// App update flow (newer released version than the running app) +//============================================================================== +TEST_CASE("a license whose released version outranks the app routes to UpdateAvailable") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; // older than default_claims p:rel (1.2.3) + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::UpdateAvailable); + CHECK(controller.updateAvailable()); + CHECK(controller.updateInfo().newVersion == "1.2.3"); + CHECK(controller.updateInfo().currentVersion == "1.2.2"); + REQUIRE(controller.license().has_value()); // still licensed: gating stays on + + // "Skip this update" records the skip and routes back to Details. + controller.dismissUpdate(); + CHECK(controller.screen() == Screen::Details); + controller.showDetails(); + CHECK(controller.screen() == Screen::Details); +} + +TEST_CASE("an up-to-date app routes straight to Details") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.3"; // equal to the released version + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); + CHECK_FALSE(controller.updateAvailable()); +} + +TEST_CASE("enableUpdatePrompt=false never shows the update screen") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.0.0"; // far behind, but updates are disabled + fx.config.enableUpdatePrompt = false; + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); + CHECK_FALSE(controller.updateAvailable()); +} + +TEST_CASE("setPreviewUpdate forces the update screen with synthetic state") +{ + controller_fixture fx; + fx.config.applicationVersion = "2.3.1"; + ActivationController controller(fx.config, fx.makeLicensing()); + + auto claims = default_claims(); + claims["p:rel"] = "2.4.0"; + auto lic = fx.makeLicensing()->validate_token_local_allow_expired(fx.token(claims)); + + controller.setPreviewUpdate(ActivationController::UpdateInfo::Phase::Ready, lic, "What's new"); + CHECK(controller.screen() == Screen::UpdateAvailable); + CHECK(controller.updateInfo().newVersion == "2.4.0"); + CHECK(controller.updateInfo().currentVersion == "2.3.1"); + CHECK(controller.updateInfo().releaseNotes == "What's new"); +} + +TEST_CASE("dismissing the update is remembered across restarts") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; // older than p:rel (1.2.3) + fx.seedStored(fx.token(default_claims())); + + { + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + REQUIRE(controller.screen() == Screen::UpdateAvailable); + controller.dismissUpdate(); + CHECK(controller.screen() == Screen::Details); + } + + // A fresh controller (same config + license file) must not prompt again: the + // dismissal was persisted next to the license. + ActivationController restarted(fx.config, fx.makeLicensing()); + restarted.start(); + REQUIRE(pumpUntil([&] { return settled(restarted); })); + CHECK(restarted.screen() == Screen::Details); + CHECK(restarted.updateAvailable()); // an update still exists... +} + +TEST_CASE("a newer release re-prompts after an earlier dismissal") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; + + fx.seedStored(fx.token(default_claims())); // p:rel 1.2.3 + { + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + REQUIRE(controller.screen() == Screen::UpdateAvailable); + controller.dismissUpdate(); // remembers 1.2.3 + } + + // The product now ships an even newer release. + auto newer = default_claims(); + newer["p:rel"] = "1.3.0"; + fx.seedStored(fx.token(newer)); + + ActivationController restarted(fx.config, fx.makeLicensing()); + restarted.start(); + REQUIRE(pumpUntil([&] { return settled(restarted); })); + CHECK(restarted.screen() == Screen::UpdateAvailable); // newer than the dismissed one + CHECK(restarted.updateInfo().newVersion == "1.3.0"); +} + +TEST_CASE("showUpdate re-opens the update screen after a dismissal") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; // older than p:rel (1.2.3) + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + REQUIRE(controller.screen() == Screen::UpdateAvailable); + + controller.dismissUpdate(); + REQUIRE(controller.screen() == Screen::Details); + CHECK(controller.updateAvailable()); // still available, just dismissed + + controller.showUpdate(); // e.g. the "Update available" badge in the license view + CHECK(controller.screen() == Screen::UpdateAvailable); +} + +TEST_CASE("showUpdate is a no-op when the app is up to date") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.3"; // equal to p:rel -> no update + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + controller.showUpdate(); + CHECK(controller.screen() == Screen::Details); // nothing to show +} + +TEST_CASE("autoPresentUpdate=false keeps startup on the license view") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; // older than p:rel (1.2.3): update available + fx.config.autoPresentUpdate = false; + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); // no automatic pop on open + CHECK(controller.updateAvailable()); // ...even though an update exists + + controller.showUpdate(/*fromLicenseView*/ true); // the badge still opens it + CHECK(controller.screen() == Screen::UpdateAvailable); +} + +TEST_CASE("the update screen tracks whether it was opened from the license view") +{ + controller_fixture fx; + fx.config.applicationVersion = "1.2.2"; // older than p:rel (1.2.3) + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + REQUIRE(controller.screen() == Screen::UpdateAvailable); + CHECK_FALSE(controller.updateCameFromLicenseView()); // auto-routed on startup + + controller.dismissUpdate(); + REQUIRE(controller.screen() == Screen::Details); + + controller.showUpdate(/*fromLicenseView*/ true); // e.g. the badge + REQUIRE(controller.screen() == Screen::UpdateAvailable); + CHECK(controller.updateCameFromLicenseView()); + + controller.showUpdate(/*fromLicenseView*/ false); // e.g. auto open/focus + CHECK_FALSE(controller.updateCameFromLicenseView()); +} + +TEST_CASE("revealing a missing installer reverts to the download state") +{ + controller_fixture fx; + fx.config.applicationVersion = "2.3.1"; + ActivationController controller(fx.config, fx.makeLicensing()); + + auto claims = default_claims(); + claims["p:rel"] = "2.4.0"; + auto lic = fx.makeLicensing()->validate_token_local_allow_expired(fx.token(claims)); + + using Phase = ActivationController::UpdateInfo::Phase; + controller.setPreviewUpdate(Phase::Done, lic); // no installer actually on disk + REQUIRE(controller.updateInfo().phase == Phase::Done); + + controller.revealUpdateDownload(); // file is gone -> fall back to Ready + CHECK(controller.updateInfo().phase == Phase::Ready); + CHECK(controller.updateInfo().progress == doctest::Approx(0.0)); +} + +TEST_CASE("ActivationState stores ignored updates as JSON and preserves unknown keys") +{ + auto file = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("moonbase-state-" + juce::Uuid().toString() + ".json"); + file.deleteFile(); + file.replaceWithText(R"({"futureKey":"keep-me"})"); // written by a "newer" build + + { + ActivationState state(file); + CHECK_FALSE(state.isUpdateIgnored("1.0.0")); + state.ignoreUpdate("1.0.0"); + state.ignoreUpdate("1.0.0"); // idempotent + state.ignoreUpdate("2.0.0"); + CHECK(state.isUpdateIgnored("1.0.0")); + CHECK(state.ignoredUpdates().size() == 2); + CHECK(state.get("futureKey").toString() == "keep-me"); // untouched + } + + // Reload from disk: entries survived and the unknown key was not clobbered. + ActivationState reloaded(file); + CHECK(reloaded.isUpdateIgnored("1.0.0")); + CHECK(reloaded.isUpdateIgnored("2.0.0")); + CHECK(reloaded.get("futureKey").toString() == "keep-me"); + + file.deleteFile(); +} + TEST_CASE("start() with a valid offline license routes to Details") { controller_fixture fx; diff --git a/tests/version_tests.cpp b/tests/version_tests.cpp new file mode 100644 index 0000000..0e28af6 --- /dev/null +++ b/tests/version_tests.cpp @@ -0,0 +1,79 @@ +#include + +#include "moonbase/version.hpp" + +using namespace moonbase; + +TEST_CASE("parse_semver handles common forms") +{ + auto full = parse_semver("2.4.0"); + REQUIRE(full.has_value()); + CHECK(full->major == 2); + CHECK(full->minor == 4); + CHECK(full->patch == 0); + CHECK(full->prerelease.empty()); + + auto leading = parse_semver("v1.2.3"); + REQUIRE(leading.has_value()); + CHECK(leading->major == 1); + CHECK(leading->minor == 2); + CHECK(leading->patch == 3); + + auto twoPart = parse_semver("2.4"); + REQUIRE(twoPart.has_value()); + CHECK(twoPart->minor == 4); + CHECK(twoPart->patch == 0); + + auto onePart = parse_semver("3"); + REQUIRE(onePart.has_value()); + CHECK(onePart->major == 3); + CHECK(onePart->minor == 0); + + auto pre = parse_semver("2.4.0-beta.1"); + REQUIRE(pre.has_value()); + CHECK(pre->prerelease == "beta.1"); + + auto build = parse_semver("2.4.0+build.7"); + REQUIRE(build.has_value()); + CHECK(build->prerelease.empty()); // build metadata is dropped + + CHECK_FALSE(parse_semver("").has_value()); + CHECK_FALSE(parse_semver("abc").has_value()); + CHECK_FALSE(parse_semver("1.x.0").has_value()); + CHECK_FALSE(parse_semver("1.2.3.4").has_value()); // too many numeric parts +} + +TEST_CASE("compare orders versions per SemVer precedence") +{ + CHECK(compare(*parse_semver("2.4.0"), *parse_semver("2.3.1")) > 0); + CHECK(compare(*parse_semver("2.3.1"), *parse_semver("2.4.0")) < 0); + CHECK(compare(*parse_semver("1.0.0"), *parse_semver("1.0.0")) == 0); + CHECK(compare(*parse_semver("2.4"), *parse_semver("2.4.0")) == 0); + CHECK(compare(*parse_semver("1.10.0"), *parse_semver("1.9.0")) > 0); // numeric, not lexical + + // A prerelease has lower precedence than the matching release. + CHECK(compare(*parse_semver("1.0.0-rc.1"), *parse_semver("1.0.0")) < 0); + CHECK(compare(*parse_semver("1.0.0-alpha"), *parse_semver("1.0.0-beta")) < 0); + CHECK(compare(*parse_semver("1.0.0-alpha.1"), *parse_semver("1.0.0-alpha")) > 0); + CHECK(compare(*parse_semver("1.0.0-1"), *parse_semver("1.0.0-alpha")) < 0); // numeric < alnum id +} + +TEST_CASE("update_available is true only for a strictly newer, parseable version") +{ + CHECK(update_available("2.3.1", "2.4.0")); + CHECK(update_available("2.3.1", "3.0.0")); + CHECK(update_available("1.9.0", "1.10.0")); + + CHECK_FALSE(update_available("2.4.0", "2.4.0")); + CHECK_FALSE(update_available("2.4.0", "2.3.9")); + + // Never prompt when either side is unparseable. + CHECK_FALSE(update_available("2.4.0", "")); + CHECK_FALSE(update_available("", "2.4.0")); + CHECK_FALSE(update_available("garbage", "2.4.0")); + + // A prerelease of the next version is still newer than the current release... + CHECK(update_available("2.3.1", "2.4.0-beta.1")); + // ...but a prerelease of the SAME version is not an update over the release. + CHECK_FALSE(update_available("2.4.0", "2.4.0-beta.1")); +} diff --git a/tests/visual/README.md b/tests/visual/README.md index 4d431fc..3486912 100644 --- a/tests/visual/README.md +++ b/tests/visual/README.md @@ -34,8 +34,14 @@ PNG per state: | `07-details` | Valid perpetual license (details, seats, deactivate) | | `07b-details-subscription` | Subscription license with an expiry date | | `07c-details-error` | Details with a deactivate failure (error) | +| `07d-details-update-available` | Details with a clickable "Update available" badge | | `08-details-offline` | Offline-activated (permanent) license | | `09-details-deactivating` | Deactivate in progress (inline spinner) | +| `10-update-loading` | Update available, fetching release notes (skeleton) | +| `11-update-ready` | Update available, notes loaded (download button) | +| `12-update-downloading` | Update installer downloading (progress bar) | +| `13-update-error` | Update details failed to load (error) | +| `14-update-gated` | Update the license can't download (Unlock CTA) | Add a state by adding a `writeSnapshot(...)` call. diff --git a/tests/visual/snapshot_main.cpp b/tests/visual/snapshot_main.cpp index e50112b..47337ba 100644 --- a/tests/visual/snapshot_main.cpp +++ b/tests/visual/snapshot_main.cpp @@ -16,6 +16,7 @@ using namespace moonbase::juce_integration; using Screen = ActivationController::Screen; +using UpdatePhase = ActivationController::UpdateInfo::Phase; namespace { @@ -119,6 +120,24 @@ moonbase::license makeSubscriptionLicense() return lic; } +// A full license whose product has a newer released version (2.4.0) than the +// app version the update snapshots run with (2.3.1), so the update screen shows. +moonbase::license makeUpdateLicense() +{ + auto lic = makeLicense(false); + lic.licensed_product.current_release_version = "2.4.0"; + return lic; +} + +// A trial with the same available update; used to render the download-gated state +// (a trial can't download installers from an owners-only product). +moonbase::license makeUpdateTrialLicense() +{ + auto lic = makeLicense(true); + lic.licensed_product.current_release_version = "2.4.0"; + return lic; +} + int gSnapW = ActivationComponent::defaultWidth; int gSnapH = ActivationComponent::defaultHeight; @@ -285,6 +304,68 @@ int main(int argc, char* argv[]) writeSnapshot(outDir, "09-details-deactivating", [](ActivationController& c) { c.setPreviewState(Screen::Details, makeLicense(false), {}, /*busy*/ true); }); + { + // Details with the "Update available" badge: an update exists but was + // dismissed, so we stay on Details and offer the badge to re-open it. + auto cfg = demoConfig(); + cfg.applicationVersion = "2.3.1"; // older than the license's released version + writeSnapshot(outDir, "07d-details-update-available", + [](ActivationController& c) + { c.setPreviewState(Screen::Details, makeUpdateLicense()); }, + cfg); + } + + { + // The update screen runs with an app version (2.3.1) older than the + // license's released version (2.4.0); release notes are plain text. + auto cfg = demoConfig(); + cfg.applicationVersion = "2.3.1"; + // Plain-text release notes, long enough to overflow the card so the + // snapshot exercises the scrollable area + scrollbar. + const juce::String notes = + "Version 2.4.0\n" + "\n" + "New oversampling modes up to 16x for cleaner high-gain tones.\n" + "8 new factory presets from the Laniakea sound pack.\n" + "Reworked the modulation matrix with per-slot depth control.\n" + "Fixes Apple Silicon AU validation under Logic 11.\n" + "Fixes a rare crash when loading presets saved in the 1.x series.\n" + "Lower CPU usage in the analyzer view.\n" + "Retina-correct metering on mixed-DPI multi-monitor setups.\n" + "New A/B compare with copy-from-B and snapshot slots.\n" + "MIDI learn now supports relative encoders.\n" + "Improved oversampling latency reporting to the host.\n" + "Various accessibility and keyboard-navigation improvements."; + + writeSnapshot(outDir, "10-update-loading", + [](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Loading, makeUpdateLicense()); }, + cfg); + + writeSnapshot(outDir, "11-update-ready", + [notes](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Ready, makeUpdateLicense(), notes); }, + cfg); + + writeSnapshot(outDir, "12-update-downloading", + [notes](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Downloading, makeUpdateLicense(), notes, 0.42); }, + cfg); + + writeSnapshot(outDir, "13-update-error", + [](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Ready, makeUpdateLicense(), {}, 0.0, + "Couldn't load the release details. " + "Check your connection and try again."); }, + cfg); + + writeSnapshot(outDir, "14-update-gated", + [notes](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Ready, makeUpdateTrialLicense(), notes, 0.0, + {}, /*canDownload*/ false); }, + cfg); + } + juce::Logger::outputDebugString("UI snapshots written to " + outDir.getFullPathName()); return 0; }