Files
DndGamePlayer/app/main/index.ts
T
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00

546 lines
19 KiB
TypeScript

import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import { EffectsStore } from './effects/effectsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
createBootWindow,
destroyBootWindow,
setBootWindowStatus,
waitForBootWindowReady,
} from './windows/bootWindow';
import {
applyDockIconIfNeeded,
closeMultiWindow,
createEditorWindowDeferred,
createWindows,
focusEditorWindow,
isMultiWindowOpen,
markAppQuitting,
openMultiWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
function emitZipProgress(evt: {
kind: 'import' | 'export';
stage: string;
percent: number;
detail?: string;
}): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(
evt.kind === 'import' ? ipcChannels.project.importZipProgress : ipcChannels.project.exportZipProgress,
evt,
);
}
}
/**
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
*/
if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GPU === '1') {
app.disableHardwareAcceleration();
}
if (process.platform === 'win32') {
app.setAppUserModelId('com.dndplayer.app');
}
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).
protocol.registerSchemesAsPrivileged([
{
scheme: 'dnd',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
]);
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
focusEditorWindow();
});
}
const projectStore = new ZipProjectStore();
/** Без меню Electron не вешает горячие клавиши DevTools (Ctrl+Shift+I / F12). */
function wantsDevToolsMenu(): boolean {
return (
process.env.NODE_ENV === 'development' ||
Boolean(process.env.VITE_DEV_SERVER_URL) ||
process.env.DND_OPEN_DEVTOOLS === '1'
);
}
function installAppMenuForSession(): void {
if (!wantsDevToolsMenu()) {
Menu.setApplicationMenu(null);
return;
}
const template: Electron.MenuItemConstructorOptions[] = [];
if (process.platform === 'darwin') {
template.push({
label: app.name,
submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }],
});
}
template.push({
label: 'Вид',
submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'toggleDevTools' }],
});
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
const effectsStore = new EffectsStore();
const videoStore = new VideoPlaybackStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.effects.stateChanged, { state });
}
}
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.video.stateChanged, { state });
}
}
// Периодически чистим истёкшие эффекты (в основном молнии).
setInterval(() => {
if (effectsStore.pruneExpired()) {
emitEffectsState();
}
}, 500);
// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции).
setInterval(() => {
if (videoStore.getState().playing) {
emitVideoState();
}
}, 500);
function emitSessionState(): void {
const project = projectStore.getOpenProject();
const state: SessionState = {
project,
currentSceneId: project?.currentSceneId ?? null,
};
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.session.stateChanged, { state });
}
}
/**
* Упакованное приложение: экран загрузки → проверки → редактор.
* В dev по умолчанию без экрана; тест: `DND_SHOW_BOOT=1`. Отключить везде: `DND_SKIP_BOOT=1`.
*/
async function runStartupAfterHandlers(licenseService: LicenseService): Promise<void> {
const useBootSequence =
process.env.DND_SKIP_BOOT !== '1' && (app.isPackaged || process.env.DND_SHOW_BOOT === '1');
if (!useBootSequence) {
createWindows();
emitSessionState();
emitEffectsState();
emitVideoState();
return;
}
const splash = createBootWindow();
try {
await waitForBootWindowReady(splash);
} catch (err) {
console.error('[boot] splash load failed', err);
destroyBootWindow(splash);
createWindows();
emitSessionState();
emitEffectsState();
emitVideoState();
return;
}
splash.show();
setBootWindowStatus(splash, 'Инициализация…');
try {
setBootWindowStatus(splash, 'Подготовка данных…');
await projectStore.ensureRoots();
} catch (e) {
console.error('[boot] ensureRoots', e);
}
setBootWindowStatus(splash, 'Проверка лицензии…');
// Сеть в `getStatus()` не блокируем старт: синхронный снимок, отзыв — в фоне.
licenseService.getStatusSync();
queueMicrotask(() => {
licenseService.getStatus();
});
setBootWindowStatus(splash, 'Загрузка редактора…');
const editor = createEditorWindowDeferred();
const bootEditorMs = 2000;
await Promise.race([
waitForEditorWindowReady(editor),
new Promise<void>((resolve) => setTimeout(resolve, bootEditorMs)),
]);
setBootWindowStatus(splash, 'Готово');
destroyBootWindow(splash);
if (!editor.isDestroyed()) {
editor.show();
editor.focus();
}
emitSessionState();
emitEffectsState();
emitVideoState();
}
async function main() {
await app.whenReady();
/**
* `ZipProjectStore` пишет `project.json` в кэш синхронно с мутациями, а упаковку `.dnd.zip`
* откладывает (`queueSave`, ~250 мс). Без финального `saveNow()` при выходе на диске
* остаётся старый zip — при следующем открытии кэш пересоздаётся из него и теряются
* недавние поля (в т.ч. `campaignAudios`).
*/
let appQuittingAfterPendingSave = false;
app.on('before-quit', (e) => {
if (appQuittingAfterPendingSave) return;
e.preventDefault();
appQuittingAfterPendingSave = true;
void projectStore
.saveNow()
.catch((err: unknown) => {
console.error('[before-quit] saveNow failed', err);
})
.finally(() => {
app.quit();
});
});
const licenseService = new LicenseService(app.getPath('userData'));
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
return { ok: true };
});
registerHandler(ipcChannels.app.getVersion, () => ({
version: getAppSemanticVersion(),
buildNumber: getOptionalBuildNumber(),
}));
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, () => {
openMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen };
});
registerHandler(ipcChannels.windows.getMultiWindowState, () => {
return { open: isMultiWindowOpen() };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
return {
projects: projects.map((p) => ({
id: p.id,
name: p.name,
updatedAt: p.updatedAt,
fileName: p.fileName,
})),
};
});
registerHandler(ipcChannels.project.create, async ({ name }) => {
const project = await projectStore.createProject(name);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.get, () => {
return { project: projectStore.getOpenProject() };
});
registerHandler(ipcChannels.project.saveNow, async () => {
await projectStore.saveNow();
return { ok: true };
});
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
emitEffectsState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => {
const open = projectStore.getOpenProject();
if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
await projectStore.updateProject((p) => ({
...p,
currentGraphNodeId: graphNodeId,
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
emitEffectsState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
currentGraphNodeId: p?.currentGraphNodeId ?? null,
currentSceneId: p?.currentSceneId ?? null,
};
});
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => {
const next = await projectStore.updateConnections(sceneId, connections);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || filePaths.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project, imported: [] };
}
const result = await projectStore.importMediaFiles(sceneId, filePaths);
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importCampaignAudio, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Аудио',
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || filePaths.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { canceled: true as const, project, imported: [] };
}
const result = await projectStore.importCampaignAudioFiles(filePaths);
emitSessionState();
return { canceled: false as const, ...result };
});
registerHandler(ipcChannels.project.updateCampaignAudios, async ({ audios }) => {
const project = await projectStore.setCampaignAudios(audios);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],
});
if (canceled || !filePaths[0]) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project };
}
const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
const project = await projectStore.clearScenePreview(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => {
const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => {
const project = await projectStore.addSceneGraphNode(sceneId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => {
const project = await projectStore.removeSceneGraphNode(nodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => {
const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => {
const project = await projectStore.removeSceneGraphEdge(edgeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
const project = await projectStore.renameOpenProject(name, fileBaseName);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importZip, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
// Let store import; progress for unzip is emitted from unzipToDir wrapper in store.
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { canceled: false as const, project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip')
? entry.fileName
: `${entry.fileName}.dnd.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
let dest = filePath;
const lower = dest.toLowerCase();
if (!lower.endsWith('.dnd.zip')) {
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
}
emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' });
await projectStore.exportProjectZipToPath(projectId, dest, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
});
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
emitSessionState();
return { ok: true };
});
registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({
url: projectStore.getAssetFileUrl(assetId),
}));
registerHandler(ipcChannels.effects.getState, () => {
return { state: effectsStore.getState() };
});
registerHandler(ipcChannels.effects.dispatch, ({ event }) => {
effectsStore.dispatch(event);
emitEffectsState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
registerHandler(ipcChannels.video.dispatch, ({ event }) => {
videoStore.dispatch(event);
emitVideoState();
return { ok: true };
});
installIpcRouter();
applyDockIconIfNeeded();
await runStartupAfterHandlers(licenseService);
app.on('activate', () => {
focusEditorWindow();
});
}
if (gotTheLock) {
void main();
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});