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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,54 @@ online-activated paid licenses; calling it for offline or trial tokens raises
same way they do for `validate_token_online`, but with no grace-period
fallback — revoke is a one-shot operation.

## Offline Activation

For machines without internet access, Moonbase supports a file-based flow: the
app emits a **device token** ("machine file"), the user exchanges it for a
license token on the Moonbase activation page, and the app reads that token back
in. No network is involved on the device.

1. Generate the device token and write it to a file (conventionally `.dt`):

```cpp
const auto device_token = licensing.generate_device_token();
std::ofstream("device-token.dt") << device_token;
```

2. The user uploads `device-token.dt` and receives a license token file (the
raw JWT, conventionally `license.mb`) in return. They can do this through any
of:

- **Moonbase's hosted portal** — `https://<your-tenant>.moonbase.sh/activate`.
- **The embedded storefront on your own site** — trigger the
[`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods)
intent (`Moonbase.activate_product()`), which prompts for the device token
and hands back the license token file. The `deviceTokenFileExtension`
(default `.dt`) and `licenseTokenFileName` (default `license-file.mb`)
config options control the file types involved.
- **Your own custom flow** — drive the exchange yourself with the Moonbase
[APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/)
(the `/api/customer/inventory/activate` endpoint).

3. Read the downloaded token back in, validate it locally, and persist it:

```cpp
std::ifstream file("license.mb");
const std::string token((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());

auto license = licensing.read_offline_license(token); // local validation only
licensing.store().store_local_license(license);
```

`read_offline_license` runs the same local checks as `validate_token_local`
(signature, audience, issuer, device fingerprint, expiry) and additionally
requires the token to have been issued via offline activation, throwing
`license_invalid_error` otherwise. On startup, validate the stored token with
`validate_token_local` — offline tokens are never re-validated against the API
and [cannot be revoked](#revoking-an-activation); they stay valid until the
machine's device fingerprint changes.

## Custom Fingerprinting and Storage

```cpp
Expand Down
40 changes: 40 additions & 0 deletions docs/juce.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,46 @@ unlockStatus.revokeActivationAsync(
});
```

## Offline activation

For machines without internet access, the bridge wraps Moonbase's file-based
flow. Emit a device token ("machine file"), have the user exchange it for a
license token, then load the token back in:

```cpp
// Step 1: write the machine file for the user to upload.
const auto deviceToken = unlockStatus.deviceTokenContents();
file.replaceWithText(juce::String(deviceToken)); // e.g. "MyPlugin.dt"

// Step 2: load the license token the user downloaded from the portal.
using O = moonbase::juce_bridge::MoonbaseUnlockStatus::OfflineActivationOutcome;
if (unlockStatus.activateOffline(downloadedFile.loadFileAsString()) == O::Activated)
repaintActivationLabel(); // unlockStatus.isMoonbaseUnlocked() is now true
else
showError("That license token is not valid for this device.");
```

The user can exchange the device token for a license token through any of:

- **Moonbase's hosted portal** — `https://<tenant>.moonbase.sh/activate`.
- **The embedded storefront on your own site** — the
[`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods)
intent (`Moonbase.activate_product()`) prompts for the `.dt` and returns the
license token file.
- **Your own custom flow** — drive the exchange with the Moonbase
[APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/).

`activateOffline` validates the token **locally only** (signature, audience,
device fingerprint, expiry, and that it was issued via offline activation),
persists it to the store on success, and transitions the bridge to unlocked. It
does no network I/O, so it's safe to call synchronously on the message thread.
Offline-activated tokens are never re-validated online and can't be revoked —
use `clearLicense()` for a local-only forget.

[`PluginActivationComponent.h`](../examples/juce/PluginActivationComponent.h)
wires both steps to **Save machine file…** / **Load license token…** buttons
using `juce::FileChooser`.

## Gating features

```cpp
Expand Down
56 changes: 56 additions & 0 deletions examples/juce/MoonbaseJuceBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,62 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus
return true;
}

// ---- Offline activation --------------------------------------------------

// Returns the device token ("machine file") contents for offline
// activation. Write this to a file (conventionally ".dt") and have the user
// upload it at https://<tenant>.moonbase.sh/activate to obtain an
// offline-activated license token in return.
[[nodiscard]] std::string deviceTokenContents() const
{
return licensing_->generate_device_token();
}

enum class OfflineActivationOutcome
{
Activated, // Token validated locally and persisted. Bridge is now unlocked.
Invalid, // Token failed local validation, wasn't an offline token, or
// wasn't issued for this device. Bridge state unchanged.
};

// Activates from an offline license token the user downloaded from the
// portal (the raw JWT contents of the license file). Validation is local
// only — no network — so this is safe to call synchronously on the message
// thread. On success the token is persisted and the bridge transitions to
// unlocked.
OfflineActivationOutcome activateOffline(const juce::String& tokenContents)
{
moonbase::license lic;
try
{
lic = licensing_->read_offline_license(tokenContents.trim().toStdString());
}
catch (const std::exception&)
{
return OfflineActivationOutcome::Invalid;
}

++validationGeneration_; // invalidate any in-flight async revalidation

try
{
// Under the update lock so an in-flight validate_token_online for a
// prior token can't race-persist over the newly-activated license.
auto guard = licensing_->store().lock_for_update();
licensing_->store().store_local_license(lic);
}
catch (const moonbase::storage_error&)
{
// Activation succeeded but persistence failed; still mark unlocked
// for this session so the user isn't locked out by a bad disk.
}

const juce::ScopedLock lock(stateLock_);
pendingRequest_.reset();
setUnlockedLocked(std::move(lic));
return OfflineActivationOutcome::Activated;
}

// Drops the local license and any pending activation request.
void clearLicense()
{
Expand Down
142 changes: 128 additions & 14 deletions examples/juce/PluginActivationComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

#include "MoonbaseJuceBridge.h"

#include <memory>

#include <juce_gui_basics/juce_gui_basics.h>

class PluginActivationComponent : public juce::Component,
Expand All @@ -20,21 +22,29 @@ class PluginActivationComponent : public juce::Component,
: unlockStatus_(makeOptions(), makeStore())
{
addAndMakeVisible(statusLabel_);
statusLabel_.setJustificationType(juce::Justification::centredLeft);
statusLabel_.setJustificationType(juce::Justification::topLeft);

addAndMakeVisible(activateButton_);
activateButton_.onClick = [this] { startActivation(); };

addAndMakeVisible(deactivateButton_);
deactivateButton_.onClick = [this] { startRevoke(); };

// Offline activation: emit a machine file, then load the license token
// the user receives back from the Moonbase activation page.
addAndMakeVisible(saveMachineFileButton_);
saveMachineFileButton_.onClick = [this] { saveMachineFile(); };

addAndMakeVisible(loadLicenseButton_);
loadLicenseButton_.onClick = [this] { loadLicenseToken(); };

// Async load: never blocks the message thread on libcurl. The label is
// updated optimistically from the local-validation result, then again
// from the message-thread callback once the online check resolves.
unlockStatus_.tryLoadStoredLicenseAsync([this](auto) { refreshLabel(); });
refreshLabel();

setSize(520, 184);
setSize(520, 320);
}

~PluginActivationComponent() override
Expand All @@ -45,12 +55,19 @@ class PluginActivationComponent : public juce::Component,
void resized() override
{
auto area = getLocalBounds().reduced(16);
statusLabel_.setBounds(area.removeFromTop(96));
statusLabel_.setBounds(area.removeFromTop(184));
area.removeFromTop(8);
auto buttons = area.removeFromTop(36);
activateButton_.setBounds(buttons.removeFromLeft(200));
buttons.removeFromLeft(12);
deactivateButton_.setBounds(buttons.removeFromLeft(200));

auto onlineRow = area.removeFromTop(36);
activateButton_.setBounds(onlineRow.removeFromLeft(200));
onlineRow.removeFromLeft(12);
deactivateButton_.setBounds(onlineRow.removeFromLeft(200));

area.removeFromTop(12);
auto offlineRow = area.removeFromTop(36);
saveMachineFileButton_.setBounds(offlineRow.removeFromLeft(200));
offlineRow.removeFromLeft(12);
loadLicenseButton_.setBounds(offlineRow.removeFromLeft(200));
}

[[nodiscard]] bool isUnlocked() const noexcept
Expand Down Expand Up @@ -144,6 +161,62 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB
});
}

// Offline activation, step 1: write the device token ("machine file") to
// disk. The user uploads it at https://<tenant>.moonbase.sh/activate.
void saveMachineFile()
{
const auto deviceToken = unlockStatus_.deviceTokenContents();

fileChooser_ = std::make_unique<juce::FileChooser>(
"Save machine file",
juce::File::getSpecialLocation(juce::File::userDesktopDirectory)
.getChildFile("MoonbaseJuceExample.dt"),
"*.dt");

const auto flags = juce::FileBrowserComponent::saveMode
| juce::FileBrowserComponent::canSelectFiles
| juce::FileBrowserComponent::warnAboutOverwriting;

fileChooser_->launchAsync(flags, [this, deviceToken](const juce::FileChooser& chooser)
{
const auto file = chooser.getResult();
if (file == juce::File{})
return; // user cancelled

if (file.replaceWithText(deviceToken))
refreshLabel("Machine file saved. Upload it at your Moonbase activation\n"
"page, then load the license token you receive back.");
else
refreshLabel("Could not write the machine file.");
});
}

// Offline activation, step 2: load the offline license token the user
// downloaded from the portal. Validation is local-only — no network.
void loadLicenseToken()
{
fileChooser_ = std::make_unique<juce::FileChooser>(
"Load license token",
juce::File::getSpecialLocation(juce::File::userDesktopDirectory),
"*.mb;*.jwt;*.txt");

const auto flags = juce::FileBrowserComponent::openMode
| juce::FileBrowserComponent::canSelectFiles;

fileChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser)
{
const auto file = chooser.getResult();
if (file == juce::File{})
return; // user cancelled

using Outcome = moonbase::juce_bridge::MoonbaseUnlockStatus::OfflineActivationOutcome;
if (unlockStatus_.activateOffline(file.loadFileAsString()) == Outcome::Activated)
refreshLabel();
else
refreshLabel("That license token is not valid for this device.");
});
}

void refreshLabel(const juce::String& explicitMessage = {})
{
if (explicitMessage.isNotEmpty())
Expand All @@ -155,22 +228,59 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB
if (auto license = unlockStatus_.moonbaseLicense())
{
juce::String text;
text << "Unlocked as " << juce::String(license->issued_to.email);

// Who the license is issued to (name + email when both are present).
text << "Unlocked as ";
if (! license->issued_to.name.empty())
text << juce::String(license->issued_to.name) << " <"
<< juce::String(license->issued_to.email) << ">";
else
text << juce::String(license->issued_to.email);
if (license->trial)
text << " (trial)";

// Activation method — "Online" or "Offline". Offline-activated
// licenses are validated locally only and can't be revoked.
text << "\nActivation: "
<< juce::String(moonbase::to_string(license->method));

// The licensed product (and its current release, when the token
// carries one).
text << "\nProduct: " << juce::String(license->licensed_product.name);
if (license->licensed_product.current_release_version)
text << " v"
<< juce::String(*license->licensed_product.current_release_version);

// Any sub-products / add-ons this license owns.
if (! license->owned_sub_product_ids.empty())
{
juce::StringArray addons;
for (const auto& id : license->owned_sub_product_ids)
addons.add(juce::String(id));
text << "\nAdd-ons: " << addons.joinIntoString(", ");
}

// Subscription this license is tied to, if any.
if (license->subscription_id)
text << "\nSubscription: " << juce::String(*license->subscription_id);

// getExpiryTime() comes from juce::OnlineUnlockStatus and is
// populated from the Moonbase license's expires_at by the bridge.
const auto expiry = unlockStatus_.getExpiryTime();
if (expiry.toMilliseconds() > 0)
text << "\nExpires " << expiry.toString(true, true);
text << "\nExpires: "
<< (expiry.toMilliseconds() > 0 ? expiry.toString(true, true)
: juce::String("never"));

// Last successful validation — refreshed by validate_token_online
// (or the local-only fallback within grace) and persisted into the
// license's `validated_at` claim.
const auto validatedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
license->validated_at.time_since_epoch()).count();
text << "\nLast validated " << juce::Time(validatedMs).toString(true, true);
// license's `validated_at` claim. Not meaningful for offline tokens,
// which are never re-validated online.
if (license->method != moonbase::activation_method::offline)
{
const auto validatedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
license->validated_at.time_since_epoch()).count();
text << "\nLast validated " << juce::Time(validatedMs).toString(true, true);
}

statusLabel_.setText(text, juce::dontSendNotification);
}
Expand All @@ -185,6 +295,10 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB
juce::Label statusLabel_;
juce::TextButton activateButton_ { "Activate..." };
juce::TextButton deactivateButton_ { "Deactivate" };
juce::TextButton saveMachineFileButton_ { "Save machine file..." };
juce::TextButton loadLicenseButton_ { "Load license token..." };

std::unique_ptr<juce::FileChooser> fileChooser_;

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PluginActivationComponent)
};
11 changes: 11 additions & 0 deletions examples/juce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ against the public demo environment (`https://demo.moonbase.sh`, product
`demo-app`). The same files double as drop-in reference code for your own
plugin.

The window has two rows of buttons:

- **Activate… / Deactivate** — the online browser activation flow.
- **Save machine file… / Load license token…** — the offline flow: save the
device token (`.dt`), exchange it for a license token, then load the token you
get back. The device token can be uploaded to Moonbase's hosted portal
(`https://<tenant>.moonbase.sh/activate`), to your own site via the embedded
storefront's [`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods)
intent, or to a custom flow built on the Moonbase
[APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/).

## Files

- `MoonbaseJuceBridge.h` — the bridge: fingerprint provider, metadata helper,
Expand Down
Loading
Loading