Skip to main content

max / audiofiles

15.4 KB · 470 lines History Blame Raw
1 //! License key activation for audiofiles standalone app.
2 //!
3 //! Manages machine identity, license caching, and activation/deactivation
4 //! against the MNW license key API. Once activated, the result is cached
5 //! locally so the app works offline indefinitely.
6
7 use std::io;
8 use std::path::Path;
9 use std::sync::Arc;
10
11 use parking_lot::Mutex;
12 use serde::{Deserialize, Serialize};
13 use thiserror::Error;
14
15 /// Cached license data, persisted to `license.json`.
16 #[derive(Debug, Clone, Serialize, Deserialize)]
17 pub struct LicenseCache {
18 pub key_code: String,
19 pub machine_id: String,
20 pub activated_at: String,
21 }
22
23 /// Whether the app has a valid cached license.
24 pub enum LicenseStatus {
25 Unlicensed,
26 Licensed(LicenseCache),
27 }
28
29 /// Shared slot for async activation results, polled each frame.
30 pub type ActivationResult = Arc<Mutex<Option<Result<(), ActivationError>>>>;
31
32 // ── API request/response types ──
33
34 #[derive(Serialize)]
35 struct ValidateRequest<'a> {
36 key: &'a str,
37 machine_id: &'a str,
38 label: Option<&'a str>,
39 }
40
41 #[derive(Deserialize)]
42 struct ValidateResponse {
43 valid: bool,
44 #[serde(default)]
45 error: Option<String>,
46 }
47
48 #[derive(Serialize)]
49 struct DeactivateRequest<'a> {
50 key: &'a str,
51 machine_id: &'a str,
52 }
53
54 #[derive(Deserialize)]
55 struct DeactivateResponse {
56 success: bool,
57 message: String,
58 }
59
60 // ── Machine identity ──
61
62 /// Read or create a stable machine ID (UUIDv4) for this installation.
63 pub fn get_or_create_machine_id(data_dir: &Path) -> String {
64 let path = data_dir.join("machine_id");
65 if let Ok(id) = std::fs::read_to_string(&path) {
66 let id = id.trim().to_string();
67 if !id.is_empty() {
68 return id;
69 }
70 }
71 let id = uuid::Uuid::new_v4().to_string();
72 let _ = std::fs::create_dir_all(data_dir);
73 if let Err(e) = std::fs::write(&path, &id) {
74 tracing::error!("Failed to write machine_id to {}: {e}", path.display());
75 }
76 id
77 }
78
79 // ── License file I/O ──
80
81 /// Load a cached license from disk. Returns `Unlicensed` if missing or corrupt.
82 pub fn load_license(data_dir: &Path) -> LicenseStatus {
83 let path = data_dir.join("license.json");
84 let bytes = match std::fs::read(&path) {
85 Ok(b) => b,
86 Err(_) => return LicenseStatus::Unlicensed,
87 };
88 match serde_json::from_slice::<LicenseCache>(&bytes) {
89 Ok(cache) => LicenseStatus::Licensed(cache),
90 Err(e) => {
91 tracing::warn!("Corrupt license.json, treating as unlicensed: {e}");
92 LicenseStatus::Unlicensed
93 }
94 }
95 }
96
97 /// Write a license cache to disk (atomic: write .tmp then rename).
98 pub fn save_license(data_dir: &Path, cache: &LicenseCache) -> io::Result<()> {
99 let path = data_dir.join("license.json");
100 let tmp = data_dir.join("license.json.tmp");
101 let json = serde_json::to_string_pretty(cache)
102 .map_err(io::Error::other)?;
103 std::fs::write(&tmp, &json)?;
104 std::fs::rename(&tmp, &path)
105 }
106
107 /// Remove the cached license file.
108 pub fn remove_license(data_dir: &Path) -> io::Result<()> {
109 let path = data_dir.join("license.json");
110 match std::fs::remove_file(&path) {
111 Ok(()) => Ok(()),
112 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
113 Err(e) => Err(e),
114 }
115 }
116
117 // ── HTTP activation/deactivation ──
118
119 /// Classified activation failure for per-class UI messaging.
120 #[derive(Debug, Clone)]
121 pub enum ActivationError {
122 /// Couldn't reach the activation server (DNS, timeout, connection refused).
123 Network,
124 /// HTTP non-2xx response from the server.
125 Server(u16),
126 /// Server rejected the key as unknown / malformed.
127 InvalidKey,
128 /// Key is already activated on another machine (or hit its activation limit).
129 MachineLimit,
130 /// Anything else (response parse error, unexpected message).
131 Other(String),
132 }
133
134 impl std::fmt::Display for ActivationError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 Self::Network => write!(f, "Couldn't reach the activation server. Check your connection and try again."),
138 Self::Server(code) => write!(f, "The activation server returned an error ({code}). Try again in a few minutes."),
139 Self::InvalidKey => write!(f, "We didn't recognise that key. Double-check spelling, or get a new one."),
140 Self::MachineLimit => write!(f, "This key is already in use on another machine. Deactivate it there first."),
141 Self::Other(msg) => write!(f, "{msg}"),
142 }
143 }
144 }
145
146 /// Classify a server-returned error string into a structured variant. Falls
147 /// back to `Other` when no substring matches. The server's user-facing error
148 /// strings are the only signal we have; if those strings change on the server
149 /// side, this classifier needs updating.
150 fn classify_server_error(msg: &str) -> ActivationError {
151 let lower = msg.to_lowercase();
152 if lower.contains("machine") || lower.contains("already activated") || lower.contains("limit") {
153 ActivationError::MachineLimit
154 } else if lower.contains("invalid") || lower.contains("not found") || lower.contains("unknown") {
155 ActivationError::InvalidKey
156 } else {
157 ActivationError::Other(msg.to_string())
158 }
159 }
160
161 /// Activate a license key against the MNW API.
162 ///
163 /// Sends the key and machine ID to the server for validation. On success the
164 /// server records an activation slot; on failure the returned error is
165 /// classified for per-class UI messaging.
166 pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), ActivationError> {
167 let client = reqwest::Client::builder()
168 .timeout(std::time::Duration::from_secs(15))
169 .build()
170 .map_err(|_| ActivationError::Other("Couldn't initialise HTTP client".to_string()))?;
171
172 let url = format!("{server_url}/api/keys/validate");
173 let body = ValidateRequest {
174 key,
175 machine_id,
176 label: None,
177 };
178
179 let resp = client
180 .post(&url)
181 .json(&body)
182 .send()
183 .await
184 .map_err(|e| {
185 if e.is_timeout() || e.is_connect() || e.is_request() {
186 ActivationError::Network
187 } else {
188 ActivationError::Other(format!("Network error: {e}"))
189 }
190 })?;
191
192 if !resp.status().is_success() {
193 return Err(ActivationError::Server(resp.status().as_u16()));
194 }
195
196 let parsed: ValidateResponse = resp
197 .json()
198 .await
199 .map_err(|e| ActivationError::Other(format!("Invalid response: {e}")))?;
200
201 if parsed.valid {
202 Ok(())
203 } else {
204 let msg = parsed.error.unwrap_or_else(|| "Invalid license key".to_string());
205 Err(classify_server_error(&msg))
206 }
207 }
208
209 /// Best-effort deactivation failure. The caller logs; no user-facing copy.
210 #[derive(Error, Debug)]
211 pub enum DeactivationError {
212 #[error("HTTP client error: {0}")]
213 ClientBuild(reqwest::Error),
214 #[error("Network error: {0}")]
215 Network(reqwest::Error),
216 #[error("Server returned {0}")]
217 Server(reqwest::StatusCode),
218 #[error("Invalid response: {0}")]
219 InvalidResponse(reqwest::Error),
220 #[error("Server rejected deactivation: {0}")]
221 Rejected(String),
222 }
223
224 /// Deactivate a license key (best-effort, fire-and-forget).
225 pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), DeactivationError> {
226 let client = reqwest::Client::builder()
227 .timeout(std::time::Duration::from_secs(15))
228 .build()
229 .map_err(DeactivationError::ClientBuild)?;
230
231 let url = format!("{server_url}/api/keys/deactivate");
232 let body = DeactivateRequest { key, machine_id };
233
234 let resp = client
235 .post(&url)
236 .json(&body)
237 .send()
238 .await
239 .map_err(DeactivationError::Network)?;
240
241 if !resp.status().is_success() {
242 return Err(DeactivationError::Server(resp.status()));
243 }
244
245 let parsed: DeactivateResponse = resp
246 .json()
247 .await
248 .map_err(DeactivationError::InvalidResponse)?;
249
250 if parsed.success {
251 Ok(())
252 } else {
253 Err(DeactivationError::Rejected(parsed.message))
254 }
255 }
256
257 // ── Trial state ──
258
259 /// Persisted trial state: tracks when the user first launched the app.
260 #[derive(Debug, Clone, Serialize, Deserialize)]
261 pub struct TrialState {
262 pub first_launch_date: String,
263 /// Last time the app was launched — used to detect system clock rollback.
264 #[serde(default)]
265 pub last_seen_date: Option<String>,
266 }
267
268 /// Load the trial state from `trial.json` in the config directory.
269 pub fn load_trial(config_dir: &Path) -> Option<TrialState> {
270 let path = config_dir.join("trial.json");
271 let bytes = std::fs::read(&path).ok()?;
272 serde_json::from_slice(&bytes).ok()
273 }
274
275 /// Save the trial state to `trial.json` in the config directory.
276 pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> {
277 let path = config_dir.join("trial.json");
278 let tmp = config_dir.join("trial.json.tmp");
279 let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
280 std::fs::write(&tmp, &json)?;
281 std::fs::rename(&tmp, &path)
282 }
283
284 /// Calculate days remaining in the trial (goes negative after day 30).
285 ///
286 /// Detects system clock rollback: if `now < last_seen_date`, assumes the clock
287 /// was set back to extend the trial and returns 0 (expired).
288 pub fn trial_days_remaining(trial: &TrialState) -> i64 {
289 let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else {
290 return 0;
291 };
292 let now = chrono::Utc::now();
293
294 // Clock rollback detection: if now is before last_seen_date, expire immediately
295 if let Some(ref last) = trial.last_seen_date
296 && let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last)
297 && now.signed_duration_since(last_seen).num_hours() < -1 {
298 // Allow up to 1 hour of drift (DST, NTP correction)
299 return 0;
300 }
301
302 let elapsed = now.signed_duration_since(first);
303 30 - elapsed.num_days()
304 }
305
306 /// Update the last_seen_date to now. Call on each app launch.
307 pub fn touch_trial(config_dir: &std::path::Path) {
308 if let Some(mut trial) = load_trial(config_dir) {
309 trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339());
310 let _ = save_trial(config_dir, &trial);
311 }
312 }
313
314 #[cfg(test)]
315 mod tests {
316 use super::*;
317
318 #[test]
319 fn machine_id_created_and_idempotent() {
320 let dir = tempfile::tempdir().unwrap();
321 let id1 = get_or_create_machine_id(dir.path());
322 let id2 = get_or_create_machine_id(dir.path());
323 assert_eq!(id1, id2);
324 assert!(!id1.is_empty());
325 // Should be a valid UUID
326 assert!(uuid::Uuid::parse_str(&id1).is_ok());
327 }
328
329 #[test]
330 fn machine_id_creates_data_dir() {
331 let dir = tempfile::tempdir().unwrap();
332 let nested = dir.path().join("sub").join("dir");
333 let id = get_or_create_machine_id(&nested);
334 assert!(!id.is_empty());
335 assert!(nested.join("machine_id").exists());
336 }
337
338 #[test]
339 fn save_load_license_roundtrip() {
340 let dir = tempfile::tempdir().unwrap();
341 let cache = LicenseCache {
342 key_code: "bright-castle-forest-river-falcon".to_string(),
343 machine_id: "test-machine".to_string(),
344 activated_at: "2026-03-30T12:00:00Z".to_string(),
345 };
346 save_license(dir.path(), &cache).unwrap();
347 match load_license(dir.path()) {
348 LicenseStatus::Licensed(loaded) => {
349 assert_eq!(loaded.key_code, cache.key_code);
350 assert_eq!(loaded.machine_id, cache.machine_id);
351 assert_eq!(loaded.activated_at, cache.activated_at);
352 }
353 LicenseStatus::Unlicensed => panic!("Expected Licensed"),
354 }
355 }
356
357 #[test]
358 fn load_missing_returns_unlicensed() {
359 let dir = tempfile::tempdir().unwrap();
360 assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed));
361 }
362
363 #[test]
364 fn load_corrupt_returns_unlicensed() {
365 let dir = tempfile::tempdir().unwrap();
366 std::fs::write(dir.path().join("license.json"), "not json{{{").unwrap();
367 assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed));
368 }
369
370 #[test]
371 fn remove_license_deletes_file() {
372 let dir = tempfile::tempdir().unwrap();
373 let cache = LicenseCache {
374 key_code: "test".to_string(),
375 machine_id: "m".to_string(),
376 activated_at: "now".to_string(),
377 };
378 save_license(dir.path(), &cache).unwrap();
379 assert!(dir.path().join("license.json").exists());
380 remove_license(dir.path()).unwrap();
381 assert!(!dir.path().join("license.json").exists());
382 }
383
384 #[test]
385 fn remove_license_missing_is_ok() {
386 let dir = tempfile::tempdir().unwrap();
387 assert!(remove_license(dir.path()).is_ok());
388 }
389
390 #[test]
391 fn validate_response_deserializes_success() {
392 let json = r#"{"valid": true}"#;
393 let resp: ValidateResponse = serde_json::from_str(json).unwrap();
394 assert!(resp.valid);
395 assert!(resp.error.is_none());
396 }
397
398 #[test]
399 fn validate_response_deserializes_failure() {
400 let json = r#"{"valid": false, "error": "invalid_key"}"#;
401 let resp: ValidateResponse = serde_json::from_str(json).unwrap();
402 assert!(!resp.valid);
403 assert_eq!(resp.error.as_deref(), Some("invalid_key"));
404 }
405
406 #[test]
407 fn validate_response_ignores_extra_fields() {
408 let json = r#"{"valid": true, "activated": true, "license": {"item_id": "abc", "max_activations": 5, "activation_count": 1, "created_at": "2026-01-01T00:00:00Z"}}"#;
409 let resp: ValidateResponse = serde_json::from_str(json).unwrap();
410 assert!(resp.valid);
411 }
412
413 #[test]
414 fn deactivate_response_deserializes() {
415 let json = r#"{"success": true, "message": "deactivated"}"#;
416 let resp: DeactivateResponse = serde_json::from_str(json).unwrap();
417 assert!(resp.success);
418 assert_eq!(resp.message, "deactivated");
419 }
420
421 #[test]
422 fn save_load_trial_roundtrip() {
423 let dir = tempfile::tempdir().unwrap();
424 let state = TrialState {
425 first_launch_date: "2026-04-01T00:00:00Z".to_string(),
426 last_seen_date: None,
427 };
428 save_trial(dir.path(), &state).unwrap();
429 let loaded = load_trial(dir.path()).unwrap();
430 assert_eq!(loaded.first_launch_date, state.first_launch_date);
431 }
432
433 #[test]
434 fn load_trial_missing_returns_none() {
435 let dir = tempfile::tempdir().unwrap();
436 assert!(load_trial(dir.path()).is_none());
437 }
438
439 #[test]
440 fn trial_days_remaining_fresh() {
441 let state = TrialState {
442 first_launch_date: chrono::Utc::now().to_rfc3339(),
443 last_seen_date: None,
444 };
445 assert_eq!(trial_days_remaining(&state), 30);
446 }
447
448 #[test]
449 fn trial_days_remaining_expired() {
450 let past = chrono::Utc::now() - chrono::Duration::days(35);
451 let state = TrialState {
452 first_launch_date: past.to_rfc3339(),
453 last_seen_date: None,
454 };
455 assert_eq!(trial_days_remaining(&state), -5);
456 }
457
458 #[test]
459 fn trial_clock_rollback_detected() {
460 let now = chrono::Utc::now();
461 let future = now + chrono::Duration::days(10);
462 let state = TrialState {
463 first_launch_date: now.to_rfc3339(),
464 last_seen_date: Some(future.to_rfc3339()),
465 };
466 // now < last_seen_date by 10 days → clock was rolled back → expire
467 assert_eq!(trial_days_remaining(&state), 0);
468 }
469 }
470