Files
DndGamePlayer/app/main/index.ts
T
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:58:16 +08:00

1113 lines
40 KiB
TypeScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
PROJECT_ZIP_SAVE_DIALOG_FILTER,
isProjectZipFileName,
normalizeSaveProjectZipPath,
projectZipFileNameFromBase,
stripProjectZipExtension,
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
createBootWindow,
destroyBootWindow,
setBootWindowStatus,
waitForBootWindowReady,
} from './windows/bootWindow';
import {
applyDockIconIfNeeded,
closeMultiWindow,
closeSceneDescriptionWindow,
createEditorWindowDeferred,
createWindows,
focusEditorWindow,
getSceneDescriptionContent,
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openMultiWindow,
openNpcsEditorWindow,
openNpcsWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
closeNpcsEditorWindow,
closeNpcsWindow,
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,
);
}
}
function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.scenePreviewImportProgress, 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.ttrpgplayer.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 sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.effects.stateChanged, { state });
}
}
function emitMaterialsOverlayState(): void {
const state = materialsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.materialsOverlay.stateChanged, { state });
}
}
function syncMaterialsOverlayWithProject(project: Project | null): void {
if (!project) {
materialsOverlayStore.clear();
return;
}
const ids = new Set((project.materials ?? []).map((m) => m.id));
materialsOverlayStore.ensureMaterialStillExists(ids);
}
function emitNpcsOverlayState(): void {
const state = npcsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.npcsOverlay.stateChanged, { state });
}
}
function syncNpcsOverlayWithProject(project: Project | null): void {
if (!project) {
npcsOverlayStore.clear();
return;
}
const ids = new Set((project.npcs ?? []).map((n) => n.id));
npcsOverlayStore.ensureNpcStillExists(ids);
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneDarkness.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
const enabled = Boolean(scene?.darkenScene) && scene?.previewAssetType === 'image';
sceneDarknessStore.switchScene(cacheKey, enabled);
}
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();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitVideoState();
return;
}
const splash = createBootWindow();
try {
await waitForBootWindowReady(splash);
} catch (err) {
console.error('[boot] splash load failed', err);
destroyBootWindow(splash);
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
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();
emitMaterialsOverlayState();
emitNpcsOverlayState();
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(),
packaged: app.isPackaged,
}));
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, () => {
sceneDarknessStore.resetSession();
openMultiWindow();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitSceneDarknessState();
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.windows.openSceneDescription, ({ html }) => {
openSceneDescriptionWindow(html);
return { ok: true };
});
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
closeSceneDescriptionWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
return { html: getSceneDescriptionContent() };
});
registerHandler(ipcChannels.windows.openMaterials, () => {
openMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMaterials, () => {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeNpcsEditor, () => {
closeNpcsEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcs, () => {
openNpcsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeNpcs, () => {
closeNpcsWindow();
return { ok: true };
});
registerHandler(ipcChannels.materialsOverlay.getState, () => {
return { state: materialsOverlayStore.getState() };
});
registerHandler(ipcChannels.materialsOverlay.dispatch, ({ event }) => {
materialsOverlayStore.dispatch(event);
emitMaterialsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.npcsOverlay.getState, () => {
return { state: npcsOverlayStore.getState() };
});
registerHandler(ipcChannels.npcsOverlay.dispatch, ({ event }) => {
npcsOverlayStore.dispatch(event);
emitNpcsOverlayState();
return { ok: true };
});
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.close, async () => {
await projectStore.closeOpenProject();
effectsStore.clear();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
});
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();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
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();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
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);
const project = projectStore.getOpenProject();
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
syncSceneDarknessForProject(project);
emitSceneDarknessState();
}
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, filePaths: pathsFromDrop }) => {
let filePaths = pathsFromDrop ?? [];
if (!pathsFromDrop) {
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: openDialogFilterLabel('videoAndAudio', app.getLocale()),
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || picked.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project, imported: [] };
}
filePaths = picked;
}
const result = await projectStore.importMediaFiles(sceneId, filePaths);
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importCampaignAudio, async ({ filePaths: pathsFromDrop }) => {
let filePaths = pathsFromDrop ?? [];
if (!pathsFromDrop) {
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || picked.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { canceled: true as const, project, imported: [] };
}
filePaths = picked;
}
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.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialsOrder, async ({ materialIds }) => {
const project = await projectStore.setMaterialsOrder(materialIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialRotation, async ({ materialId, rotationDeg }) => {
const project = await projectStore.setMaterialRotation(materialId, rotationDeg);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('NPC avatar is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertNpc({
...(npcId ? { npcId } : {}),
name,
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
});
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
},
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId, binding }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
const project = await projectStore.updateNpcPosition(npcId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteNpc, async ({ npcId }) => {
const project = await projectStore.deleteNpc(npcId);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setNpcsOrder, async ({ npcIds }) => {
const project = await projectStore.setNpcsOrder(npcIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickNpcAvatar, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(
ipcChannels.project.upsertNpcRelation,
async ({ relationId, sourceNpcId, targetNpcId, label }) => {
const project = await projectStore.upsertNpcRelation({
...(relationId ? { relationId } : {}),
sourceNpcId,
targetNpcId,
label,
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteNpcRelation, async ({ relationId }) => {
const project = await projectStore.deleteNpcRelation(relationId);
emitSessionState();
return { project };
});
registerHandler(
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => {
const project = await projectStore.setNpcGroupsOrder(groupIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('imagesAndVideo', app.getLocale()),
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, assetId: null, background: false };
}
filePath = filePaths[0];
}
const result = await projectStore.importScenePreviewMedia(sceneId, filePath);
emitSessionState();
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'queued',
project: result.project,
});
void (async () => {
try {
emitScenePreviewImportProgress({ sceneId, assetId: result.assetId, phase: 'optimizing' });
const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId);
if (finalized.changed) {
emitSessionState();
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'done',
project: finalized.project,
});
}
} catch (e) {
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'error',
message: e instanceof Error ? e.message : String(e),
});
}
})();
return result;
});
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.setSceneGraphNodeSideStoryStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeSideStoryStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSideStoryLineTitle, async ({ graphNodeId, title }) => {
const project = await projectStore.updateSideStoryLineTitle(graphNodeId, title);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setSceneListOrder, async ({ sceneListOrder }) => {
const project = await projectStore.setSceneListOrder(sceneListOrder);
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: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
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.getProjectStorylines, async ({ projectId, labels }) => {
const storylines = await projectStore.getProjectStorylines(projectId, labels);
return { storylines };
});
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const filePath = filePaths[0];
const peek = await projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
return { canceled: false as const, ...peek };
});
registerHandler(ipcChannels.project.pickImportZipFile, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
return { canceled: false as const, filePath: filePaths[0] };
});
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(
ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
npcResolutions,
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
},
);
registerHandler(
ipcChannels.project.mergeImportFromProject,
async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
sourceProjectId,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
npcResolutions,
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
},
);
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (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 { project };
});
registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => {
if (mode === 'folder') {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Папка мира или модуля Foundry VTT',
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Архив мира или модуля Foundry VTT',
filters: [
{ name: 'Foundry package', extensions: ['zip', 'fvtt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
});
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
try {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
} catch (e) {
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' });
throw e;
}
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (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 };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
});
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.sceneDarkness.getState, () => {
return { state: sceneDarknessStore.getState() };
});
registerHandler(ipcChannels.sceneDarkness.dispatch, ({ event }) => {
sceneDarknessStore.dispatch(event);
emitSceneDarknessState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
registerHandler(ipcChannels.video.dispatch, ({ event }) => {
videoStore.dispatch(event);
emitVideoState();
return { ok: true };
});
installAutoUpdater(licenseService, registerHandler);
installIpcRouter();
applyDockIconIfNeeded();
await runStartupAfterHandlers(licenseService);
app.on('activate', () => {
focusEditorWindow();
});
}
if (gotTheLock) {
void main();
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});