From da108c0f5b722d19a77aa95ca1d7e5693b1019a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Wed, 28 Oct 2026 22:43:18 +0100 Subject: [PATCH 1/2] fix: let Moonbase decide whether a release can be downloaded The update screen judged download permission on the device from two of Moonbase's rules: owning the product and being a signed-in customer. A release limited to a group of customers, or to none, was still offered, and Moonbase then refused the download. The release endpoint already says whether this customer may download the release (downloadsAllowed). release_info now reads it and can_download uses it, keeping the device's rules only for a Moonbase that doesn't send it. download_needs_full_license says whether activating a full license would lift a refusal, and the JUCE update screen offers "Unlock full version" only then; otherwise it says the license can't download the update, and the notes take the empty button slot. --- docs/core-sdk.md | 8 +- docs/juce-module.md | 24 ++++-- include/moonbase/inventory.hpp | 66 +++++++++++---- .../juce/ActivationController.cpp | 20 +++-- .../juce/ActivationController.h | 17 ++-- .../juce/ui/ActivationComponent.cpp | 28 ++++--- .../moonbase_licensing/moonbase/inventory.hpp | 66 +++++++++++---- tests/inventory_tests.cpp | 83 +++++++++++++++++++ tests/juce/controller_tests.cpp | 27 ++++++ tests/visual/README.md | 3 +- tests/visual/snapshot_main.cpp | 8 ++ 11 files changed, 285 insertions(+), 65 deletions(-) diff --git a/docs/core-sdk.md b/docs/core-sdk.md index df2e7f4..68cdaf4 100644 --- a/docs/core-sdk.md +++ b/docs/core-sdk.md @@ -276,9 +276,11 @@ if (!moonbase::covers_version(license, latest_version)) { other Moonbase SDK. The vendored [`release-access-vectors.json`](../tests/vectors/release-access-vectors.json) pins it down case by case. For downloads, `inventory_client::get_release` says whether the -customer's licenses cover a release (`release_covered`, with `release_access` and -`upgrade_url`), `can_download` takes that into account, and `get_download_url` throws -`release_not_covered_error` for a release they don't cover. `revoke_activation` and +customer may download a release (`downloads_allowed`, which an older Moonbase doesn't +send) and whether their licenses cover it (`release_covered`, with `release_access` and +`upgrade_url`). `can_download` takes both into account, `download_needs_full_license` +says whether activating a full license would lift a refusal, and `get_download_url` +throws `release_not_covered_error` for a release they don't cover. `revoke_activation` and the add-on calls never judge the version, so a license can always be deactivated and its add-ons managed. diff --git a/docs/juce-module.md b/docs/juce-module.md index b1c14de..57b1e92 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -693,15 +693,21 @@ Details / Trial screen (it never interrupts the locked Expired screen). It then: `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`). When Moonbase says the customer's -licenses don't cover the new version, the screen offers the upgrade instead and says what -they cover (`updateInfo().covered`, `releaseAccess` and `upgradeUrl`; see -[Licenses that cover some versions](#licenses-that-cover-some-versions)). +**Download gating.** The product response says whether this customer may download the +release (`downloadsAllowed`), which Moonbase decides with every rule the release has: +owning the product, belonging to a group of customers it is limited to, and the versions +their licenses cover. `moonbase::can_download(license, info)` uses that answer. An older +Moonbase doesn't send it, so there it applies the release access-control level +(`downloadsNeedsUser` / `downloadsNeedsOwnership`) itself: 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 can't download it, the screen swaps the Download button for a +short note, so the user never hits a 403 (`updateInfo().canDownload`). When a full license +is what's missing, as for a trial, it offers an **Unlock full version** CTA +(`updateInfo().needsFullLicense`); for a release Moonbase keeps from this customer for +another reason, it says the license can't download it and offers nothing. When Moonbase +says the customer's licenses don't cover the new version, the screen offers the upgrade +instead and says what they cover (`updateInfo().covered`, `releaseAccess` and +`upgradeUrl`; see [Licenses that cover some versions](#licenses-that-cover-some-versions)). Both inventory requests authenticate with the license token via the `Authorization: LicenseToken ` scheme (`moonbase::inventory_client`). Observe diff --git a/include/moonbase/inventory.hpp b/include/moonbase/inventory.hpp index 7a06961..2d210e7 100644 --- a/include/moonbase/inventory.hpp +++ b/include/moonbase/inventory.hpp @@ -25,14 +25,19 @@ 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). +// The plain-text release notes plus enough to know an installer exists and +// whether this customer may download it. 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 + // Whether the customer the license token belongs to may download this + // release, as Moonbase decides it with every rule the release has + // (ownership, customer groups, the versions their licenses cover). Empty + // from an older Moonbase that doesn't send it. + std::optional downloads_allowed; // Whether the customer's licenses cover this release. False only when // Moonbase says they don't, for a license that covers only some versions; @@ -53,28 +58,55 @@ struct download_target { 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; and an owner gets only a release their licenses cover. +namespace detail { + +// An authenticated customer has a real user id, while an anonymous trial +// carries the nil GUID. +inline bool has_signed_in_customer(const license& lic) +{ + return !lic.issued_to.id.empty() && lic.issued_to.id != "00000000-0000-0000-0000-000000000000"; +} + +} // namespace detail + +// Whether `license` may download the installer of a release (from +// get_release). Moonbase says so itself (downloads_allowed). Without that, from +// an older Moonbase, it is worked out as Moonbase would for the common rules: a +// full (non-trial) license owns or subscribes to the product and a trial does +// not, a signed-in customer has a user id where an anonymous trial has none, +// and an owner gets only a release their licenses cover. inline bool can_download(const license& lic, const release_info& info) { if (!info.release_covered) { return false; } - 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) { + if (info.downloads_allowed) { + return *info.downloads_allowed; + } + if (info.downloads_need_ownership && lic.trial) { return false; } - if (info.downloads_need_user && !has_user) { + if (info.downloads_need_user && !detail::has_signed_in_customer(lic)) { return false; } return true; } +// Whether a full license is what keeps `license` from downloading a release its +// licenses cover: the release is for owners and this is a trial, or for +// signed-in customers and this license has none. Activating a full license, +// which signs the customer in too, is then the way to it. False when it can +// download, and when Moonbase refuses it for another reason, such as a release +// limited to a group of customers, which a full license doesn't change. +inline bool download_needs_full_license(const license& lic, const release_info& info) +{ + if (!info.release_covered || can_download(lic, info)) { + return false; + } + return (info.downloads_need_ownership && lic.trial) + || (info.downloads_need_user && !detail::has_signed_in_customer(lic)); +} + namespace detail { inline std::string inventory_product_path(const licensing_options& options) @@ -184,9 +216,10 @@ class inventory_client { // // Every failure throws api_error with the HTTP status. These endpoints never // refuse the license itself: a token they cannot use just makes the request - // anonymous, so a private product then answers 404. For a customer whose - // licenses don't cover every release, the result says whether they cover - // this one (release_covered) and where to upgrade. + // anonymous, so a private product then answers 404. The result says + // whether that customer may download the release (downloads_allowed), and, + // for a customer whose licenses don't cover every release, whether they + // cover this one (release_covered) and where to upgrade. [[nodiscard]] release_info get_release(std::string_view version, std::string_view license_token) const { @@ -223,6 +256,9 @@ class inventory_client { && !json.at("downloads").empty(); info.downloads_need_user = json.value("downloadsNeedsUser", false); info.downloads_need_ownership = json.value("downloadsNeedsOwnership", false); + if (json.contains("downloadsAllowed") && json.at("downloadsAllowed").is_boolean()) { + info.downloads_allowed = json.at("downloadsAllowed").get(); + } // Present only when the customer's licenses don't cover every release. if (json.contains("releaseAccess") && json.at("releaseAccess").is_object()) { const auto& access = json.at("releaseAccess"); diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 41afa4e..605d22a 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -2322,7 +2322,9 @@ void ActivationController::fetchUpdateInfo() if (info) { self->updateInfo_.releaseNotes = juce::String::fromUTF8(info->description.c_str()); - self->updateInfo_.canDownload = self->licenseCanDownload(*info); + const auto& license = self->license_; + self->updateInfo_.canDownload = license && moonbase::can_download(*license, *info); + self->updateInfo_.needsFullLicense = license && moonbase::download_needs_full_license(*license, *info); // Moonbase judges this by all of the customer's licenses, as // validating the new version does: the seat moves to one that // covers it. @@ -2343,11 +2345,6 @@ void ActivationController::fetchUpdateInfo() }); } -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) @@ -2579,6 +2576,17 @@ void ActivationController::setPreviewUpdate(UpdateInfo::Phase phase, moonbase::l updateInfo_.progress = progressValue; updateInfo_.error = std::move(error); updateInfo_.canDownload = canDownload && covered; + // A preview that can't download is refused as an owners-only release + // refuses this license: a trial needs the full version, while a full + // license is one Moonbase keeps the release from. + if (! canDownload && license_) + { + moonbase::release_info ownersOnly; + ownersOnly.downloads_need_user = true; + ownersOnly.downloads_need_ownership = true; + ownersOnly.downloads_allowed = false; + updateInfo_.needsFullLicense = moonbase::download_needs_full_license(*license_, ownersOnly); + } updateInfo_.covered = covered; if (! covered && license_) updateInfo_.releaseAccess = license_->release_access; diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index 46f1c1a..be4a7bf 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -81,10 +81,16 @@ class ActivationController : private juce::Timer, 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. + // Whether this license may download the installer, as Moonbase decides + // it (moonbase::can_download). False e.g. for a trial when the product + // restricts downloads to owners. Known once notes have loaded. bool canDownload = true; + // When canDownload is false but covered is true: whether a full license + // is what's missing (moonbase::download_needs_full_license), so + // unlocking the full version is the way to it. False when Moonbase keeps + // the release from this customer for another reason, such as a release + // limited to a group of customers. + bool needsFullLicense = false; // False when Moonbase says the customer's licenses don't cover the new // version (canDownload is false then too): it needs an upgrade, bought // at upgradeUrl (empty when the store sells none). Known once notes @@ -355,6 +361,8 @@ class ActivationController : private juce::Timer, // 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. + // canDownload = false previews a release only owners download: a trial is + // offered the full version, and a full license is told it can't have it. // covered = false previews a new version the license doesn't cover (its // release access comes from the license). void setPreviewUpdate(UpdateInfo::Phase phase, @@ -465,9 +473,6 @@ class ActivationController : private juce::Timer, 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; diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp index 46c11d7..8087bc9 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp @@ -2832,8 +2832,9 @@ class UpdateAvailableView : public ScreenView }; 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). + // Shown instead of Download when a full license is what this one lacks + // to 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, l.palette.onAccent, 1.8f)); unlock->onClick = [this] { controller.beginOnlineActivation(); }; @@ -2877,12 +2878,13 @@ class UpdateAvailableView : public ScreenView 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, or for the upgrade when - // it doesn't cover the new version. + // this license needs the full version for the installer, or for the + // upgrade when it doesn't cover the new version. A release Moonbase + // keeps from this customer for another reason gets neither. const bool gated = info.phase != Phase::Loading && ! info.canDownload; const bool notCovered = gated && ! info.covered; download->setVisible(! gated); - unlock->setVisible(gated && ! notCovered); + unlock->setVisible(gated && ! notCovered && info.needsFullLicense); upgrade->setVisible(notCovered && info.upgradeUrl.isNotEmpty()); upgrade->setButtonText(upgradeLabel(info.releaseAccess)); @@ -2980,6 +2982,9 @@ class UpdateAvailableView : public ScreenView const bool showStatus = info.phase == Phase::Downloading || info.error.isNotEmpty() || gated; // Two lines: the version that needs it, and what the license covers. const bool tallStatus = gated && ! info.covered && info.error.isEmpty(); + // Neither unlocking nor an upgrade the store sells would get this + // license the update, so there is no button and the notes take its room. + const bool noButton = gated && (info.covered ? ! info.needsFullLicense : info.upgradeUrl.isEmpty()); auto r = getLocalBounds(); @@ -2991,8 +2996,11 @@ class UpdateAvailableView : public ScreenView l.status = r.removeFromBottom(tallStatus ? 34 : 18); r.removeFromBottom(10); } - l.button = r.removeFromBottom(46); - r.removeFromBottom(14); + if (! noButton) + { + l.button = r.removeFromBottom(46); + r.removeFromBottom(14); + } l.header = r.removeFromTop(40); r.removeFromTop(14); @@ -3060,9 +3068,9 @@ class UpdateAvailableView : public ScreenView 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."); + const juce::String note = ! info.needsFullLicense ? "This license can't download this update." + : (lic && lic->trial) ? "This update is included with a full license." + : "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); diff --git a/modules/moonbase_licensing/moonbase/inventory.hpp b/modules/moonbase_licensing/moonbase/inventory.hpp index 7a06961..2d210e7 100644 --- a/modules/moonbase_licensing/moonbase/inventory.hpp +++ b/modules/moonbase_licensing/moonbase/inventory.hpp @@ -25,14 +25,19 @@ 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). +// The plain-text release notes plus enough to know an installer exists and +// whether this customer may download it. 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 + // Whether the customer the license token belongs to may download this + // release, as Moonbase decides it with every rule the release has + // (ownership, customer groups, the versions their licenses cover). Empty + // from an older Moonbase that doesn't send it. + std::optional downloads_allowed; // Whether the customer's licenses cover this release. False only when // Moonbase says they don't, for a license that covers only some versions; @@ -53,28 +58,55 @@ struct download_target { 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; and an owner gets only a release their licenses cover. +namespace detail { + +// An authenticated customer has a real user id, while an anonymous trial +// carries the nil GUID. +inline bool has_signed_in_customer(const license& lic) +{ + return !lic.issued_to.id.empty() && lic.issued_to.id != "00000000-0000-0000-0000-000000000000"; +} + +} // namespace detail + +// Whether `license` may download the installer of a release (from +// get_release). Moonbase says so itself (downloads_allowed). Without that, from +// an older Moonbase, it is worked out as Moonbase would for the common rules: a +// full (non-trial) license owns or subscribes to the product and a trial does +// not, a signed-in customer has a user id where an anonymous trial has none, +// and an owner gets only a release their licenses cover. inline bool can_download(const license& lic, const release_info& info) { if (!info.release_covered) { return false; } - 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) { + if (info.downloads_allowed) { + return *info.downloads_allowed; + } + if (info.downloads_need_ownership && lic.trial) { return false; } - if (info.downloads_need_user && !has_user) { + if (info.downloads_need_user && !detail::has_signed_in_customer(lic)) { return false; } return true; } +// Whether a full license is what keeps `license` from downloading a release its +// licenses cover: the release is for owners and this is a trial, or for +// signed-in customers and this license has none. Activating a full license, +// which signs the customer in too, is then the way to it. False when it can +// download, and when Moonbase refuses it for another reason, such as a release +// limited to a group of customers, which a full license doesn't change. +inline bool download_needs_full_license(const license& lic, const release_info& info) +{ + if (!info.release_covered || can_download(lic, info)) { + return false; + } + return (info.downloads_need_ownership && lic.trial) + || (info.downloads_need_user && !detail::has_signed_in_customer(lic)); +} + namespace detail { inline std::string inventory_product_path(const licensing_options& options) @@ -184,9 +216,10 @@ class inventory_client { // // Every failure throws api_error with the HTTP status. These endpoints never // refuse the license itself: a token they cannot use just makes the request - // anonymous, so a private product then answers 404. For a customer whose - // licenses don't cover every release, the result says whether they cover - // this one (release_covered) and where to upgrade. + // anonymous, so a private product then answers 404. The result says + // whether that customer may download the release (downloads_allowed), and, + // for a customer whose licenses don't cover every release, whether they + // cover this one (release_covered) and where to upgrade. [[nodiscard]] release_info get_release(std::string_view version, std::string_view license_token) const { @@ -223,6 +256,9 @@ class inventory_client { && !json.at("downloads").empty(); info.downloads_need_user = json.value("downloadsNeedsUser", false); info.downloads_need_ownership = json.value("downloadsNeedsOwnership", false); + if (json.contains("downloadsAllowed") && json.at("downloadsAllowed").is_boolean()) { + info.downloads_allowed = json.at("downloadsAllowed").get(); + } // Present only when the customer's licenses don't cover every release. if (json.contains("releaseAccess") && json.at("releaseAccess").is_object()) { const auto& access = json.at("releaseAccess"); diff --git a/tests/inventory_tests.cpp b/tests/inventory_tests.cpp index 08d045b..1d566fa 100644 --- a/tests/inventory_tests.cpp +++ b/tests/inventory_tests.cpp @@ -105,6 +105,89 @@ TEST_CASE("can_download enforces the access-control flags against the license") CHECK_FALSE(can_download(trial_anon, owners)); } +TEST_CASE("get_release reads whether Moonbase lets this customer download the release") +{ + auto transport = std::make_shared(std::deque{ + http_response{200, {}, R"({"id":"demo-app","version":"2.4.0","downloadsAllowed":false})"}, + http_response{200, {}, R"({"id":"demo-app","version":"2.4.0","downloadsAllowed":true})"}, + http_response{200, {}, R"({"id":"demo-app","version":"2.4.0","downloadsAllowed":null})"}, + http_response{200, {}, R"({"id":"demo-app","version":"2.4.0"})"}, + }); + inventory_client client(make_options(), transport); + + CHECK(client.get_release("2.4.0", "tok").downloads_allowed == false); + CHECK(client.get_release("2.4.0", "tok").downloads_allowed == true); + // An older Moonbase doesn't say. + CHECK_FALSE(client.get_release("2.4.0", "tok").downloads_allowed.has_value()); + CHECK_FALSE(client.get_release("2.4.0", "tok").downloads_allowed.has_value()); +} + +TEST_CASE("can_download takes Moonbase's answer over the access-control flags") +{ + license full; + full.issued_to.id = "user-1"; + license trial = full; + trial.trial = true; + + release_info owners; + owners.downloads_need_user = true; + owners.downloads_need_ownership = true; + + // A release limited to a group of customers this owner isn't in. + auto group = owners; + group.downloads_allowed = false; + CHECK_FALSE(can_download(full, group)); + + // A customer trying the product here who owns it on another license. + auto owned = owners; + owned.downloads_allowed = true; + CHECK(can_download(trial, owned)); + + // A release the licenses don't cover stays refused. + owned.release_covered = false; + CHECK_FALSE(can_download(full, owned)); +} + +TEST_CASE("download_needs_full_license says when unlocking the full version would help") +{ + license full; + full.issued_to.id = "user-1"; + license trial = full; + trial.trial = true; + license anonymous_trial = trial; + anonymous_trial.issued_to.id = "00000000-0000-0000-0000-000000000000"; + + release_info owners; + owners.downloads_need_user = true; + owners.downloads_need_ownership = true; + release_info customers; + customers.downloads_need_user = true; + + // Refused on the device's own rules (an older Moonbase) or by Moonbase. + CHECK(download_needs_full_license(trial, owners)); + CHECK(download_needs_full_license(anonymous_trial, customers)); + owners.downloads_allowed = false; + customers.downloads_allowed = false; + CHECK(download_needs_full_license(trial, owners)); + CHECK(download_needs_full_license(anonymous_trial, customers)); + + // Refused for something a full license doesn't change, such as a release + // limited to a group of customers, or to none. + CHECK_FALSE(download_needs_full_license(full, owners)); + CHECK_FALSE(download_needs_full_license(trial, customers)); + release_info none; + none.downloads_allowed = false; + CHECK_FALSE(download_needs_full_license(trial, none)); + + // Nothing is missing when it may download, and a release the licenses + // don't cover needs the upgrade instead. + owners.downloads_allowed = true; + CHECK_FALSE(download_needs_full_license(trial, owners)); + owners.downloads_allowed = false; + owners.release_covered = false; + CHECK_FALSE(download_needs_full_license(trial, owners)); +} + TEST_CASE("get_release tolerates a release with no notes or downloads") { auto transport = std::make_shared(std::deque{ diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 6a0c2a3..5e835d5 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -422,6 +422,33 @@ TEST_CASE("setPreviewUpdate forces the update screen with synthetic state") CHECK(controller.updateInfo().releaseNotes == "What's new"); } +TEST_CASE("an update preview that can't download offers the full version only to a trial") +{ + controller_fixture fx; + fx.config.applicationVersion = "2.3.1"; + ActivationController controller(fx.config, fx.makeLicensing()); + using Phase = ActivationController::UpdateInfo::Phase; + + auto claims = default_claims(); + claims["p:rel"] = "2.4.0"; + auto full = fx.makeLicensing()->validate_token_local_allow_expired(fx.token(claims)); + claims["trial"] = true; + auto trial = fx.makeLicensing()->validate_token_local_allow_expired(fx.token(claims)); + + controller.setPreviewUpdate(Phase::Ready, trial, {}, 0.0, {}, /*canDownload*/ false); + CHECK_FALSE(controller.updateInfo().canDownload); + CHECK(controller.updateInfo().needsFullLicense); + + // A full license that can't download is one Moonbase keeps the release from. + controller.setPreviewUpdate(Phase::Ready, full, {}, 0.0, {}, /*canDownload*/ false); + CHECK_FALSE(controller.updateInfo().canDownload); + CHECK_FALSE(controller.updateInfo().needsFullLicense); + + controller.setPreviewUpdate(Phase::Ready, trial); + CHECK(controller.updateInfo().canDownload); + CHECK_FALSE(controller.updateInfo().needsFullLicense); +} + TEST_CASE("dismissing the update is remembered across restarts") { controller_fixture fx; diff --git a/tests/visual/README.md b/tests/visual/README.md index 1e47cd5..70b3fe4 100644 --- a/tests/visual/README.md +++ b/tests/visual/README.md @@ -52,8 +52,9 @@ PNG per state: | `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) | +| `14-update-gated` | Update a trial can't download (Unlock CTA) | | `14b-update-not-covered` | Update the license doesn't cover (upgrade CTA, what it covers) | +| `14c-update-not-allowed` | Update Moonbase keeps from a full license, e.g. one limited to a group of customers (no CTA) | | `15-theme-ember-welcome` | Welcome, "Ember" theme (warm near-black, amber, monospaced) | | `15b-theme-ember-welcome-error` | The longest welcome error copy in Ember's monospaced face, where it runs widest | | `15c-theme-ember-welcome-license-invalid` | The longest lock-reason copy in Ember's monospaced face | diff --git a/tests/visual/snapshot_main.cpp b/tests/visual/snapshot_main.cpp index 1d0d734..cd867bf 100644 --- a/tests/visual/snapshot_main.cpp +++ b/tests/visual/snapshot_main.cpp @@ -772,6 +772,14 @@ int main(int argc, char* argv[]) /*covered*/ false, kPreviewUpgradeUrl); }, cfg); + + // A full license Moonbase keeps the release from, such as one limited + // to a group of customers: nothing to unlock or buy. + writeSnapshot(outDir, "14c-update-not-allowed", + [notes](ActivationController& c) + { c.setPreviewUpdate(UpdatePhase::Ready, makeUpdateLicense(), notes, 0.0, + {}, /*canDownload*/ false); }, + cfg); } //== Add-ons ============================================================== From bd3cb7d67fae05c6181f610a0678950a566d2225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Wed, 28 Oct 2026 23:19:39 +0100 Subject: [PATCH 2/2] fix: append the new download fields and leave group releases locked downloads_allowed and needsFullLicense were inserted mid-struct, which would shift a positional brace initializer written against the earlier release_info or UpdateInfo. Both now sit at the end, as earlier fields were added. download_needs_full_license could also promise that a full license unlocks a release limited to a group of customers, which buying doesn't join. It now reads downloadsNeedsGroupMembership and returns false for such a release. --- include/moonbase/inventory.hpp | 24 ++++++++++++------- .../juce/ActivationController.h | 12 +++++----- .../moonbase_licensing/moonbase/inventory.hpp | 24 ++++++++++++------- tests/inventory_tests.cpp | 22 ++++++++++++++++- 4 files changed, 59 insertions(+), 23 deletions(-) diff --git a/include/moonbase/inventory.hpp b/include/moonbase/inventory.hpp index 2d210e7..0ba4b4d 100644 --- a/include/moonbase/inventory.hpp +++ b/include/moonbase/inventory.hpp @@ -33,11 +33,6 @@ struct release_info { 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 - // Whether the customer the license token belongs to may download this - // release, as Moonbase decides it with every rule the release has - // (ownership, customer groups, the versions their licenses cover). Empty - // from an older Moonbase that doesn't send it. - std::optional downloads_allowed; // Whether the customer's licenses cover this release. False only when // Moonbase says they don't, for a license that covers only some versions; @@ -47,6 +42,15 @@ struct release_info { std::optional release_access; // Where to buy the upgrade or renewal, while the store sells one. std::optional upgrade_url; + + // Fields are only ever appended, so a brace-initialized release_info keeps + // its meaning. + bool downloads_need_group_membership = false; // download requires belonging to a group of customers + // Whether the customer the license token belongs to may download this + // release, as Moonbase decides it with every rule the release has + // (ownership, customer groups, the versions their licenses cover). Empty + // from an older Moonbase that doesn't send it. + std::optional downloads_allowed; }; // A resolved, ready-to-fetch installer URL. `url` is a short-lived presigned @@ -96,11 +100,12 @@ inline bool can_download(const license& lic, const release_info& info) // licenses cover: the release is for owners and this is a trial, or for // signed-in customers and this license has none. Activating a full license, // which signs the customer in too, is then the way to it. False when it can -// download, and when Moonbase refuses it for another reason, such as a release -// limited to a group of customers, which a full license doesn't change. +// download, and when Moonbase may refuse it for something a full license +// doesn't change: a release limited to a group of customers, which buying +// doesn't join, or to no one. inline bool download_needs_full_license(const license& lic, const release_info& info) { - if (!info.release_covered || can_download(lic, info)) { + if (!info.release_covered || info.downloads_need_group_membership || can_download(lic, info)) { return false; } return (info.downloads_need_ownership && lic.trial) @@ -256,6 +261,9 @@ class inventory_client { && !json.at("downloads").empty(); info.downloads_need_user = json.value("downloadsNeedsUser", false); info.downloads_need_ownership = json.value("downloadsNeedsOwnership", false); + if (json.contains("downloadsNeedsGroupMembership") && json.at("downloadsNeedsGroupMembership").is_boolean()) { + info.downloads_need_group_membership = json.at("downloadsNeedsGroupMembership").get(); + } if (json.contains("downloadsAllowed") && json.at("downloadsAllowed").is_boolean()) { info.downloads_allowed = json.at("downloadsAllowed").get(); } diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index be4a7bf..4a58f29 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -85,12 +85,6 @@ class ActivationController : private juce::Timer, // it (moonbase::can_download). False e.g. for a trial when the product // restricts downloads to owners. Known once notes have loaded. bool canDownload = true; - // When canDownload is false but covered is true: whether a full license - // is what's missing (moonbase::download_needs_full_license), so - // unlocking the full version is the way to it. False when Moonbase keeps - // the release from this customer for another reason, such as a release - // limited to a group of customers. - bool needsFullLicense = false; // False when Moonbase says the customer's licenses don't cover the new // version (canDownload is false then too): it needs an upgrade, bought // at upgradeUrl (empty when the store sells none). Known once notes @@ -98,6 +92,12 @@ class ActivationController : private juce::Timer, bool covered = true; std::optional releaseAccess; // what the licenses cover juce::String upgradeUrl; + // When canDownload is false but covered is true: whether a full license + // is what's missing (moonbase::download_needs_full_license), so + // unlocking the full version is the way to it. False when Moonbase keeps + // the release from this customer for another reason, such as a release + // limited to a group of customers. + bool needsFullLicense = false; }; // State backing the NotCovered screen: a license that is valid, but doesn't diff --git a/modules/moonbase_licensing/moonbase/inventory.hpp b/modules/moonbase_licensing/moonbase/inventory.hpp index 2d210e7..0ba4b4d 100644 --- a/modules/moonbase_licensing/moonbase/inventory.hpp +++ b/modules/moonbase_licensing/moonbase/inventory.hpp @@ -33,11 +33,6 @@ struct release_info { 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 - // Whether the customer the license token belongs to may download this - // release, as Moonbase decides it with every rule the release has - // (ownership, customer groups, the versions their licenses cover). Empty - // from an older Moonbase that doesn't send it. - std::optional downloads_allowed; // Whether the customer's licenses cover this release. False only when // Moonbase says they don't, for a license that covers only some versions; @@ -47,6 +42,15 @@ struct release_info { std::optional release_access; // Where to buy the upgrade or renewal, while the store sells one. std::optional upgrade_url; + + // Fields are only ever appended, so a brace-initialized release_info keeps + // its meaning. + bool downloads_need_group_membership = false; // download requires belonging to a group of customers + // Whether the customer the license token belongs to may download this + // release, as Moonbase decides it with every rule the release has + // (ownership, customer groups, the versions their licenses cover). Empty + // from an older Moonbase that doesn't send it. + std::optional downloads_allowed; }; // A resolved, ready-to-fetch installer URL. `url` is a short-lived presigned @@ -96,11 +100,12 @@ inline bool can_download(const license& lic, const release_info& info) // licenses cover: the release is for owners and this is a trial, or for // signed-in customers and this license has none. Activating a full license, // which signs the customer in too, is then the way to it. False when it can -// download, and when Moonbase refuses it for another reason, such as a release -// limited to a group of customers, which a full license doesn't change. +// download, and when Moonbase may refuse it for something a full license +// doesn't change: a release limited to a group of customers, which buying +// doesn't join, or to no one. inline bool download_needs_full_license(const license& lic, const release_info& info) { - if (!info.release_covered || can_download(lic, info)) { + if (!info.release_covered || info.downloads_need_group_membership || can_download(lic, info)) { return false; } return (info.downloads_need_ownership && lic.trial) @@ -256,6 +261,9 @@ class inventory_client { && !json.at("downloads").empty(); info.downloads_need_user = json.value("downloadsNeedsUser", false); info.downloads_need_ownership = json.value("downloadsNeedsOwnership", false); + if (json.contains("downloadsNeedsGroupMembership") && json.at("downloadsNeedsGroupMembership").is_boolean()) { + info.downloads_need_group_membership = json.at("downloadsNeedsGroupMembership").get(); + } if (json.contains("downloadsAllowed") && json.at("downloadsAllowed").is_boolean()) { info.downloads_allowed = json.at("downloadsAllowed").get(); } diff --git a/tests/inventory_tests.cpp b/tests/inventory_tests.cpp index 1d566fa..be5beed 100644 --- a/tests/inventory_tests.cpp +++ b/tests/inventory_tests.cpp @@ -62,7 +62,7 @@ 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"("downloadsNeedsUser":true,"downloadsNeedsOwnership":true,"downloadsNeedsGroupMembership":true,)" R"("downloads":[{"name":"Solstice-2.4.0.dmg","platform":"Mac"}]})"}, }); inventory_client client(make_options(), transport); @@ -70,6 +70,17 @@ TEST_CASE("get_release parses the download access-control flags") const auto info = client.get_release("2.4.0", "tok"); CHECK(info.downloads_need_user); CHECK(info.downloads_need_ownership); + CHECK(info.downloads_need_group_membership); +} + +TEST_CASE("release_info keeps its brace-initialized fields in place") +{ + // Fields are appended, so a positional initializer written against an + // earlier release_info still means what it did. + const release_info info{"2.4.0", "notes", true, true, true, false}; + CHECK(info.downloads_need_ownership); + CHECK_FALSE(info.release_covered); + CHECK_FALSE(info.downloads_allowed.has_value()); } TEST_CASE("can_download enforces the access-control flags against the license") @@ -179,6 +190,15 @@ TEST_CASE("download_needs_full_license says when unlocking the full version woul none.downloads_allowed = false; CHECK_FALSE(download_needs_full_license(trial, none)); + // A full license doesn't join the customer to a group, even where the + // release also needs owners or signed-in customers. + auto owners_in_group = owners; + owners_in_group.downloads_need_group_membership = true; + CHECK_FALSE(download_needs_full_license(trial, owners_in_group)); + auto customers_in_group = customers; + customers_in_group.downloads_need_group_membership = true; + CHECK_FALSE(download_needs_full_license(anonymous_trial, customers_in_group)); + // Nothing is missing when it may download, and a release the licenses // don't cover needs the upgrade instead. owners.downloads_allowed = true;