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
7 changes: 5 additions & 2 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ The screens:
- **Offline** — two-step machine-file flow: save the request (`generate_device_token`),
then load the response file (`read_offline_license`, validated locally).
- **Trial** — days-left badge, progress bar, included/excluded feature list, and an
Unlock action that routes into online activation.
Unlock action that routes into online activation. An endless trial (a product whose
trials never expire, so the token has no `exp`) shows a plain "Trial" badge and no
progress bar; `trialIsEndless()` tells you which one is loaded.
- **License details** — issued-to / email / plan / activation / expiry, a seat counter,
and a Deactivate action (server-side `revoke_activation`, with a local-forget fallback
for offline or trial licenses).
Expand Down Expand Up @@ -284,7 +286,8 @@ is broadcast when it arrives. To open the link your own way, set `config.openBro

Everything in `ActivationConfig` after the connection fields is brand/UI: product +
manufacturer name, `accent` colour, the Moonbase co-brand badge (`showMoonbaseBadge`), the
`trialLengthDays` + `trialFeatures` list (shown on the Trial / Expired screens),
`trialLengthDays` + `trialFeatures` list (shown on the Trial / Expired screens;
an endless trial ignores `trialLengthDays`),
`enableOffline`, and the `activationUrl`.

For a deeper re-skin, `config.palette` holds every other colour in the UI as its own
Expand Down
47 changes: 32 additions & 15 deletions include/moonbase/store.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,15 @@ class file_license_store : public license_store {
return std::nullopt;
}

errno = 0;
std::ifstream file(path_);
if (!file) {
const int err = errno;
std::ifstream file;
int err = 0;
const bool opened = retry_while_in_use([&] {
errno = 0;
file.open(path_);
err = errno;
return file.is_open();
});
if (!opened) {
throw storage_error("Could not open local license file for reading: " + utf8_path(path_)
+ errno_suffix(err));
}
Expand Down Expand Up @@ -268,29 +273,41 @@ class file_license_store : public license_store {
}
}

// Windows won't replace a file that another handle has open, as a
// sibling reading the license does for a moment (and a virus scanner
// may for longer), so try a few times there before giving up.
std::error_code ec;
if (retry_while_in_use([&] {
std::filesystem::rename(temp, target, ec);
return !ec;
})) {
return true;
}
std::filesystem::remove(temp, ec);
return false;
}

// Windows won't open or replace a file while another handle holds it in a
// conflicting way. A sibling reading the license holds it like that for a
// moment, so does a rename that just put a new license in place, and a virus
// scanner may for longer. So there, a failed attempt is tried a few more
// times before giving up; elsewhere it runs once.
template <typename Attempt>
static bool retry_while_in_use(Attempt&& attempt)
{
#if defined(_WIN32)
constexpr int attempts = 10;
#else
constexpr int attempts = 1;
#endif
std::error_code ec;
auto pause = std::chrono::milliseconds(1);
for (int attempt = 1;; ++attempt) {
std::filesystem::rename(temp, target, ec);
if (!ec) {
for (int tried = 1;; ++tried) {
if (attempt()) {
return true;
}
if (attempt == attempts) {
break;
if (tried == attempts) {
return false;
}
std::this_thread::sleep_for(pause);
pause = std::min(pause * 2, std::chrono::milliseconds(100));
}
std::filesystem::remove(temp, ec);
return false;
}

// Unique to one write, so writers never share a temporary file, whether in
Expand Down
3 changes: 2 additions & 1 deletion modules/moonbase_licensing/juce/ActivationConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ struct ActivationConfig
bool reduceMotion = false; // skip transitions, the success pop, the appear animation and the glow
// (a11y + snapshot tests); the activating spinner still turns
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)
int trialLengthDays = 14; // trial length shown on the Trial / Expired screens (trials are granted by the backend, not started from the UI);
// unused for an endless trial, whose token has no expiry

// Present the overlay (ActivationComponent::appear()) whenever the plugin is
// locked: when it opens without a license, and whenever the license is lost
Expand Down
5 changes: 5 additions & 0 deletions modules/moonbase_licensing/juce/ActivationController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,11 @@ int ActivationController::trialDaysRemaining() const
return static_cast<int>((seconds + 86399) / 86400); // ceil to whole days
}

bool ActivationController::trialIsEndless() const
{
return license_ && license_->trial && ! license_->expires_at;
}

//==============================================================================
void ActivationController::setScreen(Screen newScreen, const juce::String& message)
{
Expand Down
4 changes: 4 additions & 0 deletions modules/moonbase_licensing/juce/ActivationController.h
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ class ActivationController : private juce::Timer,

// Trial display helpers (valid only when a trial license is loaded).
[[nodiscard]] int trialDaysRemaining() const;
// True when the loaded trial has no end date: the product's trials never
// expire, so its token carries no expiry. trialDaysRemaining() is 0 then, so
// show no countdown.
[[nodiscard]] bool trialIsEndless() const;

private:
// A message-thread timer with a callback. The controller's own Timer base
Expand Down
51 changes: 32 additions & 19 deletions modules/moonbase_licensing/juce/ui/ActivationComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ int drawBrand(Graphics& g, const ActivationLookAndFeel& lnf, const juce::Drawabl
return row.getHeight();
}

// Shared geometry for the small header pills ("TRIAL · N DAYS LEFT", "TRIAL
// EXPIRED", "Active", "Update available"). One set of pads so they read as one
// Shared geometry for the small header pills ("TRIAL · N DAYS LEFT", "TRIAL",
// "TRIAL EXPIRED", "Active", "Update available"). One set of pads so they read as one
// family, and one measure path that takes the label exactly as it will be drawn
// (case included), so the padding can't be eaten by the glyphs.
// An inline error: the warning icon on the row's left edge, a gap, then up to
Expand Down Expand Up @@ -1233,9 +1233,12 @@ class TrialView : public ScreenView

// Uppercase before measuring: the pill is drawn in caps, which are wider
// than mixed case, so measuring the mixed-case string would eat the pads.
// An endless trial has no days to count, so its pill just says "Trial".
const bool endless = controller.trialIsEndless();
const int days = controller.trialDaysRemaining();
const auto pillText = (u8("Trial") + kMidDot + juce::String(days)
+ (days == 1 ? " day left" : " days left")).toUpperCase();
const auto pillText = (endless ? juce::String("Trial")
: u8("Trial") + kMidDot + juce::String(days)
+ (days == 1 ? " day left" : " days left")).toUpperCase();
auto pf = lnf.heading(10.5f);
const float pw = pill::width(pf, pillText);

Expand All @@ -1262,15 +1265,18 @@ class TrialView : public ScreenView
g.drawFittedText(subtitleText(), layout.subtitle.getX(), layout.subtitle.getY(),
layout.subtitle.getWidth(), layout.subtitle.getHeight(), Justification::topLeft, 4, 1.0f);

const auto bar = layout.bar;
g.setColour(lnf.palette.trackFill);
g.fillRoundedRectangle(bar.toFloat(), 3.0f);
const int total = controller.config().trialLengthDays;
const float frac = total > 0 ? juce::jlimit(0.0f, 1.0f, (float) days / (float) total) : 0.0f;
auto fill = bar.toFloat().withWidth((float) bar.getWidth() * frac);
g.setGradientFill(juce::ColourGradient(lnf.palette.trial, fill.getX(), 0,
lnf.palette.trialBright, fill.getRight(), 0, false));
g.fillRoundedRectangle(fill, 3.0f);
// The countdown bar; computeLayout() leaves it empty for an endless trial.
if (const auto bar = layout.bar; ! bar.isEmpty())
{
g.setColour(lnf.palette.trackFill);
g.fillRoundedRectangle(bar.toFloat(), 3.0f);
const int total = controller.config().trialLengthDays;
const float frac = total > 0 ? juce::jlimit(0.0f, 1.0f, (float) days / (float) total) : 0.0f;
auto fill = bar.toFloat().withWidth((float) bar.getWidth() * frac);
g.setGradientFill(juce::ColourGradient(lnf.palette.trial, fill.getX(), 0,
lnf.palette.trialBright, fill.getRight(), 0, false));
g.fillRoundedRectangle(fill, 3.0f);
}

// When the list is longer than the band, frame it as a bounded, scrollable
// field so the scroll affordance is unmistakable (resized() insets the
Expand Down Expand Up @@ -1309,6 +1315,9 @@ class TrialView : public ScreenView

[[nodiscard]] juce::String subtitleText() const
{
if (controller.trialIsEndless())
return "Your trial has no time limit. Unlock the full version any time to get everything.";

return juce::String(controller.trialDaysRemaining()) + " of "
+ juce::String(controller.config().trialLengthDays)
+ " days remaining. Unlock the full version any time to keep everything.";
Expand Down Expand Up @@ -1337,8 +1346,11 @@ class TrialView : public ScreenView
l.heading = r.removeFromTop(30);
r.removeFromTop(6); // gap below the heading
l.subtitle = r.removeFromTop(subtitleHeight(r.getWidth()));
r.removeFromTop(8); // tight gap above the bar
l.bar = r.removeFromTop(6);
if (! controller.trialIsEndless()) // nothing to count down, so no bar
{
r.removeFromTop(8); // tight gap above the bar
l.bar = r.removeFromTop(6);
}
r.removeFromTop(16); // gap below the bar
l.features = r;
return l;
Expand Down Expand Up @@ -1451,9 +1463,11 @@ class ExpiredView : public ScreenView
private:
juce::String bodyText() const
{
const int len = controller.config().trialLengthDays;
// An endless trial the vendor ended had no length, so leave the "N-day" out
auto& lic = controller.expiredTrial();
const int len = lic && ! lic->expires_at ? 0 : controller.config().trialLengthDays;
juce::String date;
if (auto& lic = controller.expiredTrial(); lic && lic->expires_at)
if (lic && lic->expires_at)
{
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
lic->expires_at->time_since_epoch()).count();
Expand All @@ -1463,8 +1477,7 @@ class ExpiredView : public ScreenView
if (len > 0)
s << len << "-day ";
s << "trial of " << controller.config().resolvedProductName();
if (date.isNotEmpty())
s << " ended on " << date;
s << (date.isNotEmpty() ? " ended on " + date : juce::String(" has ended"));
s << ". Unlock the full version to keep using the plugin.";
return s;
}
Expand Down
47 changes: 32 additions & 15 deletions modules/moonbase_licensing/moonbase/store.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,15 @@ class file_license_store : public license_store {
return std::nullopt;
}

errno = 0;
std::ifstream file(path_);
if (!file) {
const int err = errno;
std::ifstream file;
int err = 0;
const bool opened = retry_while_in_use([&] {
errno = 0;
file.open(path_);
err = errno;
return file.is_open();
});
if (!opened) {
throw storage_error("Could not open local license file for reading: " + utf8_path(path_)
+ errno_suffix(err));
}
Expand Down Expand Up @@ -268,29 +273,41 @@ class file_license_store : public license_store {
}
}

// Windows won't replace a file that another handle has open, as a
// sibling reading the license does for a moment (and a virus scanner
// may for longer), so try a few times there before giving up.
std::error_code ec;
if (retry_while_in_use([&] {
std::filesystem::rename(temp, target, ec);
return !ec;
})) {
return true;
}
std::filesystem::remove(temp, ec);
return false;
}

// Windows won't open or replace a file while another handle holds it in a
// conflicting way. A sibling reading the license holds it like that for a
// moment, so does a rename that just put a new license in place, and a virus
// scanner may for longer. So there, a failed attempt is tried a few more
// times before giving up; elsewhere it runs once.
template <typename Attempt>
static bool retry_while_in_use(Attempt&& attempt)
{
#if defined(_WIN32)
constexpr int attempts = 10;
#else
constexpr int attempts = 1;
#endif
std::error_code ec;
auto pause = std::chrono::milliseconds(1);
for (int attempt = 1;; ++attempt) {
std::filesystem::rename(temp, target, ec);
if (!ec) {
for (int tried = 1;; ++tried) {
if (attempt()) {
return true;
}
if (attempt == attempts) {
break;
if (tried == attempts) {
return false;
}
std::this_thread::sleep_for(pause);
pause = std::min(pause * 2, std::chrono::milliseconds(100));
}
std::filesystem::remove(temp, ec);
return false;
}

// Unique to one write, so writers never share a temporary file, whether in
Expand Down
50 changes: 50 additions & 0 deletions tests/juce/controller_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,56 @@ TEST_CASE("start() with a valid trial license routes to Trial")
CHECK(controller.screen() == Screen::Trial);
REQUIRE(controller.license().has_value());
CHECK(controller.license()->trial);
CHECK_FALSE(controller.trialIsEndless());
CHECK(controller.trialDaysRemaining() == 1); // exp is an hour out, rounded up to a day
}

TEST_CASE("an endless trial routes to Trial with no countdown and stays unlocked")
{
controller_fixture fx;
auto claims = default_claims();
claims["trial"] = true;
claims.erase("exp"); // the product's trials never expire
fx.seedStored(fx.token(claims));

ActivationController controller(fx.config, fx.makeLicensing());
controller.start();

REQUIRE(pumpUntil([&] { return settled(controller); }));
CHECK(controller.screen() == Screen::Trial);
REQUIRE(controller.license().has_value());
CHECK_FALSE(controller.license()->expires_at.has_value());
CHECK(controller.trialIsEndless());
CHECK(controller.trialDaysRemaining() == 0);

// The license watch has nothing to count down to, so a tick leaves it unlocked
pumpUntil([] { return false; }, 2500);
CHECK(controller.screen() == Screen::Trial);
CHECK(controller.licensedFlag().load());
CHECK(controller.lockReason() == LockReason::None);
}

TEST_CASE("an endless trial the vendor ends shows Expired")
{
controller_fixture fx;
auto claims = default_claims();
claims["trial"] = true;
claims.erase("exp");
claims["validated"] = now_seconds() - 3600; // past the throttle -> start() re-checks online
fx.seedStored(fx.token(claims));

fx.transport->responses.push_back(
moonbase::http_response{400, {}, R"({"errorType":"LicenseExpired","detail":"trial ended"})"});

ActivationController controller(fx.config, fx.makeLicensing());
controller.start();

REQUIRE(pumpUntil([&] { return settled(controller); }));
CHECK(controller.screen() == Screen::Expired);
CHECK_FALSE(controller.license().has_value());
REQUIRE(controller.expiredTrial().has_value());
CHECK_FALSE(controller.expiredTrial()->expires_at.has_value());
CHECK(controller.lockReason() == LockReason::Expired);
}

TEST_CASE("an expired trial shows the Expired screen and keeps the plugin locked")
Expand Down
3 changes: 2 additions & 1 deletion tests/store_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ TEST_CASE("file_license_store readers never catch the license half-written")
// Readers don't take the lock (an instance loading its license at startup
// doesn't), and load_local_license deletes a file it can't parse. So a reader
// that caught a write half done would delete the license, and the customer
// would have to activate again.
// would have to activate again. Nor may a reader fail to open it while a
// write lands, which Windows refuses for a moment after the rename.
const auto dir = std::filesystem::temp_directory_path() / ("moonbase-cpp-torn-" + unique_suffix());
std::filesystem::create_directories(dir);
const auto path = dir / "license.mb";
Expand Down
14 changes: 14 additions & 0 deletions tests/visual/snapshot_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,14 @@ moonbase::license makeExpiredTrial()
return lic;
}

// A trial on a product whose trials never expire: no exp, so no countdown.
moonbase::license makeEndlessTrial()
{
auto lic = makeLicense(true);
lic.expires_at.reset();
return lic;
}

// A full (non-trial) license that renews — i.e. a subscription with an expiry
// date, so the details screen shows "Expires" with a real date rather than "Never".
moonbase::license makeSubscriptionLicense()
Expand Down Expand Up @@ -509,6 +517,12 @@ int main(int argc, char* argv[])
cfg);
}

writeSnapshot(outDir, "06d-trial-endless", [](ActivationController& c)
{ c.setPreviewState(Screen::Trial, makeEndlessTrial()); });

writeSnapshot(outDir, "06e-trial-endless-ended", [](ActivationController& c)
{ c.setPreviewState(Screen::Expired, makeEndlessTrial()); });

writeSnapshot(outDir, "07-details", [](ActivationController& c)
{ c.setPreviewState(Screen::Details, makeLicense(false)); });

Expand Down
Loading