2fa20da94d
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест - Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay - Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены - Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json Made-with: Cursor
258 lines
8.5 KiB
TypeScript
258 lines
8.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||
import { EULA_CURRENT_VERSION } from '../../../shared/license/eulaVersion';
|
||
import type { LicenseSnapshot } from '../../../shared/license/licenseSnapshot';
|
||
import { EULA_RU_MARKDOWN } from '../../legal/eulaRu';
|
||
import { getDndApi } from '../../shared/dndApi';
|
||
import { Button } from '../../shared/ui/controls';
|
||
import styles from '../EditorApp.module.css';
|
||
|
||
type LicenseTokenModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
};
|
||
|
||
export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) {
|
||
const [token, setToken] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setToken('');
|
||
setSaving(false);
|
||
setError(null);
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose, open]);
|
||
|
||
if (!open) return null;
|
||
|
||
return createPortal(
|
||
<>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>Указать ключ</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>ЛИЦЕНЗИОННЫЙ ТОКЕН</div>
|
||
<textarea
|
||
className={styles.licenseTextarea}
|
||
value={token}
|
||
onChange={(e) => setToken(e.target.value)}
|
||
placeholder="Вставьте токен, выданный сервером лицензий…"
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving}>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
disabled={saving || !token.trim()}
|
||
onClick={() => {
|
||
void (async () => {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await getDndApi().invoke(ipcChannels.license.setToken, { token: token.trim() });
|
||
onSaved();
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
type EulaModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onAccepted: () => void;
|
||
};
|
||
|
||
export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose, open]);
|
||
|
||
if (!open) return null;
|
||
|
||
return createPortal(
|
||
<>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>Лицензионное соглашение</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={styles.eulaScroll}>{EULA_RU_MARKDOWN}</div>
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving}>
|
||
Не принимаю
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
disabled={saving}
|
||
onClick={() => {
|
||
void (async () => {
|
||
setSaving(true);
|
||
try {
|
||
await getDndApi().invoke(ipcChannels.license.acceptEula, {
|
||
version: EULA_CURRENT_VERSION,
|
||
});
|
||
onAccepted();
|
||
onClose();
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Принимаю условия
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
type LicenseAboutModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
snapshot: LicenseSnapshot | null;
|
||
};
|
||
|
||
function reasonLabel(reason: LicenseSnapshot['reason']): string {
|
||
switch (reason) {
|
||
case 'ok':
|
||
return 'Активна';
|
||
case 'none':
|
||
return 'Ключ не указан';
|
||
case 'expired':
|
||
return 'Срок действия истёк';
|
||
case 'bad_signature':
|
||
return 'Недействительная подпись';
|
||
case 'bad_payload':
|
||
return 'Неверный формат токена';
|
||
case 'malformed':
|
||
return 'Повреждённый токен';
|
||
case 'not_yet_valid':
|
||
return 'Ещё не действует';
|
||
case 'wrong_device':
|
||
return 'Другой привязанный компьютер';
|
||
case 'revoked_remote':
|
||
return 'Отозвана на сервере';
|
||
default:
|
||
return reason;
|
||
}
|
||
}
|
||
|
||
export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModalProps) {
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose, open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const expText =
|
||
snapshot?.summary?.exp != null
|
||
? new Date(snapshot.summary.exp * 1000).toLocaleString('ru-RU', {
|
||
dateStyle: 'long',
|
||
timeStyle: 'short',
|
||
})
|
||
: '—';
|
||
|
||
return createPortal(
|
||
<>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>О лицензии</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
{snapshot?.devSkip ? (
|
||
<div className={styles.fieldError}>
|
||
Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).
|
||
</div>
|
||
) : null}
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>СТАТУС</div>
|
||
<div>{snapshot ? reasonLabel(snapshot.reason) : '—'}</div>
|
||
</div>
|
||
{snapshot?.summary ? (
|
||
<>
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>ПРОДУКТ</div>
|
||
<div>{snapshot.summary.pid}</div>
|
||
</div>
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>ID ЛИЦЕНЗИИ</div>
|
||
<div style={{ wordBreak: 'break-all' }}>{snapshot.summary.sub}</div>
|
||
</div>
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>ОКОНЧАНИЕ</div>
|
||
<div>{expText}</div>
|
||
</div>
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>УСТРОЙСТВО</div>
|
||
<div style={{ wordBreak: 'break-all' }}>{snapshot.deviceId}</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className={styles.muted}>Нет данных лицензии.</div>
|
||
)}
|
||
<div className={styles.modalFooter}>
|
||
<Button variant="primary" onClick={onClose}>
|
||
Закрыть
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|