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

223
backend/license_server.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
TalkEdit License Server — Stripe webhook + license key generator.
Usage (development):
python backend/license_server.py
Then create a test license:
python backend/license_server.py generate --email test@example.com --tier pro
This is a minimal server. In production, deploy as a Cloudflare Worker,
Vercel function, or a small VPS behind nginx.
"""
import base64
import json
import os
import time
import hmac
import hashlib
from nacl.bindings import (
crypto_sign_seed_keypair,
crypto_sign,
crypto_sign_BYTES,
)
# === CONFIGURATION ===
# The Ed25519 private key (base64-encoded). Keep this secret!
# Generate with: python3 -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())"
LICENSE_PRIVATE_KEY_B64 = "ONTdT2Hn367fMlovqulz7WYQPQru7uFa/GaSfjhnR9x7Qoe7uBPNwIFeW4p7A0g05Qj14rvaQ4Mm1u/LzgeEsA=="
# Stripe webhook secret (set this in production)
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# === TIER DEFINITIONS ===
TIERS = {
"pro": {
"price_id": "price_pro_monthly", # Replace with your Stripe price ID
"features": ["bundled_deps", "auto_updates", "priority_support"],
"max_activations": 1,
"duration_days": 365,
},
"business": {
"price_id": "price_business_monthly",
"features": ["bundled_deps", "auto_updates", "priority_support",
"white_label", "audit_logging", "bulk_deployment"],
"max_activations": 10,
"duration_days": 365,
},
}
def generate_license_key(
customer_email: str,
tier: str = "pro",
license_id: str = None,
duration_days: int = None,
features: list = None,
max_activations: int = None,
) -> str:
"""Generate a signed TalkEdit license key.
Returns a string like: talkedit_v1_<base64(payload)>.<base64(signature)>
"""
if license_id is None:
license_id = f"lic_{int(time.time())}_{os.urandom(4).hex()}"
tier_config = TIERS.get(tier, TIERS["pro"])
if duration_days is None:
duration_days = tier_config["duration_days"]
if features is None:
features = tier_config["features"]
if max_activations is None:
max_activations = tier_config["max_activations"]
now = int(time.time())
payload = {
"license_id": license_id,
"customer_email": customer_email,
"tier": tier,
"features": features,
"issued_at": now,
"expires_at": now + duration_days * 86400,
"max_activations": max_activations,
}
payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
# Sign with Ed25519
seed = base64.b64decode(LICENSE_PRIVATE_KEY_B64)
if len(seed) == 64:
seed = seed[:32] # First 32 bytes are the actual seed
pk, sk = crypto_sign_seed_keypair(seed)
signed = crypto_sign(payload_bytes, sk)
signature = signed[:crypto_sign_BYTES]
payload_b64 = base64.b64encode(payload_bytes).decode().rstrip("=")
sig_b64 = base64.b64encode(signature).decode().rstrip("=")
return f"talkedit_v1_{payload_b64}.{sig_b64}"
def verify_stripe_webhook(payload: bytes, sig_header: str) -> dict:
"""Verify Stripe webhook signature and return the event."""
if not STRIPE_WEBHOOK_SECRET:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
# Stripe sends signature in the `stripe-signature` header
# Format: t=timestamp,v1=signature
parts = {}
for item in sig_header.split(","):
key, _, value = item.partition("=")
parts[key.strip()] = value.strip()
timestamp = parts.get("t", "")
expected_sig = parts.get("v1", "")
# Compute signature
signed_payload = f"{timestamp}.{payload.decode()}".encode()
computed_sig = hmac.new(
STRIPE_WEBHOOK_SECRET.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed_sig, expected_sig):
raise ValueError("Invalid webhook signature")
return json.loads(payload)
# === CLI ===
def main():
import sys
if len(sys.argv) > 1 and sys.argv[1] == "generate":
# CLI mode: generate a test license key
import argparse
parser = argparse.ArgumentParser(description="Generate TalkEdit license key")
parser.add_argument("--email", default="test@example.com")
parser.add_argument("--tier", default="pro", choices=["pro", "business"])
parser.add_argument("--days", type=int, default=None)
args = parser.parse_args(sys.argv[2:])
key = generate_license_key(
customer_email=args.email,
tier=args.tier,
duration_days=args.days,
)
print()
print("=== TALKEDIT LICENSE KEY ===")
print(key)
print()
print("Paste this into the TalkEdit app to activate.")
return
# Server mode
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
class LicenseHandler(BaseHTTPRequestHandler):
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path == "/webhook/stripe":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
sig_header = self.headers.get("Stripe-Signature", "")
try:
event = verify_stripe_webhook(body, sig_header)
event_type = event.get("type", "")
if event_type == "checkout.session.completed":
session = event["data"]["object"]
email = session.get("customer_email", session.get("customer_details", {}).get("email", "unknown"))
tier = "pro" # Map from session["metadata"]["tier"] or line items
license_key = generate_license_key(
customer_email=email,
tier=tier,
)
# In production: email the license key to the customer
print(f"License generated for {email}: {license_key[:40]}...")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok"}).encode())
else:
self.send_response(200)
self.end_headers()
except Exception as e:
print(f"Webhook error: {e}")
self.send_response(400)
self.end_headers()
self.wfile.write(str(e).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f"[license-server] {args}")
port = int(os.environ.get("PORT", 8643))
server = HTTPServer(("0.0.0.0", port), LicenseHandler)
print(f"License server listening on http://0.0.0.0:{port}")
print(f" POST /webhook/stripe - Stripe webhook")
print()
print("To generate a test license:")
print(f" python {__file__} generate --email you@example.com --tier pro")
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -12,7 +12,9 @@ import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import ZoneEditor from './components/ZoneEditor';
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
import AppendClipPanel from './components/AppendClipPanel';
import LicenseDialog from './components/LicenseDialog';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useLicenseStore } from './store/licenseStore';
import {
Film,
FolderOpen,
@ -132,6 +134,8 @@ export default function App() {
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
const [showFileMenu, setShowFileMenu] = useState(false);
const canEdit = useLicenseStore((s) => s.canEdit);
const projectSignature = useMemo(() => {
if (!videoPath) return null;
@ -196,7 +200,11 @@ export default function App() {
useKeyboardShortcuts();
// Handle Escape key to exit timeline zone modes
useEffect(() => {
useLicenseStore.getState().checkStatus();
}, []);
// Handle Escape key to exit timeline zone modes and close menus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
@ -204,9 +212,9 @@ export default function App() {
setMuteMode(false);
setGainMode(false);
setSpeedMode(false);
setShowFileMenu(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
@ -287,8 +295,10 @@ export default function App() {
setProjectFilePath(null);
setProjectName(null);
loadVideo(path);
if (canEdit) {
await transcribeVideo(path);
}
}
});
};
@ -562,44 +572,40 @@ export default function App() {
{/* Top bar */}
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-0.5">
<ToolbarButton
icon={<FilePlus2 className="w-4 h-4" />}
label="New"
onClick={handleNewProject}
/>
<div className="relative">
<ToolbarButton
icon={<FolderOpen className="w-4 h-4" />}
label="Open"
onClick={handleOpenFile}
/>
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save"
onClick={handleSaveProject}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save As"
onClick={handleSaveProjectAs}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<FileInput className="w-4 h-4" />}
label="Load"
onClick={handleLoadProject}
label="File"
onClick={() => setShowFileMenu((p) => !p)}
active={showFileMenu}
/>
{showFileMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowFileMenu(false)} />
<div className="absolute left-0 top-full mt-1 z-50 w-44 rounded-lg border border-editor-border bg-editor-surface shadow-xl py-1">
<DropdownItem icon={<FilePlus2 className="w-4 h-4" />} label="New Project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
<DropdownItem icon={<FolderOpen className="w-4 h-4" />} label="Open File" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
<DropdownItem icon={<FileInput className="w-4 h-4" />} label="Load Project" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
<div className="border-t border-editor-border my-1" />
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save As" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
</div>
</>
)}
</div>
<ToolbarButton
icon={<Scissors className="w-4 h-4" />}
label="Cut"
onClick={handleCut}
active={cutMode}
disabled={!canEdit}
/>
<ToolbarButton
icon={<VolumeX className="w-4 h-4" />}
label="Mute"
onClick={handleMute}
active={muteMode}
disabled={!canEdit}
/>
<div className="flex items-center gap-1">
<ToolbarButton
@ -607,6 +613,7 @@ export default function App() {
label="Gain Zone"
onClick={handleGain}
active={gainMode}
disabled={!canEdit}
/>
<input
type="number"
@ -617,6 +624,7 @@ export default function App() {
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Gain dB for new gain zones"
disabled={!canEdit}
/>
</div>
<div className="flex items-center gap-1">
@ -625,6 +633,7 @@ export default function App() {
label="Speed Zone"
onClick={handleSpeed}
active={speedMode}
disabled={!canEdit}
/>
<input
type="number"
@ -635,6 +644,7 @@ export default function App() {
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Playback rate for new speed zones"
disabled={!canEdit}
/>
</div>
<ToolbarButton
@ -642,35 +652,35 @@ export default function App() {
label="Zones"
active={activePanel === 'zones'}
onClick={() => togglePanel('zones')}
disabled={!videoPath}
disabled={!videoPath || !canEdit}
/>
<ToolbarButton
icon={<span className="text-[10px] font-semibold">PA</span>}
label="Pause Trim"
active={activePanel === 'silence'}
onClick={() => togglePanel('silence')}
disabled={!videoPath}
disabled={!videoPath || !canEdit}
/>
<ToolbarButton
icon={<MapPin className="w-4 h-4" />}
label="Markers"
active={activePanel === 'markers'}
onClick={() => togglePanel('markers')}
disabled={!videoPath}
disabled={!videoPath || !canEdit}
/>
<ToolbarButton
icon={<Music className="w-4 h-4" />}
label="Music"
active={activePanel === 'music'}
onClick={() => togglePanel('music')}
disabled={!videoPath}
disabled={!videoPath || !canEdit}
/>
<ToolbarButton
icon={<ListVideo className="w-4 h-4" />}
label="Append"
active={activePanel === 'append'}
onClick={() => togglePanel('append')}
disabled={!videoPath}
disabled={!videoPath || !canEdit}
/>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
<select
@ -700,7 +710,7 @@ export default function App() {
</select>
<button
onClick={handleReprocessProject}
disabled={isTranscribing || !videoPath}
disabled={isTranscribing || !videoPath || !canEdit}
title="Reprocess transcript with selected model"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
>
@ -713,7 +723,7 @@ export default function App() {
label="AI"
active={activePanel === 'ai'}
onClick={() => togglePanel('ai')}
disabled={words.length === 0}
disabled={words.length === 0 || !canEdit}
/>
<ToolbarButton
icon={<Download className="w-4 h-4" />}
@ -841,6 +851,8 @@ export default function App() {
</div>
{import.meta.env.DEV && <DevPanel />}
<LicenseDialog />
{showReprocessConfirm && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
@ -941,3 +953,30 @@ function ToolbarButton({
</button>
);
}
function DropdownItem({
icon,
label,
onClick,
disabled,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
disabled
? 'opacity-40 cursor-not-allowed'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-bg'
}`}
>
{icon}
{label}
</button>
);
}

View File

@ -0,0 +1,207 @@
import { useState } from 'react';
import { useLicenseStore } from '../store/licenseStore';
import { Key, Check, X, Loader2, Shield, Clock, AlertTriangle } from 'lucide-react';
export default function LicenseDialog() {
const { status, showDialog, setShowDialog, activateLicense } = useLicenseStore();
const [key, setKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [activating, setActivating] = useState(false);
const handleActivate = async () => {
if (!key.trim()) return;
setActivating(true);
setError(null);
const ok = await activateLicense(key.trim());
if (!ok) {
setError('Invalid license key. Check it was entered correctly.');
}
setActivating(false);
};
const formatDate = (ts: number) => {
const d = new Date(ts * 1000);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
};
if (!status) return null;
if (status.tag === 'Licensed') {
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs">
<Shield className="w-3.5 h-3.5 text-editor-success" />
<span className="text-editor-text-muted">
{status.license.tier === 'business' ? 'Business' : 'Pro'} {status.license.customer_email}
</span>
<span className="text-editor-text-muted/50">
expires {formatDate(status.license.expires_at)}
</span>
</div>
</div>
);
}
if (status.tag === 'Trial') {
return (
<>
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setShowDialog(true)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs hover:bg-editor-bg transition-colors"
>
<Clock className="w-3.5 h-3.5 text-editor-accent" />
<span className="text-editor-text-muted">
Trial {status.days_remaining} day{status.days_remaining !== 1 ? 's' : ''} left
</span>
</button>
</div>
{showDialog && (
<LicenseActivateDialog
onClose={() => setShowDialog(false)}
onActivate={handleActivate}
keyValue={key}
setKeyValue={setKey}
error={error}
activating={activating}
trialEnding={status.days_remaining <= 3}
/>
)}
</>
);
}
// Expired — show banner + activation dialog (both dismissible)
return (
<>
<ExpiredBanner onActivate={() => setShowDialog(true)} />
{showDialog && (
<LicenseActivateDialog
onClose={() => setShowDialog(false)}
onActivate={handleActivate}
keyValue={key}
setKeyValue={setKey}
error={error}
activating={activating}
expired
/>
)}
</>
);
}
/** Persistent top banner shown when trial expired — still allows export and loading */
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
return (
<div className="h-9 flex items-center justify-center gap-3 px-4 bg-red-500/15 border-b border-red-500/30 shrink-0">
<AlertTriangle className="w-3.5 h-3.5 text-red-400 shrink-0" />
<span className="text-xs text-red-300">
Trial expired export and project loading still work.&nbsp;
<button onClick={onActivate} className="underline font-medium hover:text-red-200">
Activate license
</button>
&nbsp;to restore editing.
</span>
</div>
);
}
function LicenseActivateDialog({
onClose,
onActivate,
keyValue,
setKeyValue,
error,
activating,
trialEnding,
expired,
}: {
onClose: () => void;
onActivate: () => void;
keyValue: string;
setKeyValue: (v: string) => void;
error: string | null;
activating: boolean;
trialEnding?: boolean;
expired?: boolean;
}) {
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-editor-accent" />
<h3 className="text-sm font-semibold">
{expired ? 'Trial Expired' : 'Activate TalkEdit'}
</h3>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
>
<X className="w-4 h-4" />
</button>
</div>
{expired && (
<div className="text-xs text-editor-text-muted leading-relaxed space-y-1">
<p className="font-medium text-red-300">Your 30-day trial has ended.</p>
<p>
You can still <strong>export videos</strong> and <strong>load projects</strong>.
Enter a license key to restore editing, AI tools, and all other features.
</p>
</div>
)}
{trialEnding && !expired && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-amber-300">Your trial ends soon. Activate now to keep using all features.</p>
</div>
)}
{!expired && !trialEnding && (
<p className="text-xs text-editor-text-muted leading-relaxed">
Enter your license key to activate TalkEdit Pro or Business.
You received this key by email after purchase.
</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-editor-text-muted font-medium">License Key</label>
<textarea
value={keyValue}
onChange={(e) => { setKeyValue(e.target.value); }}
placeholder="talkedit_v1_..."
rows={3}
className="w-full px-3 py-2 text-xs font-mono bg-editor-surface border border-editor-border rounded-lg text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent resize-none"
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<button
onClick={onActivate}
disabled={activating || !keyValue.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{activating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
Activate
</button>
<p className="text-[10px] text-editor-text-muted text-center">
No license? <a href="#" className="text-editor-accent hover:underline">Purchase at talked.it</a>
</p>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useLicenseStore } from '../store/licenseStore';
import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
@ -42,6 +43,7 @@ export default function TranscriptEditor({
const addGainRange = useEditorStore((s) => s.addGainRange);
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const canEdit = useLicenseStore((s) => s.canEdit);
const selectionStart = useRef<number | null>(null);
const wasDragging = useRef(false);
@ -206,7 +208,7 @@ export default function TranscriptEditor({
if (zoneDragStart.current !== null && zoneDragRange) {
const startWord = words[zoneDragRange.start];
const endWord = words[zoneDragRange.end];
if (startWord && endWord) {
if (startWord && endWord && canEdit) {
if (cutMode) addCutRange(startWord.start, endWord.end);
if (muteMode) addMuteRange(startWord.start, endWord.end);
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
@ -216,7 +218,7 @@ export default function TranscriptEditor({
zoneDragStart.current = null;
setZoneDragRange(null);
selectionStart.current = null;
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]);
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange, canEdit]);
const handleClickOutside = useCallback(
(e: React.MouseEvent) => {
@ -303,8 +305,9 @@ export default function TranscriptEditor({
const handleWordDoubleClick = useCallback((index: number) => {
if (cutMode || muteMode || gainMode || speedMode) return;
if (!canEdit) return;
startEditing(index);
}, [cutMode, muteMode, gainMode, speedMode, startEditing]);
}, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]);
// Focus edit input when it appears
useEffect(() => {
@ -556,35 +559,39 @@ export default function TranscriptEditor({
<div className="flex items-center gap-1">
<button
onClick={cutSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors disabled:opacity-40"
>
<Scissors className="w-3 h-3" />
Cut
</button>
<button
onClick={muteSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors disabled:opacity-40"
>
<VolumeX className="w-3 h-3" />
Mute
</button>
<button
onClick={gainSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors"
disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors disabled:opacity-40"
>
<SlidersHorizontal className="w-3 h-3" />
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button>
<button
onClick={speedSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors"
disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors disabled:opacity-40"
>
<Gauge className="w-3 h-3" />
Speed {speedModeValue.toFixed(2)}x
</button>
<button
onClick={handleReTranscribe}
disabled={isReTranscribing}
disabled={isReTranscribing || !canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
title="Re-run Whisper transcription on this segment"
>

View File

@ -1,5 +1,6 @@
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useLicenseStore } from '../store/licenseStore';
import { AlertTriangle } from 'lucide-react';
import { extractThumbnails } from '../lib/thumbnails';
@ -264,6 +265,7 @@ export default function WaveformTimeline({
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange);
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
const canEdit = useLicenseStore((s) => s.canEdit);
const waveformDataRef = useRef<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -1025,7 +1027,7 @@ export default function WaveformTimeline({
// Check if clicking on a zone
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
if (zoneHit) {
if (zoneHit && canEdit) {
if (zoneHit.edge === 'move') {
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
} else {
@ -1098,7 +1100,7 @@ export default function WaveformTimeline({
// Clear selection if clicking elsewhere
setSelectedZone(null);
if (cutMode || muteMode || gainMode || speedMode) {
if (canEdit && (cutMode || muteMode || gainMode || speedMode)) {
// Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
@ -1181,7 +1183,7 @@ export default function WaveformTimeline({
if (e.key === 'Escape') {
setSelectedZone(null);
editingZoneRef.current = null;
} else if (e.key === 'Delete' || e.key === 'Backspace') {
} else if (canEdit && (e.key === 'Delete' || e.key === 'Backspace')) {
if (selectedZone) {
e.preventDefault();
e.stopPropagation();
@ -1204,7 +1206,7 @@ export default function WaveformTimeline({
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, canEdit]);
useEffect(() => {
if (!selectedZone) return;

View File

@ -96,4 +96,20 @@ window.electronAPI = {
await writeTextFile(path, content);
return true;
},
activateLicense: (key: string): Promise<any> => {
return invoke('activate_license', { licenseKey: key });
},
getAppStatus: (): Promise<any> => {
return invoke('get_app_status');
},
deactivateLicense: (): Promise<void> => {
return invoke('deactivate_license');
},
hasLicenseFeature: (feature: string): Promise<boolean> => {
return invoke('has_license_feature', { feature });
},
};

View File

@ -0,0 +1,105 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface LicensePayload {
license_id: string;
customer_email: string;
tier: 'pro' | 'business';
features: string[];
issued_at: number;
expires_at: number;
max_activations: number;
}
export interface TrialState {
started_at: number;
}
export type AppStatus =
| { tag: 'Licensed'; license: LicensePayload }
| { tag: 'Trial'; days_remaining: number; started_at: number }
| { tag: 'Expired' };
interface LicenseState {
status: AppStatus | null;
isLoaded: boolean;
showDialog: boolean;
canEdit: boolean;
}
interface LicenseActions {
setStatus: (status: AppStatus | null) => void;
setShowDialog: (show: boolean) => void;
checkStatus: () => Promise<void>;
activateLicense: (key: string) => Promise<boolean>;
deactivateLicense: () => Promise<void>;
hasFeature: (feature: string) => Promise<boolean>;
}
export const useLicenseStore = create<LicenseState & LicenseActions>()(
persist(
(set) => ({
status: null,
isLoaded: false,
showDialog: false,
canEdit: true,
setStatus: (status) => {
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status, isLoaded: true, canEdit });
},
setShowDialog: (show) => set({ showDialog: show }),
checkStatus: async () => {
try {
const status = await window.electronAPI?.getAppStatus();
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit });
} catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false });
}
},
activateLicense: async (key: string): Promise<boolean> => {
try {
const license = await window.electronAPI?.activateLicense(key);
if (!license) return false;
set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true });
return true;
} catch {
return false;
}
},
deactivateLicense: async () => {
try {
await window.electronAPI?.deactivateLicense();
const s = await window.electronAPI?.getAppStatus();
const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit });
} catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false });
}
},
hasFeature: async (feature: string): Promise<boolean> => {
try {
return await window.electronAPI?.hasLicenseFeature(feature) || false;
} catch {
return false;
}
},
}),
{
name: 'talkedit-license',
partialize: (state) => {
// Only persist Licensed status (trial is ephemeral)
if (state.status?.tag === 'Licensed') {
return { status: state.status };
}
return {};
},
},
),
);

View File

@ -20,6 +20,10 @@ interface DesktopAPI {
transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>;
readFile: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<boolean>;
activateLicense: (key: string) => Promise<any>;
getAppStatus: () => Promise<any>;
deactivateLicense: () => Promise<void>;
hasLicenseFeature: (feature: string) => Promise<boolean>;
}
interface Window {

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/LicenseDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/store/licenseStore.ts","./src/types/project.ts"],"version":"5.9.3"}

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());
}
}