Лицензия, редактор, пульт и сборка

- 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
This commit is contained in:
Ivan Fontosh
2026-04-19 20:11:24 +08:00
parent 5e7dc5ea19
commit 2fa20da94d
40 changed files with 2629 additions and 211 deletions
+31
View File
@@ -253,6 +253,37 @@
margin-top: 4px;
}
.licenseBlockTitle {
font-weight: 900;
font-size: var(--text-lg);
margin-bottom: 8px;
}
.licenseTextarea {
width: 100%;
min-height: 120px;
box-sizing: border-box;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
color: var(--text0);
font: inherit;
resize: vertical;
}
.eulaScroll {
max-height: min(52vh, 420px);
overflow: auto;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
white-space: pre-wrap;
font-size: var(--text-sm);
line-height: 1.45;
}
.fileSuffix {
color: var(--text2);
font-size: var(--text-xs);
+179 -9
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, 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 type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
@@ -12,6 +14,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { useProjectState } from './state/projectState';
type SceneCard = {
@@ -29,13 +32,22 @@ export function EditorApp() {
const [query, setQuery] = useState('');
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [state, actions] = useProjectState();
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false);
const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
const licenseActive = licenseSnap?.active === true;
const [state, actions] = useProjectState(licenseActive);
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
const scenes = useMemo<SceneCard[]>(() => {
const p = state.project;
if (!p) return [];
@@ -134,6 +146,45 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown);
}, [projectMenuOpen]);
useEffect(() => {
if (!settingsMenuOpen) return;
const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setSettingsMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-settingsmenu-root="1"]')) return;
setSettingsMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [settingsMenuOpen]);
const reloadLicense = useCallback(() => {
void (async () => {
try {
const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
setLicenseSnap(s);
} catch {
setLicenseSnap(null);
}
})();
}, []);
useEffect(() => {
reloadLicense();
const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
reloadLicense();
});
return unsub;
}, [reloadLicense]);
useEffect(() => {
void (async () => {
try {
@@ -148,28 +199,63 @@ export function EditorApp() {
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const bodyOverlay =
licenseSnap === null ? (
<div>
<div className={styles.licenseBlockTitle}>Проверка лицензии</div>
<div className={styles.muted}>Подождите.</div>
</div>
) : !licenseSnap.active ? (
<div>
<div className={styles.licenseBlockTitle}>Требуется лицензия</div>
<div className={styles.muted}>
Укажите ключ в меню «Настройки» «Указать ключ». До активации доступно только меню «Настройки».
</div>
</div>
) : undefined;
return (
<>
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
<div className={styles.topBarRow}>
<button
type="button"
className={styles.brandButton}
onClick={() => void actions.closeProject()}
onClick={() => {
void actions.closeProject();
}}
title="К списку проектов"
>
<AppLogo className={styles.brandLogo} size={26} />
<div className={styles.brandTitle}>DNDGamePlayer</div>
</button>
<div className={styles.fileToolbar}>
<button
ref={settingsMenuBtnRef}
type="button"
data-settingsmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setSettingsMenuOpen((v) => !v);
}}
>
Настройки
</button>
<button
ref={projectMenuBtnRef}
type="button"
data-projectmenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
setFileMenuOpen(false);
setSettingsMenuOpen(false);
setProjectMenuOpen((v) => !v);
}}
>
@@ -181,8 +267,12 @@ export function EditorApp() {
type="button"
data-filemenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setFileMenuOpen((v) => !v);
}}
>
@@ -200,10 +290,16 @@ export function EditorApp() {
{state.project ? (
<Button
variant="primary"
disabled={!graphStartGraphNodeId}
title={graphStartSceneId ? undefined : 'Назначьте начальную сцену на графе (ПКМ по узлу)'}
disabled={!licenseActive || !graphStartGraphNodeId}
title={
!licenseActive
? 'Доступно после активации лицензии'
: graphStartSceneId
? undefined
: 'Назначьте начальную сцену на графе (ПКМ по узлу)'
}
onClick={() => {
if (!graphStartGraphNodeId) return;
if (!licenseActive || !graphStartGraphNodeId) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
graphNodeId: graphStartGraphNodeId,
@@ -319,6 +415,70 @@ export function EditorApp() {
</div>
}
/>
{settingsMenuOpen && settingsMenuPos
? createPortal(
<div
role="menu"
data-settingsmenu-root="1"
className={styles.fileMenu}
style={{ left: settingsMenuPos.left, top: settingsMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
setLicenseKeyModalOpen(true);
} else {
setOpenKeyAfterEula(true);
setEulaModalOpen(true);
}
}}
>
Указать ключ
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setAboutLicenseOpen(true);
}}
>
О лицензии
</button>
</div>,
document.body,
)
: null}
<LicenseTokenModal
open={licenseKeyModalOpen}
onClose={() => setLicenseKeyModalOpen(false)}
onSaved={() => {
reloadLicense();
}}
/>
<EulaModal
open={eulaModalOpen}
onClose={() => {
setEulaModalOpen(false);
setOpenKeyAfterEula(false);
}}
onAccepted={() => {
if (openKeyAfterEula) {
setLicenseKeyModalOpen(true);
}
setOpenKeyAfterEula(false);
}}
/>
<LicenseAboutModal
open={aboutLicenseOpen}
onClose={() => setAboutLicenseOpen(false)}
snapshot={licenseSnap}
/>
{projectMenuOpen && projectMenuPos
? createPortal(
<div
@@ -327,6 +487,17 @@ export function EditorApp() {
className={styles.fileMenu}
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setProjectMenuOpen(false);
void actions.closeProject();
}}
>
Начальный экран
</button>
<button
type="button"
role="menuitem"
@@ -372,7 +543,7 @@ export function EditorApp() {
setRenameOpen(true);
}}
>
Переименовать проект
Переименовать проект
</button>
</div>,
document.body,
@@ -894,7 +1065,6 @@ function SceneInspector({
/>
<span className={styles.spanXs}>Цикл</span>
</label>
<span>аудио</span>
<button
type="button"
title="Убрать из сцены"
@@ -921,7 +1091,7 @@ function SceneInspector({
))}
</div>
)}
<Button onClick={onUploadMedia}>Загрузить аудио</Button>
<Button onClick={onUploadMedia}>Загрузить</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
@@ -0,0 +1,257 @@
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,
);
}
+18 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
@@ -54,9 +54,13 @@ function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}
export function useProjectState(): readonly [State, Actions] {
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
const api = getDndApi();
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
const projectRef = useRef<Project | null>(null);
useEffect(() => {
projectRef.current = state.project;
}, [state.project]);
const actions = useMemo<Actions>(() => {
const refreshProjects = async () => {
@@ -77,11 +81,11 @@ export function useProjectState(): readonly [State, Actions] {
const closeProject = async () => {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
if (licenseActive) await refreshProjects();
};
const createScene = async () => {
const p = state.project;
const p = projectRef.current;
if (!p) return;
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
@@ -307,16 +311,22 @@ export function useProjectState(): readonly [State, Actions] {
exportProject,
deleteProject,
};
}, [api, state.project]);
}, [api, licenseActive]);
useEffect(() => {
if (!licenseActive) {
queueMicrotask(() => {
setState({ projects: [], project: null, selectedSceneId: null });
});
return;
}
void (async () => {
await actions.refreshProjects();
const listRes = await api.invoke(ipcChannels.project.list, {});
setState((s) => ({ ...s, projects: listRes.projects }));
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [licenseActive, api]);
return [state, actions] as const;
}