inlay_product_unlocking is a JUCE module that adds Inlay product activation and local license validation to
audio plugins. The public repository is available at
github.com/inlay-cloud/inlay-juce-module.
The module is intentionally small from the plugin's point of view:
inlay::Unlockerowns license state, activation, persistence, refresh, logout, and update checks.inlay::DefaultUIis an optional ready-to-use activation overlay for locked products.- Plugin code can use
unlocker.isLocked()as a simple realtime-safe guard for protected behaviour.
The main public files are:
inlay_product_unlocking.hUnlocker.hDefaultUI.h
Internal API, token validation, network access, callback listener implementation, and module tests live under internal/.
Core concept
Inlay protects a product by requiring a valid local access token before protected functionality is available. Activation starts in the plugin, continues in the user's browser, and returns to the plugin through a local callback listener. After activation, the module stores the identity token and signed access token under the user's application data directory for the configured Inlay product ID.
On every startup, the module reads the saved access token and verifies it locally before unlocking. Verification checks the token's RSA-SHA256 signature with the configured public key and confirms that its product ID, device ID, issue time, expiration time, and user identity are valid. A valid unexpired token can therefore unlock the product without a network connection.
The module contacts the Inlay server only when it needs to obtain or renew access: during browser activation, when startup finds no valid saved access token but a saved identity token is available, and when a valid token has reached its configured refresh interval. Refreshing happens in the background after the product has already been unlocked; a temporary network failure does not lock an otherwise valid token. If a token expires before it can be renewed, the product remains locked until the module can obtain and locally validate a new token.
Token expiration and refresh interval are set in the access token returned by the server, so they can be adjusted per product without updating the plugin. Shorter expiration times and more frequent refreshes reduce the time that access can continue after a server-side change, but require the plugin to be online more often. Longer expiration times and less frequent refreshes provide a more offline-friendly experience, with the corresponding security tradeoff.
At runtime, the unlocker has four states:
| Status | Meaning |
|---|---|
inlay::Unlocker::Status::undefined |
Initial state before startup() has been called. |
inlay::Unlocker::Status::activationRequired |
No usable persisted activation is available. The user must activate in the browser. |
inlay::Unlocker::Status::unlocking |
Activation, unlock, retry, or refresh work is in progress, or unlocking failed and can be retried. |
inlay::Unlocker::Status::unlocked |
Local access is valid and protected functionality can be enabled. |
Unlocker inherits from juce::ChangeBroadcaster. UI and other message-thread code can register a
juce::ChangeListener and read getStatus(), getError(),
getCurrentUser(), and getAppUpdate() when state changes.
For realtime code, use isLocked(). It is the only public state method intended for audio-thread use.
Module installation
The module requires C++17 or newer and declares these JUCE dependencies:
juce_corejuce_cryptographyjuce_eventsjuce_gui_basicsjuce_product_unlocking
Installing with CMake
Make sure JUCE is available before adding this repository. Inlay reuses the existing juce::juce_core target and
does not fetch or vendor JUCE when it is added to another project.
Add the repository before your juce_add_plugin or juce_add_gui_app call:
include(FetchContent)
FetchContent_Declare(
inlay_juce_module
GIT_REPOSITORY https://github.com/inlay-cloud/inlay-juce-module.git
GIT_TAG main
)
FetchContent_MakeAvailable(inlay_juce_module)
Alternatively, add this repository as a submodule somewhere in your project:
git submodule add -b main https://github.com/inlay-cloud/inlay-juce-module.git third_party/inlay-juce-module
Then add it from your root CMakeLists.txt before your plugin or app target:
add_subdirectory(third_party/inlay-juce-module EXCLUDE_FROM_ALL)
To update the submodule later:
git submodule update --remote --merge third_party/inlay-juce-module
After your juce_add_plugin or juce_add_gui_app call, link the module target:
target_link_libraries(MyPluginTarget
PRIVATE
inlay::inlay_product_unlocking)
Installing with Projucer
Download this repository or add it as a git submodule:
git submodule add -b main https://github.com/inlay-cloud/inlay-juce-module.git modules/inlay-juce-module
In Projucer, use "Add a module from a specified folder" and select:
modules/inlay-juce-module/modules/inlay_product_unlocking
If you keep the repository somewhere else, select the equivalent modules/inlay_product_unlocking folder from
that checkout. After adding the module, resave the .jucer project and rebuild.
Basic integration
Include the module from plugin code:
#include <inlay_product_unlocking/inlay_product_unlocking.h>
Create one unlocker for the protected product. In a plugin, the processor is usually the right owner because it lives independently of editor windows:
class MyProcessor : public juce::AudioProcessor
{
public:
MyProcessor();
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
bool isLocked() const { return _unlocker.isLocked(); }
bool isUnlocked() const { return ! _unlocker.isLocked(); }
inlay::Unlocker& getUnlocker() { return _unlocker; }
const inlay::Unlocker& getUnlocker() const { return _unlocker; }
private:
inlay::Unlocker _unlocker;
};
Configure the unlocker with the Inlay product ID and public key from the Inlay Console project setup:
namespace
{
inlay::Unlocker::Config makeUnlockerConfig()
{
inlay::Unlocker::Config config;
config.productId = "YOUR_INLAY_PRODUCT_ID";
config.publicKey = "YOUR_COMPANY_PUBLIC_KEY";
// Leave empty for production. Set only for development or staging.
// config.apiURL = "https://api-dev.inlay.cloud";
return config;
}
}
MyProcessor::MyProcessor()
: _unlocker (makeUnlockerConfig())
{
_unlocker.startup();
}
The default UI blocks editor interaction, but it does not protect audio or MIDI processing by itself. Guard protected processing with the realtime-safe lock flag:
void MyProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midi)
{
if (_unlocker.isLocked())
{
buffer.clear();
midi.clear();
return;
}
// Protected processing goes here.
}
Do not call getStatus(), getError(), getCurrentUser(), or other non-realtime methods
from the audio thread.
Default UI
inlay::DefaultUI is a juce::Component that listens to an Unlocker and shows:
- an Activate button when browser activation is required,
- Unlocking status while work is in progress,
- Retry and Logout buttons when unlocking reports an error,
- an update prompt when Inlay reports a newer app version.
Add it to an editor and keep the referenced unlocker alive for the full lifetime of the component:
class MyEditor : public juce::AudioProcessorEditor
{
public:
explicit MyEditor (MyProcessor& processorToEdit)
: juce::AudioProcessorEditor (processorToEdit),
_processor (processorToEdit),
_defaultUI (processorToEdit.getUnlocker())
{
addAndMakeVisible (_mainContent);
addAndMakeVisible (_defaultUI);
setSize (600, 400);
}
void resized() override
{
const auto bounds = getLocalBounds();
_mainContent.setBounds (bounds);
_defaultUI.setBounds (bounds);
_defaultUI.toFront (false);
}
private:
MyProcessor& _processor;
juce::Component _mainContent;
inlay::DefaultUI _defaultUI;
};
The default UI hides itself when the unlocker reaches Status::unlocked.
Keep project-specific layout and styling outside this module. Treat DefaultUI as the stock implementation, wrap
it from the host plugin when needed, and build a custom UI instead of editing module files for product-specific behaviour.
Custom UI
Build custom UI by listening to the unlocker:
class ActivationPanel : public juce::Component,
private juce::ChangeListener
{
public:
explicit ActivationPanel (inlay::Unlocker& unlockerToUse)
: _unlocker (unlockerToUse)
{
_unlocker.addChangeListener (this);
updateContent();
}
~ActivationPanel() override
{
_unlocker.removeChangeListener (this);
}
private:
void changeListenerCallback (juce::ChangeBroadcaster* source) override
{
if (source == &_unlocker)
updateContent();
}
void updateContent()
{
switch (_unlocker.getStatus())
{
case inlay::Unlocker::Status::activationRequired:
// Show Activate. Call _unlocker.startActivation().
break;
case inlay::Unlocker::Status::unlocking:
// Show progress, or Retry/Logout when getError() is not empty.
break;
case inlay::Unlocker::Status::unlocked:
// Hide lock UI and enable protected controls.
break;
case inlay::Unlocker::Status::undefined:
break;
}
}
inlay::Unlocker& _unlocker;
};
Useful methods for custom UI:
| Method | Use |
|---|---|
startup() |
Starts loading and validating local license state. Call once after constructing the unlocker. |
startActivation() |
Starts browser-based activation. Usually called from an Activate button. |
retryUnlocking() |
Retries after an unlocking error. |
logout() |
Clears local activation state and returns to activation required. |
getStatus() |
Returns the current status for UI/message-thread code. |
getError() |
Returns the last user-facing error message. |
getCurrentUser() |
Returns the activated user's email address when available. |
getAppUpdate() |
Returns available app update information when visible. |
skipCurrentAppUpdateVersion() |
Hides the currently visible app update version. |
openWebsite(url) |
Opens a URL in the user's default browser. |
isLocked() |
Realtime-safe lock check for protected code paths. |
The expected custom UI flow is:
startup()moves the unlocker out ofStatus::undefined.- In
Status::activationRequired, show Activate and callstartActivation()from the user's action. - In
Status::unlocking, show progress. IfgetError()is not empty, offer Retry and Logout actions. - In
Status::unlocked, hide blocking UI and enable the protected experience.
Configuration
inlay::Unlocker::Config accepts:
| Field | Required | Meaning |
|---|---|---|
productId |
Yes | Inlay product ID for the plugin or application. |
publicKey |
Yes | Public key used to verify access tokens for this product and device. |
apiURL |
No | API base URL override. Leave empty to use https://api.inlay.cloud. |
Use a separate Inlay product ID for each separately licensed product. Do not reuse a host plugin product ID for add-ons or other separately sold content.
Local persistence
The module stores activation data below the user's application data directory:
InlayCloud/<productId>/
The stored data currently includes:
| File | Purpose |
|---|---|
id-token |
Persisted identity token used to request fresh access. |
access-token |
Persisted access token validated locally before unlocking. |
lock.json |
Lightweight cross-instance lock used while refreshing access. |
activation-event.json |
Cross-instance activation event used to wake other plugin instances after activation. |
update.json |
Cached app update information returned by Inlay. |
skipped-update.json |
App update version skipped by the user. |
Calling logout() clears the saved identity/access state and moves the unlocker back to
Status::activationRequired.
App updates
Inlay access responses can include application update information. The unlocker caches that information locally and exposes
it through getAppUpdate() when:
- a version and URL are available,
- the version is not the currently running plugin/app version,
- the user has not skipped that version.
DefaultUI shows a native update prompt after unlock. Custom UI can call getAppUpdate(),
skipCurrentAppUpdateVersion(), and openWebsite() directly.
Integration checklist
- Add the module to the JUCE project.
- Include
<inlay_product_unlocking/inlay_product_unlocking.h>. - Create one
inlay::Unlockerper protected product instance. - Provide
productIdandpublicKeyfrom the Inlay Console. - Call
startup()once after constructing the unlocker. - Use
isLocked()to guard protected realtime/audio behaviour. - Add
inlay::DefaultUIto the editor, or build a customChangeListenerUI. - Keep activation and UI methods on the message thread.
- Add Logout and app-update handling if the plugin needs custom controls.
Development notes
- Keep one unlocker per protected product instance.
- Construct
DefaultUIonly after the referenced unlocker exists. - Call
startup()once after constructing the unlocker. - Call
startActivation(),retryUnlocking(),logout(), and update-related methods from UI/message-thread code. - Use
isLocked()from realtime/audio code. - Use
ChangeBroadcasterupdates for editor state instead of polling. - Functions that can fail should return
juce::Resultwhen adding new public API.