use crate::client::cache::{
clear_cache_from_disk, load_cache_from_disk, save_cache_to_disk, CachedValidation,
};
use crate::client::encrypted_storage::{
clear_license_from_disk, load_license_from_disk, save_license_to_disk,
};
use crate::client::errors::{ClientApiError, ClientErrorCode, ServerErrorResponse};
use crate::client::responses::{
BindResult, FeatureResult, HeartbeatResult, ServerBindResponse, ServerFeatureResponse,
ServerHeartbeatResponse, ServerReleaseResponse, ServerValidateResponse, ValidationResult,
};
use crate::errors::{LicenseError, LicenseResult};
use crate::hardware::get_hardware_id;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const REQUEST_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub license_key: String,
pub server_url: String,
#[serde(default)]
pub hardware_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached: Option<CachedValidation>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub license_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub client_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub expiry_date: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub signature: String,
#[serde(default)]
pub is_active: bool,
}
#[derive(Debug, Serialize)]
struct BindRequest {
license_key: String,
hardware_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
device_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
device_info: Option<String>,
}
#[derive(Debug, Serialize)]
struct ReleaseRequest {
license_key: String,
hardware_id: String,
}
#[derive(Debug, Serialize)]
struct ValidateRequest {
license_key: String,
hardware_id: String,
}
#[derive(Debug, Serialize)]
struct HeartbeatRequest {
license_key: String,
hardware_id: String,
}
#[derive(Debug, Serialize)]
struct FeatureRequest {
license_key: String,
hardware_id: String,
feature: String,
}
#[derive(Debug, Serialize)]
struct LegacyLicenseRequest {
license_id: String,
client_id: String,
}
#[derive(Debug, Deserialize)]
struct LegacyLicenseResponse {
success: bool,
}
impl License {
pub fn new(license_key: String, server_url: String) -> Self {
Self {
license_key,
server_url,
hardware_id: String::new(),
cached: None,
license_id: String::new(),
client_id: String::new(),
expiry_date: String::new(),
features: Vec::new(),
signature: String::new(),
is_active: false,
}
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new())
}
async fn parse_error_response(resp: reqwest::Response) -> LicenseError {
let status = resp.status();
match resp.json::<ServerErrorResponse>().await {
Ok(err_resp) => LicenseError::ClientApiError(err_resp.into()),
Err(_) => LicenseError::ServerError(format!("Request failed with status {}", status)),
}
}
pub async fn bind(
&mut self,
device_name: Option<&str>,
device_info: Option<&str>,
) -> LicenseResult<BindResult> {
let hardware_id = get_hardware_id();
let request = BindRequest {
license_key: self.license_key.clone(),
hardware_id: hardware_id.clone(),
device_name: device_name.map(|s| s.to_string()),
device_info: device_info.map(|s| s.to_string()),
};
let resp = Self::http_client()
.post(format!("{}/api/v1/client/bind", self.server_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::parse_error_response(resp).await);
}
let server_resp: ServerBindResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse bind response: {e}"))
})?;
self.hardware_id = hardware_id.clone();
self.is_active = true;
self.license_id = server_resp.license_id.clone();
self.client_id = hardware_id.clone();
self.features = server_resp.features.clone();
self.expiry_date = server_resp.expires_at.clone().unwrap_or_default();
self.save_to_disk().await?;
Ok(server_resp.into())
}
pub async fn release(&mut self) -> LicenseResult<()> {
if self.hardware_id.is_empty() {
return Err(LicenseError::InvalidLicense(
"License is not bound to any hardware.".to_string(),
));
}
let request = ReleaseRequest {
license_key: self.license_key.clone(),
hardware_id: self.hardware_id.clone(),
};
let resp = Self::http_client()
.post(format!("{}/api/v1/client/release", self.server_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::parse_error_response(resp).await);
}
let _: ServerReleaseResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse release response: {e}"))
})?;
self.hardware_id.clear();
self.is_active = false;
self.client_id.clear();
self.cached = None;
clear_license_from_disk().await?;
clear_cache_from_disk().await?;
Ok(())
}
pub async fn validate(&mut self) -> LicenseResult<ValidationResult> {
self.ensure_bound()?;
let request = ValidateRequest {
license_key: self.license_key.clone(),
hardware_id: self.hardware_id.clone(),
};
let resp = Self::http_client()
.post(format!("{}/api/v1/client/validate", self.server_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::parse_error_response(resp).await);
}
let server_resp: ServerValidateResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse validate response: {e}"))
})?;
let result: ValidationResult = server_resp.into();
self.cached = Some(CachedValidation::new(
self.license_key.clone(),
self.hardware_id.clone(),
result.features.clone(),
result.tier.clone(),
result.expires_at.clone(),
result.grace_period_ends_at.clone(),
));
if let Some(ref cache) = self.cached {
let _ = save_cache_to_disk(cache).await;
}
self.save_to_disk().await?;
Ok(result)
}
pub fn validate_offline(&self) -> LicenseResult<ValidationResult> {
let cache = match &self.cached {
Some(c) => c.clone(),
None => {
return Err(LicenseError::InvalidLicense(
"No cached validation available. Call validate() while online first."
.to_string(),
));
}
};
if !cache.matches_hardware() {
return Err(LicenseError::InvalidLicense(
"Cached validation does not match current hardware.".to_string(),
));
}
if cache.license_key != self.license_key {
return Err(LicenseError::InvalidLicense(
"Cached validation is for a different license.".to_string(),
));
}
if cache.is_license_expired() {
return Err(LicenseError::ClientApiError(ClientApiError::new(
ClientErrorCode::LicenseExpired,
"License has expired.",
)));
}
if !cache.is_valid_for_offline() {
return Err(LicenseError::ClientApiError(
ClientApiError::grace_period_expired(),
));
}
let warning = cache.grace_period_ends_at.as_ref().map(|ends_at| {
format!(
"Offline mode - license must be validated online before {}",
ends_at
)
});
Ok(ValidationResult {
features: cache.features.clone(),
tier: cache.tier.clone(),
expires_at: cache.expires_at.clone(),
grace_period_ends_at: cache.grace_period_ends_at.clone(),
warning,
bandwidth_used_bytes: None,
bandwidth_limit_bytes: None,
})
}
pub async fn validate_with_fallback(&mut self) -> LicenseResult<ValidationResult> {
match self.validate().await {
Ok(result) => Ok(result),
Err(LicenseError::NetworkError(_)) => {
self.validate_offline()
}
Err(e) => Err(e),
}
}
pub async fn validate_feature(&self, feature: &str) -> LicenseResult<FeatureResult> {
self.ensure_bound()?;
let request = FeatureRequest {
license_key: self.license_key.clone(),
hardware_id: self.hardware_id.clone(),
feature: feature.to_string(),
};
let resp = Self::http_client()
.post(format!(
"{}/api/v1/client/validate-feature",
self.server_url
))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::parse_error_response(resp).await);
}
let server_resp: ServerFeatureResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse feature response: {e}"))
})?;
Ok(server_resp.into())
}
pub async fn heartbeat(&mut self) -> LicenseResult<HeartbeatResult> {
self.ensure_bound()?;
let request = HeartbeatRequest {
license_key: self.license_key.clone(),
hardware_id: self.hardware_id.clone(),
};
let resp = Self::http_client()
.post(format!("{}/api/v1/client/heartbeat", self.server_url))
.json(&request)
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::parse_error_response(resp).await);
}
let server_resp: ServerHeartbeatResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse heartbeat response: {e}"))
})?;
let result: HeartbeatResult = server_resp.into();
if let Some(ref new_grace) = result.grace_period_ends_at {
if let Some(ref mut cache) = self.cached {
cache.grace_period_ends_at = Some(new_grace.clone());
let _ = save_cache_to_disk(cache).await;
}
}
Ok(result)
}
#[deprecated(since = "0.2.0", note = "Use bind() instead")]
pub async fn activate(&mut self) -> LicenseResult<()> {
let server_url = &self.server_url;
let client_id = get_hardware_id();
let payload = LegacyLicenseRequest {
license_id: self.license_id.clone(),
client_id: client_id.clone(),
};
let resp = Self::http_client()
.post(format!("{}/activate", server_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
return Err(LicenseError::ServerError(format!(
"Activation failed with HTTP status {}",
resp.status()
)));
}
let body: LegacyLicenseResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse activation response: {e}"))
})?;
if !body.success {
return Err(LicenseError::InvalidLicense(
"Activation failed on server.".to_string(),
));
}
self.is_active = true;
self.client_id = client_id.clone();
self.hardware_id = client_id;
save_license_to_disk(self).await?;
Ok(())
}
#[deprecated(since = "0.2.0", note = "Use release() instead")]
pub async fn deactivate(&mut self) -> LicenseResult<()> {
let server_url = &self.server_url;
let client_id = get_hardware_id();
let payload = LegacyLicenseRequest {
license_id: self.license_id.clone(),
client_id,
};
let resp = Self::http_client()
.post(format!("{}/deactivate", server_url))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
return Err(LicenseError::ServerError(format!(
"Deactivation failed with HTTP status {}",
resp.status()
)));
}
let body: LegacyLicenseResponse = resp.json().await.map_err(|e| {
LicenseError::ServerError(format!("Failed to parse deactivation response: {e}"))
})?;
if !body.success {
return Err(LicenseError::InvalidLicense(
"Deactivation failed on server.".to_string(),
));
}
self.is_active = false;
clear_license_from_disk().await?;
Ok(())
}
#[deprecated(since = "0.2.0", note = "Use heartbeat() instead")]
pub async fn legacy_heartbeat(&self) -> LicenseResult<bool> {
let current_hardware_id = get_hardware_id();
if self.client_id != current_hardware_id {
return Err(LicenseError::InvalidLicense(
"Hardware mismatch for heartbeat.".to_string(),
));
}
crate::client::heartbeat::send_heartbeat(self).await
}
pub async fn load_from_disk() -> LicenseResult<Self> {
let mut license = load_license_from_disk().await?;
if let Ok(cache) = load_cache_from_disk().await {
license.cached = Some(cache);
}
Ok(license)
}
pub async fn save_to_disk(&self) -> LicenseResult<()> {
save_license_to_disk(self).await
}
pub async fn clear_local_storage() -> LicenseResult<()> {
clear_license_from_disk().await?;
clear_cache_from_disk().await?;
Ok(())
}
fn ensure_bound(&self) -> LicenseResult<()> {
if self.hardware_id.is_empty() {
return Err(LicenseError::InvalidLicense(
"License is not bound. Call bind() first.".to_string(),
));
}
if self.hardware_id != get_hardware_id() {
return Err(LicenseError::ClientApiError(ClientApiError::new(
ClientErrorCode::HardwareMismatch,
"License is bound to different hardware.",
)));
}
Ok(())
}
pub fn is_bound(&self) -> bool {
!self.hardware_id.is_empty()
}
pub fn key(&self) -> &str {
&self.license_key
}
pub fn server(&self) -> &str {
&self.server_url
}
pub fn bound_hardware(&self) -> Option<&str> {
if self.hardware_id.is_empty() {
None
} else {
Some(&self.hardware_id)
}
}
pub fn cached_validation(&self) -> Option<&CachedValidation> {
self.cached.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_license_is_unbound() {
let license = License::new(
"TEST-XXXX-XXXX-XXXX".to_string(),
"http://localhost:8080".to_string(),
);
assert!(!license.is_bound());
assert!(license.bound_hardware().is_none());
assert_eq!(license.key(), "TEST-XXXX-XXXX-XXXX");
assert_eq!(license.server(), "http://localhost:8080");
}
#[test]
fn ensure_bound_fails_when_unbound() {
let license = License::new(
"TEST-XXXX-XXXX-XXXX".to_string(),
"http://localhost:8080".to_string(),
);
let result = license.ensure_bound();
assert!(result.is_err());
}
#[test]
fn validate_offline_requires_cache() {
let license = License::new(
"TEST-XXXX-XXXX-XXXX".to_string(),
"http://localhost:8080".to_string(),
);
let result = license.validate_offline();
assert!(result.is_err());
}
}