use crate::cache::LicenseCache;
use crate::config::{Config, OfflineFallbackMode};
use crate::error::{Error, Result};
use crate::events::{Event, EventKind};
use crate::models::*;
use crate::telemetry::{generate_device_id, Telemetry};
use chrono::Utc;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, RwLock};
use tracing::{debug, error, info, warn};
#[derive(Clone)]
pub struct LicenseSeat {
inner: Arc<LicenseSeatInner>,
}
struct LicenseSeatInner {
config: Config,
http: reqwest::Client,
cache: LicenseCache,
event_tx: broadcast::Sender<Event>,
online: RwLock<bool>,
}
impl LicenseSeat {
pub fn new(config: Config) -> Self {
let http = build_http_client(&config);
let cache = LicenseCache::new(&config.storage_prefix);
let (event_tx, _) = broadcast::channel(64);
let inner = Arc::new(LicenseSeatInner {
config,
http,
cache,
event_tx,
online: RwLock::new(true),
});
let sdk = Self { inner };
if let Some(license) = sdk.inner.cache.get_license() {
debug!("Loaded cached license: {}", license.license_key);
sdk.emit(Event::with_license(EventKind::LicenseLoaded, license));
}
sdk
}
pub async fn activate(&self, license_key: &str) -> Result<License> {
self.activate_with_options(license_key, ActivationOptions::default()).await
}
pub async fn activate_with_options(
&self,
license_key: &str,
options: ActivationOptions,
) -> Result<License> {
let product_slug = self.require_product_slug()?;
let device_id = options
.device_id
.or_else(|| self.inner.config.device_identifier.clone())
.unwrap_or_else(generate_device_id);
self.emit(Event::new(EventKind::ActivationStart));
let mut body = serde_json::json!({
"device_id": device_id,
});
if let Some(name) = &options.device_name {
body["device_name"] = serde_json::json!(name);
}
if let Some(metadata) = &options.metadata {
body["metadata"] = serde_json::json!(metadata);
}
let path = format!("/products/{}/licenses/{}/activate", product_slug, license_key);
match self.post::<ActivationResponse>(&path, Some(body)).await {
Ok(activation) => {
let license = License {
license_key: license_key.to_string(),
device_id,
activation_id: activation.id,
activated_at: activation.activated_at,
last_validated: Utc::now(),
validation: None,
};
self.inner.cache.set_license(&license)?;
self.emit(Event::with_license(EventKind::ActivationSuccess, license.clone()));
info!("License activated: {}", license_key);
Ok(license)
}
Err(e) => {
self.emit(Event::with_error(EventKind::ActivationError, e.to_string()));
Err(e)
}
}
}
pub async fn validate(&self) -> Result<ValidationResult> {
let license = self.inner.cache.get_license().ok_or(Error::NoActiveLicense)?;
self.validate_key(&license.license_key).await
}
pub async fn validate_key(&self, license_key: &str) -> Result<ValidationResult> {
let product_slug = self.require_product_slug()?;
let device_id = self.inner.cache.get_device_id();
self.emit(Event::new(EventKind::ValidationStart));
let mut body = serde_json::Map::new();
if let Some(id) = &device_id {
body.insert("device_id".into(), serde_json::json!(id));
}
let path = format!("/products/{}/licenses/{}/validate", product_slug, license_key);
let body = if body.is_empty() { None } else { Some(serde_json::Value::Object(body)) };
match self.post::<ValidationResult>(&path, body).await {
Ok(result) => {
self.inner.cache.update_validation(&result)?;
self.inner.cache.set_last_seen_timestamp(Utc::now().timestamp())?;
if result.valid {
self.emit(Event::with_validation(EventKind::ValidationSuccess, result.clone()));
info!("License validated successfully");
} else {
self.emit(Event::with_validation(EventKind::ValidationFailed, result.clone()));
warn!("License validation failed: {:?}", result.code);
}
Ok(result)
}
Err(e) => {
self.emit(Event::with_error(EventKind::ValidationError, e.to_string()));
if e.is_business_error() {
self.inner.cache.clear();
self.emit(Event::with_error(EventKind::LicenseRevoked, e.to_string()));
return Err(e);
}
if self.should_fallback_offline(&e) {
#[cfg(feature = "offline")]
{
return self.validate_offline().await;
}
}
Err(e)
}
}
}
pub async fn deactivate(&self) -> Result<()> {
let product_slug = self.require_product_slug()?;
let license = self.inner.cache.get_license().ok_or(Error::NoActiveLicense)?;
self.emit(Event::new(EventKind::DeactivationStart));
let path = format!(
"/products/{}/licenses/{}/deactivate",
product_slug, license.license_key
);
let body = serde_json::json!({ "device_id": license.device_id });
match self.post::<DeactivationResponse>(&path, Some(body)).await {
Ok(_) => {
self.inner.cache.clear();
self.emit(Event::new(EventKind::DeactivationSuccess));
info!("License deactivated");
Ok(())
}
Err(e) => {
if let Error::Api { status, code, .. } = &e {
if *status == 404 || *status == 410 {
self.inner.cache.clear();
self.emit(Event::new(EventKind::DeactivationSuccess));
return Ok(());
}
if *status == 422 {
if let Some(c) = code {
if ["revoked", "already_deactivated", "not_active", "not_found", "suspended", "expired"]
.contains(&c.as_str())
{
self.inner.cache.clear();
self.emit(Event::new(EventKind::DeactivationSuccess));
return Ok(());
}
}
}
}
self.emit(Event::with_error(EventKind::DeactivationError, e.to_string()));
Err(e)
}
}
}
pub async fn heartbeat(&self) -> Result<HeartbeatResponse> {
let product_slug = self.require_product_slug()?;
let license = self.inner.cache.get_license().ok_or(Error::NoActiveLicense)?;
let path = format!(
"/products/{}/licenses/{}/heartbeat",
product_slug, license.license_key
);
let body = serde_json::json!({ "device_id": license.device_id });
match self.post::<HeartbeatResponse>(&path, Some(body)).await {
Ok(response) => {
self.emit(Event::new(EventKind::HeartbeatSuccess));
debug!("Heartbeat sent successfully");
Ok(response)
}
Err(e) => {
self.emit(Event::with_error(EventKind::HeartbeatError, e.to_string()));
Err(e)
}
}
}
pub fn check_entitlement(&self, entitlement_key: &str) -> EntitlementStatus {
let Some(license) = self.inner.cache.get_license() else {
return EntitlementStatus {
active: false,
reason: Some(EntitlementReason::NoLicense),
expires_at: None,
entitlement: None,
};
};
let Some(validation) = &license.validation else {
return EntitlementStatus {
active: false,
reason: Some(EntitlementReason::NoLicense),
expires_at: None,
entitlement: None,
};
};
let entitlements = &validation.license.active_entitlements;
let entitlement = entitlements.iter().find(|e| e.key == entitlement_key);
match entitlement {
None => EntitlementStatus {
active: false,
reason: Some(EntitlementReason::NotFound),
expires_at: None,
entitlement: None,
},
Some(e) => {
if let Some(expires_at) = e.expires_at {
if expires_at < Utc::now() {
return EntitlementStatus {
active: false,
reason: Some(EntitlementReason::Expired),
expires_at: Some(expires_at),
entitlement: Some(e.clone()),
};
}
}
EntitlementStatus {
active: true,
reason: None,
expires_at: e.expires_at,
entitlement: Some(e.clone()),
}
}
}
}
pub fn has_entitlement(&self, entitlement_key: &str) -> bool {
self.check_entitlement(entitlement_key).active
}
pub fn status(&self) -> LicenseStatus {
let Some(license) = self.inner.cache.get_license() else {
return LicenseStatus::Inactive {
message: "No license activated".into(),
};
};
let Some(validation) = &license.validation else {
return LicenseStatus::Pending {
message: "License pending validation".into(),
};
};
if !validation.valid {
return LicenseStatus::Invalid {
message: validation
.message
.clone()
.or_else(|| validation.code.clone())
.unwrap_or_else(|| "License invalid".into()),
};
}
LicenseStatus::Active {
details: LicenseStatusDetails {
license: license.license_key,
device: license.device_id,
activated_at: license.activated_at,
last_validated: license.last_validated,
entitlements: validation.license.active_entitlements.clone(),
},
}
}
pub fn current_license(&self) -> Option<License> {
self.inner.cache.get_license()
}
pub async fn health_check(&self) -> Result<HealthResponse> {
self.get("/health").await
}
pub fn reset(&self) {
self.inner.cache.clear();
self.emit(Event::new(EventKind::SdkReset));
info!("SDK state reset");
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.inner.event_tx.subscribe()
}
fn require_product_slug(&self) -> Result<&str> {
if self.inner.config.product_slug.is_empty() {
return Err(Error::ProductSlugRequired);
}
Ok(&self.inner.config.product_slug)
}
fn emit(&self, event: Event) {
let _ = self.inner.event_tx.send(event);
}
fn should_fallback_offline(&self, error: &Error) -> bool {
match self.inner.config.offline_fallback_mode {
OfflineFallbackMode::Always => true,
OfflineFallbackMode::NetworkOnly => error.is_network_error(),
}
}
#[cfg(feature = "offline")]
async fn validate_offline(&self) -> Result<ValidationResult> {
Err(Error::OfflineVerificationFailed("Offline validation not implemented".into()))
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.request(reqwest::Method::GET, path, None::<()>).await
}
async fn post<T: DeserializeOwned>(&self, path: &str, body: Option<serde_json::Value>) -> Result<T> {
self.request(reqwest::Method::POST, path, body).await
}
async fn request<T: DeserializeOwned, B: serde::Serialize>(
&self,
method: reqwest::Method,
path: &str,
body: Option<B>,
) -> Result<T> {
let url = format!("{}{}", self.inner.config.api_base_url, path);
let mut request = self.inner.http.request(method, &url);
if let Some(b) = body {
let mut json_body = serde_json::to_value(&b)?;
if self.inner.config.telemetry_enabled {
if let serde_json::Value::Object(ref mut map) = json_body {
let telemetry = Telemetry::collect(
self.inner.config.app_version.clone(),
self.inner.config.app_build.clone(),
);
map.insert("telemetry".into(), serde_json::to_value(telemetry)?);
}
}
request = request.json(&json_body);
}
let mut last_error = None;
for attempt in 0..=self.inner.config.max_retries {
if attempt > 0 {
let delay = self.inner.config.retry_delay * 2u32.pow(attempt - 1);
tokio::time::sleep(delay).await;
debug!("Retry attempt {} for {}", attempt, path);
}
match request.try_clone().unwrap().send().await {
Ok(response) => {
let status = response.status().as_u16();
if response.status().is_success() {
return response.json().await.map_err(Error::from);
}
let error_body: serde_json::Value = response.json().await.unwrap_or_default();
let (code, message, details) = parse_error_response(&error_body);
let error = Error::api(status, code, message, details);
if error.is_business_error() {
return Err(error);
}
last_error = Some(error);
}
Err(e) => {
last_error = Some(Error::Network(e));
}
}
}
Err(last_error.unwrap())
}
}
#[derive(Debug, Clone, Default)]
pub struct ActivationOptions {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
impl ActivationOptions {
pub fn with_device_name(name: impl Into<String>) -> Self {
Self {
device_name: Some(name.into()),
..Default::default()
}
}
}
fn build_http_client(config: &Config) -> reqwest::Client {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&format!("licenseseat-rust/{}", crate::VERSION)).unwrap(),
);
if !config.api_key.is_empty() {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", config.api_key)) {
headers.insert(AUTHORIZATION, value);
}
}
reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client")
}
fn parse_error_response(
body: &serde_json::Value,
) -> (Option<String>, String, Option<HashMap<String, serde_json::Value>>) {
if let Some(error) = body.get("error").and_then(|e| e.as_object()) {
let code = error.get("code").and_then(|c| c.as_str()).map(String::from);
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error")
.to_string();
let details = error.get("details").and_then(|d| {
d.as_object()
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
});
return (code, message, details);
}
let code = body.get("code").and_then(|c| c.as_str()).map(String::from);
let message = body
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error")
.to_string();
(code, message, None)
}