Skip to main content

max / audiofiles

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