JUCE Module

moonbase_licensing is a drop-in JUCE module that adds Moonbase license activation, and a built-in activation UI, to any JUCE 8 app or plugin. Add the module, fill in three fields, show one component.

It ships inside the open-source Moonbase-sh/moonbase-cpp repository (MIT licensed) at modules/moonbase_licensing/, and talks to the Moonbase licensing API natively. It is not a juce::OnlineUnlockStatus wrapper.

The module supports:

  • Browser-based activations and offline machine-file activations
  • Time-based trials, with a days-remaining screen and an included-features list
  • Automatic license re-validation with a configurable grace period
  • Server-side deactivation, so users can free up an activation seat
  • Cross-SDK device identity, so a license activated in a web or Electron app validates in your plugin
  • In-app update notifications with release notes and an installer download
  • A themeable UI covering every activation state, plus a headless controller if you would rather build your own

Here is the first thing a customer sees when they open an unlicensed build, with your own product name, logo and accent colour:

The welcome screen: a product name and logo, an "Activate Solstice" heading, an "Activate online" button, and a "No internet? Activate offline" link

Requirements

  • JUCE 8.0.4 or newer
  • A C++17 compiler
  • macOS, Windows, Linux, iOS or Android

The module has no third-party dependencies. Everything it needs comes from either JUCE or the operating system:

ConcernHow
HTTPjuce::WebInputStream, so no CURL
JSONa bundled nlohmann/json single header
RS256 token verificationOS-native: Security.framework (macOS, iOS), CNG/bcrypt (Windows), system libcrypto (Linux)

There is nothing to brew, vcpkg or apt install. The one platform caveat is that on Linux the module links the always-present system libcrypto.

Installation

Pull moonbase-cpp into your project, as a git submodule or through FetchContent, and point your build at the module folder. On Apple platforms the module compiles an Objective-C++ translation unit for its Security.framework crypto backend, so your CMake project needs the Objective-C languages enabled.

if(APPLE)
    enable_language(OBJC OBJCXX)
endif()

juce_add_module(external/moonbase-cpp/modules/moonbase_licensing)

target_link_libraries(MyPlugin PRIVATE moonbase_licensing)
target_compile_definitions(MyPlugin PRIVATE JUCE_USE_CURL=0)

Two details are worth knowing about the FetchContent variant. SOURCE_SUBDIR modules points at a directory that has no CMakeLists.txt, so the repository is populated but the C++ SDK's root CMake is never added: that root exposes an interface target pulling in OpenSSL and CURL, and the module needs neither. JUCE_USE_CURL=0 keeps JUCE on its own HTTP stack, which is the transport the module uses.

For Projucer projects, use Modules → Add a module → Add a module from a specified folder… and select modules/moonbase_licensing. The bundled SDK headers and nlohmann/json resolve from the module's own search paths, so there is nothing else to configure.

Configuration

The module is configured in code through ActivationConfig. Only three fields are required, and you will find all of them in the Implementation guide for your product in the Moonbase app.

#include <moonbase_licensing/moonbase_licensing.h>
using namespace moonbase::juce_integration;

ActivationConfig config;
config.endpoint  = "https://your-account.moonbase.sh";
config.productId = "your-product";
config.publicKey = embeddedPublicKeyPem;   // your product's RSA public key

In a plugin build, productName, manufacturerName and applicationVersion fill themselves in from the JucePlugin_Name, JucePlugin_Manufacturer and JucePlugin_VersionString macros. Set them yourself in a plain app.

Misconfiguration does not throw out of construction. A missing or malformed endpoint, productId or publicKey puts the component into its error state and reports the underlying reason through the diagnostics sink described below.

Showing the UI

Add ActivationComponent as a modal over your editor, which locks the plugin until it is activated:

auto activation = std::make_unique<ActivationComponent>(config);
activation->onClose = [this] { /* dismiss the modal */ };
activation->onActivationChanged = [this](bool isActivated) { /* enable or disable UI */ };
addAndMakeVisible(*activation);

Or pop it from a menu item as a standalone window:

ActivationDialog::show(config, [](bool wasActivated) { /* update UI */ });

The screens

ActivationComponent owns an ActivationController, a headless state machine that decides which of these to show. Transitions use JUCE 8's animation API, so screens cross-fade rather than snap.

  • Welcome. Activate online through the browser, or activate offline.
  • Activating. Opens the browser and polls for the fulfilled activation. A device chip shows the local fingerprint and platform, and Cancel aborts.
  • Success. An animated confirmation with a mini license card.
  • Offline. The two-step machine-file flow: save the request file, then load the response file, which is validated locally.
  • Trial. Days remaining, a progress bar, the included and excluded feature lists, and an unlock action that routes into online activation.
  • Trial expired. A locked screen offering unlock or offline activation.
  • License details. Who the license is issued to, the plan, activation type, expiry, a seat counter, and a deactivate action that releases the seat server-side.
  • Update available. Shown when the license reports a newer released version than the running build.

The trial screen: a free-trial panel with days remaining, a progress bar, and an "Unlock full version" button

The license details screen: licensed-to name, email, plan, activation type, expiry, seat count, and a "Deactivate this device" button

Network calls run on a controller-owned thread pool, while every state change and repaint happens on the message thread. Destroying the controller cancels any in-flight request and joins its workers, so you can call start() straight from an editor constructor and destroy the editor at any point, including during plugin scanning or rapid open and close, without guarding it.

Gating your plugin

In a plugin, license state has to outlive the editor. Give the processor the controller and let the editor share it, rather than re-syncing two copies:

// In your AudioProcessor:
ActivationController activation { makeConfig() };   // persistent
// activation.start();  // load any stored license

// In createEditor(): share the processor's controller with the UI.
auto* editor = new ActivationComponent (processor.activation);   // non-owning overload

Gate the audio thread on the lock-free flag:

void processBlock (juce::AudioBuffer<float>& buffer, ...) override
{
    if (! activation.licensedFlag().load())
        buffer.clear();
}

Or use LicenseGate for a click-free fade when the license state changes. The module never silences audio itself, so the gating stays yours:

LicenseGate gate;   // a member of your processor

void prepareToPlay (double sr, int) override
{
    gate.prepare (sr);
    gate.reset (activation.licensedFlag().load());
}

void processBlock (juce::AudioBuffer<float>& b, ...) override
{
    gate.process (b.getArrayOfWritePointers(), b.getNumChannels(), b.getNumSamples(),
                  activation.licensedFlag().load());
}

For richer decisions, controller().license() is the full moonbase::license, including trial, expires_at, issued_to.email, owned_sub_product_ids and any custom properties. Read it on the message thread. ActivationController is a juce::ChangeBroadcaster, so observing it means reading screen() and license() on change and repainting.

The validated license is persisted to userApplicationDataDirectory/<manufacturer>/<product>/license.mb, which you can override with config.licenseFile.

Offline activations

The offline flow is built into the Welcome and Offline screens, including drag-and-drop for the response file, so most integrations need no code at all. Set config.enableOffline = false to hide it.

If you are driving the controller from your own UI, the flow is three calls: saveOfflineRequest() writes the device token to a file the customer uploads, setOfflineResponse() takes the file they get back, and activateOffline() validates it locally and persists it. Offline licenses are permanent and are never re-validated against the API.

See Offline activations for the customer-facing side of the flow.

Branding and theming

Everything in ActivationConfig after the connection fields is presentation: the product and manufacturer names, an accent colour, a logo Drawable, overridable copy through config.strings, the trialLengthDays and trialFeatures list shown on the trial screens, the activation URL, and showMoonbaseBadge for the Moonbase co-brand in the footer.

config.accent           = juce::Colour(0xff186cdc);
config.logo             = juce::Drawable::createFromImageData(...);
config.trialLengthDays  = 14;
config.showMoonbaseBadge = false;

For a deeper re-skin, mutate ActivationLookAndFeel::palette, where every colour in the UI is an individual token, and point the heading, body and mono font helpers at your own typefaces.

In-app updates

A validated license carries the product's current released version in its claims. On launch and after every re-validation, the controller compares that against the running version and, when a newer release exists, shows the update screen instead of the license or trial screen. It loads the release notes from the Moonbase inventory API and downloads the installer for the current platform in-app, with progress.

The update available screen: an "Update available" pill, a "Solstice 1.0.0 is ready" heading, a "What's new" changelog card, a Download button, and a "Skip this update" link

config.applicationVersion = "2.3.1";   // or rely on JucePlugin_VersionString
config.enableUpdatePrompt = true;      // default, set false to never prompt
config.autoPresentUpdate  = true;      // default, present it when the plugin opens
config.downloadDirectory  = {};        // default, the user's Downloads folder

Downloads respect the release access-control level you set on the product. A trial cannot download from an owners-only release, so in that case the screen swaps the download button for an unlock call to action instead of letting the user hit a permission error. "Skip this update" is remembered until a newer version ships, and the license screen keeps a clickable Update available badge for anyone who dismissed it.

Device identity

By default the module identifies the device with moonbase::moonbase_device_id_resolver, which implements the cross-SDK Moonbase device fingerprint spec (version 2, ids stamped mbd2_). A license activated in a web or Electron app built on @moonbase.sh/licensing validates in your plugin, and the other way round.

Nothing is shelled out to and no privileged file is read, so the same id comes back inside a sandboxed host and whether or not the process is elevated. Inspect what it resolved to with controller().describeDevice(), which returns the id, spec version, platform tag, and the names of the contributing parameters but never their values. That is safe to put behind a "Copy diagnostics" button.

On iOS and Android there is no identifier that unrelated apps can read, so those platforms get a scoped id stamped mbd2s_, derived from identifierForVendor and ANDROID_ID. It is stable for the device within the platform's own scope, but it is deliberately not cross-SDK, and config.allowDeviceNameFallback is forbidden there rather than merely ignored.

Elsewhere, a machine with no usable hardware identity (a cloned VM image, a minimal container) fails activation rather than binding something weak. Set config.allowDeviceNameFallback = true to accept a weaker id derived from the computer name instead; those are stamped mbd2n_ so they can be told apart from real hardware bindings.

Supply your own resolver with config.deviceIdResolver when you need an application-specific device ID.

Re-validation and entitlements

The module never polls on a timer. It validates on launch, and again whenever you ask it to, throttled to no more than once per onlineCheckInterval. A license stays usable offline until onlineGracePeriod elapses since its last successful online validation.

config.onlineCheckInterval = std::chrono::hours (1);       // default 5 minutes
config.onlineGracePeriod   = std::chrono::hours (24 * 30); // default 7 days
config.httpConnectTimeout  = std::chrono::seconds (5);
config.httpRequestTimeout  = std::chrono::seconds (15);

When a user buys something mid-session, an add-on or an upgrade, re-validate so the new entitlements load without a restart:

activation->controller().refreshLicense (/* force */ true, [] (bool refreshed) {
    if (refreshed)
        reloadFeatures();   // read controller().license() again
});

This runs asynchronously and silently, with no screen change. force bypasses the throttle, which is what you want straight after a purchase; pass false for a polite background re-check. A network failure is non-fatal: the current license is kept and the reason goes to the diagnostics sink.

Diagnostics and telemetry

The UI shows friendly, end-user-facing copy. To see the underlying reason behind a failure, whether that is bad configuration, a rejected token, an unreachable server or a failed write, wire up a diagnostic sink. It is invoked on the message thread.

config.onDiagnostic = [] (const juce::String& message) {
    juce::Logger::writeToLog ("[activation] " + message);
};

Telemetry is off by default. One flag attaches JUCE system and host metadata to every activation and validation request, and you can add fields of your own:

config.analytics.enabled = true;           // OS, CPU, JUCE version, memory
config.analytics.includeHostInfo = true;   // DAW host and plugin format
config.analytics.includeLocaleInfo = true; // language and region

config.metadata["app.channel"] = "beta";
config.onCollectMetadata = [] (std::map<std::string, std::string>& m) {
    m["cohort"] = abTestCohort();
};

Host and plugin fields are only captured when juce_audio_processors is part of the build, so they light up automatically in a plugin and are skipped in a plain app.

Sample app and reference plugin

A runnable standalone sample lives in the repository at examples/juce-native/ and runs against the public Moonbase demo account. It fetches JUCE on first configure and adds the module with juce_add_module, exactly the way a downstream project consumes it:

cmake -B build -DMOONBASE_BUILD_JUCE_NATIVE_EXAMPLE=ON
cmake --build build --target MoonbaseActivationNative

For a complete plugin, DRIFT is an open-source JUCE 8 audio plugin built around the module, with the full CMake setup, the processor-owned controller, and macOS and Windows release pipelines.

DRIFT, a JUCE 8 plugin using the moonbase_licensing module end to end.

The moonbase_licensing JUCE module and the C++ SDK it is built on.

The full module reference lives alongside the source.

The OnlineUnlockStatus bridge

There is a second JUCE integration path, built on the same core SDK: a copy-paste reference header that wraps juce::OnlineUnlockStatus. It has no UI of its own, and it is the right choice if your product is already built on OnlineUnlockStatus, or you are still on JUCE 7.

Native moduleOnlineUnlockStatus bridge
FormDrop-in JUCE moduleCopy-paste reference header
Built-in UIYes, themeable and animatedNo, you build it
JUCE integrationNative Moonbase APIjuce::OnlineUnlockStatus wrapper
JUCE version8.0.4+7+
Device fingerprintSpec v2, cross-SDKSpec v2, cross-SDK
Third-party depsNoneInherits the core SDK's CURL and OpenSSL
Entry pointActivationComponent / ActivationDialogMoonbaseUnlockStatus
Best forNew plugins wanting a ready-made UIProducts already on OnlineUnlockStatus, or JUCE 7

See the C++ SDK page for the bridge, and for using Moonbase licensing from non-JUCE C++ applications.

Was this page helpful?