//! License key activation for audiofiles standalone app. //! //! Manages machine identity, license caching, and activation/deactivation //! against the MNW license key API. Once activated, the result is cached //! locally so the app works offline indefinitely. use std::io; use std::path::Path; use std::sync::Arc; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use thiserror::Error; /// Cached license data, persisted to `license.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LicenseCache { pub key_code: String, pub machine_id: String, pub activated_at: String, } /// Whether the app has a valid cached license. pub enum LicenseStatus { Unlicensed, Licensed(LicenseCache), } /// Shared slot for async activation results, polled each frame. pub type ActivationResult = Arc>>>; // ── API request/response types ── #[derive(Serialize)] struct ValidateRequest<'a> { key: &'a str, machine_id: &'a str, label: Option<&'a str>, } #[derive(Deserialize)] struct ValidateResponse { valid: bool, #[serde(default)] error: Option, } #[derive(Serialize)] struct DeactivateRequest<'a> { key: &'a str, machine_id: &'a str, } #[derive(Deserialize)] struct DeactivateResponse { success: bool, message: String, } // ── Machine identity ── /// Read or create a stable machine ID (UUIDv4) for this installation. pub fn get_or_create_machine_id(data_dir: &Path) -> String { let path = data_dir.join("machine_id"); if let Ok(id) = std::fs::read_to_string(&path) { let id = id.trim().to_string(); if !id.is_empty() { return id; } } let id = uuid::Uuid::new_v4().to_string(); let _ = std::fs::create_dir_all(data_dir); if let Err(e) = std::fs::write(&path, &id) { tracing::error!("Failed to write machine_id to {}: {e}", path.display()); } id } // ── License file I/O ── /// Load a cached license from disk. Returns `Unlicensed` if missing or corrupt. pub fn load_license(data_dir: &Path) -> LicenseStatus { let path = data_dir.join("license.json"); let bytes = match std::fs::read(&path) { Ok(b) => b, Err(_) => return LicenseStatus::Unlicensed, }; match serde_json::from_slice::(&bytes) { Ok(cache) => LicenseStatus::Licensed(cache), Err(e) => { tracing::warn!("Corrupt license.json, treating as unlicensed: {e}"); LicenseStatus::Unlicensed } } } /// Write a license cache to disk (atomic: write .tmp then rename). pub fn save_license(data_dir: &Path, cache: &LicenseCache) -> io::Result<()> { let path = data_dir.join("license.json"); let tmp = data_dir.join("license.json.tmp"); let json = serde_json::to_string_pretty(cache) .map_err(io::Error::other)?; std::fs::write(&tmp, &json)?; std::fs::rename(&tmp, &path) } /// Remove the cached license file. pub fn remove_license(data_dir: &Path) -> io::Result<()> { let path = data_dir.join("license.json"); match std::fs::remove_file(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e), } } // ── HTTP activation/deactivation ── /// Classified activation failure for per-class UI messaging. #[derive(Debug, Clone)] pub enum ActivationError { /// Couldn't reach the activation server (DNS, timeout, connection refused). Network, /// HTTP non-2xx response from the server. Server(u16), /// Server rejected the key as unknown / malformed. InvalidKey, /// Key is already activated on another machine (or hit its activation limit). MachineLimit, /// Anything else (response parse error, unexpected message). Other(String), } impl std::fmt::Display for ActivationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Network => write!(f, "Couldn't reach the activation server. Check your connection and try again."), Self::Server(code) => write!(f, "The activation server returned an error ({code}). Try again in a few minutes."), Self::InvalidKey => write!(f, "We didn't recognise that key. Double-check spelling, or get a new one."), Self::MachineLimit => write!(f, "This key is already in use on another machine. Deactivate it there first."), Self::Other(msg) => write!(f, "{msg}"), } } } /// Classify a server-returned error string into a structured variant. Falls /// back to `Other` when no substring matches. The server's user-facing error /// strings are the only signal we have; if those strings change on the server /// side, this classifier needs updating. fn classify_server_error(msg: &str) -> ActivationError { let lower = msg.to_lowercase(); if lower.contains("machine") || lower.contains("already activated") || lower.contains("limit") { ActivationError::MachineLimit } else if lower.contains("invalid") || lower.contains("not found") || lower.contains("unknown") { ActivationError::InvalidKey } else { ActivationError::Other(msg.to_string()) } } /// Activate a license key against the MNW API. /// /// Sends the key and machine ID to the server for validation. On success the /// server records an activation slot; on failure the returned error is /// classified for per-class UI messaging. pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), ActivationError> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|_| ActivationError::Other("Couldn't initialise HTTP client".to_string()))?; let url = format!("{server_url}/api/keys/validate"); let body = ValidateRequest { key, machine_id, label: None, }; let resp = client .post(&url) .json(&body) .send() .await .map_err(|e| { if e.is_timeout() || e.is_connect() || e.is_request() { ActivationError::Network } else { ActivationError::Other(format!("Network error: {e}")) } })?; if !resp.status().is_success() { return Err(ActivationError::Server(resp.status().as_u16())); } let parsed: ValidateResponse = resp .json() .await .map_err(|e| ActivationError::Other(format!("Invalid response: {e}")))?; if parsed.valid { Ok(()) } else { let msg = parsed.error.unwrap_or_else(|| "Invalid license key".to_string()); Err(classify_server_error(&msg)) } } /// Best-effort deactivation failure. The caller logs; no user-facing copy. #[derive(Error, Debug)] pub enum DeactivationError { #[error("HTTP client error: {0}")] ClientBuild(reqwest::Error), #[error("Network error: {0}")] Network(reqwest::Error), #[error("Server returned {0}")] Server(reqwest::StatusCode), #[error("Invalid response: {0}")] InvalidResponse(reqwest::Error), #[error("Server rejected deactivation: {0}")] Rejected(String), } /// Deactivate a license key (best-effort, fire-and-forget). pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), DeactivationError> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(DeactivationError::ClientBuild)?; let url = format!("{server_url}/api/keys/deactivate"); let body = DeactivateRequest { key, machine_id }; let resp = client .post(&url) .json(&body) .send() .await .map_err(DeactivationError::Network)?; if !resp.status().is_success() { return Err(DeactivationError::Server(resp.status())); } let parsed: DeactivateResponse = resp .json() .await .map_err(DeactivationError::InvalidResponse)?; if parsed.success { Ok(()) } else { Err(DeactivationError::Rejected(parsed.message)) } } // ── Trial state ── /// Persisted trial state: tracks when the user first launched the app. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrialState { pub first_launch_date: String, /// Last time the app was launched — used to detect system clock rollback. #[serde(default)] pub last_seen_date: Option, } /// Load the trial state from `trial.json` in the config directory. pub fn load_trial(config_dir: &Path) -> Option { let path = config_dir.join("trial.json"); let bytes = std::fs::read(&path).ok()?; serde_json::from_slice(&bytes).ok() } /// Save the trial state to `trial.json` in the config directory. pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> { let path = config_dir.join("trial.json"); let tmp = config_dir.join("trial.json.tmp"); let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?; std::fs::write(&tmp, &json)?; std::fs::rename(&tmp, &path) } /// Calculate days remaining in the trial (goes negative after day 30). /// /// Detects system clock rollback: if `now < last_seen_date`, assumes the clock /// was set back to extend the trial and returns 0 (expired). pub fn trial_days_remaining(trial: &TrialState) -> i64 { let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else { return 0; }; let now = chrono::Utc::now(); // Clock rollback detection: if now is before last_seen_date, expire immediately if let Some(ref last) = trial.last_seen_date && let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last) && now.signed_duration_since(last_seen).num_hours() < -1 { // Allow up to 1 hour of drift (DST, NTP correction) return 0; } let elapsed = now.signed_duration_since(first); 30 - elapsed.num_days() } /// Update the last_seen_date to now. Call on each app launch. pub fn touch_trial(config_dir: &std::path::Path) { if let Some(mut trial) = load_trial(config_dir) { trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339()); let _ = save_trial(config_dir, &trial); } } #[cfg(test)] mod tests { use super::*; #[test] fn machine_id_created_and_idempotent() { let dir = tempfile::tempdir().unwrap(); let id1 = get_or_create_machine_id(dir.path()); let id2 = get_or_create_machine_id(dir.path()); assert_eq!(id1, id2); assert!(!id1.is_empty()); // Should be a valid UUID assert!(uuid::Uuid::parse_str(&id1).is_ok()); } #[test] fn machine_id_creates_data_dir() { let dir = tempfile::tempdir().unwrap(); let nested = dir.path().join("sub").join("dir"); let id = get_or_create_machine_id(&nested); assert!(!id.is_empty()); assert!(nested.join("machine_id").exists()); } #[test] fn save_load_license_roundtrip() { let dir = tempfile::tempdir().unwrap(); let cache = LicenseCache { key_code: "bright-castle-forest-river-falcon".to_string(), machine_id: "test-machine".to_string(), activated_at: "2026-03-30T12:00:00Z".to_string(), }; save_license(dir.path(), &cache).unwrap(); match load_license(dir.path()) { LicenseStatus::Licensed(loaded) => { assert_eq!(loaded.key_code, cache.key_code); assert_eq!(loaded.machine_id, cache.machine_id); assert_eq!(loaded.activated_at, cache.activated_at); } LicenseStatus::Unlicensed => panic!("Expected Licensed"), } } #[test] fn load_missing_returns_unlicensed() { let dir = tempfile::tempdir().unwrap(); assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed)); } #[test] fn load_corrupt_returns_unlicensed() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("license.json"), "not json{{{").unwrap(); assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed)); } #[test] fn remove_license_deletes_file() { let dir = tempfile::tempdir().unwrap(); let cache = LicenseCache { key_code: "test".to_string(), machine_id: "m".to_string(), activated_at: "now".to_string(), }; save_license(dir.path(), &cache).unwrap(); assert!(dir.path().join("license.json").exists()); remove_license(dir.path()).unwrap(); assert!(!dir.path().join("license.json").exists()); } #[test] fn remove_license_missing_is_ok() { let dir = tempfile::tempdir().unwrap(); assert!(remove_license(dir.path()).is_ok()); } #[test] fn validate_response_deserializes_success() { let json = r#"{"valid": true}"#; let resp: ValidateResponse = serde_json::from_str(json).unwrap(); assert!(resp.valid); assert!(resp.error.is_none()); } #[test] fn validate_response_deserializes_failure() { let json = r#"{"valid": false, "error": "invalid_key"}"#; let resp: ValidateResponse = serde_json::from_str(json).unwrap(); assert!(!resp.valid); assert_eq!(resp.error.as_deref(), Some("invalid_key")); } #[test] fn validate_response_ignores_extra_fields() { let json = r#"{"valid": true, "activated": true, "license": {"item_id": "abc", "max_activations": 5, "activation_count": 1, "created_at": "2026-01-01T00:00:00Z"}}"#; let resp: ValidateResponse = serde_json::from_str(json).unwrap(); assert!(resp.valid); } #[test] fn deactivate_response_deserializes() { let json = r#"{"success": true, "message": "deactivated"}"#; let resp: DeactivateResponse = serde_json::from_str(json).unwrap(); assert!(resp.success); assert_eq!(resp.message, "deactivated"); } #[test] fn save_load_trial_roundtrip() { let dir = tempfile::tempdir().unwrap(); let state = TrialState { first_launch_date: "2026-04-01T00:00:00Z".to_string(), last_seen_date: None, }; save_trial(dir.path(), &state).unwrap(); let loaded = load_trial(dir.path()).unwrap(); assert_eq!(loaded.first_launch_date, state.first_launch_date); } #[test] fn load_trial_missing_returns_none() { let dir = tempfile::tempdir().unwrap(); assert!(load_trial(dir.path()).is_none()); } #[test] fn trial_days_remaining_fresh() { let state = TrialState { first_launch_date: chrono::Utc::now().to_rfc3339(), last_seen_date: None, }; assert_eq!(trial_days_remaining(&state), 30); } #[test] fn trial_days_remaining_expired() { let past = chrono::Utc::now() - chrono::Duration::days(35); let state = TrialState { first_launch_date: past.to_rfc3339(), last_seen_date: None, }; assert_eq!(trial_days_remaining(&state), -5); } #[test] fn trial_clock_rollback_detected() { let now = chrono::Utc::now(); let future = now + chrono::Duration::days(10); let state = TrialState { first_launch_date: now.to_rfc3339(), last_seen_date: Some(future.to_rfc3339()), }; // now < last_seen_date by 10 days → clock was rolled back → expire assert_eq!(trial_days_remaining(&state), 0); } }