From 53a3cc979888b20d104cd6aa7c8ba2af40508887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Tue, 13 Oct 2026 08:36:31 +0200 Subject: [PATCH 1/2] feat(juce): show endless trials without a countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moonbase products can now offer trials that never expire, whose tokens carry no exp. The core SDK already accepted them, but the JUCE module's trial screen read the missing expiry as zero days: "TRIAL · 0 DAYS LEFT", "0 of 14 days remaining" and an empty progress bar on a trial that was still running. ActivationController gains trialIsEndless(). The trial screen then shows a plain "TRIAL" badge, says the trial has no time limit and drops the bar. The ended screen leaves out the "N-day" length for a trial that had none, and says the trial "has ended" when there is no end date to name, instead of a sentence without a verb. --- docs/juce-module.md | 7 ++- .../juce/ActivationConfig.h | 3 +- .../juce/ActivationController.cpp | 5 ++ .../juce/ActivationController.h | 4 ++ .../juce/ui/ActivationComponent.cpp | 51 ++++++++++++------- tests/juce/controller_tests.cpp | 50 ++++++++++++++++++ tests/visual/snapshot_main.cpp | 14 +++++ 7 files changed, 112 insertions(+), 22 deletions(-) diff --git a/docs/juce-module.md b/docs/juce-module.md index 8271e7e..960db74 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -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). @@ -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 diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index 75b13f8..7c3382d 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -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 diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 268b467..f64ebc4 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -1266,6 +1266,11 @@ int ActivationController::trialDaysRemaining() const return static_cast((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) { diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index e0959b7..9ccf275 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -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 diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp index 85a317c..55c0097 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp @@ -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 @@ -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); @@ -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 @@ -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."; @@ -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; @@ -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( lic->expires_at->time_since_epoch()).count(); @@ -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; } diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 9ba1e23..935438b 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -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") diff --git a/tests/visual/snapshot_main.cpp b/tests/visual/snapshot_main.cpp index 76a065b..cc397ad 100644 --- a/tests/visual/snapshot_main.cpp +++ b/tests/visual/snapshot_main.cpp @@ -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() @@ -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)); }); From 22e014b0ef44dcfe85c54c3144ba99e19291e6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Tue, 13 Oct 2026 09:00:34 +0200 Subject: [PATCH 2/2] fix: retry opening the license on Windows while a write lands std::filesystem::rename on Windows keeps the renamed license open with DELETE access for a moment after it lands, and std::ifstream does not share delete access, so a reader opening the license in that window got a sharing violation and load_local_license threw storage_error. The torn-write test caught this on the Windows runner. Reading now goes through the same short retry the rename already used on Windows, pulled out into one helper. Elsewhere it still tries once. --- include/moonbase/store.hpp | 47 +++++++++++++------ modules/moonbase_licensing/moonbase/store.hpp | 47 +++++++++++++------ tests/store_tests.cpp | 3 +- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/include/moonbase/store.hpp b/include/moonbase/store.hpp index 73f148c..e81261c 100644 --- a/include/moonbase/store.hpp +++ b/include/moonbase/store.hpp @@ -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)); } @@ -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 + 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 diff --git a/modules/moonbase_licensing/moonbase/store.hpp b/modules/moonbase_licensing/moonbase/store.hpp index 73f148c..e81261c 100644 --- a/modules/moonbase_licensing/moonbase/store.hpp +++ b/modules/moonbase_licensing/moonbase/store.hpp @@ -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)); } @@ -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 + 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 diff --git a/tests/store_tests.cpp b/tests/store_tests.cpp index 528c80a..96ad158 100644 --- a/tests/store_tests.cpp +++ b/tests/store_tests.cpp @@ -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";