Skip to main content

max / audiofiles

Add trial mode, bump to 0.4.0 Allow using audiofiles without a license key via a 30-day trial countdown that goes negative after expiry without locking anyone out. Serves as a tech demo for MNW/SyncKit license key validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-17 05:27 UTC
Commit: e814333c06e9ddb94a796b26bf3082a30848e4c7
Parent: 3b5a387
13 files changed, +233 insertions, -32 deletions
@@ -4,6 +4,14 @@ All notable changes to audiofiles will be documented in this file.
4 4
5 5 Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 6
7 + ## [0.4.0] — 2026-04-16
8 +
9 + ### Added
10 + - Trial mode: use the app without a license key with a 30-day countdown
11 + - Trial button on activation screen with days remaining indicator
12 + - Trial status display in Settings > License section
13 + - Trial state persisted to `~/.config/audiofiles/trial.json`
14 +
7 15 ## [0.3.6] — 2026-04-16
8 16
9 17 ### Changed
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-app"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [dependencies]
@@ -189,6 +189,39 @@ pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Re
189 189 }
190 190 }
191 191
192 + // ── Trial state ──
193 +
194 + /// Persisted trial state: tracks when the user first launched the app.
195 + #[derive(Debug, Clone, Serialize, Deserialize)]
196 + pub struct TrialState {
197 + pub first_launch_date: String,
198 + }
199 +
200 + /// Load the trial state from `trial.json` in the config directory.
201 + pub fn load_trial(config_dir: &Path) -> Option<TrialState> {
202 + let path = config_dir.join("trial.json");
203 + let bytes = std::fs::read(&path).ok()?;
204 + serde_json::from_slice(&bytes).ok()
205 + }
206 +
207 + /// Save the trial state to `trial.json` in the config directory.
208 + pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> {
209 + let path = config_dir.join("trial.json");
210 + let tmp = config_dir.join("trial.json.tmp");
211 + let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
212 + std::fs::write(&tmp, &json)?;
213 + std::fs::rename(&tmp, &path)
214 + }
215 +
216 + /// Calculate days remaining in the trial (goes negative after day 30).
217 + pub fn trial_days_remaining(trial: &TrialState) -> i64 {
218 + let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else {
219 + return 30;
220 + };
221 + let elapsed = chrono::Utc::now().signed_duration_since(first);
222 + 30 - elapsed.num_days()
223 + }
224 +
192 225 #[cfg(test)]
193 226 mod tests {
194 227 use super::*;
@@ -295,4 +328,38 @@ mod tests {
295 328 assert!(resp.success);
296 329 assert_eq!(resp.message, "deactivated");
297 330 }
331 +
332 + #[test]
333 + fn save_load_trial_roundtrip() {
334 + let dir = tempfile::tempdir().unwrap();
335 + let state = TrialState {
336 + first_launch_date: "2026-04-01T00:00:00Z".to_string(),
337 + };
338 + save_trial(dir.path(), &state).unwrap();
339 + let loaded = load_trial(dir.path()).unwrap();
340 + assert_eq!(loaded.first_launch_date, state.first_launch_date);
341 + }
342 +
343 + #[test]
344 + fn load_trial_missing_returns_none() {
345 + let dir = tempfile::tempdir().unwrap();
346 + assert!(load_trial(dir.path()).is_none());
347 + }
348 +
349 + #[test]
350 + fn trial_days_remaining_fresh() {
351 + let state = TrialState {
352 + first_launch_date: chrono::Utc::now().to_rfc3339(),
353 + };
354 + assert_eq!(trial_days_remaining(&state), 30);
355 + }
356 +
357 + #[test]
358 + fn trial_days_remaining_expired() {
359 + let past = chrono::Utc::now() - chrono::Duration::days(35);
360 + let state = TrialState {
361 + first_launch_date: past.to_rfc3339(),
362 + };
363 + assert_eq!(trial_days_remaining(&state), -5);
364 + }
298 365 }
@@ -260,13 +260,13 @@ mod tests {
260 260 let dir = tempfile::tempdir().unwrap();
261 261 let reg = Some(make_registry(dir.path()));
262 262 let status = license::LicenseStatus::Licensed(make_license_cache());
263 - assert_eq!(resolve_initial_screen(&reg, &status), AppScreen::Browser);
263 + assert_eq!(resolve_initial_screen(&reg, &status, false), AppScreen::Browser);
264 264 }
265 265
266 266 #[test]
267 267 fn initial_screen_licensed_without_registry() {
268 268 let status = license::LicenseStatus::Licensed(make_license_cache());
269 - assert_eq!(resolve_initial_screen(&None, &status), AppScreen::VaultSetup);
269 + assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::VaultSetup);
270 270 }
271 271
272 272 #[test]
@@ -274,13 +274,27 @@ mod tests {
274 274 let dir = tempfile::tempdir().unwrap();
275 275 let reg = Some(make_registry(dir.path()));
276 276 let status = license::LicenseStatus::Unlicensed;
277 - assert_eq!(resolve_initial_screen(&reg, &status), AppScreen::Activation);
277 + assert_eq!(resolve_initial_screen(&reg, &status, false), AppScreen::Activation);
278 278 }
279 279
280 280 #[test]
281 281 fn initial_screen_unlicensed_without_registry() {
282 282 let status = license::LicenseStatus::Unlicensed;
283 - assert_eq!(resolve_initial_screen(&None, &status), AppScreen::Activation);
283 + assert_eq!(resolve_initial_screen(&None, &status, false), AppScreen::Activation);
284 + }
285 +
286 + #[test]
287 + fn initial_screen_trial_with_registry() {
288 + let dir = tempfile::tempdir().unwrap();
289 + let reg = Some(make_registry(dir.path()));
290 + let status = license::LicenseStatus::Unlicensed;
291 + assert_eq!(resolve_initial_screen(&reg, &status, true), AppScreen::Browser);
292 + }
293 +
294 + #[test]
295 + fn initial_screen_trial_without_registry() {
296 + let status = license::LicenseStatus::Unlicensed;
297 + assert_eq!(resolve_initial_screen(&None, &status, true), AppScreen::VaultSetup);
284 298 }
285 299
286 300 // ── License migration ──
@@ -387,19 +401,21 @@ enum AppScreen {
387 401 Browser,
388 402 }
389 403
390 - /// Determine the initial screen based on vault registry and license status.
404 + /// Determine the initial screen based on vault registry, license status, and trial.
391 405 ///
392 406 /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it
393 407 /// can be tested without constructing the full app.
394 408 fn resolve_initial_screen(
395 409 vault_registry: &Option<VaultRegistry>,
396 410 license_status: &license::LicenseStatus,
411 + has_trial: bool,
397 412 ) -> AppScreen {
398 - match (vault_registry, license_status) {
399 - (Some(_), license::LicenseStatus::Licensed(_)) => AppScreen::Browser,
400 - (Some(_), license::LicenseStatus::Unlicensed) => AppScreen::Activation,
401 - (None, license::LicenseStatus::Licensed(_)) => AppScreen::VaultSetup,
402 - (None, license::LicenseStatus::Unlicensed) => AppScreen::Activation,
413 + let licensed_or_trial = matches!(license_status, license::LicenseStatus::Licensed(_)) || has_trial;
414 + match (vault_registry, licensed_or_trial) {
415 + (Some(_), true) => AppScreen::Browser,
416 + (Some(_), false) => AppScreen::Activation,
417 + (None, true) => AppScreen::VaultSetup,
418 + (None, false) => AppScreen::Activation,
403 419 }
404 420 }
405 421
@@ -435,6 +451,7 @@ struct AudioFilesApp {
435 451 activation_error: Option<String>,
436 452 activating: bool,
437 453 license_cache: Option<license::LicenseCache>,
454 + trial_state: Option<license::TrialState>,
438 455 }
439 456
440 457 impl AudioFilesApp {
@@ -454,6 +471,7 @@ impl AudioFilesApp {
454 471
455 472 let machine_id = license::get_or_create_machine_id(&config_dir);
456 473 let license_status = license::load_license(&config_dir);
474 + let trial_state = license::load_trial(&config_dir);
457 475
458 476 // Load (or create) the vault registry
459 477 let vault_registry = match vault::load_registry() {
@@ -464,7 +482,9 @@ impl AudioFilesApp {
464 482 }
465 483 };
466 484
467 - let screen = resolve_initial_screen(&vault_registry, &license_status);
485 + let screen = resolve_initial_screen(&vault_registry, &license_status, trial_state.is_some());
486 +
487 + let licensed_or_trial = matches!(&license_status, license::LicenseStatus::Licensed(_)) || trial_state.is_some();
468 488
469 489 let (data_dir, browser, error, sync_manager, license_cache) =
470 490 match (&vault_registry, &license_status) {
@@ -476,6 +496,13 @@ impl AudioFilesApp {
476 496 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir));
477 497 (data_dir, browser, error, sync_manager, Some(cache.clone()))
478 498 }
499 + // Registry exists, unlicensed but in trial → open the active vault (no sync)
500 + (Some(reg), license::LicenseStatus::Unlicensed) if trial_state.is_some() => {
501 + let data_dir = reg.active.clone();
502 + let _ = std::fs::create_dir_all(&data_dir);
503 + let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir));
504 + (data_dir, browser, error, None, None)
505 + }
479 506 // Registry exists but unlicensed (deactivated and reactivated)
480 507 (Some(reg), license::LicenseStatus::Unlicensed) => {
481 508 tracing::info!("No valid license, showing activation screen");
@@ -486,9 +513,13 @@ impl AudioFilesApp {
486 513 tracing::info!("Licensed but no vault registry, showing vault setup");
487 514 (default_vault.clone(), None, None, None, Some(cache.clone()))
488 515 }
489 - // No registry + unlicensed → activation first
516 + // No registry + unlicensed → activation first (or vault setup if trial)
490 517 (None, license::LicenseStatus::Unlicensed) => {
491 - tracing::info!("No license, showing activation screen");
518 + if licensed_or_trial {
519 + tracing::info!("Trial mode, showing vault setup");
520 + } else {
521 + tracing::info!("No license, showing activation screen");
522 + }
492 523 (default_vault.clone(), None, None, None, None)
493 524 }
494 525 };
@@ -516,6 +547,7 @@ impl AudioFilesApp {
516 547 activation_error: None,
517 548 activating: false,
518 549 license_cache,
550 + trial_state,
519 551 };
520 552 app.sync_vault_list_to_browser();
521 553 app.sync_license_to_browser();
@@ -542,6 +574,9 @@ impl AudioFilesApp {
542 574 if let Some(ref mut browser) = self.browser {
543 575 if let Some(ref cache) = self.license_cache {
544 576 browser.settings.license_key_masked = Some(mask_key(&cache.key_code));
577 + browser.settings.trial_days_remaining = None;
578 + } else if let Some(ref trial) = self.trial_state {
579 + browser.settings.trial_days_remaining = Some(license::trial_days_remaining(trial));
545 580 }
546 581 let mid = &self.machine_id;
547 582 browser.settings.machine_id = Some(
@@ -613,18 +648,6 @@ impl AudioFilesApp {
613 648 egui::CentralPanel::default().show(ctx, |ui| {
614 649 let available = ui.available_size();
615 650
616 - // Temporary alpha skip button — top-right corner
617 - ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
618 - ui.add_space(8.0);
619 - if ui.small_button("\u{2715}").on_hover_text("Skip activation (alpha)").clicked() {
620 - if self.vault_registry.is_some() {
621 - self.activate_browser();
622 - } else {
623 - self.screen = AppScreen::VaultSetup;
624 - }
625 - }
626 - });
627 -
628 651 ui.add_space((available.y * 0.35).max(40.0));
629 652
630 653 ui.vertical_centered(|ui| {
@@ -669,10 +692,48 @@ impl AudioFilesApp {
669 692 "Get a license key",
670 693 "https://makenot.work/store/audiofiles",
671 694 );
695 +
696 + // Trial button
697 + ui.add_space(24.0);
698 + ui.separator();
699 + ui.add_space(8.0);
700 +
701 + let trial_label = if let Some(ref trial) = self.trial_state {
702 + let days = license::trial_days_remaining(trial);
703 + if days > 0 {
704 + format!("I am still testing the software ({days} days left)")
705 + } else {
706 + format!("I am still \"testing\" the software :) ({days} days)")
707 + }
708 + } else {
709 + "I am still testing the software".to_string()
710 + };
711 +
712 + if ui.button(trial_label).clicked() {
713 + self.start_trial();
714 + }
672 715 });
673 716 });
674 717 }
675 718
719 + /// Start or continue trial mode: create trial state if needed, then proceed.
720 + fn start_trial(&mut self) {
721 + if self.trial_state.is_none() {
722 + let trial = license::TrialState {
723 + first_launch_date: chrono::Utc::now().to_rfc3339(),
724 + };
725 + if let Err(e) = license::save_trial(&self.config_dir, &trial) {
726 + tracing::error!("Failed to save trial state: {e}");
727 + }
728 + self.trial_state = Some(trial);
729 + }
730 + if self.vault_registry.is_some() {
731 + self.activate_browser();
732 + } else {
733 + self.screen = AppScreen::VaultSetup;
734 + }
735 + }
736 +
676 737 /// Spawn the async activation request.
677 738 fn start_activation(&mut self) {
678 739 self.activating = true;
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-bench"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [[bin]]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-browser"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [features]
@@ -199,6 +199,8 @@ pub struct SettingsUiState {
199 199 pub license_key_masked: Option<String>,
200 200 /// Machine ID for display.
201 201 pub machine_id: Option<String>,
202 + /// Trial days remaining (None if not in trial mode).
203 + pub trial_days_remaining: Option<i64>,
202 204 }
203 205
204 206 /// GUI state for the sync setup and panel.
@@ -395,6 +395,20 @@ fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) {
395 395 ui.label("Key:");
396 396 ui.label(egui::RichText::new(masked).color(theme::text_secondary()));
397 397 });
398 + } else if let Some(days) = state.settings.trial_days_remaining {
399 + let text = if days > 0 {
400 + format!("Trial: {days} days left")
401 + } else {
402 + format!("Trial: {days} days")
403 + };
404 + let color = if days > 7 {
405 + theme::text_secondary()
406 + } else if days > 0 {
407 + theme::accent_yellow()
408 + } else {
409 + theme::text_muted()
410 + };
411 + ui.label(egui::RichText::new(text).color(color));
398 412 }
399 413 if let Some(ref mid) = state.settings.machine_id {
400 414 ui.horizontal(|ui| {
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-core"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [features]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-rhai"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [dependencies]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-sync"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [dependencies]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-train"
3 - version = "0.3.6"
3 + version = "0.4.0"
4 4 edition.workspace = true
5 5
6 6 [[bin]]
@@ -0,0 +1,49 @@
1 + # Trial Mode for Audiofiles
2 +
3 + ## Overview
4 +
5 + Allow users to use Audiofiles without a license key by clicking a "I am still testing the software" button. A 30-day countdown tracks usage, but never locks the user out.
6 +
7 + ## Behavior
8 +
9 + ### Before Day 30
10 +
11 + - On launch (or in a license prompt area), show a button: **"I am still testing the software"**
12 + - Clicking it dismisses the prompt and grants full access to all features
13 + - A small, non-intrusive indicator shows days remaining: `Trial: 23 days left`
14 + - The countdown is based on calendar days since first launch, not usage days
15 +
16 + ### After Day 30
17 +
18 + - The software continues to work identically — no features are disabled
19 + - The button text changes to: **"I am still "testing" the software :)"**
20 + - The days indicator goes negative: `Trial: -4 days`
21 + - No nag screens, no popups, no degraded experience
22 +
23 + ## Implementation Notes
24 +
25 + ### Trial State
26 +
27 + - Store `first_launch_date` in local app config (e.g. `~/.config/audiofiles/trial.json` or equivalent platform path)
28 + - Calculate `days_elapsed = (now - first_launch_date).days`
29 + - Display `days_remaining = 30 - days_elapsed` (goes negative naturally)
30 +
31 + ### UI States
32 +
33 + | State | Button Text | Indicator |
34 + |-------|------------|-----------|
35 + | `days_remaining > 0` | "I am still testing the software" | `Trial: {n} days left` |
36 + | `days_remaining <= 0` | "I am still \"testing\" the software :)" | `Trial: {n} days` |
37 +
38 + ### License Key Integration
39 +
40 + - If a valid MNW/SyncKit license key is entered, the trial indicator and button disappear entirely
41 + - Trial mode and licensed mode are mutually exclusive display states — the underlying functionality is identical
42 + - This serves as a tech demo for MNW/SyncKit license key validation, not as actual DRM
43 +
44 + ### What This Does NOT Do
45 +
46 + - Does not disable any features
47 + - Does not lock the user out
48 + - Does not phone home or enforce anything server-side
49 + - Does not reset or tamper-proof the trial date (if someone deletes the config, they get another 30 days — that's fine)