diff --git a/docs/juce-module.md b/docs/juce-module.md index b6c8a5e..13e7c22 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -157,8 +157,11 @@ the success pop, and the breathing top-edge glow. From JUCE 8.0.4 these run on `juce_animation` (`juce::Animator` / `ValueAnimatorBuilder` / `Easings`) when your project links it; otherwise the module uses its own equivalent, with the same cubic-bezier curves, so the motion is identical on every JUCE version. Either way -a 60 Hz `juce::Timer` supplies the ticks, not a `VBlankAnimatorUpdater`. Set -`config.reduceMotion` to turn all of it off. +a 60 Hz `juce::Timer` supplies the ticks, not a `VBlankAnimatorUpdater`. That timer +only runs while the component is on screen: hiding it or any parent stops it, so a panel +kept hidden in every editor costs nothing. Set `config.reduceMotion` to turn off the transitions, the success pop, the +appear animation and the glow. The activating spinner keeps turning, because it is the +one sign that the wait is still alive. ## Gating @@ -202,6 +205,46 @@ void processBlock (juce::AudioBuffer& b, ...) override `issued_to.email`, `owned_sub_product_ids`, custom `properties`, etc. — for richer gating decisions (read it on the message thread). +To react to the license itself (reload features, update your own status), assign +`onLicenseChanged` before `start()`. It runs on the message thread once `start()` has +settled, then only when the license changes: activated, refreshed into a new token, +picked up from another instance, revoked, expired or cleared. Screen changes, progress +and a re-check that changed nothing don't fire it. `ActivationComponent::onActivationChanged` +follows the same rules, separately for each component. + +```cpp +activation.onLicenseChanged = [this] (bool licensed) { reloadFeatures(); }; +activation.start(); +``` + +### Expiry and other instances + +Once started, the controller keeps an eye on the license by itself, with a local check +every 2 seconds (a look at the license file and the clock, no network): + +- A trial or subscription whose `exp` passes while the plugin is open locks then, not at + the next launch. A trial goes to the **Trial expired** screen. +- A license that has gone unverified for longer than `onlineGracePeriod` gets one more + online check, and locks if Moonbase can't be reached, as it would at launch. +- If either happens while the user is activating (typically a trial being unlocked), the + license locks on the spot and the activation carries on; when it lands, it replaces the + license. The same goes for a license another instance removes during an activation. +- When another plugin instance, the standalone app, or a host that runs each plugin in + its own process activates, refreshes or deactivates, the other controllers pick it up + within a couple of seconds, with no reload. They all share the license file. A refresh + elsewhere updates the license in place without moving the screen; a different + activation replaces it, and anything still in flight for the old one is dropped. A + stored license nobody has verified within `onlineGracePeriod` is not picked up until + an instance re-validates it. + +Sandboxed formats (AUv3, Mac App Store builds) each keep the license in their own +container, so they don't share an activation with the other formats. + +For a custom UI, `pendingBrowserUrl()` returns the browser link while an online +activation is waiting, so you can show or copy it when the browser didn't open; a change +is broadcast when it arrives. To open the link your own way, set `config.openBrowser` +(return `false` when it couldn't be opened). + ## Branding / theming Everything in `ActivationConfig` after the connection fields is brand/UI: product + @@ -421,17 +464,20 @@ activation->controller().refreshLicense (/*force*/ true, [] (bool refreshed) { ``` It runs async and silently (no screen change). On success the license is updated + -persisted and `onActivationChanged` fires; `controller().license()` then reflects the new +persisted, and when the server sent a new token `onLicenseChanged` and +`onActivationChanged` fire; `controller().license()` then reflects the new `owned_sub_product_ids`, `properties`, expiry, and seat counts. `force` bypasses the SDK's `online_validation_min_interval` throttle (you want that right after a purchase); pass `false` for a polite background re-check that respects it. A network failure is non-fatal: the current license is kept and the reason goes to `onDiagnostic`. So are rate limiting, a server error, and a response that didn't come from Moonbase (a captive -portal's sign-in page). A definitive rejection is not: when the server says the license +portal's sign-in page), as long as the license is within `onlineGracePeriod` of its last +successful check. A definitive rejection is not: when the server says the license was revoked or has lapsed, or that the store has closed, the controller drops it -and shows the welcome screen, and `onActivationChanged` fires. The license file stays, as it does when -`start()` meets the same answer, so the next launch checks it again. Offline licenses are -a no-op (they are permanent and not server-tracked). +and shows the welcome screen, and `onActivationChanged` fires. The same happens when the +grace period has run out and Moonbase still can't be reached. The license file stays, as it +does when `start()` meets the same answer, so the next launch checks it again. Offline +licenses are a no-op (they are permanent and not server-tracked). ### Cadence and timeouts @@ -445,10 +491,12 @@ config.httpConnectTimeout = std::chrono::seconds (5); config.httpRequestTimeout = std::chrono::seconds (15); ``` -The SDK never polls on a timer; it validates on launch (`start()`) and whenever you call -`refreshLicense()`, throttled to no more than once per `onlineCheckInterval`. A license -stays usable offline until `onlineGracePeriod` elapses since its last successful online -validation. +The SDK never polls the server on a timer; it validates online on launch (`start()`) and +whenever you call `refreshLicense()`, throttled to no more than once per +`onlineCheckInterval`. A license stays usable offline until `onlineGracePeriod` elapses +since its last successful online validation; the controller's local check (see +[Expiry and other instances](#expiry-and-other-instances)) enforces that mid-session too, +with one last online attempt before it locks. ## App updates diff --git a/examples/juce-native/Main.cpp b/examples/juce-native/Main.cpp index aaae3b4..b165d05 100644 --- a/examples/juce-native/Main.cpp +++ b/examples/juce-native/Main.cpp @@ -1,9 +1,10 @@ // Standalone sample app for the moonbase_licensing JUCE module. // // It mimics a real plugin editor ("Solstice") with a License button, and shows -// the activation flow as a MODAL OVERLAY on top of it. "Open Solstice", the -// close button, and a successful activation all just dismiss the overlay to -// reveal the app underneath; the License button brings it back. The endpoint / +// the activation flow as a MODAL OVERLAY on top of it. "Open Solstice" (shown +// once activation succeeds), "Continue" on the trial screen, and the close +// button dismiss the overlay to reveal the app underneath; the License button +// brings it back. The endpoint / // product id / public key are the public Moonbase demo values. #include @@ -83,7 +84,7 @@ class PluginEditor : public juce::Component addAndMakeVisible(licenseButton); activation = std::make_unique(makeConfig()); - activation->onClose = [this] { hideActivation(); }; // "Open", close (X), success all dismiss + activation->onClose = [this] { hideActivation(); }; // "Open {product}", "Continue" and the close (X) dismiss activation->onActivationChanged = [this](bool activated) { // On launch, lock behind the modal only if not already licensed. diff --git a/examples/juce-native/README.md b/examples/juce-native/README.md index 7dd9669..ecbe4c5 100644 --- a/examples/juce-native/README.md +++ b/examples/juce-native/README.md @@ -6,8 +6,9 @@ against the public demo environment (`https://demo.moonbase.sh`, product `demo-a It mimics a plugin editor for a fictional "Solstice" plugin and presents `ActivationComponent` as a **modal overlay** on top of it (`overlayBackdrop = true`). -"Open Solstice", the close button, and a successful activation all dismiss the -overlay to reveal the app underneath; the License button brings it back. That is the +"Open Solstice" (shown once activation succeeds), "Continue" on the trial screen, and +the close button dismiss the overlay to reveal the app underneath; the License button +brings it back. That is the shape most plugins want, so the file doubles as reference wiring. It also exercises the config surface beyond the three required fields: product and diff --git a/include/moonbase/detail/crypto/der.hpp b/include/moonbase/detail/crypto/der.hpp index 622f59e..e92ad37 100644 --- a/include/moonbase/detail/crypto/der.hpp +++ b/include/moonbase/detail/crypto/der.hpp @@ -2,15 +2,16 @@ // Minimal DER/TLV reader, just enough to normalize an RSA public key into // PKCS#1 `RSAPublicKey` form and (for the Windows CNG backend) split it into -// its modulus and exponent. Shared by the Apple and Windows crypto backends so -// they accept exactly the same key inputs the OpenSSL backend does: PEM SPKI +// its modulus and exponent. Accepted inputs: PEM SPKI // (`-----BEGIN PUBLIC KEY-----`), PEM PKCS#1 (`-----BEGIN RSA PUBLIC KEY-----`), // and raw base64 of either DER encoding. // -// The OpenSSL backend does not use this file — it lets OpenSSL parse the key. +// Every backend turns the key text into DER with decode_key_bytes, so a key +// string that works on one platform works on all of them. The OpenSSL backend +// then hands the DER to OpenSSL; the Apple and Windows backends normalize it +// with the reader below. #include -#include #include #include #include @@ -74,29 +75,33 @@ inline tlv read_tlv(cursor& c) return tlv{tag, content, length}; } -// Strip the PEM armor (if any) and base64-decode to raw DER bytes. +// Strip the PEM armor (if any) and base64-decode to raw DER bytes. The armor is +// found by its markers rather than by line, so a key that lost its line breaks +// on the way (an XML attribute, a JSON string, an environment variable) or +// picked up indentation still decodes. base64_decode skips the whitespace left +// inside the body. inline std::vector decode_key_bytes(const std::string& key_material) { - if (key_material.find("-----BEGIN") != std::string::npos) { - std::string body; - std::istringstream stream(key_material); - std::string line; - bool inside = false; - while (std::getline(stream, line)) { - if (line.find("-----BEGIN") != std::string::npos) { - inside = true; - continue; - } - if (line.find("-----END") != std::string::npos) { - break; - } - if (inside) { - body += line; - } - } - return base64_decode(body); + constexpr std::string_view begin_marker = "-----BEGIN"; + constexpr std::string_view end_marker = "-----END"; + constexpr std::string_view dashes = "-----"; + + const auto begin = key_material.find(begin_marker); + if (begin == std::string::npos) { + return base64_decode(key_material); } - return base64_decode(key_material); + + // The label ("PUBLIC KEY", "RSA PUBLIC KEY") runs up to the next five dashes. + const auto label_end = key_material.find(dashes, begin + begin_marker.size()); + if (label_end == std::string::npos) { + return {}; + } + + const auto body_begin = label_end + dashes.size(); + const auto body_end = key_material.find(end_marker, body_begin); + const std::string_view body = std::string_view(key_material).substr( + body_begin, body_end == std::string::npos ? std::string_view::npos : body_end - body_begin); + return base64_decode(body); } // Normalize any accepted key shape to PKCS#1 `RSAPublicKey` DER diff --git a/include/moonbase/detail/crypto/openssl_backend.hpp b/include/moonbase/detail/crypto/openssl_backend.hpp index 9ab2d08..30cb93e 100644 --- a/include/moonbase/detail/crypto/openssl_backend.hpp +++ b/include/moonbase/detail/crypto/openssl_backend.hpp @@ -12,13 +12,13 @@ #include #include -#include #include -#include #include #include +#include #include "moonbase/detail/base64.hpp" +#include "moonbase/detail/crypto/der.hpp" #include "moonbase/errors.hpp" namespace moonbase::detail::crypto { @@ -38,7 +38,6 @@ namespace openssl_detail { #endif using evp_pkey_ptr = std::unique_ptr; -using bio_ptr = std::unique_ptr; using evp_md_ctx_ptr = std::unique_ptr; inline evp_pkey_ptr make_empty_pkey() @@ -46,44 +45,6 @@ inline evp_pkey_ptr make_empty_pkey() return evp_pkey_ptr(nullptr, EVP_PKEY_free); } -inline bio_ptr make_memory_bio(const std::string& value) -{ - return bio_ptr(BIO_new_mem_buf(value.data(), static_cast(value.size())), BIO_free); -} - -inline evp_pkey_ptr read_pem_public_key(const std::string& public_key) -{ - { - auto bio = make_memory_bio(public_key); - if (bio) { - if (auto* pkey = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr)) { - return evp_pkey_ptr(pkey, EVP_PKEY_free); - } - } - } - - { - auto bio = make_memory_bio(public_key); - if (bio) { - if (auto* rsa = PEM_read_bio_RSAPublicKey(bio.get(), nullptr, nullptr, nullptr)) { - auto* pkey = EVP_PKEY_new(); - if (!pkey) { - RSA_free(rsa); - throw license_invalid_error("Could not allocate RSA public key"); - } - if (EVP_PKEY_assign_RSA(pkey, rsa) != 1) { - RSA_free(rsa); - EVP_PKEY_free(pkey); - throw license_invalid_error("Could not assign RSA public key"); - } - return evp_pkey_ptr(pkey, EVP_PKEY_free); - } - } - } - - return make_empty_pkey(); -} - inline evp_pkey_ptr read_der_public_key(const std::vector& der) { const unsigned char* cursor = der.data(); @@ -113,18 +74,13 @@ inline evp_pkey_ptr read_der_public_key(const std::vector& der) #pragma GCC diagnostic pop #endif +// The key text goes through the same decoder as the Apple and Windows backends +// rather than OpenSSL's PEM reader, which rejects an indented armor line, so +// every platform accepts the same key strings. inline evp_pkey_ptr load_public_key(const std::string& public_key) { - if (public_key.find("-----BEGIN") != std::string::npos) { - auto pkey = read_pem_public_key(public_key); - if (pkey) { - return pkey; - } - } - try { - auto der = base64_decode(public_key); - auto pkey = read_der_public_key(der); + auto pkey = read_der_public_key(der::decode_key_bytes(public_key)); if (pkey) { return pkey; } diff --git a/modules/moonbase_licensing/README.md b/modules/moonbase_licensing/README.md index 5fdd797..968edc6 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -134,7 +134,10 @@ if (! activation->controller().license().has_value()) `controller().license()` is the full `moonbase::license` (`trial`, `expires_at`, `issued_to.email`, seat counts, sub-product ownership, custom `properties`, …) for -richer gating, and `onActivationChanged` fires whenever it changes. +richer gating, and `onActivationChanged` fires whenever it changes (not on screen +navigation). The controller re-checks the license by itself: a trial that ends while the +plugin is open locks then, and an activation in another plugin instance or process is +picked up within a couple of seconds. ## Going further diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index f8b7828..657e7d0 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -119,7 +119,8 @@ struct ActivationConfig bool showMoonbaseBadge = true; bool enableOffline = true; // show the offline activation flow - bool reduceMotion = false; // skip transition/spinner/pop animation (a11y + snapshot tests) + 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) @@ -183,6 +184,14 @@ struct ActivationConfig // debug activation issues in the field. Invoked on the message thread. std::function onDiagnostic; + // Opens the browser link for online activation. Leave it empty for the + // system's default browser, or route the link through your own UI where the + // plugin can't launch one itself. Return false when the link couldn't be + // opened: the controller reports that to onDiagnostic, and + // controller().pendingBrowserUrl() still has the link to show or copy. + // Invoked on the message thread. + std::function openBrowser; + //== Telemetry / analytics ================================================= // Off by default. Set analytics.enabled = true to attach JUCE system/host // metadata (OS, CPU, JUCE version, DAW host, plugin format, ...) to every diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 329afb2..7bf509f 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -4,10 +4,16 @@ #include "ActivationController.h" #include "juce_http_transport.h" +#include + namespace moonbase::juce_integration { namespace { constexpr int kPollIntervalMs = 2000; +// How often the license watch looks at the license file and the clock. Both +// checks are local (a stat, a small read when the file changed, and a time +// compare), so this costs next to nothing. +constexpr int kLicenseWatchIntervalMs = 2000; // Diagnostic-only error text. For transport failures (moonbase::api_error) the // SDK stashes actionable guidance (e.g. the macOS network entitlement hint) in @@ -115,7 +121,19 @@ ActivationController::ActivationController(ActivationConfig config) { JavaVM* vm = nullptr; if (env->GetJavaVM(&vm) == 0) + { moonbase::android::set_jni_environment(vm, juce::getAppContext().get()); + + // The workers are plain threads. JUCE's networking attaches them to + // the VM, and Android aborts a thread that ends still attached, so + // detach on the way out, as JUCE's own threads do. + threadPool_.setWorkerExitHook([vm] + { + JNIEnv* attached = nullptr; + if (vm->GetEnv(reinterpret_cast(&attached), JNI_VERSION_1_2) == JNI_OK) + vm->DetachCurrentThread(); + }); + } } #endif @@ -196,6 +214,7 @@ std::optional ActivationController::describeDev ActivationController::~ActivationController() { + stopLicenseWatch(); stopTimer(); ++updateGeneration_; // drop any queued update-flow continuations updateDownload_.reset(); // cancels + joins the installer download thread @@ -204,7 +223,7 @@ ActivationController::~ActivationController() // plugin binary) are gone. cancelInFlight_ makes the drain near-instant. if (cancelInFlight_) cancelInFlight_(); - threadPool_.removeAllJobs(true, 5000); + threadPool_.stop(); } void ActivationController::setDeviceLabel(juce::String deviceName) @@ -244,6 +263,17 @@ void ActivationController::start() setScreen(Screen::Loading); + // Watch the license from here on (see the header). Only a file store has a + // file another instance can change. The first look is taken before the load, + // so a change that lands while it runs is still seen. + if (auto* fileStore = dynamic_cast(&licensing_->store())) + watchedFile_ = fileStore->path(); + watchedStamp_.reset(); + if (watchedFile_) + watchedStamp_ = stampLicenseFile(); + licenseWatch_.onTick = [this] { watchLicense(); }; + licenseWatch_.startTimer(kLicenseWatchIntervalMs); + const auto generation = ++generation_; juce::WeakReference safe(this); auto licensing = licensing_; @@ -424,8 +454,13 @@ void ActivationController::beginOnlineActivation() } self->pendingRequest_ = request; - juce::URL(juce::String(request->browser_url)).launchInDefaultBrowser(); + const juce::URL link(juce::String(request->browser_url)); + const bool opened = self->config_.openBrowser ? self->config_.openBrowser(link) + : link.launchInDefaultBrowser(); + if (! opened) + self->emitDiagnostic("Couldn't open the browser for activation; pendingBrowserUrl() has the link."); self->startTimer(kPollIntervalMs); + self->sendChangeMessage(); // pendingBrowserUrl() is set now }); }); } @@ -439,6 +474,11 @@ void ActivationController::cancelActivation() showWelcome(); } +juce::String ActivationController::pendingBrowserUrl() const +{ + return pendingRequest_ ? juce::String(pendingRequest_->browser_url) : juce::String(); +} + void ActivationController::refreshLicense(bool force, std::function onComplete) { if (! ensureReady()) @@ -447,6 +487,15 @@ void ActivationController::refreshLicense(bool force, std::function return; } + // An activation brings a fresh license of its own, and a re-check now would + // supersede its requests (stranding the flow if the request hasn't arrived). + if (activationInFlight()) + { + emitDiagnostic("refreshLicense: skipped while an activation is in progress."); + if (onComplete) onComplete(false); + return; + } + // Nothing to refresh, or an offline license (permanent + server-untracked). if (! license_ || license_->method == moonbase::activation_method::offline) { @@ -456,18 +505,22 @@ void ActivationController::refreshLicense(bool force, std::function return; } + ++refreshesInFlight_; const auto generation = ++generation_; const auto token = license_->token; const auto currentLicense = *license_; // for the expired-trial case (re-validation can't return it) const bool wasTrial = license_->trial; + const auto gracePeriod = config_.onlineGracePeriod; juce::WeakReference safe(this); auto licensing = licensing_; - threadPool_.addJob([safe, generation, token, currentLicense, wasTrial, licensing, force, onComplete]() mutable + threadPool_.addJob([safe, generation, token, currentLicense, wasTrial, gracePeriod, licensing, force, + onComplete]() mutable { std::optional refreshed; bool expired = false; bool rejected = false; + bool pastGrace = false; juce::String diag; try { @@ -503,12 +556,19 @@ void ActivationController::refreshLicense(bool force, std::function catch (const std::exception& ex) { diag = describeError(ex); + // No answer, or none that counts as a verdict. Within the grace + // period that is a network blip; past it the license has gone + // unverified for longer than the app allows, which locks, as it + // does at launch. + pastGrace = std::chrono::system_clock::now() - currentLicense.validated_at > gracePeriod; } - juce::MessageManager::callAsync([safe, generation, refreshed, expired, rejected, currentLicense, - wasTrial, licensing, diag, onComplete]() mutable + juce::MessageManager::callAsync([safe, generation, refreshed, expired, rejected, pastGrace, + currentLicense, wasTrial, licensing, diag, onComplete]() mutable { auto* self = safe.get(); + if (self != nullptr) + --self->refreshesInFlight_; if (self == nullptr || generation != self->generation_.load()) { // Superseded (e.g. deactivate / clearLicense bumped the @@ -545,13 +605,15 @@ void ActivationController::refreshLicense(bool force, std::function self->showTrialExpired(currentLicense); if (onComplete) onComplete(false); } - else if (expired || rejected) + else if (expired || rejected || pastGrace) { // Lock the way start() does for the same answer: drop the license // but leave the file, so the next launch checks it again. A full // license lands here when its subscription lapsed: the server // says LicenseExpired for that, as it does for an ended trial. - self->emitDiagnostic("License rejected on re-validation: " + diag); + self->emitDiagnostic(pastGrace + ? "Couldn't re-validate within the offline grace period: " + diag + : "License rejected on re-validation: " + diag); self->applyLicense(std::nullopt); if (onComplete) onComplete(false); } @@ -875,6 +937,224 @@ void ActivationController::deleteStoredMatching(const juce::String& activationId } } +//============================================================================== +// License watch: local checks only (a stat of the license file, a read when it +// changed, and a look at the clock), so no request goes out unless a license has +// actually run out. +void ActivationController::watchLicense() +{ + // Nothing is settled while the stored license is still loading. + if (screen_ == Screen::Loading) + return; + + checkLicenseFile(); + checkLicenseDeadlines(); +} + +void ActivationController::stopLicenseWatch() +{ + licenseWatch_.stopTimer(); +} + +ActivationController::FileStamp ActivationController::stampLicenseFile() const +{ + std::error_code ec; + FileStamp stamp; + stamp.exists = std::filesystem::is_regular_file(*watchedFile_, ec); + if (stamp.exists) + { + stamp.size = std::filesystem::file_size(*watchedFile_, ec); + stamp.modified = std::filesystem::last_write_time(*watchedFile_, ec); + } + return stamp; +} + +void ActivationController::checkLicenseFile() +{ + if (! watchedFile_) + return; + + const auto stamp = stampLicenseFile(); + if (watchedStamp_ && *watchedStamp_ == stamp) + return; + + // Only a file that was there and went away means another instance removed + // it. One that never got written (a failed save) must not lock the license + // this instance holds in memory. + const bool existed = watchedStamp_ && watchedStamp_->exists; + watchedStamp_ = stamp; + + if (! stamp.exists) + { + if (existed) + onLicenseFileRemoved(); + return; + } + + // Read the file itself rather than through the store. The store lock can be + // held by another process for the length of a network check, and + // load_local_license() deletes a file it can't parse, which is exactly what + // a file caught mid-write looks like. A torn read just fails to parse; the + // write that completes it changes the stamp, so the next tick reads it again. + std::optional onDisk; + try + { + std::ifstream in(*watchedFile_); + onDisk = nlohmann::json::parse(in).get(); + } + catch (const std::exception&) + { + return; + } + + if (license_ && onDisk->token == license_->token) + return; // our own write, or a sibling's that changed nothing + + std::optional stored; + try + { + stored = licensing_->validate_token_local(onDisk->token); + } + catch (const std::exception&) + { + // Expired, bound to another device, or tampered with: nothing this + // instance can use. Its own checks decide about its own license. + return; + } + + // Signed and unexpired, but unverified for longer than the grace period: + // start() would only take it after an online check, so don't unlock on it + // here either. Whoever re-validates it writes a fresh copy. + if (stored->method != moonbase::activation_method::offline + && std::chrono::system_clock::now() - stored->validated_at > config_.onlineGracePeriod) + return; + + adoptStoredLicense(std::move(*stored)); +} + +void ActivationController::onLicenseFileRemoved() +{ + // Another instance or process deactivated or forgot this machine's license, + // so this one locks too. + if (! license_) + return; + + if (activationInFlight()) + { + // The user is activating here (typically unlocking a trial): lock, but + // leave the flow on screen and running, as an expiry does. When it + // lands, it brings a license of its own. + emitDiagnostic("The license file was removed by another instance or process; locking. " + "The activation in progress carries on."); + setLicense(std::nullopt); + sendChangeMessage(); + return; + } + + emitDiagnostic("The license file was removed by another instance or process; locking."); + endActivationFlows(); // drop in-flight work for the removed license (a refresh, a deactivation) + applyLicense(std::nullopt); +} + +void ActivationController::adoptStoredLicense(moonbase::license stored) +{ + // Two processes' refreshes can land on disk out of order; never trade the + // copy we hold for an older one of the same activation. + if (license_ && license_->activation_id == stored.activation_id + && stored.validated_at < license_->validated_at) + return; + + if (license_ && license_->activation_id == stored.activation_id) + { + // A newer copy of the activation we hold, typically a sibling's + // re-validation: take it without moving the screen, so an open flow or + // an update download is never interrupted. + setLicense(std::move(stored)); + sendChangeMessage(); + return; + } + + // A different activation: activated or replaced elsewhere. Anything still + // in flight here belongs to the old one, and must not land on top of the + // new license (a refresh of a revoked activation would lock it, or write the + // old token back over the new file), so drop it all and show the license. + emitDiagnostic("Picked up a license stored by another instance or process."); + endActivationFlows(); + applyLicense(std::move(stored)); +} + +bool ActivationController::activationInFlight() const noexcept +{ + // From the click (the request is still being created) until it completes or + // is cancelled. + return pendingRequest_.has_value() || screen_ == Screen::BrowserWait; +} + +void ActivationController::checkLicenseDeadlines() +{ + // Not while a refresh is already on its way (the host's own, after a + // purchase, or an earlier one of ours): starting another would supersede it. + if (! license_ || busy_ || refreshesInFlight_ > 0) + return; + + const auto now = std::chrono::system_clock::now(); + const bool expired = license_->expires_at && *license_->expires_at <= now; + const bool offline = license_->method == moonbase::activation_method::offline; + const bool pastGrace = ! offline && now - license_->validated_at > config_.onlineGracePeriod; + if (! expired && ! pastGrace) + return; + + if (activationInFlight()) + { + // Typically a trial being unlocked. A re-check would supersede the + // activation's own requests, so stop honouring the license right here + // and leave the flow alone: the activation replaces it when it lands, + // and cancelling it returns to the welcome screen. + emitDiagnostic("The license ran out while an activation was in progress; locking."); + if (license_->trial) + expiredTrial_ = license_; + setLicense(std::nullopt); + sendChangeMessage(); + return; + } + + if (offline) + { + // Offline licenses are never re-validated, and one past its `exp` is + // dead for good: lock and remove it, as start() does. + emitDiagnostic("The offline license has expired; removing it."); + ++generation_; + deleteStoredLicense(); + applyLicense(std::nullopt); + return; + } + + if (! expired) + { + const auto steadyNow = std::chrono::steady_clock::now(); + if (lastGraceCheck_ && steadyNow - *lastGraceCheck_ < config_.onlineCheckInterval) + return; + lastGraceCheck_ = steadyNow; + } + + // Re-check it. A passed `exp` locks with no network call (a trial routes to + // the Expired screen); past the grace period the server gets one more + // chance, and the license locks if it can't be reached. + refreshLicense(false); +} + +void ActivationController::endActivationFlows() +{ + stopTimer(); + pendingRequest_.reset(); + pollInFlight_ = false; + ++generation_; // in-flight continuations no-op from here on... + busy_ = false; // ...including a deactivation's, which would have cleared this + offlineResponse_ = juce::File(); + offlineRequestSaved_ = false; + offlineError_.clear(); +} + //============================================================================== void ActivationController::showWelcome() { @@ -901,6 +1181,7 @@ void ActivationController::setPreviewState(Screen screen, std::optional value) license_ = std::move(value); // Publish for the audio thread (this always runs on the message thread). licensed_.store(license_.has_value(), std::memory_order_release); + notifyLicenseChange(); +} + +void ActivationController::notifyLicenseChange() +{ + // Deliver after the current call has finished updating the screen and + // status, so the host reads a consistent controller. Several changes in one + // go collapse into one delivery, which reports only if the end result moved. + if (std::exchange(licenseChangePending_, true)) + return; + + juce::WeakReference safe(this); + juce::MessageManager::callAsync([safe] + { + if (auto* self = safe.get()) + self->deliverLicenseChange(); + }); +} + +void ActivationController::deliverLicenseChange() +{ + licenseChangePending_ = false; + + auto token = license_ ? std::optional(license_->token) : std::nullopt; + if (licenseReported_ && token == reportedToken_) + return; + + licenseReported_ = true; + reportedToken_ = std::move(token); + if (onLicenseChanged) + onLicenseChanged(license_.has_value()); } ActivationController::Screen ActivationController::screenForCurrentLicense() const @@ -985,8 +1297,8 @@ void ActivationController::applyLicense(std::optional value) statusMessage_.clear(); // A validated license that outranks the running app routes to the update - // screen first (unless dismissed this session); "Remind me later" then falls - // through to the normal Details / Trial screen. + // screen first (unless that version was skipped); "Skip this update" then + // falls through to the normal Details / Trial screen. const auto dest = screenForCurrentLicense(); if ((dest == Screen::Details || dest == Screen::Trial) && config_.autoPresentUpdate && updateAvailable() && ! updateDismissedForCurrentLicense()) @@ -1282,8 +1594,9 @@ void ActivationController::revealUpdateDownload() void ActivationController::dismissUpdate() { - // Remember the dismissed version so we don't prompt for it again next session - // (a newer release still prompts). Persisted in the JSON state file. + // Remember the skipped version so we don't prompt for it again, in this + // session or the next (a newer release still prompts). Persisted in the JSON + // state file. if (state_) state_->ignoreUpdate(updateInfo_.newVersion); @@ -1299,6 +1612,7 @@ void ActivationController::setPreviewUpdate(UpdateInfo::Phase phase, moonbase::l ++generation_; ++updateGeneration_; stopTimer(); + stopLicenseWatch(); // a preview stays exactly as set pollInFlight_ = false; busy_ = false; diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index dcc51df..8b601ff 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -7,14 +7,23 @@ // can never clobber a newer state (cancel, a fresh activation, deactivate). // // Observe it as a juce::ChangeBroadcaster: on every change, read screen() and -// license() and repaint. +// license() and repaint. To hear only about the license itself, use +// onLicenseChanged. +// +// Once started, it also watches the license on its own, with no network +// traffic: a license whose `exp` passes or whose offline grace period runs out +// is re-checked (and locked if it has ended) without a restart, and a license +// another plugin instance or process activates, refreshes or removes is picked +// up within a couple of seconds. #include #include #include +#include #include #include #include +#include #include @@ -22,6 +31,7 @@ #include "ActivationConfig.h" #include "ActivationState.h" +#include "WorkerPool.h" namespace moonbase::juce_integration { @@ -85,22 +95,40 @@ class ActivationController : private juce::Timer, ~ActivationController() override; + // Fired on the message thread once start() has settled, then whenever the + // license itself changes: activated, refreshed into a new token, picked up + // from another instance, revoked, expired or cleared. Not fired for screen + // changes, progress or a re-check that changed nothing. `licensed` matches + // licensedFlag(); read license() for the rest. Assign it before start(). + std::function onLicenseChanged; + //== Lifecycle ============================================================= - // Loads + validates any stored license and routes to the right screen. + // Loads + validates any stored license, routes to the right screen and + // starts watching the license (see the class comment). void start(); //== Online activation ===================================================== void beginOnlineActivation(); // request + open browser + poll void cancelActivation(); // stop polling, back to Welcome + // The browser link for the online activation in progress, so a custom UI can + // show or copy it when the browser didn't open. Empty until the request has + // been created (a change is broadcast when it arrives), and again once the + // activation completes or is cancelled. + [[nodiscard]] juce::String pendingBrowserUrl() const; + //== Re-validation ========================================================= // Re-check the current license against the server and refresh its entitlements // (sub-product ownership, properties, expiry, seats) in place. Call this after - // a purchase so newly granted features load without an app restart. Runs async - // and silently (no screen change); on success the license updates and - // onActivationChanged fires so you can reload features. `force` bypasses the - // online-validation throttle (use it right after a purchase). No-op for offline - // licenses. The optional callback runs on the message thread with the outcome. + // a purchase so newly granted features load without an app restart. Runs async; + // on success the license updates, and onLicenseChanged / onActivationChanged + // fire when the server sent a new token, so you can reload features. `force` + // bypasses the online-validation throttle (use it right after a purchase). + // A revoked or lapsed license locks, and so does one that can't be checked + // once the offline grace period has run out; within it a network failure + // keeps the license. No-op for offline licenses, and while an online + // activation is in progress (it brings a fresh license of its own). The + // optional callback runs on the message thread with the outcome. void refreshLicense(bool force = true, std::function onComplete = {}); //== App update flow ======================================================= @@ -128,8 +156,10 @@ class ActivationController : private juce::Timer, // "Done" state of the update flow). void revealUpdateDownload(); - // "Remind me later": leave the update screen for the normal Details / Trial - // screen and don't prompt again this session. + // "Skip this update": leave the update screen for the normal Details / Trial + // screen and don't prompt for this version again. The skipped version is + // persisted in the state file, so it holds across restarts; a newer release + // still prompts. void dismissUpdate(); //== Offline activation ==================================================== @@ -204,6 +234,31 @@ class ActivationController : private juce::Timer, [[nodiscard]] int trialDaysRemaining() const; private: + // A message-thread timer with a callback. The controller's own Timer base + // is the activation poll, so the license watch needs a second one. + class CallbackTimer : public juce::Timer + { + public: + std::function onTick; + void timerCallback() override + { + if (onTick) + onTick(); + } + }; + + // What the license watch last saw of the license file. + struct FileStamp + { + bool exists = false; + std::uintmax_t size = 0; + std::filesystem::file_time_type modified{}; + bool operator==(const FileStamp& other) const + { + return exists == other.exists && size == other.size && modified == other.modified; + } + }; + void timerCallback() override; // juce::URL::DownloadTaskListener: both arrive on a background thread and @@ -231,6 +286,20 @@ class ActivationController : private juce::Timer, void showTrialExpired(moonbase::license expired); // locks + routes to the Expired screen [[nodiscard]] Screen screenForCurrentLicense() const; // Welcome / Trial / Details void onActivationFulfilled(moonbase::license value); + void endActivationFlows(); // stop polling, forget offline-flow progress, drop in-flight continuations + + //== License watch (no network) ============================================ + void watchLicense(); // one tick: file changes, then deadlines + [[nodiscard]] FileStamp stampLicenseFile() const; // watchedFile_ must be set + void checkLicenseFile(); + void checkLicenseDeadlines(); + [[nodiscard]] bool activationInFlight() const noexcept; + void onLicenseFileRemoved(); + void adoptStoredLicense(moonbase::license stored); + void stopLicenseWatch(); + + void notifyLicenseChange(); // coalesced, async onLicenseChanged + void deliverLicenseChange(); void deleteStoredMatching(const juce::String& activationId); void deleteStoredLicense(); // best-effort delete of the local license file void setDeviceLabel(juce::String deviceName); @@ -247,9 +316,10 @@ class ActivationController : private juce::Timer, // Network/file work runs here instead of detached threads, so the destructor // can drain workers (after cancelInFlight_ unblocks any in-flight request); - // nothing outlives the controller. + // nothing outlives the controller. A module-owned pool rather than a + // juce::ThreadPool, which can kill a thread at teardown (see WorkerPool.h). std::function cancelInFlight_; - juce::ThreadPool threadPool_ { 2 }; + detail::WorkerPool threadPool_ { 2 }; Screen screen_ = Screen::Loading; std::optional license_; @@ -268,6 +338,21 @@ class ActivationController : private juce::Timer, bool busy_ = false; bool pollInFlight_ = false; + //== License watch ========================================================= + CallbackTimer licenseWatch_; + std::optional watchedFile_; // empty when the store is not a file + std::optional watchedStamp_; // last look; taken first in start() + int refreshesInFlight_ = 0; // refreshLicense() calls whose answer hasn't landed yet + // When the watch last asked the server about a license past its grace + // period. Spaced by onlineCheckInterval, so a skewed clock can't turn the + // watch into a request every tick. + std::optional lastGraceCheck_; + + // onLicenseChanged bookkeeping: the token last reported (nullopt = no license). + bool licenseChangePending_ = false; + bool licenseReported_ = false; + std::optional reportedToken_; + // Bumped by every state-changing entry point; async continuations capture it // and no-op if it has moved on by the time they run on the message thread. std::atomic generation_{0}; diff --git a/modules/moonbase_licensing/juce/WorkerPool.h b/modules/moonbase_licensing/juce/WorkerPool.h new file mode 100644 index 0000000..1ef1be9 --- /dev/null +++ b/modules/moonbase_licensing/juce/WorkerPool.h @@ -0,0 +1,153 @@ +#pragma once + +// The controller's background workers: a few std::threads draining a queue. +// +// Deliberately not a juce::ThreadPool. When one is destroyed, JUCE gives each +// idle thread 500 ms to notice and exit, then kills it by force. On JUCE 6.1.3 +// an idle pool thread now and then misses that window, and a killed thread can +// leave a lock held that hangs a later teardown in the same process. These +// workers sleep on a condition variable with no timeout and are always joined: +// never killed, and never left running. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace moonbase::juce_integration::detail { + +class WorkerPool +{ +public: + explicit WorkerPool(int numThreads) + { + for (int i = 0; i < numThreads; ++i) + { + try + { + threads_.emplace_back([this] { run(); }); + } + catch (const std::system_error&) + { + break; // out of threads: make do with the ones we have + } + } + runInline_ = threads_.empty(); + } + + ~WorkerPool() { stop(); } + + // Runs the job on a worker. With no worker at all (the system refused to + // start one) it runs right here instead, so the caller still gets its answer. + void addJob(std::function job) + { + if (runInline_) + { + runJob(job); + return; + } + + { + std::lock_guard lock(mutex_); + if (stopping_) + return; + jobs_.push_back(std::move(job)); + } + wake_.notify_one(); + } + + // Runs on each worker as it exits, e.g. to detach it from the Java VM on + // Android. Set it once, right after construction. + void setWorkerExitHook(std::function hook) + { + std::lock_guard lock(mutex_); + onExit_ = std::move(hook); + } + + // Drops the jobs that haven't started, then waits for the running ones and + // joins the workers. Callers cancel what they can first (the controller + // cancels its HTTP requests), so this is normally instant. It never gives up + // on a worker, because one left running could still be executing module code + // after the plugin binary is unloaded. The longest it waits is a job blocked + // on the license lock while another process holds it for its own network + // check, which that process's request timeouts bound. Safe to call twice. + void stop() + { + if (threads_.empty()) + return; // already stopped (or never had a worker) + + std::deque> dropped; // destroyed below, outside the lock + { + std::lock_guard lock(mutex_); + stopping_ = true; + dropped.swap(jobs_); + } + wake_.notify_all(); + + for (auto& thread : threads_) + thread.join(); + threads_.clear(); + } + +private: + void run() + { + juce::Thread::setCurrentThreadName("Moonbase worker"); + + std::unique_lock lock(mutex_); + for (;;) + { + wake_.wait(lock, [this] { return stopping_ || ! jobs_.empty(); }); + if (stopping_) + break; + + auto job = std::move(jobs_.front()); + jobs_.pop_front(); + lock.unlock(); + runJob(job); + lock.lock(); + } + + const auto onExit = onExit_; + lock.unlock(); + if (onExit) + onExit(); + } + + static void runJob(std::function& job) + { + // JUCE's networking creates Objective-C objects on Apple platforms, and + // a plain std::thread has no autorelease pool of its own. + JUCE_AUTORELEASEPOOL + { + try + { + job(); + } + catch (...) + { + jassertfalse; // jobs handle their own errors; this one escaped + } + job = nullptr; // release the captures here, not under the queue lock + } + } + + std::mutex mutex_; + std::condition_variable wake_; // a job arrived, or stop() was called + std::deque> jobs_; + std::function onExit_; + bool stopping_ = false; + + std::vector threads_; + bool runInline_ = false; + + JUCE_DECLARE_NON_COPYABLE(WorkerPool) +}; + +} // namespace moonbase::juce_integration::detail diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp index cca075f..ccea368 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include namespace moonbase::juce_integration { @@ -732,13 +734,15 @@ class BrowserWaitView : public ScreenView, ~BrowserWaitView() override { stopTimer(); } // Self-drive the spinner with a timer so it animates regardless of the - // shared animation updater — only while this screen is visible. - void visibilityChanged() override + // shared animation updater, but only while this screen is up and the panel + // around it is visible. It keeps turning under reduceMotion: it is the one + // sign that the wait is still alive, not decoration. + void visibilityChanged() override { updateSpinner(); } + + void setOwnerVisible(bool visible) { - if (isVisible()) - startTimerHz(60); - else - stopTimer(); + ownerVisible = visible; + updateSpinner(); } void timerCallback() override @@ -790,6 +794,19 @@ class BrowserWaitView : public ScreenView, } private: + void updateSpinner() + { + if (isVisible() && ownerVisible) + { + if (! isTimerRunning()) + startTimerHz(60); + } + else + { + stopTimer(); + } + } + void drawSpinner(Graphics& g, Rectangle area) { const float diameter = 58.0f; @@ -839,6 +856,7 @@ class BrowserWaitView : public ScreenView, std::unique_ptr monitorIcon; Rectangle spinnerRepaint; float spinPhase = 0.0f; + bool ownerVisible = false; }; //============================================================================== @@ -1775,7 +1793,7 @@ class DetailsView : public ScreenView //============================================================================== // Update available: a valid license, but the backend reports a newer released // version (the p:rel claim outranks the app version). Shows release notes and an -// in-app installer download with progress; "Remind me later" dismisses it. +// in-app installer download with progress; "Skip this update" dismisses it. class UpdateNotesList : public juce::Component { public: @@ -2087,10 +2105,31 @@ class UpdateAvailableView : public ScreenView std::unique_ptr skip; }; +//============================================================================== +// Calls back whenever the panel starts or stops being on screen because it, a +// parent or its window changed. Minimising is the one change it can't see. +struct ShowingWatcher final : public juce::ComponentMovementWatcher +{ + ShowingWatcher(juce::Component& watched, std::function callback) + : juce::ComponentMovementWatcher(&watched), onChange(std::move(callback)) + { + } + + using juce::ComponentMovementWatcher::componentMovedOrResized; + using juce::ComponentMovementWatcher::componentVisibilityChanged; + + void componentMovedOrResized(bool, bool) override {} + void componentPeerChanged() override { onChange(); } + void componentVisibilityChanged() override { onChange(); } + + std::function onChange; +}; + //============================================================================== // Impl struct ActivationComponent::Impl : public juce::ChangeListener, - private juce::Timer + private juce::Timer, + private juce::AsyncUpdater { // Owns a controller built from the config. Impl(ActivationComponent& o, ActivationConfig cfg) @@ -2144,24 +2183,70 @@ struct ActivationComponent::Impl : public juce::ChangeListener, owner.addChildComponent(*moonbaseBadge); // shown only when config.showMoonbaseBadge buildAnimators(); - // Drive the animations from a timer rather than a VBlankAnimatorUpdater: - // the latter did not deliver ticks reliably for a freshly-shown - // plugin/app window. update() uses the hi-res clock. - startTimerHz(60); + showingWatcher = std::make_unique(owner, [this] { updateTicking(); }); + updateTicking(); controller.addChangeListener(this); if (ownsController) - controller.start(); // load any stored license + route + { + controller.start(); // load any stored license + route + } else - changeListenerCallback(nullptr); // shared + already started: sync to its current state + { + // Shared + already started: show its current state now, but tell the + // host (close button, onActivationChanged, an update auto-present) + // only once it has had the chance to wire its callbacks, which it + // does after this constructor returns. + syncViews(); + triggerAsyncUpdate(); + } } ~Impl() override { + showingWatcher.reset(); + cancelPendingUpdate(); stopTimer(); controller.removeChangeListener(this); } + // Drive the animations from a timer rather than a VBlankAnimatorUpdater: + // the latter did not deliver ticks reliably for a freshly-shown plugin/app + // window. update() uses the hi-res clock. The timer follows whether the + // panel is on screen: 60 Hz while it is and something can animate, off + // while the panel or any parent is hidden (showingWatcher reports when that + // changes), so an overlay kept hidden in every editor costs nothing. In + // between, it looks a few times a second for the one change nothing reports: + // a minimised window coming back. + void updateTicking() + { + const bool showing = owner.isShowing(); + int hz = 0; + if (panelAndParentsVisible()) + hz = showing && ! controller.config().reduceMotion ? 60 : 4; + + if (hz == 0) + stopTimer(); + else if (getTimerInterval() != 1000 / hz) + startTimerHz(hz); + tickingWhileShowing = showing; + + if (browser != nullptr) + browser->setOwnerVisible(showing); + } + + // The panel and every parent above it are visible. It can still be off + // screen: in a minimised window, or not in a window yet. + [[nodiscard]] bool panelAndParentsVisible() const + { + for (const juce::Component* c = &owner; c != nullptr; c = c->getParentComponent()) + if (! c->isVisible()) + return false; + return true; + } + + void handleAsyncUpdate() override { notifyHost(); } + // Explicitly present the update screen now (host hook). Routes to the update // screen as an auto-presentation; the overlay then appears via the change // callback. No-op when no update is available. @@ -2171,7 +2256,18 @@ struct ActivationComponent::Impl : public juce::ChangeListener, controller.showUpdate(/*fromLicenseView*/ false); } - void timerCallback() override { updater.update(); } + void timerCallback() override + { + if (owner.isShowing() != tickingWhileShowing) + { + updateTicking(); // minimised or restored + return; + } + + // Animations run on the clock, so after time off screen they catch up. + if (tickingWhileShowing) + updater.update(); + } std::vector views() { @@ -2198,6 +2294,14 @@ struct ActivationComponent::Impl : public juce::ChangeListener, } void changeListenerCallback(juce::ChangeBroadcaster*) override + { + cancelPendingUpdate(); // this covers the deferred initial sync too + syncViews(); + notifyHost(); + } + + // Route the views to the controller's current screen. + void syncViews() { const auto screen = controller.screen(); auto* next = viewFor(screen); @@ -2229,7 +2333,7 @@ struct ActivationComponent::Impl : public juce::ChangeListener, // open. Not when reached from the license-view badge (already visible). if (screen == ActivationController::Screen::UpdateAvailable && ! controller.updateCameFromLicenseView()) - appear(); + autoPresentPending = true; } else if (active != nullptr) { @@ -2239,7 +2343,7 @@ struct ActivationComponent::Impl : public juce::ChangeListener, if (controller.screen() == ActivationController::Screen::Success) { - if (controller.config().reduceMotion || ! successAnim) + if (! animating() || ! successAnim) { success->setPop(1.0f); } @@ -2249,11 +2353,40 @@ struct ActivationComponent::Impl : public juce::ChangeListener, successAnim->start(); } } + } + + // The side effects the host sees: the close button (it depends on + // owner.onClose), an update auto-present, and onActivationChanged. + void notifyHost() + { + if (std::exchange(autoPresentPending, false)) + appear(); updateCloseButton(); owner.repaint(); - if (owner.onActivationChanged) - owner.onActivationChanged(controller.license().has_value()); + + // Report the settled state once, then only real license changes: never + // screen navigation, busy flips or download progress. Counted as reported + // only once a callback has received it, so a late-wired host still hears + // the current state on the next change. + if (controller.screen() == ActivationController::Screen::Loading || ! owner.onActivationChanged) + return; + + const auto& license = controller.license(); + auto token = license ? std::optional(license->token) : std::nullopt; + if (activationReported && token == reportedToken) + return; + + activationReported = true; + reportedToken = std::move(token); + owner.onActivationChanged(license.has_value()); + } + + // Whether motion plays right now: not under reduceMotion, and not while the + // panel is off screen (nothing would tick it; it should land on its last frame). + [[nodiscard]] bool animating() const + { + return ! controller.config().reduceMotion && owner.isShowing(); } //== Animation ============================================================ @@ -2316,7 +2449,10 @@ struct ActivationComponent::Impl : public juce::ChangeListener, updater.addAnimation(*transitionAnim); updater.addAnimation(*successAnim); updater.addAnimation(*appearAnim); - glowAnim->start(); + + // Decorative, so reduceMotion leaves the glow still at its first frame. + if (! controller.config().reduceMotion) + glowAnim->start(); } void appear() @@ -2408,7 +2544,7 @@ struct ActivationComponent::Impl : public juce::ChangeListener, return; } - if (controller.config().reduceMotion || ! transitionAnim || w <= 0) + if (! animating() || ! transitionAnim || w <= 0) { finishTransition(); return; @@ -2610,6 +2746,14 @@ struct ActivationComponent::Impl : public juce::ChangeListener, std::optional glowAnim, transitionAnim, successAnim, appearAnim; float glowPhase = 0.0f; + std::unique_ptr showingWatcher; + bool tickingWhileShowing = false; + + // Host-facing state (notifyHost). + bool autoPresentPending = false; + bool activationReported = false; + std::optional reportedToken; // nullopt = reported as not activated + // Modal appear/dismiss animation state. float appear_ = 1.0f; // 1 = fully shown, 0 = hidden float appearScale_ = 1.0f; // panel scale derived from appear_ @@ -2645,4 +2789,10 @@ void ActivationComponent::paint(Graphics& g) { impl->paintChrome(g); } void ActivationComponent::resized() { impl->layout(); } +void ActivationComponent::visibilityChanged() +{ + if (impl != nullptr) // not while impl is still being built or torn down + impl->updateTicking(); +} + } // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/juce/ui/ActivationComponent.h b/modules/moonbase_licensing/juce/ui/ActivationComponent.h index 387b68c..19bca76 100644 --- a/modules/moonbase_licensing/juce/ui/ActivationComponent.h +++ b/modules/moonbase_licensing/juce/ui/ActivationComponent.h @@ -31,13 +31,20 @@ class ActivationComponent : public juce::Component ~ActivationComponent() override; - // Called when the user dismisses the flow from a "done" state — the - // Welcome "no thanks" is not offered, so this fires from Success ("Open …") - // and Trial ("Continue"). ActivationDialog wires this to close the window. + // Called when the user dismisses the flow from a "done" state. The Welcome + // "no thanks" is not offered, so this fires from Success ("Open {product}"), + // Trial ("Continue"), the close button once a license is loaded, and "Skip + // this update" on an auto-presented update. ActivationDialog wires this to + // close the window. std::function onClose; - // Fired whenever activation state settles (true once a valid license is - // loaded). Handy for gating: enable your plugin when this reports true. + // Fired once the activation state has settled (after the stored license has + // loaded), then whenever the license itself changes: activated, refreshed + // into a new token, picked up from another instance, revoked, expired or + // cleared. Never for screen navigation, busy flips or download progress, so + // it is a safe place to reload features. true once a valid license is + // loaded. Assign it right after construction; the first report is deferred + // until then. std::function onActivationChanged; [[nodiscard]] ActivationController& controller(); @@ -46,8 +53,9 @@ class ActivationComponent : public juce::Component // backdrop. Use these (instead of setVisible) when overlaying a host app: // appear() -> setVisible(true) and animate in // dismiss() -> animate out, then setVisible(false) - // "Open", the close button, and a successful activation call onClose; wire - // onClose to dismiss(). + // A successful activation shows an "Open {product}" button rather than + // closing by itself; that button and the close button call onClose, so wire + // onClose to dismiss(). While hidden the panel runs no timers. void appear(); void dismiss(); @@ -62,6 +70,7 @@ class ActivationComponent : public juce::Component void paint(juce::Graphics&) override; void resized() override; + void visibilityChanged() override; private: struct Impl; diff --git a/modules/moonbase_licensing/moonbase/detail/crypto/der.hpp b/modules/moonbase_licensing/moonbase/detail/crypto/der.hpp index 622f59e..e92ad37 100644 --- a/modules/moonbase_licensing/moonbase/detail/crypto/der.hpp +++ b/modules/moonbase_licensing/moonbase/detail/crypto/der.hpp @@ -2,15 +2,16 @@ // Minimal DER/TLV reader, just enough to normalize an RSA public key into // PKCS#1 `RSAPublicKey` form and (for the Windows CNG backend) split it into -// its modulus and exponent. Shared by the Apple and Windows crypto backends so -// they accept exactly the same key inputs the OpenSSL backend does: PEM SPKI +// its modulus and exponent. Accepted inputs: PEM SPKI // (`-----BEGIN PUBLIC KEY-----`), PEM PKCS#1 (`-----BEGIN RSA PUBLIC KEY-----`), // and raw base64 of either DER encoding. // -// The OpenSSL backend does not use this file — it lets OpenSSL parse the key. +// Every backend turns the key text into DER with decode_key_bytes, so a key +// string that works on one platform works on all of them. The OpenSSL backend +// then hands the DER to OpenSSL; the Apple and Windows backends normalize it +// with the reader below. #include -#include #include #include #include @@ -74,29 +75,33 @@ inline tlv read_tlv(cursor& c) return tlv{tag, content, length}; } -// Strip the PEM armor (if any) and base64-decode to raw DER bytes. +// Strip the PEM armor (if any) and base64-decode to raw DER bytes. The armor is +// found by its markers rather than by line, so a key that lost its line breaks +// on the way (an XML attribute, a JSON string, an environment variable) or +// picked up indentation still decodes. base64_decode skips the whitespace left +// inside the body. inline std::vector decode_key_bytes(const std::string& key_material) { - if (key_material.find("-----BEGIN") != std::string::npos) { - std::string body; - std::istringstream stream(key_material); - std::string line; - bool inside = false; - while (std::getline(stream, line)) { - if (line.find("-----BEGIN") != std::string::npos) { - inside = true; - continue; - } - if (line.find("-----END") != std::string::npos) { - break; - } - if (inside) { - body += line; - } - } - return base64_decode(body); + constexpr std::string_view begin_marker = "-----BEGIN"; + constexpr std::string_view end_marker = "-----END"; + constexpr std::string_view dashes = "-----"; + + const auto begin = key_material.find(begin_marker); + if (begin == std::string::npos) { + return base64_decode(key_material); } - return base64_decode(key_material); + + // The label ("PUBLIC KEY", "RSA PUBLIC KEY") runs up to the next five dashes. + const auto label_end = key_material.find(dashes, begin + begin_marker.size()); + if (label_end == std::string::npos) { + return {}; + } + + const auto body_begin = label_end + dashes.size(); + const auto body_end = key_material.find(end_marker, body_begin); + const std::string_view body = std::string_view(key_material).substr( + body_begin, body_end == std::string::npos ? std::string_view::npos : body_end - body_begin); + return base64_decode(body); } // Normalize any accepted key shape to PKCS#1 `RSAPublicKey` DER diff --git a/modules/moonbase_licensing/moonbase/detail/crypto/openssl_backend.hpp b/modules/moonbase_licensing/moonbase/detail/crypto/openssl_backend.hpp index 9ab2d08..30cb93e 100644 --- a/modules/moonbase_licensing/moonbase/detail/crypto/openssl_backend.hpp +++ b/modules/moonbase_licensing/moonbase/detail/crypto/openssl_backend.hpp @@ -12,13 +12,13 @@ #include #include -#include #include -#include #include #include +#include #include "moonbase/detail/base64.hpp" +#include "moonbase/detail/crypto/der.hpp" #include "moonbase/errors.hpp" namespace moonbase::detail::crypto { @@ -38,7 +38,6 @@ namespace openssl_detail { #endif using evp_pkey_ptr = std::unique_ptr; -using bio_ptr = std::unique_ptr; using evp_md_ctx_ptr = std::unique_ptr; inline evp_pkey_ptr make_empty_pkey() @@ -46,44 +45,6 @@ inline evp_pkey_ptr make_empty_pkey() return evp_pkey_ptr(nullptr, EVP_PKEY_free); } -inline bio_ptr make_memory_bio(const std::string& value) -{ - return bio_ptr(BIO_new_mem_buf(value.data(), static_cast(value.size())), BIO_free); -} - -inline evp_pkey_ptr read_pem_public_key(const std::string& public_key) -{ - { - auto bio = make_memory_bio(public_key); - if (bio) { - if (auto* pkey = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr)) { - return evp_pkey_ptr(pkey, EVP_PKEY_free); - } - } - } - - { - auto bio = make_memory_bio(public_key); - if (bio) { - if (auto* rsa = PEM_read_bio_RSAPublicKey(bio.get(), nullptr, nullptr, nullptr)) { - auto* pkey = EVP_PKEY_new(); - if (!pkey) { - RSA_free(rsa); - throw license_invalid_error("Could not allocate RSA public key"); - } - if (EVP_PKEY_assign_RSA(pkey, rsa) != 1) { - RSA_free(rsa); - EVP_PKEY_free(pkey); - throw license_invalid_error("Could not assign RSA public key"); - } - return evp_pkey_ptr(pkey, EVP_PKEY_free); - } - } - } - - return make_empty_pkey(); -} - inline evp_pkey_ptr read_der_public_key(const std::vector& der) { const unsigned char* cursor = der.data(); @@ -113,18 +74,13 @@ inline evp_pkey_ptr read_der_public_key(const std::vector& der) #pragma GCC diagnostic pop #endif +// The key text goes through the same decoder as the Apple and Windows backends +// rather than OpenSSL's PEM reader, which rejects an indented armor line, so +// every platform accepts the same key strings. inline evp_pkey_ptr load_public_key(const std::string& public_key) { - if (public_key.find("-----BEGIN") != std::string::npos) { - auto pkey = read_pem_public_key(public_key); - if (pkey) { - return pkey; - } - } - try { - auto der = base64_decode(public_key); - auto pkey = read_der_public_key(der); + auto pkey = read_der_public_key(der::decode_key_bytes(public_key)); if (pkey) { return pkey; } diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 927eeff..b304f90 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -15,9 +15,14 @@ #include +#include +#include #include +#include #include +#include #include +#include #include #include @@ -47,6 +52,13 @@ bool pumpUntil(const std::function& cond, int timeoutMs = 5000) return cond(); } +// Keep the message loop running for a while, e.g. to show that something does +// NOT happen. +void pumpFor(int ms) +{ + pumpUntil([] { return false; }, ms); +} + bool settled(const ActivationController& c) { return c.screen() != Screen::Loading; @@ -127,6 +139,33 @@ struct controller_fixture } }; +// Holds every request at a gate until the test releases it, then answers from +// the queue (or fails, like a dropped connection, when it runs dry). For +// arranging what lands while a request is still on its way. +struct gated_transport : moonbase::http_transport +{ + juce::WaitableEvent gate{ true }; // manual reset: once released, stays open + std::atomic entered{ false }; + std::atomic requests{ 0 }; + std::mutex mutex; + std::deque responses; + + moonbase::http_response send(const moonbase::http_request&) override + { + ++requests; + entered = true; + gate.wait(); + std::lock_guard lock(mutex); + if (responses.empty()) + throw moonbase::api_error(0, "no response queued"); + auto response = responses.front(); + responses.pop_front(); + return response; + } + + void release() { gate.signal(); } +}; + } // namespace //============================================================================== @@ -1068,6 +1107,33 @@ TEST_CASE("a refresh that lands after the license is cleared does not resurrect CHECK_FALSE(fx.licenseFile.existsAsFile()); // and NOT recreated on disk } +TEST_CASE("the module accepts a public key in every common text shape") +{ + // Runs on the native backend on Apple and Windows and on OpenSSL on Linux, so + // together with the core suite every backend sees the same key strings. + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + + const std::vector> formats{{"SPKI", fx.key.public_pem}, + {"PKCS#1", fx.key.public_pkcs1_pem}}; + for (const auto& format : formats) + { + for (const auto& shape : moonbase::tests::key_text_shapes(format.second)) + { + CAPTURE(format.first); + CAPTURE(shape.first); + auto config = fx.config; + config.publicKey = juce::String(shape.second); + ActivationController controller(config, std::make_shared( + config.toLicensingOptions(), fx.store, fx.fingerprint, + fx.transport)); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); + } + } +} + TEST_CASE("a public key with an out-of-bounds DER length is rejected cleanly") { // Outer SEQUENCE(len 5) wrapping an INTEGER whose short-form length (0x7F) @@ -1177,6 +1243,444 @@ TEST_CASE("refreshLicense keeps the license through what only looks like a verdi } } +TEST_CASE("refreshLicense locks once the grace period has run out and Moonbase can't be reached") +{ + controller_fixture fx; + fx.config.onlineGracePeriod = std::chrono::seconds(20); + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + auto claims = default_claims(); + claims["validated"] = now_seconds() - 30; // past the grace period, so start() checks online + fx.seedStored(fx.token(claims)); + + // Moonbase answers start(), but its clock runs a little behind ours, so the + // license it returns is already past the grace period here. + auto behind = default_claims(); + behind["validated"] = now_seconds() - 25; + fx.transport->responses.push_back(moonbase::http_response{200, {}, fx.token(behind)}); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + // Nothing queued: the forced re-check can't reach Moonbase. + bool done = false, ok = true; + controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; }); + REQUIRE(pumpUntil([&] { return done; })); + + CHECK_FALSE(ok); + CHECK_FALSE(controller.licensedFlag().load()); + CHECK(controller.screen() == Screen::Welcome); + CHECK(fx.licenseFile.existsAsFile()); // kept for the next launch, as start() keeps it + CHECK(diags.joinIntoString(" ").contains("offline grace period")); +} + +//============================================================================== +// License watch: expiry and other instances, without a restart +//============================================================================== +TEST_CASE("a trial that ends while the plugin is open locks without a restart") +{ + controller_fixture fx; + auto claims = default_claims(); + claims["trial"] = true; + claims["exp"] = now_seconds() + 2; + fx.seedStored(fx.token(claims)); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; })); + REQUIRE(controller.licensedFlag().load()); + + CHECK(pumpUntil([&] { return controller.screen() == Screen::Expired; }, 10000)); + CHECK_FALSE(controller.licensedFlag().load()); + REQUIRE(controller.expiredTrial().has_value()); + CHECK(fx.transport->requests.empty()); // a passed exp is decided locally +} + +TEST_CASE("a license offline past its grace period locks while the plugin is open") +{ + controller_fixture fx; + fx.config.onlineGracePeriod = std::chrono::seconds(3); + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + auto claims = default_claims(); + claims["validated"] = now_seconds(); // fresh, so start() stays local + fx.seedStored(fx.token(claims)); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + CHECK(fx.transport->requests.empty()); + + // Nothing queued: once the grace period ends, Moonbase can't be reached. + CHECK(pumpUntil([&] { return controller.screen() == Screen::Welcome; }, 10000)); + CHECK_FALSE(controller.licensedFlag().load()); + CHECK(fx.transport->requests.size() == 1); // one last attempt, then locked + CHECK(diags.joinIntoString(" ").contains("offline grace period")); + CHECK(fx.licenseFile.existsAsFile()); +} + +TEST_CASE("an activation in another instance unlocks this one without a reload") +{ + controller_fixture fx; + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Welcome; })); + + fx.seedStored(fx.token(default_claims())); // what the other instance writes on activation + + CHECK(pumpUntil([&] { return controller.screen() == Screen::Details; }, 6000)); + CHECK(controller.licensedFlag().load()); + CHECK(fx.transport->requests.empty()); // validated locally, no network +} + +TEST_CASE("a sibling's re-validation updates this instance without moving its screen") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + controller.showOffline(); // somewhere a re-route would move it away from + + auto upgraded = default_claims(); + upgraded["sp:owned"] = "demo-app-pro,demo-app-extra,demo-app-mega"; + fx.seedStored(fx.token(upgraded)); + + CHECK(pumpUntil([&] { return controller.license() + && controller.license()->owned_sub_product_ids.size() == 3; }, + 6000)); + CHECK(controller.screen() == Screen::Offline); +} + +TEST_CASE("a license file caught mid-write is left alone and read again") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + // Another writer has truncated the file and written only half of it so far. + const auto full = fx.licenseFile.loadFileAsString(); + REQUIRE(fx.licenseFile.replaceWithText(full.substring(0, full.length() / 2))); + pumpFor(4500); // two watch ticks + CHECK(controller.licensedFlag().load()); + CHECK(fx.licenseFile.existsAsFile()); // not deleted as corrupt + + // The write completes, with a newer license this instance then picks up. + auto upgraded = default_claims(); + upgraded["sp:owned"] = "demo-app-pro,demo-app-extra,demo-app-mega"; + fx.seedStored(fx.token(upgraded)); + CHECK(pumpUntil([&] { return controller.license() + && controller.license()->owned_sub_product_ids.size() == 3; }, + 6000)); +} + +TEST_CASE("deactivating in another instance locks this one too") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController controller(fx.config, fx.makeLicensing()); + ActivationController other(fx.config, fx.makeLicensing()); + controller.start(); + other.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details && other.screen() == Screen::Details; })); + + other.clearLicense(); // removes the shared license file + + CHECK(pumpUntil([&] { return controller.screen() == Screen::Welcome; }, 6000)); + CHECK_FALSE(controller.licensedFlag().load()); +} + +TEST_CASE("a license that couldn't be saved is not locked by the watch") +{ + // Activated, but the license file couldn't be written: the license stays + // unlocked for the session, and a file that never existed is not mistaken + // for one another instance removed. + controller_fixture fx; + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Welcome; })); + REQUIRE(fx.licenseFile.createDirectory()); // a folder in the way: every save fails + + auto claims = default_claims(); + claims["method"] = "Offline"; + auto responseFile = fx.licenseFile.getParentDirectory().getChildFile(juce::Uuid().toString() + ".mb"); + responseFile.replaceWithText(fx.token(claims)); + controller.setOfflineResponse(responseFile); + controller.activateOffline(); + REQUIRE(controller.licensedFlag().load()); + + pumpFor(4500); // two watch ticks + CHECK(controller.licensedFlag().load()); + CHECK(controller.screen() == Screen::Success); + responseFile.deleteFile(); + fx.licenseFile.deleteRecursively(); +} + +TEST_CASE("a stored license past its grace period is not picked up without a server check") +{ + controller_fixture fx; + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Welcome; })); + + // Signed, for this device and unexpired, but last verified 8 days ago: past + // the default 7-day grace period. + auto stale = default_claims(); + stale["validated"] = now_seconds() - 8 * 24 * 3600; + fx.seedStored(fx.token(stale)); + + // Not even for a moment: two watch ticks, and it must never unlock. + CHECK_FALSE(pumpUntil([&] { return controller.licensedFlag().load(); }, 4500)); + CHECK(controller.screen() == Screen::Welcome); + CHECK(fx.transport->requests.empty()); +} + +TEST_CASE("a replacement activation from another instance is not undone by an older refresh") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); // activation-123 + auto gated = std::make_shared(); + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, fx.fingerprint, gated); + ActivationController controller(fx.config, licensing, "dev", [gated] { gated->release(); }); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + // A refresh of activation-123 is on its way; by the time it answers, that + // activation has been revoked... + { + std::lock_guard lock(gated->mutex); + gated->responses.push_back(moonbase::http_response{ + 400, {}, R"({"title":"Not allowed","detail":"License has been revoked","status":400,"errorType":"LicenseActivationRevoked"})"}); + } + bool done = false, ok = true; + controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; }); + REQUIRE(pumpUntil([&] { return gated->entered.load(); })); + + // ...because another instance re-activated this machine as activation-456. + auto replacement = default_claims(); + replacement["id"] = "activation-456"; + fx.seedStored(fx.token(replacement)); + REQUIRE(pumpUntil([&] { return controller.license() && controller.license()->activation_id == "activation-456"; }, + 6000)); + + gated->release(); + REQUIRE(pumpUntil([&] { return done; })); + CHECK_FALSE(ok); // the old refresh was superseded... + CHECK(controller.licensedFlag().load()); // ...and did not lock the replacement + REQUIRE(controller.license().has_value()); + CHECK(controller.license()->activation_id == "activation-456"); + auto stored = fx.store->load_local_license(); + REQUIRE(stored.has_value()); + CHECK(stored->activation_id == "activation-456"); // nor wrote the old one back +} + +TEST_CASE("the license watch does not supersede a refresh the host asked for") +{ + controller_fixture fx; + fx.config.onlineGracePeriod = std::chrono::seconds(3); + auto claims = default_claims(); + claims["validated"] = now_seconds(); // fresh, so start() stays local + fx.seedStored(fx.token(claims)); + auto gated = std::make_shared(); + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, fx.fingerprint, gated); + ActivationController controller(fx.config, licensing, "dev", [gated] { gated->release(); }); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + // The host re-checks after a purchase; the answer takes a while, long + // enough for the grace period to run out in the meantime. + auto upgraded = default_claims(); + upgraded["sp:owned"] = "demo-app-pro,demo-app-extra,demo-app-mega"; + { + std::lock_guard lock(gated->mutex); + gated->responses.push_back(moonbase::http_response{200, {}, fx.token(upgraded)}); + } + bool done = false, ok = false; + controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; }); + REQUIRE(pumpUntil([&] { return gated->entered.load(); })); + pumpFor(5000); // past the grace period, two watch ticks + + gated->release(); + REQUIRE(pumpUntil([&] { return done; })); + CHECK(ok); // the host's refresh landed + REQUIRE(controller.license().has_value()); + CHECK(controller.license()->owned_sub_product_ids.size() == 3); + CHECK(gated->requests.load() == 1); // and the watch didn't start a second one over it +} + +TEST_CASE("a license removed elsewhere during an activation locks but leaves the activation running") +{ + controller_fixture fx; + fx.config.openBrowser = [](const juce::URL&) { return true; }; + auto claims = default_claims(); + claims["trial"] = true; + fx.seedStored(fx.token(claims)); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; })); + + fx.transport->responses.push_back(moonbase::http_response{ + 200, {}, R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123?format=JWT","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + controller.beginOnlineActivation(); // "Unlock" + REQUIRE(pumpUntil([&] { return controller.pendingBrowserUrl().isNotEmpty(); })); + + { + auto guard = fx.store->lock_for_update(); // another instance forgets the license + fx.store->delete_local_license(); + } + + CHECK(pumpUntil([&] { return ! controller.licensedFlag().load(); }, 6000)); + CHECK(controller.screen() == Screen::BrowserWait); // the flow is still on screen... + CHECK(controller.pendingBrowserUrl().isNotEmpty()); // ...and still waiting + controller.cancelActivation(); + CHECK(controller.screen() == Screen::Welcome); +} + +TEST_CASE("onLicenseChanged reports the settled state, then only license changes") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController controller(fx.config, fx.makeLicensing()); + std::vector reports; + controller.onLicenseChanged = [&](bool licensed) { reports.push_back(licensed); }; + controller.start(); + REQUIRE(pumpUntil([&] { return reports.size() == 1; })); + CHECK(reports.front()); + + // Navigation and a re-check that changes nothing are not license changes. + controller.showOffline(); + controller.showDetails(); + bool done = false; + controller.refreshLicense(false, [&](bool) { done = true; }); // within the throttle: same token + REQUIRE(pumpUntil([&] { return done; })); + pumpFor(200); + CHECK(reports.size() == 1); + + // A refresh that brings a new token is. + auto upgraded = default_claims(); + upgraded["sp:owned"] = "demo-app-pro,demo-app-extra,demo-app-mega"; + fx.transport->responses.push_back(moonbase::http_response{200, {}, fx.token(upgraded)}); + done = false; + controller.refreshLicense(true, [&](bool) { done = true; }); + REQUIRE(pumpUntil([&] { return done && reports.size() == 2; })); + CHECK(reports.back()); + + controller.clearLicense(); + REQUIRE(pumpUntil([&] { return reports.size() == 3; })); + CHECK_FALSE(reports.back()); +} + +TEST_CASE("onLicenseChanged reports an unlicensed start once") +{ + controller_fixture fx; + ActivationController controller(fx.config, fx.makeLicensing()); + std::vector reports; + controller.onLicenseChanged = [&](bool licensed) { reports.push_back(licensed); }; + controller.start(); + REQUIRE(pumpUntil([&] { return ! reports.empty(); })); + controller.showOffline(); + controller.showWelcome(); + pumpFor(200); + CHECK(reports == std::vector{false}); +} + +TEST_CASE("the browser link reaches a custom UI, which is told when it arrives") +{ + controller_fixture fx; + juce::StringArray opened; + fx.config.openBrowser = [&](const juce::URL& url) { opened.add(url.toString(true)); return false; }; + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Welcome; })); + CHECK(controller.pendingBrowserUrl().isEmpty()); + + // What a custom UI does: listen for changes and read the link. + struct LinkWatcher : juce::ChangeListener + { + explicit LinkWatcher(ActivationController& c) : controller(c) {} + void changeListenerCallback(juce::ChangeBroadcaster*) override { link = controller.pendingBrowserUrl(); } + ActivationController& controller; + juce::String link; + } watcher(controller); + controller.addChangeListener(&watcher); + + fx.transport->responses.push_back(moonbase::http_response{ + 200, {}, R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123?format=JWT","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + controller.beginOnlineActivation(); + + CHECK(pumpUntil([&] { return watcher.link.isNotEmpty(); })); + CHECK(watcher.link.contains("token=request-123")); + CHECK(opened == juce::StringArray{watcher.link}); // through the host's hook, not the system browser + CHECK(diags.joinIntoString(" ").contains("pendingBrowserUrl()")); // the hook said it couldn't open it + + controller.cancelActivation(); + CHECK(controller.pendingBrowserUrl().isEmpty()); + controller.removeChangeListener(&watcher); +} + +TEST_CASE("a trial that ends while it is being unlocked still locks, and the activation carries on") +{ + controller_fixture fx; + fx.config.openBrowser = [](const juce::URL&) { return true; }; + auto claims = default_claims(); + claims["trial"] = true; + claims["exp"] = now_seconds() + 2; + fx.seedStored(fx.token(claims)); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; })); + + // "Unlock": start an online activation and leave it waiting (no poll answers). + fx.transport->responses.push_back(moonbase::http_response{ + 200, {}, R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123?format=JWT","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + controller.beginOnlineActivation(); + REQUIRE(pumpUntil([&] { return controller.pendingBrowserUrl().isNotEmpty(); })); + REQUIRE(controller.licensedFlag().load()); + + CHECK(pumpUntil([&] { return ! controller.licensedFlag().load(); }, 10000)); + CHECK(controller.screen() == Screen::BrowserWait); // the flow was left alone + CHECK(controller.pendingBrowserUrl().isNotEmpty()); // and is still waiting + CHECK_FALSE(controller.license().has_value()); + + controller.cancelActivation(); + CHECK(controller.screen() == Screen::Welcome); +} + +TEST_CASE("refreshLicense leaves an activation in progress alone") +{ + controller_fixture fx; + fx.config.openBrowser = [](const juce::URL&) { return true; }; + auto claims = default_claims(); + claims["trial"] = true; + fx.seedStored(fx.token(claims)); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; })); + + fx.transport->responses.push_back(moonbase::http_response{ + 200, {}, R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123?format=JWT","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + controller.beginOnlineActivation(); + + // Straight away, before the request has even come back: what a host does + // when another editor opens. + bool done = false, ok = true; + controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; }); + CHECK(done); + CHECK_FALSE(ok); + + CHECK(pumpUntil([&] { return controller.pendingBrowserUrl().isNotEmpty(); })); // the request still landed + CHECK(controller.screen() == Screen::BrowserWait); + controller.cancelActivation(); +} + //============================================================================== // Validation / network tuning //============================================================================== @@ -1324,6 +1828,31 @@ TEST_CASE("ActivationComponent can share an externally-owned controller") CHECK(component.controller().licensedFlag().load()); } +TEST_CASE("a component sharing a settled controller reports its state after construction") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController shared(fx.config, fx.makeLicensing()); + shared.start(); + REQUIRE(pumpUntil([&] { return shared.screen() == Screen::Details; })); + + ActivationComponent component(shared); + std::vector reports; + component.onActivationChanged = [&](bool active) { reports.push_back(active); }; // wired after the ctor + REQUIRE(pumpUntil([&] { return ! reports.empty(); })); + CHECK(reports == std::vector{true}); + + // Navigation is not an activation change. + shared.showOffline(); + shared.showDetails(); + pumpFor(200); + CHECK(reports.size() == 1); + + shared.clearLicense(); + REQUIRE(pumpUntil([&] { return reports.size() == 2; })); + CHECK_FALSE(reports.back()); +} + TEST_CASE("LicenseGate gates click-free: pass-through licensed, ramp to silence unlicensed") { LicenseGate gate; @@ -1418,6 +1947,74 @@ TEST_CASE("destroying the controller mid-request cancels and joins without hangi CHECK(blocking->entered.load()); } +TEST_CASE("the worker pool runs every job it is given") +{ + std::atomic ran{0}; + { + detail::WorkerPool pool(2); + for (int i = 0; i < 50; ++i) + pool.addJob([&ran] { ++ran; }); + REQUIRE(pumpUntil([&] { return ran.load() == 50; })); + } + CHECK(ran.load() == 50); +} + +TEST_CASE("stopping the worker pool waits for running jobs and drops queued ones") +{ + std::atomic started{false}, release{false}, finished{false}; + std::atomic queuedRan{0}; + + detail::WorkerPool pool(1); + pool.addJob([&] + { + started = true; + while (! release.load()) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + finished = true; + }); + for (int i = 0; i < 5; ++i) + pool.addJob([&queuedRan] { ++queuedRan; }); // behind the busy worker + REQUIRE(pumpUntil([&] { return started.load(); })); + + std::thread releaser([&] + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + release = true; + }); + pool.stop(); + releaser.join(); + + CHECK(finished.load()); // the running job was waited for, however long it took + CHECK(queuedRan.load() == 0); // the queued ones never started +} + +TEST_CASE("the worker exit hook runs once on every worker") +{ + std::atomic exits{0}; + { + detail::WorkerPool pool(2); + pool.setWorkerExitHook([&exits] { ++exits; }); + pool.stop(); + CHECK(exits.load() == 2); + } + CHECK(exits.load() == 2); // and not again from the destructor +} + +TEST_CASE("worker pools come and go without stalling") +{ + // The juce::ThreadPool this replaces sometimes killed an idle thread here + // on JUCE 6.1.3, and a later teardown could hang. + juce::Random random(7); + const auto before = std::chrono::steady_clock::now(); + for (int i = 0; i < 200; ++i) + { + detail::WorkerPool pool(2); + pool.addJob([] {}); + std::this_thread::sleep_for(std::chrono::milliseconds(random.nextInt(20))); + } + CHECK(std::chrono::steady_clock::now() - before < std::chrono::seconds(30)); +} + //============================================================================== // Device identity //============================================================================== diff --git a/tests/test_helpers.hpp b/tests/test_helpers.hpp index 3833e94..815724b 100644 --- a/tests/test_helpers.hpp +++ b/tests/test_helpers.hpp @@ -90,6 +90,46 @@ inline generated_key generate_key() #pragma GCC diagnostic pop #endif +// The same PEM key in the shapes it tends to arrive in after a trip through a +// config file, an XML attribute, a JSON string or an environment variable. Every +// crypto backend must accept all of them. +inline std::vector> key_text_shapes(const std::string& pem) +{ + std::string body; + std::string crlf; + std::string indented; + std::string spaced; + std::string joined; + std::size_t start = 0; + while (start < pem.size()) { + auto end = pem.find('\n', start); + if (end == std::string::npos) { + end = pem.size(); + } + const auto line = pem.substr(start, end - start); + start = end + 1; + if (line.empty()) { + continue; + } + if (line.find("-----") == std::string::npos) { + body += line; + } + crlf += line + "\r\n"; + indented += " " + line + "\n"; + spaced += (spaced.empty() ? "" : " ") + line; + joined += line; + } + + return { + {"multi-line PEM", pem}, + {"CRLF PEM", crlf}, + {"indented PEM", indented}, + {"single-line PEM, spaces", spaced}, + {"single-line PEM, no separators", joined}, + {"base64 DER", body}, + }; +} + inline std::vector sign_rs256(EVP_PKEY* key, const std::string& input) { evp_md_ctx_ptr context(EVP_MD_CTX_new(), EVP_MD_CTX_free); diff --git a/tests/validator_tests.cpp b/tests/validator_tests.cpp index 47dd7b9..1af7464 100644 --- a/tests/validator_tests.cpp +++ b/tests/validator_tests.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include "moonbase/detail/time.hpp" #include "moonbase/device_id_resolver.hpp" @@ -71,6 +74,22 @@ TEST_CASE("PKCS#1 RSA public keys are accepted") CHECK(result.id == "license-123"); } +TEST_CASE("a public key is accepted in every common text shape") +{ + auto key = moonbase::tests::generate_key(); + const auto token = moonbase::tests::make_token(key.key.get(), moonbase::tests::default_claims()); + + const std::vector> formats{{"SPKI", key.public_pem}, + {"PKCS#1", key.public_pkcs1_pem}}; + for (const auto& format : formats) { + for (const auto& shape : moonbase::tests::key_text_shapes(format.second)) { + CAPTURE(format.first); + CAPTURE(shape.first); + CHECK(make_validator(shape.second).validate_token(token).id == "license-123"); + } + } +} + TEST_CASE("validated timestamp can fall back to legacy ver claim") { auto key = moonbase::tests::generate_key(); diff --git a/tests/visual/README.md b/tests/visual/README.md index 2fed9b1..b407085 100644 --- a/tests/visual/README.md +++ b/tests/visual/README.md @@ -11,8 +11,9 @@ baseline. state, constructs an `ActivationComponent` and uses two module seams to make the frame deterministic: -- `ActivationConfig::reduceMotion` — transitions/spinner/pop jump straight to their - final frame (also a real accessibility option). +- `ActivationConfig::reduceMotion`: transitions, the success pop and the glow jump + straight to their final frame (also a real accessibility option). The spinner keeps + turning under it, but a snapshot is taken before its first tick. - `ActivationController::setPreviewState(screen, license, error)` — forces any screen with a synthetic license, no network, no stored state.