From 34842f5bd65fff6642e1207221e70ade0fa9d8a7 Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 11:52:35 +0000 Subject: [PATCH] Implement IronLicensing Rust SDK - Add LicenseClient with validation, activation, deactivation - Add feature checking with has_feature/require_feature pattern - Add trial management and in-app purchase support - Add thread-safe operations with parking_lot::RwLock - Add global client with once_cell for singleton pattern - Add machine ID persistence for activation tracking Co-Authored-By: Claude --- .gitignore | 11 ++ Cargo.toml | 31 +++++ README.md | 308 ++++++++++++++++++++++++++++++++++++++++++++++- src/client.rs | 172 ++++++++++++++++++++++++++ src/config.rs | 84 +++++++++++++ src/error.rs | 39 ++++++ src/lib.rs | 160 ++++++++++++++++++++++++ src/transport.rs | 290 ++++++++++++++++++++++++++++++++++++++++++++ src/types.rs | 198 ++++++++++++++++++++++++++++++ 9 files changed, 1291 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/client.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/transport.rs create mode 100644 src/types.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17e785e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target +Cargo.lock +**/*.rs.bk +*.pdb +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +.ironlicensing/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9f8fd24 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ironlicensing" +version = "1.0.0" +edition = "2021" +authors = ["IronServices "] +description = "Official Rust SDK for IronLicensing - Software licensing and activation" +license = "MIT" +repository = "https://github.com/IronServices/ironlicensing-rust" +homepage = "https://ironlicensing.com" +documentation = "https://docs.rs/ironlicensing" +keywords = ["licensing", "activation", "software-licensing", "drm"] +categories = ["api-bindings", "authentication"] +readme = "README.md" + +[dependencies] +reqwest = { version = "0.11", features = ["json", "blocking"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["full"], optional = true } +thiserror = "1.0" +uuid = { version = "1.0", features = ["v4"] } +parking_lot = "0.12" +once_cell = "1.18" +dirs = "5.0" +hostname = "0.3" + +[features] +default = [] + +[dev-dependencies] +tokio = { version = "1.0", features = ["full", "test-util"] } diff --git a/README.md b/README.md index 1f5c6e0..e42493a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,306 @@ -# ironlicensing-rust -IronLicensing SDK for Rust - Software licensing and activation +# IronLicensing Rust SDK + +Official Rust SDK for [IronLicensing](https://ironlicensing.com) - Software licensing and activation for your applications. + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +ironlicensing = "1.0" +``` + +## Quick Start + +### Using Client Instance + +```rust +use ironlicensing::{LicenseClient, LicenseOptions}; + +fn main() -> Result<(), Box> { + // Create a client + let client = LicenseClient::with_credentials( + "pk_live_your_public_key", + "your-product-slug" + )?; + + // Validate a license + let result = client.validate("IRON-XXXX-XXXX-XXXX-XXXX"); + if result.valid { + println!("License is valid!"); + if let Some(license) = &result.license { + println!("Status: {:?}", license.status); + } + } else { + println!("Validation failed: {:?}", result.error); + } + + // Check for features + if client.has_feature("premium") { + println!("Premium features enabled!"); + } + + Ok(()) +} +``` + +### Using Global Client + +```rust +use ironlicensing::{init, validate, has_feature}; + +fn main() -> Result<(), Box> { + // Initialize the global client + init("pk_live_your_public_key", "your-product-slug")?; + + // Use global functions + let result = validate("IRON-XXXX-XXXX-XXXX-XXXX")?; + if result.valid { + println!("License is valid!"); + } + + if has_feature("premium")? { + println!("Premium features enabled!"); + } + + Ok(()) +} +``` + +## Configuration + +```rust +use ironlicensing::{LicenseClient, LicenseOptions}; +use std::time::Duration; + +let options = LicenseOptions::new("pk_live_xxx", "your-product") + .api_base_url("https://api.ironlicensing.com") + .debug(true) + .enable_offline_cache(true) + .cache_validation_minutes(60) + .offline_grace_days(7) + .http_timeout(Duration::from_secs(30)); + +let client = LicenseClient::new(options)?; +``` + +## License Validation + +```rust +let result = client.validate("IRON-XXXX-XXXX-XXXX-XXXX"); + +if result.valid { + if let Some(license) = &result.license { + println!("License: {}", license.key); + println!("Status: {:?}", license.status); + println!("Type: {:?}", license.license_type); + println!("Activations: {}/{}", + license.current_activations, + license.max_activations); + } +} +``` + +## License Activation + +```rust +// Simple activation (uses hostname as machine name) +let result = client.activate("IRON-XXXX-XXXX-XXXX-XXXX"); + +// With custom machine name +let result = client.activate_with_name( + "IRON-XXXX-XXXX-XXXX-XXXX", + Some("Production Server") +); + +if result.valid { + println!("License activated successfully!"); + + // View activations + if let Some(activations) = &result.activations { + for activation in activations { + println!("- {} ({:?})", + activation.machine_name.as_deref().unwrap_or("Unknown"), + activation.platform); + } + } +} +``` + +## License Deactivation + +```rust +if client.deactivate() { + println!("License deactivated from this machine"); +} +``` + +## Feature Checking + +```rust +// Check if feature is available +if client.has_feature("advanced-analytics") { + // Enable advanced analytics +} + +// Require feature (returns error if not available) +client.require_feature("export-pdf")?; +// Feature is available, continue with export + +// Get feature details +if let Some(feature) = client.get_feature("max-users") { + println!("Feature: {} - {:?}", feature.name, feature.description); +} +``` + +## Trial Management + +```rust +let result = client.start_trial("user@example.com"); + +if result.valid { + println!("Trial started!"); + if let Some(license) = &result.license { + println!("Trial key: {}", license.key); + if let Some(expires) = &license.expires_at { + println!("Expires: {}", expires); + } + } +} +``` + +## In-App Purchase + +```rust +// Get available tiers +let tiers = client.get_tiers(); +for tier in &tiers { + println!("{} - ${:.2} {}", tier.name, tier.price, tier.currency); +} + +// Start checkout +let checkout = client.start_purchase("tier-id", "user@example.com"); +if checkout.success { + if let Some(url) = &checkout.checkout_url { + println!("Checkout URL: {}", url); + // Open URL in browser for user to complete purchase + } +} +``` + +## License Status + +```rust +use ironlicensing::LicenseStatus; + +// Get current license +if let Some(license) = client.license() { + println!("Licensed to: {:?}", license.email); +} + +// Check status +match client.status() { + LicenseStatus::Valid => println!("License is valid"), + LicenseStatus::Expired => println!("License has expired"), + LicenseStatus::Trial => println!("Running in trial mode"), + LicenseStatus::NotActivated => println!("No license activated"), + status => println!("Status: {:?}", status), +} + +// Quick checks +if client.is_licensed() { + println!("Application is licensed"); +} + +if client.is_trial() { + println!("Running in trial mode"); +} +``` + +## License Types + +| Type | Description | +|------|-------------| +| `Perpetual` | One-time purchase, never expires | +| `Subscription` | Recurring payment, expires if not renewed | +| `Trial` | Time-limited trial license | + +## License Statuses + +| Status | Description | +|--------|-------------| +| `Valid` | License is valid and active | +| `Expired` | License has expired | +| `Suspended` | License temporarily suspended | +| `Revoked` | License permanently revoked | +| `Trial` | Active trial license | +| `TrialExpired` | Trial period ended | +| `NotActivated` | No license activated | + +## Thread Safety + +The client uses `parking_lot::RwLock` and is safe to share across threads: + +```rust +use std::sync::Arc; +use std::thread; + +let client = Arc::new(LicenseClient::with_credentials("pk_live_xxx", "product")?); + +let handles: Vec<_> = (0..10).map(|_| { + let client = Arc::clone(&client); + thread::spawn(move || { + if client.has_feature("concurrent-feature") { + // Safe to call from multiple threads + } + }) +}).collect(); + +for handle in handles { + handle.join().unwrap(); +} +``` + +## Error Handling + +```rust +use ironlicensing::{LicenseError, LicenseResult}; + +// Validation errors +let result = client.validate(license_key); +if !result.valid { + if let Some(error) = &result.error { + match error.as_str() { + "license_not_found" => println!("Invalid license key"), + "license_expired" => println!("Your license has expired"), + "max_activations_reached" => println!("No more activations available"), + _ => println!("Error: {}", error), + } + } +} + +// Feature requirement errors +match client.require_feature("premium") { + Ok(()) => println!("Feature available"), + Err(LicenseError::FeatureRequired(feature)) => { + println!("Feature '{}' requires a valid license", feature); + } + Err(e) => println!("Error: {}", e), +} +``` + +## Machine ID + +The SDK automatically generates and persists a unique machine ID at `~/.ironlicensing/machine_id`. This ID is used for: +- Tracking activations per machine +- Preventing license sharing +- Offline validation + +```rust +let machine_id = client.machine_id(); +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..cdfe5d2 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,172 @@ +use crate::config::LicenseOptions; +use crate::error::{LicenseError, Result}; +use crate::transport::Transport; +use crate::types::{CheckoutResult, Feature, License, LicenseResult, LicenseStatus, LicenseType, ProductTier}; +use parking_lot::RwLock; + +/// The main IronLicensing client. +/// Thread-safe and can be shared across threads. +pub struct LicenseClient { + options: LicenseOptions, + transport: Transport, + current_license: RwLock>, + license_key: RwLock>, +} + +impl LicenseClient { + /// Create a new LicenseClient with the given options. + pub fn new(options: LicenseOptions) -> Result { + if options.public_key.is_empty() { + return Err(LicenseError::PublicKeyRequired); + } + if options.product_slug.is_empty() { + return Err(LicenseError::ProductSlugRequired); + } + + let transport = Transport::new(&options); + + if options.debug { + println!("[IronLicensing] Client initialized"); + } + + Ok(Self { + options, + transport, + current_license: RwLock::new(None), + license_key: RwLock::new(None), + }) + } + + /// Create a new client with public key and product slug. + pub fn with_credentials(public_key: impl Into, product_slug: impl Into) -> Result { + Self::new(LicenseOptions::new(public_key, product_slug)) + } + + /// Validate a license key. + pub fn validate(&self, license_key: &str) -> LicenseResult { + let result = self.transport.validate(license_key); + if result.valid { + if let Some(license) = &result.license { + *self.current_license.write() = Some(license.clone()); + *self.license_key.write() = Some(license_key.to_string()); + } + } + result + } + + /// Activate a license key on this machine. + pub fn activate(&self, license_key: &str) -> LicenseResult { + self.activate_with_name(license_key, None) + } + + /// Activate a license key with a custom machine name. + pub fn activate_with_name(&self, license_key: &str, machine_name: Option<&str>) -> LicenseResult { + let result = self.transport.activate(license_key, machine_name); + if result.valid { + if let Some(license) = &result.license { + *self.current_license.write() = Some(license.clone()); + *self.license_key.write() = Some(license_key.to_string()); + } + } + result + } + + /// Deactivate the current license from this machine. + pub fn deactivate(&self) -> bool { + let key = self.license_key.read().clone(); + if let Some(key) = key { + if self.transport.deactivate(&key) { + *self.current_license.write() = None; + *self.license_key.write() = None; + return true; + } + } + false + } + + /// Start a trial for the given email. + pub fn start_trial(&self, email: &str) -> LicenseResult { + let result = self.transport.start_trial(email); + if result.valid { + if let Some(license) = &result.license { + *self.current_license.write() = Some(license.clone()); + *self.license_key.write() = Some(license.key.clone()); + } + } + result + } + + /// Check if a feature is available in the current license. + pub fn has_feature(&self, feature_key: &str) -> bool { + self.current_license + .read() + .as_ref() + .map(|l| l.has_feature(feature_key)) + .unwrap_or(false) + } + + /// Require a feature to be available. + /// Returns an error if the feature is not available. + pub fn require_feature(&self, feature_key: &str) -> Result<()> { + if !self.has_feature(feature_key) { + return Err(LicenseError::FeatureRequired(feature_key.to_string())); + } + Ok(()) + } + + /// Get a feature from the current license. + pub fn get_feature(&self, feature_key: &str) -> Option { + self.current_license + .read() + .as_ref() + .and_then(|l| l.get_feature(feature_key).cloned()) + } + + /// Get the current license. + pub fn license(&self) -> Option { + self.current_license.read().clone() + } + + /// Get the current license status. + pub fn status(&self) -> LicenseStatus { + self.current_license + .read() + .as_ref() + .map(|l| l.status) + .unwrap_or(LicenseStatus::NotActivated) + } + + /// Check if the application is licensed (valid or trial). + pub fn is_licensed(&self) -> bool { + self.current_license + .read() + .as_ref() + .map(|l| matches!(l.status, LicenseStatus::Valid | LicenseStatus::Trial)) + .unwrap_or(false) + } + + /// Check if running in trial mode. + pub fn is_trial(&self) -> bool { + self.current_license + .read() + .as_ref() + .map(|l| l.status == LicenseStatus::Trial || l.license_type == LicenseType::Trial) + .unwrap_or(false) + } + + /// Get available product tiers for purchase. + pub fn get_tiers(&self) -> Vec { + self.transport.get_tiers() + } + + /// Start a checkout session for the specified tier. + pub fn start_purchase(&self, tier_id: &str, email: &str) -> CheckoutResult { + self.transport.start_checkout(tier_id, email) + } + + /// Get the machine ID used for activations. + pub fn machine_id(&self) -> &str { + self.transport.machine_id() + } +} + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dba19c7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +/// Configuration options for the LicenseClient. +#[derive(Debug, Clone)] +pub struct LicenseOptions { + /// Public key for your product (required). + pub public_key: String, + /// Product slug identifier (required). + pub product_slug: String, + /// API base URL. + pub api_base_url: String, + /// Enable debug logging. + pub debug: bool, + /// Enable offline license caching. + pub enable_offline_cache: bool, + /// Cache validation interval in minutes. + pub cache_validation_minutes: u32, + /// Offline grace period in days. + pub offline_grace_days: u32, + /// HTTP request timeout. + pub http_timeout: Duration, +} + +impl LicenseOptions { + /// Create new options with required public key and product slug. + pub fn new(public_key: impl Into, product_slug: impl Into) -> Self { + Self { + public_key: public_key.into(), + product_slug: product_slug.into(), + ..Default::default() + } + } + + /// Set the API base URL. + pub fn api_base_url(mut self, url: impl Into) -> Self { + self.api_base_url = url.into(); + self + } + + /// Enable or disable debug mode. + pub fn debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + /// Enable or disable offline caching. + pub fn enable_offline_cache(mut self, enable: bool) -> Self { + self.enable_offline_cache = enable; + self + } + + /// Set cache validation interval in minutes. + pub fn cache_validation_minutes(mut self, minutes: u32) -> Self { + self.cache_validation_minutes = minutes; + self + } + + /// Set offline grace period in days. + pub fn offline_grace_days(mut self, days: u32) -> Self { + self.offline_grace_days = days; + self + } + + /// Set HTTP request timeout. + pub fn http_timeout(mut self, timeout: Duration) -> Self { + self.http_timeout = timeout; + self + } +} + +impl Default for LicenseOptions { + fn default() -> Self { + Self { + public_key: String::new(), + product_slug: String::new(), + api_base_url: "https://api.ironlicensing.com".to_string(), + debug: false, + enable_offline_cache: true, + cache_validation_minutes: 60, + offline_grace_days: 7, + http_timeout: Duration::from_secs(30), + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..860dc7f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +/// Errors that can occur in the IronLicensing SDK. +#[derive(Debug, Error)] +pub enum LicenseError { + /// Client is not initialized. + #[error("IronLicensing client not initialized")] + NotInitialized, + + /// Public key is required. + #[error("Public key is required")] + PublicKeyRequired, + + /// Product slug is required. + #[error("Product slug is required")] + ProductSlugRequired, + + /// A required feature is not available. + #[error("Feature '{0}' requires a valid license")] + FeatureRequired(String), + + /// HTTP request error. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// JSON serialization/deserialization error. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// IO error. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// API error returned from the server. + #[error("API error: {0}")] + Api(String), +} + +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..865a59f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,160 @@ +//! IronLicensing SDK for Rust +//! +//! Official Rust SDK for [IronLicensing](https://ironlicensing.com) - Software licensing +//! and activation for your applications. +//! +//! # Quick Start +//! +//! ```rust,no_run +//! use ironlicensing::{LicenseClient, LicenseOptions}; +//! +//! fn main() -> Result<(), Box> { +//! // Create a client +//! let client = LicenseClient::with_credentials( +//! "pk_live_your_public_key", +//! "your-product-slug" +//! )?; +//! +//! // Validate a license +//! let result = client.validate("IRON-XXXX-XXXX-XXXX-XXXX"); +//! if result.valid { +//! println!("License is valid!"); +//! } +//! +//! // Check for features +//! if client.has_feature("premium") { +//! println!("Premium features enabled!"); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! # Global Client +//! +//! For convenience, you can use a global client: +//! +//! ```rust,no_run +//! use ironlicensing::{init, validate, has_feature}; +//! +//! fn main() -> Result<(), Box> { +//! // Initialize the global client +//! init("pk_live_your_public_key", "your-product-slug")?; +//! +//! // Use global functions +//! let result = validate("IRON-XXXX-XXXX-XXXX-XXXX")?; +//! if result.valid { +//! println!("Valid!"); +//! } +//! +//! if has_feature("premium")? { +//! println!("Premium enabled!"); +//! } +//! +//! Ok(()) +//! } +//! ``` + +mod client; +mod config; +mod error; +mod transport; +mod types; + +pub use client::LicenseClient; +pub use config::LicenseOptions; +pub use error::{LicenseError, Result}; +pub use types::*; + +use once_cell::sync::OnceCell; +use std::sync::Arc; + +static GLOBAL_CLIENT: OnceCell> = OnceCell::new(); + +/// Initialize the global IronLicensing client. +pub fn init(public_key: impl Into, product_slug: impl Into) -> Result<()> { + init_with_options(LicenseOptions::new(public_key, product_slug)) +} + +/// Initialize the global client with custom options. +pub fn init_with_options(options: LicenseOptions) -> Result<()> { + let client = LicenseClient::new(options)?; + GLOBAL_CLIENT + .set(Arc::new(client)) + .map_err(|_| LicenseError::Api("Client already initialized".to_string())) +} + +/// Get the global client. +pub fn get_client() -> Result<&'static Arc> { + GLOBAL_CLIENT.get().ok_or(LicenseError::NotInitialized) +} + +/// Validate a license key using the global client. +pub fn validate(license_key: &str) -> Result { + Ok(get_client()?.validate(license_key)) +} + +/// Activate a license key using the global client. +pub fn activate(license_key: &str) -> Result { + Ok(get_client()?.activate(license_key)) +} + +/// Activate a license key with a machine name using the global client. +pub fn activate_with_name(license_key: &str, machine_name: Option<&str>) -> Result { + Ok(get_client()?.activate_with_name(license_key, machine_name)) +} + +/// Deactivate the current license using the global client. +pub fn deactivate() -> Result { + Ok(get_client()?.deactivate()) +} + +/// Start a trial using the global client. +pub fn start_trial(email: &str) -> Result { + Ok(get_client()?.start_trial(email)) +} + +/// Check if a feature is available using the global client. +pub fn has_feature(feature_key: &str) -> Result { + Ok(get_client()?.has_feature(feature_key)) +} + +/// Require a feature using the global client. +pub fn require_feature(feature_key: &str) -> Result<()> { + get_client()?.require_feature(feature_key) +} + +/// Get a feature using the global client. +pub fn get_feature(feature_key: &str) -> Result> { + Ok(get_client()?.get_feature(feature_key)) +} + +/// Get the current license using the global client. +pub fn license() -> Result> { + Ok(get_client()?.license()) +} + +/// Get the license status using the global client. +pub fn status() -> Result { + Ok(get_client()?.status()) +} + +/// Check if licensed using the global client. +pub fn is_licensed() -> Result { + Ok(get_client()?.is_licensed()) +} + +/// Check if in trial mode using the global client. +pub fn is_trial() -> Result { + Ok(get_client()?.is_trial()) +} + +/// Get available tiers using the global client. +pub fn get_tiers() -> Result> { + Ok(get_client()?.get_tiers()) +} + +/// Start a purchase using the global client. +pub fn start_purchase(tier_id: &str, email: &str) -> Result { + Ok(get_client()?.start_purchase(tier_id, email)) +} diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..e79d02b --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,290 @@ +use crate::config::LicenseOptions; +use crate::error::{LicenseError, Result}; +use crate::types::{CheckoutResult, LicenseResult, ProductTier}; +use reqwest::blocking::Client as HttpClient; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use uuid::Uuid; + +pub struct Transport { + base_url: String, + public_key: String, + product_slug: String, + debug: bool, + http_client: HttpClient, + machine_id: String, +} + +#[derive(Serialize)] +struct ValidateRequest { + #[serde(rename = "licenseKey")] + license_key: String, + #[serde(rename = "machineId")] + machine_id: String, +} + +#[derive(Serialize)] +struct ActivateRequest { + #[serde(rename = "licenseKey")] + license_key: String, + #[serde(rename = "machineId")] + machine_id: String, + #[serde(rename = "machineName")] + machine_name: String, + platform: String, +} + +#[derive(Serialize)] +struct DeactivateRequest { + #[serde(rename = "licenseKey")] + license_key: String, + #[serde(rename = "machineId")] + machine_id: String, +} + +#[derive(Serialize)] +struct TrialRequest { + email: String, + #[serde(rename = "machineId")] + machine_id: String, +} + +#[derive(Serialize)] +struct CheckoutRequest { + #[serde(rename = "tierId")] + tier_id: String, + email: String, +} + +#[derive(Deserialize)] +struct TiersResponse { + tiers: Vec, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, +} + +impl Transport { + pub fn new(options: &LicenseOptions) -> Self { + let http_client = HttpClient::builder() + .timeout(options.http_timeout) + .build() + .unwrap_or_default(); + + let machine_id = Self::get_or_create_machine_id(); + + Self { + base_url: options.api_base_url.clone(), + public_key: options.public_key.clone(), + product_slug: options.product_slug.clone(), + debug: options.debug, + http_client, + machine_id, + } + } + + fn log(&self, msg: &str) { + if self.debug { + println!("[IronLicensing] {}", msg); + } + } + + fn get_machine_id_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironlicensing") + .join("machine_id") + } + + fn get_or_create_machine_id() -> String { + let id_path = Self::get_machine_id_path(); + + if let Ok(id) = fs::read_to_string(&id_path) { + return id.trim().to_string(); + } + + let id = Uuid::new_v4().to_string(); + + if let Some(parent) = id_path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&id_path, &id); + + id + } + + pub fn machine_id(&self) -> &str { + &self.machine_id + } + + fn get_hostname() -> String { + hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + } + + fn get_platform() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "unknown" + } + } + + pub fn validate(&self, license_key: &str) -> LicenseResult { + let preview = &license_key[..license_key.len().min(10)]; + self.log(&format!("Validating: {}...", preview)); + + let request = ValidateRequest { + license_key: license_key.to_string(), + machine_id: self.machine_id.clone(), + }; + + self.post("/api/v1/validate", &request) + } + + pub fn activate(&self, license_key: &str, machine_name: Option<&str>) -> LicenseResult { + let preview = &license_key[..license_key.len().min(10)]; + self.log(&format!("Activating: {}...", preview)); + + let machine_name = machine_name + .map(String::from) + .unwrap_or_else(Self::get_hostname); + + let request = ActivateRequest { + license_key: license_key.to_string(), + machine_id: self.machine_id.clone(), + machine_name, + platform: Self::get_platform().to_string(), + }; + + self.post("/api/v1/activate", &request) + } + + pub fn deactivate(&self, license_key: &str) -> bool { + self.log("Deactivating license"); + + let request = DeactivateRequest { + license_key: license_key.to_string(), + machine_id: self.machine_id.clone(), + }; + + match self + .http_client + .post(format!("{}/api/v1/deactivate", self.base_url)) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .header("X-Product-Slug", &self.product_slug) + .json(&request) + .send() + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } + + pub fn start_trial(&self, email: &str) -> LicenseResult { + self.log(&format!("Starting trial for: {}", email)); + + let request = TrialRequest { + email: email.to_string(), + machine_id: self.machine_id.clone(), + }; + + self.post("/api/v1/trial", &request) + } + + pub fn get_tiers(&self) -> Vec { + self.log("Fetching product tiers"); + + match self + .http_client + .get(format!("{}/api/v1/tiers", self.base_url)) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .header("X-Product-Slug", &self.product_slug) + .send() + { + Ok(resp) if resp.status().is_success() => resp + .json::() + .map(|r| r.tiers) + .unwrap_or_default(), + _ => vec![], + } + } + + pub fn start_checkout(&self, tier_id: &str, email: &str) -> CheckoutResult { + self.log(&format!("Starting checkout for tier: {}", tier_id)); + + let request = CheckoutRequest { + tier_id: tier_id.to_string(), + email: email.to_string(), + }; + + match self + .http_client + .post(format!("{}/api/v1/checkout", self.base_url)) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .header("X-Product-Slug", &self.product_slug) + .json(&request) + .send() + { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + + if status.is_success() { + match serde_json::from_str::(&body) { + Ok(mut result) => { + result.success = true; + result + } + Err(e) => CheckoutResult::failure(e.to_string()), + } + } else { + let error = serde_json::from_str::(&body) + .map(|e| e.error) + .unwrap_or_else(|_| "Checkout failed".to_string()); + CheckoutResult::failure(error) + } + } + Err(e) => CheckoutResult::failure(e.to_string()), + } + } + + fn post(&self, path: &str, body: &T) -> LicenseResult { + match self + .http_client + .post(format!("{}{}", self.base_url, path)) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .header("X-Product-Slug", &self.product_slug) + .json(body) + .send() + { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + + if status.is_success() { + serde_json::from_str(&body).unwrap_or_else(|e| LicenseResult::failure(e.to_string())) + } else { + let error = serde_json::from_str::(&body) + .map(|e| e.error) + .unwrap_or_else(|_| "Request failed".to_string()); + LicenseResult::failure(error) + } + } + Err(e) => LicenseResult::failure(e.to_string()), + } + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..f7138ad --- /dev/null +++ b/src/types.rs @@ -0,0 +1,198 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// License status representing the current state of a license. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LicenseStatus { + Valid, + Expired, + Suspended, + Revoked, + Invalid, + Trial, + TrialExpired, + NotActivated, + #[serde(other)] + Unknown, +} + +impl Default for LicenseStatus { + fn default() -> Self { + Self::NotActivated + } +} + +/// License type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LicenseType { + Perpetual, + Subscription, + Trial, +} + +impl Default for LicenseType { + fn default() -> Self { + Self::Perpetual + } +} + +/// A feature included in a license. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Feature { + pub key: String, + pub name: String, + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// License information. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct License { + pub id: String, + pub key: String, + pub status: LicenseStatus, + #[serde(rename = "type")] + pub license_type: LicenseType, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub company: Option, + #[serde(default)] + pub features: Vec, + #[serde(default)] + pub max_activations: i32, + #[serde(default)] + pub current_activations: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_validated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +impl License { + /// Check if a feature is enabled. + pub fn has_feature(&self, feature_key: &str) -> bool { + self.features + .iter() + .any(|f| f.key == feature_key && f.enabled) + } + + /// Get a feature by key. + pub fn get_feature(&self, feature_key: &str) -> Option<&Feature> { + self.features.iter().find(|f| f.key == feature_key) + } +} + +/// An activation of a license on a machine. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Activation { + pub id: String, + pub machine_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub activated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen_at: Option, +} + +/// Result of a license validation or activation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LicenseResult { + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub activations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default)] + pub cached: bool, +} + +impl LicenseResult { + pub fn success(license: License) -> Self { + Self { + valid: true, + license: Some(license), + activations: None, + error: None, + cached: false, + } + } + + pub fn failure(error: impl Into) -> Self { + Self { + valid: false, + license: None, + activations: None, + error: Some(error.into()), + cached: false, + } + } +} + +/// Result of starting a checkout. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub checkout_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl CheckoutResult { + pub fn success(checkout_url: String, session_id: String) -> Self { + Self { + success: true, + checkout_url: Some(checkout_url), + session_id: Some(session_id), + error: None, + } + } + + pub fn failure(error: impl Into) -> Self { + Self { + success: false, + checkout_url: None, + session_id: None, + error: Some(error.into()), + } + } +} + +/// A product tier available for purchase. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductTier { + pub id: String, + pub slug: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub price: f64, + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub billing_period: Option, + #[serde(default)] + pub features: Vec, +}