improve home screen
This commit is contained in:
@ -584,69 +584,133 @@ export default function App() {
|
|||||||
|
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-editor-bg">
|
<div className="h-screen flex flex-col bg-editor-bg relative overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-8 px-6">
|
{/* Background pattern */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{
|
||||||
<Film className="w-14 h-14 text-editor-accent opacity-80" />
|
backgroundImage: `radial-gradient(circle at 25% 25%, #6366f1 1px, transparent 1px),
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1>
|
radial-gradient(circle at 75% 75%, #818cf8 1px, transparent 1px)`,
|
||||||
<p className="text-editor-text-muted text-sm max-w-sm text-center">
|
backgroundSize: '60px 60px, 80px 80px',
|
||||||
Offline AI-powered video editor.
|
}} />
|
||||||
|
|
||||||
|
{/* Animated audio waveform background — left and right sides filling to center */}
|
||||||
|
<div className="absolute inset-y-0 left-0 w-[30%] flex items-center justify-end gap-[3px] pr-16 pointer-events-none opacity-[0.09]">
|
||||||
|
{[
|
||||||
|
[60, 1.3, 0.0], [100, 1.0, 0.12], [40, 1.6, 0.24], [120, 0.9, 0.08],
|
||||||
|
[80, 1.2, 0.2], [30, 1.8, 0.04], [110, 1.1, 0.28], [50, 1.5, 0.16],
|
||||||
|
[70, 1.4, 0.08], [140, 0.85, 0.24], [60, 1.3, 0.12], [90, 1.2, 0.0],
|
||||||
|
[130, 0.95, 0.2], [45, 1.7, 0.28], [80, 1.4, 0.04], [55, 1.1, 0.16],
|
||||||
|
[100, 1.3, 0.24], [35, 1.6, 0.12], [120, 0.9, 0.0], [65, 1.5, 0.2],
|
||||||
|
[85, 1.2, 0.08], [150, 0.8, 0.28], [40, 1.9, 0.16], [95, 1.1, 0.04],
|
||||||
|
[75, 1.4, 0.24], [25, 2.0, 0.12], [105, 1.05, 0.2], [155, 0.82, 0.08],
|
||||||
|
[50, 1.55, 0.28], [115, 0.92, 0.16], [70, 1.35, 0.04], [135, 0.88, 0.24],
|
||||||
|
[90, 1.15, 0.12], [42, 1.75, 0.0], [125, 0.98, 0.2], [58, 1.45, 0.08],
|
||||||
|
].map(([peak, dur, delay], i) => (
|
||||||
|
<div
|
||||||
|
key={`left-${i}`}
|
||||||
|
className="welcome-audio-bar"
|
||||||
|
style={{
|
||||||
|
'--bar-peak': `${peak}px`,
|
||||||
|
'--bar-duration': `${dur}s`,
|
||||||
|
'--bar-delay': `${delay}s`,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-y-0 right-0 w-[30%] flex items-center justify-start gap-[3px] pl-16 pointer-events-none opacity-[0.09]">
|
||||||
|
{[
|
||||||
|
[100, 1.0, 0.0], [60, 1.3, 0.08], [130, 0.9, 0.16], [40, 1.6, 0.04],
|
||||||
|
[80, 1.2, 0.12], [150, 0.85, 0.24], [50, 1.5, 0.2], [110, 1.1, 0.28],
|
||||||
|
[70, 1.4, 0.08], [30, 1.8, 0.16], [140, 0.9, 0.0], [90, 1.2, 0.24],
|
||||||
|
[60, 1.3, 0.12], [120, 0.95, 0.04], [85, 1.4, 0.2], [45, 1.7, 0.28],
|
||||||
|
[160, 0.8, 0.08], [55, 1.5, 0.24], [100, 1.1, 0.0], [75, 1.3, 0.16],
|
||||||
|
[35, 1.9, 0.2], [115, 1.0, 0.12], [65, 1.6, 0.28], [140, 0.88, 0.04],
|
||||||
|
[95, 1.25, 0.24], [25, 1.85, 0.08], [125, 0.93, 0.16], [155, 0.78, 0.28],
|
||||||
|
[48, 1.65, 0.12], [82, 1.32, 0.2], [108, 1.08, 0.04], [72, 1.42, 0.24],
|
||||||
|
[135, 0.9, 0.16], [38, 1.78, 0.0], [62, 1.48, 0.08], [118, 1.02, 0.28],
|
||||||
|
].map(([peak, dur, delay], i) => (
|
||||||
|
<div
|
||||||
|
key={`right-${i}`}
|
||||||
|
className="welcome-audio-bar"
|
||||||
|
style={{
|
||||||
|
'--bar-peak': `${peak}px`,
|
||||||
|
'--bar-duration': `${dur}s`,
|
||||||
|
'--bar-delay': `${delay}s`,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-editor-accent/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-6 relative z-10">
|
||||||
|
{/* App icon */}
|
||||||
|
<div className="w-36 h-36 rounded-[20px] bg-editor-accent/10 border border-editor-accent/20 flex items-center justify-center shadow-lg shadow-editor-accent/10">
|
||||||
|
<svg width="96" height="96" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.7"/>
|
||||||
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-editor-text">
|
||||||
|
Talk<span className="text-editor-accent">Edit</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-editor-text-muted text-sm max-w-sm text-center leading-relaxed">
|
||||||
|
The offline video editor that doesn't slow down on long files.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Whisper model selector */}
|
{/* Action row — button + model selector side by side */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label>
|
|
||||||
<select
|
|
||||||
value={whisperModel}
|
|
||||||
onChange={(e) => setWhisperModel(e.target.value)}
|
|
||||||
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
|
|
||||||
>
|
|
||||||
<optgroup label="Multilingual (any language)">
|
|
||||||
<option value="tiny">tiny — ~75 MB · fastest, low accuracy</option>
|
|
||||||
<option value="base">base — ~140 MB · fast, decent accuracy</option>
|
|
||||||
<option value="small">small — ~460 MB · good balance</option>
|
|
||||||
<option value="medium">medium — ~1.5 GB · better accuracy</option>
|
|
||||||
<option value="large-v2">large-v2 — ~2.9 GB · high accuracy</option>
|
|
||||||
<option value="large-v3">large-v3 — ~2.9 GB · best overall ★</option>
|
|
||||||
<option value="large-v3-turbo">large-v3-turbo — ~1.6 GB · fast + accurate ★</option>
|
|
||||||
<option value="distil-large-v3">distil-large-v3 — ~1.5 GB · fast, near large-v3 quality</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="English-only (faster & more accurate for English)">
|
|
||||||
<option value="tiny.en">tiny.en — ~75 MB · fastest English</option>
|
|
||||||
<option value="base.en">base.en — ~140 MB · fast English</option>
|
|
||||||
<option value="small.en">small.en — ~460 MB · good English</option>
|
|
||||||
<option value="medium.en">medium.en — ~1.5 GB · great English</option>
|
|
||||||
<option value="distil-small.en">distil-small.en — ~190 MB · fast English ★</option>
|
|
||||||
<option value="distil-medium.en">distil-medium.en — ~750 MB · best fast English ★</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-editor-text-muted text-center max-w-sm">
|
|
||||||
For noisy/YouTube videos use <span className="text-white">large-v3</span> or <span className="text-white">large-v3-turbo</span>.
|
|
||||||
English-only models are ~10% faster and more accurate for English content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenFile}
|
onClick={handleOpenFile}
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
className="flex items-center gap-2 px-8 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-xl text-white font-medium transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
Open Video File
|
Process File
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleLoadProject}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<FileInput className="w-4 h-4" />
|
|
||||||
Load Project (.aive)
|
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-editor-surface/50 border border-editor-border/50">
|
||||||
|
<span className="text-xs text-editor-text-muted whitespace-nowrap">Transcription model:</span>
|
||||||
|
<select
|
||||||
|
value={whisperModel}
|
||||||
|
onChange={(e) => setWhisperModel(e.target.value)}
|
||||||
|
className="bg-transparent text-xs text-editor-text focus:outline-none [color-scheme:dark]"
|
||||||
|
title="Choose Whisper model for transcription accuracy"
|
||||||
|
>
|
||||||
|
<optgroup label="Multilingual">
|
||||||
|
<option value="tiny">tiny (75 MB · fastest)</option>
|
||||||
|
<option value="base">base (140 MB · fast)</option>
|
||||||
|
<option value="small">small (460 MB · balanced)</option>
|
||||||
|
<option value="medium">medium (1.5 GB · better)</option>
|
||||||
|
<option value="large-v3-turbo">large-v3-turbo (1.6 GB ★)</option>
|
||||||
|
<option value="large-v3">large-v3 (2.9 GB · best)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="English-only (faster)">
|
||||||
|
<option value="tiny.en">tiny.en (75 MB)</option>
|
||||||
|
<option value="base.en">base.en (140 MB)</option>
|
||||||
|
<option value="small.en">small.en (460 MB)</option>
|
||||||
|
<option value="medium.en">medium.en (1.5 GB)</option>
|
||||||
|
<option value="distil-small.en">distil-small.en (190 MB ★)</option>
|
||||||
|
<option value="distil-medium.en">distil-medium.en (750 MB ★)</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLoadProject}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FileInput className="w-4 h-4" />
|
||||||
|
Load Project (.aive)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{licenseStatus?.tag === 'Trial' && (
|
{licenseStatus?.tag === 'Trial' && (
|
||||||
<div className="h-9 flex items-center justify-center gap-2 px-4 bg-editor-accent/10 border-t border-editor-accent/20 shrink-0">
|
<div className="h-9 flex items-center justify-center gap-2 px-4 bg-editor-accent/10 border-t border-editor-accent/20 shrink-0 relative z-10">
|
||||||
<Clock className="w-3.5 h-3.5 text-editor-accent shrink-0" />
|
<Clock className="w-3.5 h-3.5 text-editor-accent shrink-0" />
|
||||||
<span className="text-xs text-editor-accent">
|
<span className="text-xs text-editor-accent">
|
||||||
Free trial: {licenseStatus.days_remaining} day{licenseStatus.days_remaining !== 1 ? 's' : ''} remaining
|
Free trial: {licenseStatus.days_remaining} day{licenseStatus.days_remaining !== 1 ? 's' : ''} remaining
|
||||||
@ -661,7 +725,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{licenseStatus?.tag === 'Expired' && (
|
{licenseStatus?.tag === 'Expired' && (
|
||||||
<div className="h-9 flex items-center justify-center gap-2 px-4 bg-red-500/15 border-t border-red-500/30 shrink-0">
|
<div className="h-9 flex items-center justify-center gap-2 px-4 bg-red-500/15 border-t border-red-500/30 shrink-0 relative z-10">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
<AlertTriangle className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
||||||
<span className="text-xs text-red-300">Trial expired</span>
|
<span className="text-xs text-red-300">Trial expired</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -7,17 +7,43 @@ export default function LicenseDialog() {
|
|||||||
const [key, setKey] = useState('');
|
const [key, setKey] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activating, setActivating] = useState(false);
|
const [activating, setActivating] = useState(false);
|
||||||
|
const [confirmedEmail, setConfirmedEmail] = useState<string | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
|
||||||
const handleActivate = async () => {
|
const handleActivate = async () => {
|
||||||
if (!key.trim()) return;
|
if (!key.trim()) return;
|
||||||
setActivating(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const ok = await activateLicense(key.trim());
|
// If we already verified and the user confirmed, complete activation
|
||||||
if (!ok) {
|
if (confirmedEmail) {
|
||||||
|
setActivating(true);
|
||||||
|
const ok = await activateLicense(key.trim());
|
||||||
|
if (!ok) {
|
||||||
|
setError('Invalid license key. Check it was entered correctly.');
|
||||||
|
}
|
||||||
|
setActivating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Verify the key (don't cache yet) to get the email
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
const payload = await window.electronAPI?.verifyLicense(key.trim());
|
||||||
|
if (payload?.customer_email) {
|
||||||
|
setConfirmedEmail(payload.customer_email);
|
||||||
|
} else {
|
||||||
|
setError('Invalid license key. Check it was entered correctly.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
setError('Invalid license key. Check it was entered correctly.');
|
setError('Invalid license key. Check it was entered correctly.');
|
||||||
}
|
}
|
||||||
setActivating(false);
|
setVerifying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeny = () => {
|
||||||
|
setConfirmedEmail(null);
|
||||||
|
setKey('');
|
||||||
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (ts: number) => {
|
const formatDate = (ts: number) => {
|
||||||
@ -58,41 +84,47 @@ export default function LicenseDialog() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{showDialog && (
|
||||||
<LicenseActivateDialog
|
<LicenseActivateDialog
|
||||||
onClose={() => setShowDialog(false)}
|
onClose={() => { setShowDialog(false); handleDeny(); }}
|
||||||
onActivate={handleActivate}
|
onActivate={handleActivate}
|
||||||
|
onDeny={handleDeny}
|
||||||
keyValue={key}
|
keyValue={key}
|
||||||
setKeyValue={setKey}
|
setKeyValue={setKey}
|
||||||
error={error}
|
error={error}
|
||||||
activating={activating}
|
activating={activating}
|
||||||
expired
|
verifying={verifying}
|
||||||
|
confirmedEmail={confirmedEmail}
|
||||||
|
trialEnding={status.days_remaining <= 3}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expired — show banner + activation dialog (both dismissible)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExpiredBanner onActivate={() => setShowDialog(true)} />
|
||||||
|
|
||||||
|
{showDialog && (
|
||||||
|
<LicenseActivateDialog
|
||||||
|
onClose={() => { setShowDialog(false); handleDeny(); }}
|
||||||
|
onActivate={handleActivate}
|
||||||
|
onDeny={handleDeny}
|
||||||
|
keyValue={key}
|
||||||
|
setKeyValue={setKey}
|
||||||
|
error={error}
|
||||||
|
activating={activating}
|
||||||
|
verifying={verifying}
|
||||||
|
confirmedEmail={confirmedEmail}
|
||||||
|
expired
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Persistent top banner shown when trial expired — still allows export and loading */
|
/** Persistent top banner shown when trial expired — still allows export and loading */
|
||||||
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
|
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
|
||||||
return (
|
return (
|
||||||
@ -112,22 +144,78 @@ function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
|
|||||||
function LicenseActivateDialog({
|
function LicenseActivateDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onDeny,
|
||||||
keyValue,
|
keyValue,
|
||||||
setKeyValue,
|
setKeyValue,
|
||||||
error,
|
error,
|
||||||
activating,
|
activating,
|
||||||
|
verifying,
|
||||||
|
confirmedEmail,
|
||||||
trialEnding,
|
trialEnding,
|
||||||
expired,
|
expired,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
keyValue: string;
|
keyValue: string;
|
||||||
setKeyValue: (v: string) => void;
|
setKeyValue: (v: string) => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
activating: boolean;
|
activating: boolean;
|
||||||
|
verifying: boolean;
|
||||||
|
confirmedEmail: string | null;
|
||||||
trialEnding?: boolean;
|
trialEnding?: boolean;
|
||||||
expired?: boolean;
|
expired?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const isProcessing = activating || verifying;
|
||||||
|
|
||||||
|
if (confirmedEmail) {
|
||||||
|
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 gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-editor-accent" />
|
||||||
|
<h3 className="text-sm font-semibold">Confirm License</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-editor-surface border border-editor-border space-y-1">
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
This license key is registered to:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-editor-text">{confirmedEmail}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
License keys are tied to your email. Sharing this key may result in deactivation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={onDeny}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={activating}
|
||||||
|
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{activating ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
|
||||||
<div
|
<div
|
||||||
@ -188,15 +276,15 @@ function LicenseActivateDialog({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
disabled={activating || !keyValue.trim()}
|
disabled={isProcessing || !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-40 rounded-lg text-sm font-medium transition-colors"
|
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-40 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{activating ? (
|
{isProcessing ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Check className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
Activate
|
{verifying ? 'Verifying...' : 'Verify Key'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-[10px] text-editor-text-muted text-center">
|
<p className="text-[10px] text-editor-text-muted text-center">
|
||||||
|
|||||||
@ -8,11 +8,26 @@
|
|||||||
100% { transform: scaleY(0.3); opacity: 0.5; }
|
100% { transform: scaleY(0.3); opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes audioBounce {
|
||||||
|
0% { height: 12px; }
|
||||||
|
50% { height: var(--bar-peak); }
|
||||||
|
100% { height: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
.wave-bar {
|
.wave-bar {
|
||||||
animation: waveBar 0.9s ease-in-out infinite;
|
animation: waveBar 0.9s ease-in-out infinite;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.welcome-audio-bar {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #6366f1;
|
||||||
|
transform-origin: bottom;
|
||||||
|
animation: audioBounce var(--bar-duration) ease-in-out infinite;
|
||||||
|
animation-delay: var(--bar-delay);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@ -101,6 +101,10 @@ window.electronAPI = {
|
|||||||
return invoke('activate_license', { licenseKey: key });
|
return invoke('activate_license', { licenseKey: key });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
verifyLicense: (key: string): Promise<any> => {
|
||||||
|
return invoke('verify_license', { licenseKey: key });
|
||||||
|
},
|
||||||
|
|
||||||
getAppStatus: (): Promise<any> => {
|
getAppStatus: (): Promise<any> => {
|
||||||
return invoke('get_app_status');
|
return invoke('get_app_status');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -331,10 +331,14 @@ describe('editorStore', () => {
|
|||||||
useEditorStore.getState().addSpeedRange(4, 5, 1.5);
|
useEditorStore.getState().addSpeedRange(4, 5, 1.5);
|
||||||
|
|
||||||
const project = useEditorStore.getState().saveProject();
|
const project = useEditorStore.getState().saveProject();
|
||||||
expect(project.cutRanges.length).toBe(1);
|
expect(project.cutRanges).toBeDefined();
|
||||||
expect(project.muteRanges.length).toBe(1);
|
expect(project.cutRanges!.length).toBe(1);
|
||||||
expect(project.gainRanges.length).toBe(1);
|
expect(project.muteRanges).toBeDefined();
|
||||||
expect(project.speedRanges.length).toBe(1);
|
expect(project.muteRanges!.length).toBe(1);
|
||||||
|
expect(project.gainRanges).toBeDefined();
|
||||||
|
expect(project.gainRanges!.length).toBe(1);
|
||||||
|
expect(project.speedRanges).toBeDefined();
|
||||||
|
expect(project.speedRanges!.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setProjectFilePath sets and reads back', () => {
|
test('setProjectFilePath sets and reads back', () => {
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
|
|||||||
status: null,
|
status: null,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
showDialog: false,
|
showDialog: false,
|
||||||
canEdit: true,
|
canEdit: false,
|
||||||
|
|
||||||
setStatus: (status) => {
|
setStatus: (status) => {
|
||||||
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
|
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@ -29,6 +29,7 @@ interface DesktopAPI {
|
|||||||
writeFile: (path: string, content: string) => Promise<boolean>;
|
writeFile: (path: string, content: string) => Promise<boolean>;
|
||||||
activateLicense: (key: string) => Promise<any>;
|
activateLicense: (key: string) => Promise<any>;
|
||||||
getAppStatus: () => Promise<any>;
|
getAppStatus: () => Promise<any>;
|
||||||
|
verifyLicense: (key: string) => Promise<any>;
|
||||||
deactivateLicense: () => Promise<void>;
|
deactivateLicense: () => Promise<void>;
|
||||||
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||||
listModels: () => Promise<ModelInfo[]>;
|
listModels: () => Promise<ModelInfo[]>;
|
||||||
|
|||||||
@ -245,6 +245,13 @@ fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus,
|
|||||||
Ok(licensing::get_app_status(&data_dir))
|
Ok(licensing::get_app_status(&data_dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify a license key signature without caching. Returns the payload.
|
||||||
|
#[tauri::command]
|
||||||
|
fn verify_license(license_key: String) -> Result<licensing::LicensePayload, String> {
|
||||||
|
licensing::verify_license_key(&license_key)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify and activate a license key.
|
/// Verify and activate a license key.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result<licensing::LicensePayload, String> {
|
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result<licensing::LicensePayload, String> {
|
||||||
@ -417,6 +424,7 @@ pub fn run() {
|
|||||||
get_app_status,
|
get_app_status,
|
||||||
activate_license,
|
activate_license,
|
||||||
deactivate_license,
|
deactivate_license,
|
||||||
|
verify_license,
|
||||||
start_trial,
|
start_trial,
|
||||||
has_license_feature,
|
has_license_feature,
|
||||||
list_models,
|
list_models,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ 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 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;
|
pub const TRIAL_DURATION_SECS: u64 = 7 * 86400;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct LicensePayload {
|
pub struct LicensePayload {
|
||||||
@ -25,6 +25,13 @@ pub struct TrialState {
|
|||||||
pub started_at: u64,
|
pub started_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On-disk format with integrity seed to deter tampering.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct TrialFile {
|
||||||
|
started_at: u64,
|
||||||
|
seed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "tag")]
|
#[serde(tag = "tag")]
|
||||||
pub enum AppStatus {
|
pub enum AppStatus {
|
||||||
@ -113,6 +120,52 @@ pub fn trial_file_path(app_data_dir: &PathBuf) -> PathBuf {
|
|||||||
app_data_dir.join("trial.json")
|
app_data_dir.join("trial.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn trial_sentinel_path(app_data_dir: &PathBuf) -> PathBuf {
|
||||||
|
app_data_dir.join(".trial_lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple integrity check constant — not crypto-grade, but deters casual editing.
|
||||||
|
const TRIAL_SEED: u64 = 0x9F3A_2E7D_C1B8_5604;
|
||||||
|
|
||||||
|
pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState {
|
||||||
|
let path = trial_file_path(app_data_dir);
|
||||||
|
let sentinel = trial_sentinel_path(app_data_dir);
|
||||||
|
let now = now_secs();
|
||||||
|
|
||||||
|
// If sentinel exists but trial was deleted, refuse to create a new one.
|
||||||
|
let sentinel_exists = sentinel.exists();
|
||||||
|
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(wrapped) = serde_json::from_str::<TrialFile>(&content) {
|
||||||
|
// Verify integrity
|
||||||
|
if (wrapped.started_at ^ TRIAL_SEED) == wrapped.seed {
|
||||||
|
return TrialState { started_at: wrapped.started_at };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sentinel_exists {
|
||||||
|
// Trial was tampered with — return an expired trial
|
||||||
|
return TrialState { started_at: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(&sentinel, "1");
|
||||||
|
let _ = std::fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::to_string(&TrialFile {
|
||||||
|
started_at: trial.started_at,
|
||||||
|
seed: trial.started_at ^ TRIAL_SEED,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
trial
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result<LicensePayload, LicenseError> {
|
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result<LicensePayload, LicenseError> {
|
||||||
let path = license_file_path(app_data_dir);
|
let path = license_file_path(app_data_dir);
|
||||||
let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?;
|
let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?;
|
||||||
@ -136,26 +189,6 @@ pub fn remove_license(app_data_dir: &PathBuf) {
|
|||||||
|
|
||||||
// --- Trial logic ---
|
// --- 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) {
|
pub fn get_trial_info(trial: &TrialState) -> (u64, u64, bool) {
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let elapsed = now.saturating_sub(trial.started_at);
|
let elapsed = now.saturating_sub(trial.started_at);
|
||||||
@ -176,8 +209,8 @@ pub fn get_trial_days_remaining(trial: &TrialState) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_trial(app_data_dir: &PathBuf) {
|
pub fn clear_trial(app_data_dir: &PathBuf) {
|
||||||
let path = trial_file_path(app_data_dir);
|
let _ = std::fs::remove_file(trial_file_path(app_data_dir));
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(trial_sentinel_path(app_data_dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the overall app status: Licensed > Trial > Expired.
|
/// Get the overall app status: Licensed > Trial > Expired.
|
||||||
@ -248,7 +281,7 @@ mod tests {
|
|||||||
let trial = TrialState { started_at: now_secs() };
|
let trial = TrialState { started_at: now_secs() };
|
||||||
let days = get_trial_days_remaining(&trial);
|
let days = get_trial_days_remaining(&trial);
|
||||||
assert!(days.is_some());
|
assert!(days.is_some());
|
||||||
assert_eq!(days.unwrap(), 30);
|
assert_eq!(days.unwrap(), 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user