Skip to main content
Migrate Already selling? Move your customers to Keylight without re-issuing a single key.
Keylight
Blog
rust tauri license-keys upgrades

Rust App Licensing: One Crate, the Tauri Plugin, No Blocked UI

7 min read Nicolas Demanez — Founder

Most rust app licensing I see in the wild is a hand-written check against a home-made server. It works until the first customer opens the app on a plane, the first refund that never revokes, or the first upgrade that needs a relaunch to show up. The keylight crate exists so a Rust developer never writes that server. One crate for native apps and CLIs, one Tauri plugin built on top of it, and one dashboard that owns the rules.

This post is the Rust seat: how to pick the entry point, activate and validate in a handful of lines, refresh entitlements after an upgrade without touching the main thread, and turn on signed settings when you are ready. Offline verification already has its own post and I will not repeat it here.

Two entry points, one crate

Pick the plain keylight crate when your binary is the whole app: a CLI, a daemon, a native window built with egui or iced, a plugin host. Pick tauri-plugin-keylight when the UI lives in a webview. Both wrap the same client, share the same lease format, and read the same dashboard settings. The choice is only about who calls the client: your Rust code, or your frontend over invoke.

# Native app or CLI
[dependencies]
keylight = "0.6"

# Tauri v2 app
[dependencies]
tauri-plugin-keylight = "0.6"

The Tauri plugin also ships an npm package, tauri-plugin-keylight-api, with typed wrappers for the frontend. The versions move together, so keep them on the same minor.

There is no async runtime in the plain crate. HTTP goes through ureq and blocks. That is a deliberate choice: a CLI should not drag tokio in just to check a license, and a native app already has a thread to spare. The Tauri plugin handles threading for you, which I cover below.

Activate and validate in five lines

Build a config, build a client, activate once, validate on launch. That is the whole loop.

use keylight::{Keylight, KeylightConfig, LicenseState};

let cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...")
    .trusted_key("k1", "<base64 ed25519 public key from your dashboard>")
    .trial_duration_days(14)
    .build();

let kl = Keylight::new(cfg)?;

// First run, or when the customer pastes a key.
let act = kl.activate("USER-LICENSE-KEY")?;
println!("activated: {}", act.activated);

// Every launch after that.
let val = kl.validate()?;
println!("valid: {} state: {:?}", val.valid, kl.state());

state() is the thing you gate on. It resolves to one of Trial { days_left }, Licensed, Limited, FreeTier, Expired, or Invalid, and it reads from the cached lease without a network call. has_entitlement("pro") does the same for individual features. There is no tier field anywhere in the crate. A tier is just the set of entitlement strings the server put in the lease, and a higher plan carries more of them.

Two small habits keep this clean. Keep the sdk_key out of source control the same way you would any secret. And prefer check_on_launch() over calling validate() yourself at startup: it refreshes only when a check is due, starts the trial clock on a fresh install, and never touches the network when the lease is still fresh.

For the Tauri plugin the same loop moves to the frontend:

// src-tauri/src/lib.rs
tauri::Builder::default()
    .plugin(tauri_plugin_keylight::init(cfg))
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
import { activate, checkOnLaunch, hasEntitlement } from "tauri-plugin-keylight-api";

await checkOnLaunch();
await activate(key);
const pro = await hasEntitlement("pro");

The capability grant and the full frontend wiring are in How to Add License Keys to a Tauri App. What changed since that post is the threading: in 0.6.1 every plugin command that does I/O is declared #[tauri::command(async)], so activate and validate run off the main thread and the webview stays responsive while the request is in flight.

The trial length lives in the dashboard now

The trial_duration_days(14) line above is a seed, not the truth. It is what a fresh install uses before it has ever spoken to the server. The server sends the real trial length and the free tier flag on every validate and on the keyless beacon, and the client merges them field by field. From then on effective_trial_duration_days() and effective_free_tier_enabled() return the server’s values.

let days = kl.effective_trial_duration_days();   // server value, else seed, else 0
let free = kl.effective_free_tier_enabled();     // server value, else seed, else false

That means you can cut a trial from 14 days to 7 in the dashboard and every shipped build follows on its next check, with no rebuild and no release. If you want the settings before the first validate, fetch_config() reads them explicitly. It is not on the launch path on purpose: launch stays at zero extra network calls.

One precision that matters: a server value of 0 is a real setting and turns trials off. Absence is not zero. The crate keeps those apart, so a product with no trial configured falls back to your seed instead of silently disabling trials.

Refresh after an upgrade, off the main thread

Here is the flow I care most about. A customer on the basic plan opens the customer portal, buys the pro tier, and comes back to your app. The old answer was “quit and reopen” or “paste your new key.” The right answer is that the pro feature just appears.

refresh_after_upgrade does that. It snapshots the current entitlement set and state, then polls validate every poll_interval until one of them changes or timeout runs out. It returns true on a change and false on timeout. If no license is stored it returns false immediately with no network call.

It blocks. It sleeps with std::thread::sleep between polls, which is exactly what you want in a CLI and exactly what you do not want on a UI thread. So spawn it. Keylight is Send + Sync but not Clone, so wrap it in an Arc:

use std::{sync::Arc, time::Duration};

let kl: Arc<Keylight> = Arc::new(Keylight::new(cfg)?);

// The customer clicked "Upgrade", you opened kl.upgrade_url() in the browser.
// Now wait for the purchase to land, without touching the UI thread.
let worker = Arc::clone(&kl);
std::thread::spawn(move || {
    let changed = worker.refresh_after_upgrade(
        Duration::from_secs(30),
        Duration::from_secs(2),
    );
    if changed {
        // Send a message to your UI loop: re-read state() and has_entitlement().
    }
});

The UI thread never calls into the network. It waits for the message, then reads state() and has_entitlement(), both of which are cheap local reads. If the customer takes longer than 30 seconds to finish checkout, the call returns false and the normal validation cadence picks the change up later. Nothing is lost, the feature just shows up on the next check instead of the next second.

Same key, higher tier. The customer keeps one license for life. And note what is not here: an in-app checkout. The purchase happens in the hosted portal today, your app opens the URL from upgrade_url(), and the refresh brings the result back. I am not going to promise a native purchase sheet until it exists.

In a Tauri app the frontend drives it:

import { refreshAfterUpgrade, hasEntitlement } from "tauri-plugin-keylight-api";

const changed = await refreshAfterUpgrade({ timeoutSecs: 30, pollIntervalSecs: 2 });
if (changed && (await hasEntitlement("pro"))) showProPanel();

The plugin command is async, so the polling runs in Tauri’s runtime and the webview keeps painting.

Signed settings, when you are ready

Everything the server sends about trial length and free tier can be signed by your dashboard, and the crate can refuse anything that is not. This is off by default. Turn it on with one builder call and a pinned key:

let cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...")
    .trusted_key("k1", "<base64 ed25519 public key>")
    .require_signed_config(true)
    .build();

With the flag on, trust is rooted only in the keys compiled into the binary. The crate deliberately does not fetch a keyset at runtime for this check, because keys fetched over the same channel that serves the settings would let anyone who can forge one forge the other. A settings payload that does not verify is never cached. The client keeps your seed and moves on, so a tampered or replayed response cannot stretch a trial or flip the free tier.

Why off by default: the server only signs settings for a product that has a trial length configured in the dashboard. Flip the switch on a product that is not signed and every legitimate response gets rejected, freezing the install on its compiled-in seed. Set the trial length first, then enable the flag, and do it before launch rather than after the first leak.

What the server still owns

Activation limits, revocation, expiry, renewals, and the entitlement set all live on the server and arrive in the signed lease. Your Rust code never decides whether a key is valid. It asks, caches the signed answer, and verifies that answer locally until the next check is due. That is the split that makes offline validation safe, and it is the same split every Keylight SDK uses, checked against the same conformance vectors so a lease verifies identically in Rust, Swift, JavaScript, C#, and C++.

The Rust SDK install docs cover the store and transport options I skipped here. Pricing starts free on the pricing page, and the crate is MIT.

If your Rust app hits a case this post does not cover, send your feedback and I will extend it.

Nicolas Demanez, Founder

Frequently asked

Do I need tokio to use the keylight crate?+

No. The crate is synchronous and uses blocking HTTP through ureq. It works in a plain binary or CLI with no async runtime. If you already run tokio, call it from a blocking task or a plain std thread.

Should a Tauri app use the keylight crate or the Tauri plugin?+

Use the tauri-plugin-keylight crate. It wraps the same client, registers as a Tauri v2 plugin, and exposes activate, validate, and hasEntitlement to your frontend over invoke. Every plugin command that touches the network runs async, so the webview never stalls.

How does a Rust app pick up a paid upgrade without a restart?+

Call refresh_after_upgrade on a background thread after the customer returns from the portal. It polls validate until the entitlement set or state changes, then returns true. Your UI thread only reads the result.

Is the trial length compiled into the Rust binary?+

No. The value in your config is only a seed for first launch. The server sends the trial length and free tier flag on every validate, and effective_trial_duration_days returns the server value once it has one. Change it in the dashboard and shipped builds follow.

Ready to ship?

Create your account and start licensing your apps in under a minute. Free forever tier included.

Start Free