added licensing stuff and free trial timer

This commit is contained in:
2026-05-06 01:35:42 -06:00
parent 810957747b
commit 835719a907
13 changed files with 1121 additions and 53 deletions

110
src-tauri/Cargo.lock generated
View File

@ -79,7 +79,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "app"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dirs 5.0.1",
"ed25519-dalek",
"hound",
"log",
"serde",
@ -146,6 +148,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@ -450,6 +458,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -599,6 +613,33 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -633,6 +674,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -826,6 +877,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "embed-resource"
version = "3.0.8"
@ -907,6 +982,12 @@ dependencies = [
"log",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "field-offset"
version = "0.3.6"
@ -2536,6 +2617,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -3397,6 +3488,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@ -3491,6 +3591,16 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"

View File

@ -1,15 +1,13 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
description = "TalkEdit - AI-powered video editor"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@ -29,3 +27,5 @@ dirs = "5.0"
ureq = "2.9"
hound = "3.5"
tempfile = "3.10"
ed25519-dalek = "2"
base64 = "0.22"

View File

@ -1,5 +1,7 @@
// --- Commands ---
use tauri::Manager;
mod paths;
mod transcription;
mod video_editor;
@ -8,6 +10,7 @@ mod diarization;
mod ai_provider;
mod caption_generator;
mod background_removal;
mod licensing;
#[tauri::command]
fn get_projects_directory() -> Result<String, String> {
@ -204,6 +207,72 @@ async fn save_captions(content: String, output_path: String) -> Result<String, S
.map_err(|e| format!("Task error: {:?}", e))?
}
/// Get the combined app status: licensed, trial, or expired.
#[tauri::command]
fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
Ok(licensing::get_app_status(&data_dir))
}
/// Verify and activate a license key.
#[tauri::command]
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result<licensing::LicensePayload, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
let payload = licensing::verify_license_key(&license_key)
.map_err(|e| e.to_string())?;
licensing::cache_license(&data_dir, &license_key)
.map_err(|e| e.to_string())?;
// Clear trial state since user has a valid license
licensing::clear_trial(&data_dir);
Ok(payload)
}
/// Remove the cached license (deactivate). Trial will resume if still valid.
#[tauri::command]
fn deactivate_license(app_handle: tauri::AppHandle) -> Result<(), String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
licensing::remove_license(&data_dir);
Ok(())
}
/// Start the free trial if not already started. Returns trial info.
#[tauri::command]
fn start_trial(app_handle: tauri::AppHandle) -> Result<licensing::TrialState, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
let trial = licensing::get_or_start_trial(&data_dir);
Ok(trial)
}
/// Check if a specific feature is enabled (requires valid license).
#[tauri::command]
fn has_license_feature(app_handle: tauri::AppHandle, feature: String) -> Result<bool, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
match licensing::load_cached_license(&data_dir) {
Ok(payload) => Ok(licensing::has_feature(&payload, &feature)),
Err(_) => Ok(false),
}
}
/// Check if background removal is available
#[tauri::command]
async fn is_background_removal_available() -> Result<bool, String> {
@ -239,6 +308,27 @@ pub fn run() {
.build(),
)?;
}
// Check for cached license or trial at startup
if let Ok(data_dir) = app.path().app_data_dir() {
match licensing::get_app_status(&data_dir) {
licensing::AppStatus::Licensed { license: payload } => {
log::info!(
"License: {} ({}), expires {}",
payload.customer_email,
payload.tier,
payload.expires_at,
);
}
licensing::AppStatus::Trial { days_remaining, .. } => {
log::info!("Trial active: {days_remaining} days remaining");
}
licensing::AppStatus::Expired => {
log::info!("Trial expired — license activation required");
}
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -263,6 +353,11 @@ pub fn run() {
save_captions,
is_background_removal_available,
remove_background_on_export,
get_app_status,
activate_license,
deactivate_license,
start_trial,
has_license_feature,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

260
src-tauri/src/licensing.rs Normal file
View File

@ -0,0 +1,260 @@
use base64::engine::general_purpose::STANDARD_NO_PAD as BASE64;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub const TALKEDIT_PUBLIC_KEY: [u8; 32] = [123, 66, 135, 187, 184, 19, 205, 192, 129, 94, 91, 138, 123, 3, 72, 52, 229, 8, 245, 226, 187, 218, 67, 131, 38, 214, 239, 203, 206, 7, 132, 176];
pub const TRIAL_DURATION_SECS: u64 = 30 * 86400;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LicensePayload {
pub license_id: String,
pub customer_email: String,
pub tier: String,
pub features: Vec<String>,
pub issued_at: u64,
pub expires_at: u64,
pub max_activations: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TrialState {
pub started_at: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "tag")]
pub enum AppStatus {
Licensed { license: LicensePayload },
Trial { days_remaining: u32, started_at: u64 },
Expired,
}
#[derive(Debug)]
pub enum LicenseError {
InvalidFormat,
InvalidSignature,
Expired,
DecodeError(String),
CryptoError(String),
}
impl std::fmt::Display for LicenseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid license key format"),
Self::InvalidSignature => write!(f, "License signature is invalid"),
Self::Expired => write!(f, "License has expired"),
Self::DecodeError(e) => write!(f, "License decode error: {e}"),
Self::CryptoError(e) => write!(f, "Crypto error: {e}"),
}
}
}
// --- License key verification ---
pub fn verify_license_key(license_key: &str) -> Result<LicensePayload, LicenseError> {
let stripped = license_key
.strip_prefix("talkedit_v1_")
.ok_or(LicenseError::InvalidFormat)?;
let dot_pos = stripped.rfind('.').ok_or(LicenseError::InvalidFormat)?;
let payload_b64 = &stripped[..dot_pos];
let sig_b64 = &stripped[dot_pos + 1..];
if payload_b64.is_empty() || sig_b64.is_empty() {
return Err(LicenseError::InvalidFormat);
}
let payload_bytes = BASE64
.decode(payload_b64)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let sig_bytes = BASE64
.decode(sig_b64)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&TALKEDIT_PUBLIC_KEY)
.map_err(|e| LicenseError::CryptoError(e.to_string()))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
verifying_key
.verify_strict(&payload_bytes, &signature)
.map_err(|_| LicenseError::InvalidSignature)?;
let payload: LicensePayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let now = now_secs();
if now > payload.expires_at {
return Err(LicenseError::Expired);
}
Ok(payload)
}
// --- License file I/O ---
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn license_file_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("license.key")
}
pub fn trial_file_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("trial.json")
}
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result<LicensePayload, LicenseError> {
let path = license_file_path(app_data_dir);
let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?;
verify_license_key(content.trim())
}
pub fn cache_license(app_data_dir: &PathBuf, license_key: &str) -> Result<(), LicenseError> {
let path = license_file_path(app_data_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
}
std::fs::write(&path, license_key)
.map_err(|e| LicenseError::DecodeError(e.to_string()))
}
pub fn remove_license(app_data_dir: &PathBuf) {
let path = license_file_path(app_data_dir);
let _ = std::fs::remove_file(&path);
}
// --- Trial logic ---
pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState {
let path = trial_file_path(app_data_dir);
let now = now_secs();
// Try to load existing trial
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(trial) = serde_json::from_str::<TrialState>(&content) {
return trial;
}
}
// Start new trial
let trial = TrialState { started_at: now };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, serde_json::to_string(&trial).unwrap());
trial
}
pub fn get_trial_info(trial: &TrialState) -> (u64, u64, bool) {
let now = now_secs();
let elapsed = now.saturating_sub(trial.started_at);
let remaining = TRIAL_DURATION_SECS.saturating_sub(elapsed);
let days_remaining = remaining / 86400;
let is_active = remaining > 0;
(days_remaining, remaining, is_active)
}
/// Check if trial is still valid and return remaining days.
pub fn get_trial_days_remaining(trial: &TrialState) -> Option<u32> {
let (days, _, active) = get_trial_info(trial);
if active {
Some(days as u32)
} else {
None
}
}
pub fn clear_trial(app_data_dir: &PathBuf) {
let path = trial_file_path(app_data_dir);
let _ = std::fs::remove_file(&path);
}
/// Get the overall app status: Licensed > Trial > Expired.
pub fn get_app_status(app_data_dir: &PathBuf) -> AppStatus {
// 1. Check for valid license
if let Ok(license) = load_cached_license(app_data_dir) {
return AppStatus::Licensed { license };
}
// 2. Check for active trial
let trial = get_or_start_trial(app_data_dir);
if let Some(days) = get_trial_days_remaining(&trial) {
return AppStatus::Trial {
days_remaining: days,
started_at: trial.started_at,
};
}
// 3. Everything expired
AppStatus::Expired
}
pub fn has_feature(payload: &LicensePayload, feature: &str) -> bool {
payload.features.iter().any(|f| f == feature)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: &str = "talkedit_v1_eyJsaWNlbnNlX2lkIjoidGVzdF8wMDEiLCJjdXN0b21lcl9lbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJ0aWVyIjoicHJvIiwiZmVhdHVyZXMiOlsiYnVuZGxlZF9kZXBzIiwiYXV0b191cGRhdGVzIiwiYmdfcmVtb3ZhbCJdLCJpc3N1ZWRfYXQiOjE3NzgwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDk1MzYwMDAsIm1heF9hY3RpdmF0aW9ucyI6M30.vnT0xa8GqINY5ZvU5cCulmlwiS6IUr+Ln5yVzgY+KmGfAlfhiiOpQIsFNiwaG0rb8qk5//7CWFDe6kQdEFnnBw";
#[test]
fn test_verify_valid_license() {
let result = verify_license_key(TEST_KEY);
assert!(result.is_ok(), "Expected OK, got: {:?}", result);
let payload = result.unwrap();
assert_eq!(payload.customer_email, "test@example.com");
assert_eq!(payload.tier, "pro");
assert!(payload.features.contains(&"bg_removal".to_string()));
}
#[test]
fn test_verify_invalid_signature() {
let dot = TEST_KEY.rfind('.').unwrap();
let prefix = &TEST_KEY[..dot + 1];
let bad_sig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let tampered = format!("{prefix}{bad_sig}");
let result = verify_license_key(&tampered);
assert!(matches!(result, Err(LicenseError::InvalidSignature)));
}
#[test]
fn test_verify_bad_format() {
let result = verify_license_key("not-a-license-key");
assert!(matches!(result, Err(LicenseError::InvalidFormat)));
}
#[test]
fn test_has_feature() {
let payload = verify_license_key(TEST_KEY).unwrap();
assert!(has_feature(&payload, "bg_removal"));
assert!(!has_feature(&payload, "nonexistent_feature"));
}
#[test]
fn test_trial_dates() {
let trial = TrialState { started_at: now_secs() };
let days = get_trial_days_remaining(&trial);
assert!(days.is_some());
assert_eq!(days.unwrap(), 30);
}
#[test]
fn test_expired_trial() {
let trial = TrialState { started_at: 0 }; // epoch = 1970, definitely expired
let days = get_trial_days_remaining(&trial);
assert!(days.is_none());
}
}