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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/core-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 15 additions & 9 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>` scheme (`moonbase::inventory_client`). Observe
Expand Down
74 changes: 59 additions & 15 deletions include/moonbase/inventory.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@

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
Expand All @@ -42,6 +42,15 @@ struct release_info {
std::optional<license_release_access> release_access;
// Where to buy the upgrade or renewal, while the store sells one.
std::optional<std::string> 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<bool> downloads_allowed;
};

// A resolved, ready-to-fetch installer URL. `url` is a short-lived presigned
Expand All @@ -53,28 +62,56 @@ 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 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 || info.downloads_need_group_membership || 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)
Expand Down Expand Up @@ -184,9 +221,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
{
Expand Down Expand Up @@ -223,6 +261,12 @@ 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<bool>();
}
if (json.contains("downloadsAllowed") && json.at("downloadsAllowed").is_boolean()) {
info.downloads_allowed = json.at("downloadsAllowed").get<bool>();
}
// 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");
Expand Down
20 changes: 14 additions & 6 deletions modules/moonbase_licensing/juce/ActivationController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 11 additions & 6 deletions modules/moonbase_licensing/juce/ActivationController.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ 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;
// False when Moonbase says the customer's licenses don't cover the new
// version (canDownload is false then too): it needs an upgrade, bought
Expand All @@ -92,6 +92,12 @@ class ActivationController : private juce::Timer,
bool covered = true;
std::optional<moonbase::license_release_access> 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 18 additions & 10 deletions modules/moonbase_licensing/juce/ui/ActivationComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<StyledButton>(l, StyledButton::Style::accent, "Unlock full version",
icons::fromStroke(icons::lock, l.palette.onAccent, 1.8f));
unlock->onClick = [this] { controller.beginOnlineActivation(); };
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading