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/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/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/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/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/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"; 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)); });