v0.4.0
[0.4.0] - 2026-09-04
Two breaking changes. Both are one-line fixes in your code, but neither is
silent — read them before upgrading.
Changed — BREAKING
-
trial_duration_daysnow defaults to0, not14. Trials are opt-in.
A product that never calls.trial_duration_days(n)previously granted every
new install a 14-day full-entitlement trial it never asked for; that
install now resolves straight to the free tier (or to expired, when the
product has no free tier).If you rely on a trial, you must now say so explicitly:
KeylightConfig::builder(tenant, product, sdk_key) .trial_duration_days(14) // previously implicit — now required .build()
Upgrading without this line silently ends trials for new installs. Existing
installs already inside a trial window are unaffected: the trial start is
persisted, and the window is read from it. -
upgrade_url()now points at the portal's authenticated license route.
It used to buildportal.keylight.dev/p/{tenant}/upgrade/{product}?key=…,
a retired route — the only public portal page left is/p/{tenant}/claim/ {product}, so customers following the old link landed on a 404 instead of a
checkout. It now returns:https://portal.keylight.dev/t/{tenant}/license/{normalized_key}/upgradeThe customer signs in with a magic link on arrival and upgrades in-portal.
The key rides in the path (normalized: whitespace and dashes stripped,
uppercased — matching the portal'snormalizedKey), not in a query string,
and the product id is gone from the URL because the license carries its own
product server-side. If you display, log, or deep-link this URL, re-check
those call sites.
Added
-
start_keyless_heartbeat()— a keyless cadence for hosts the Tauri plugin
doesn't cover.report_keyless_statehas never had a cadence in this crate,
so a resident non-Tauri host — a daemon, a service, a desktop app built
without the plugin — beaconed once at startup and then looked dead to the
dashboard for as long as it ran:last_seennever moved pastfirst_seen.let kl = Arc::new(Keylight::new(cfg)?); let _heartbeat = kl.start_keyless_heartbeat(Duration::from_secs(6 * 60 * 60));
It takes
Arc<Self>because a thread cannot outlive a&selfborrow, and
returns an RAII handle: dropping it stops and joins the thread, so the cadence
can't outlive the scope that asked for it. The thread holds only aWeak, so
it can never keep the client alive — drop the lastArcand the next tick
exits. A zero interval is refused rather than spun on.Each tick reports only when
state()is keyless; a licensed device sends
nothing and reports liveness through/validate. The thread keeps ticking
across that boundary, so a lapsed license resumes on its own. The 24h debounce
insidereport_keyless_stateis untouched.Opt-in by design — unlike the Tauri plugin, the crate does not spawn threads
behind your back. -
keyless_state_for(&LicenseState) -> Option<KeylessState>is now public.
The Tauri plugin had a private copy; the rule now lives in one place and the
plugin uses it. The match is exhaustive, so adding aLicenseStatevariant
fails to compile here rather than silently falling through to "beacon it".
Changed
-
The Tauri plugin's keyless heartbeat is now on by default. A Tauri app is
typically a tray app that stays resident for days; with the heartbeat off,
it reported itself once per launch and the dashboard showed a live install as
last seen on the day it was installed —last_seennever moved past
first_seen.HeartbeatOptions::default()now hasenabled: true.The heartbeat no longer bundles license revalidation. It used to call
refresh_if_needed()on every tick, which would have meant turning on a
background network call on licensed devices as a side effect of wanting
liveness. That is now a separate opt-in flag, off by default:HeartbeatOptions { revalidate: true, ..Default::default() }
The default path sends only the anonymous keyless beacon, which the SDK
already debounces to one request per 24h. Hosts callinginit()get the
cadence with no code change;enabled: falsestill turns everything off.