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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -202,6 +205,46 @@ void processBlock (juce::AudioBuffer<float>& 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 +
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
9 changes: 5 additions & 4 deletions examples/juce-native/Main.cpp
Original file line number Diff line number Diff line change
@@ -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 <moonbase_licensing/moonbase_licensing.h>
Expand Down Expand Up @@ -83,7 +84,7 @@ class PluginEditor : public juce::Component
addAndMakeVisible(licenseButton);

activation = std::make_unique<ActivationComponent>(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.
Expand Down
5 changes: 3 additions & 2 deletions examples/juce-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 29 additions & 24 deletions include/moonbase/detail/crypto/der.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cstddef>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
Expand Down Expand Up @@ -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<unsigned char> 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
Expand Down
56 changes: 6 additions & 50 deletions include/moonbase/detail/crypto/openssl_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
#include <string_view>
#include <vector>

#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <openssl/sha.h>
#include <openssl/x509.h>

#include "moonbase/detail/base64.hpp"
#include "moonbase/detail/crypto/der.hpp"
#include "moonbase/errors.hpp"

namespace moonbase::detail::crypto {
Expand All @@ -38,52 +38,13 @@ namespace openssl_detail {
#endif

using evp_pkey_ptr = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>;
using bio_ptr = std::unique_ptr<BIO, decltype(&BIO_free)>;
using evp_md_ctx_ptr = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>;

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<int>(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<unsigned char>& der)
{
const unsigned char* cursor = der.data();
Expand Down Expand Up @@ -113,18 +74,13 @@ inline evp_pkey_ptr read_der_public_key(const std::vector<unsigned char>& 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;
}
Expand Down
5 changes: 4 additions & 1 deletion modules/moonbase_licensing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion modules/moonbase_licensing/juce/ActivationConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -183,6 +184,14 @@ struct ActivationConfig
// debug activation issues in the field. Invoked on the message thread.
std::function<void(const juce::String& message)> 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<bool(const juce::URL& url)> 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
Expand Down
Loading
Loading