added licensing stuff and free trial timer
This commit is contained in:
223
backend/license_server.py
Normal file
223
backend/license_server.py
Normal 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()
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
207
frontend/src/components/LicenseDialog.tsx
Normal file
207
frontend/src/components/LicenseDialog.tsx
Normal 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.
|
||||
<button onClick={onActivate} className="underline font-medium hover:text-red-200">
|
||||
Activate license
|
||||
</button>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
105
frontend/src/store/licenseStore.ts
Normal file
105
frontend/src/store/licenseStore.ts
Normal 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 {};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
4
frontend/src/vite-env.d.ts
vendored
4
frontend/src/vite-env.d.ts
vendored
@ -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 {
|
||||
|
||||
@ -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
110
src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
260
src-tauri/src/licensing.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user