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, effectsDefaultTool } from './effects/effectsStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; 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 { SceneViewStore } from './sceneView/sceneViewStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore'; import { TokensStore } from './tokens/tokensStore'; 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, openSceneEditorWindow, openNpcsWindow, openSceneDescriptionWindow, closeMaterialsWindow, closeNpcsEditorWindow, closeSceneEditorWindow, closeNpcsWindow, sendToAppWindows, togglePresentationFullscreen, waitForEditorWindowReady, warmNpcsEditorWindow, } 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); } } function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string; }): void { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.project.materialUpsertProgress, evt); } } function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string; }): void { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.project.npcUpsertProgress, 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 отключена во всех режимах (в т.ч. без пункта «Вид»). */ function installAppMenuForSession(): void { Menu.setApplicationMenu(null); } const effectsStore = new EffectsStore(); const sceneDarknessStore = new SceneDarknessStore(); const sceneTrapsStore = new SceneTrapsStore(); const sceneViewStore = new SceneViewStore(); const videoStore = new VideoPlaybackStore(); const materialsOverlayStore = new MaterialsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore(); const sceneTokensSessionStore = new SceneTokensSessionStore(); let tokensStore: TokensStore | null = null; 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 emitSceneViewState(): void { const state = sceneViewStore.getState(); for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.sceneView.stateChanged, { state }); } } function emitTokensState(): void { const tokens = tokensStore?.list() ?? []; for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.tokens.stateChanged, { tokens }); } } function emitSceneTokensSessionState(): void { const state = sceneTokensSessionStore.getState(); for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.sceneTokensSession.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 emitSceneTrapsState(): void { const state = sceneTrapsStore.getState(); for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.sceneTraps.stateChanged, { state }); } } function syncSceneTrapsForProject(project: Project): void { const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; const trapIds = (scene?.traps ?? []).map((t) => t.id); sceneTrapsStore.switchScene(cacheKey, trapIds); } 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, }; // Не шлём полный project в editor/splash — редактор обновляется через invoke-ответы. sendToAppWindows(ipcChannels.session.stateChanged, { state }); } /** * Упакованное приложение: экран загрузки → проверки → редактор. * В dev по умолчанию без экрана; тест: `DND_SHOW_BOOT=1`. Отключить везде: `DND_SKIP_BOOT=1`. */ async function runStartupAfterHandlers(licenseService: LicenseService): Promise { 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((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')); tokensStore = new TokensStore(app.getPath('userData')); await tokensStore.ensureLoaded(); setLicenseAssert(() => { licenseService.assertForIpc(); }); installAppMenuForSession(); registerDndAssetProtocol(projectStore, tokensStore); 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(); sceneTrapsStore.resetSession(); sceneTokensSessionStore.reset(); effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); openMultiWindow(); const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); syncSceneTrapsForProject(project); } emitSceneDarknessState(); emitSceneTrapsState(); emitSceneTokensSessionState(); emitEffectsState(); 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.openSceneEditor, () => { openSceneEditorWindow(); return { ok: true }; }); registerHandler(ipcChannels.windows.closeSceneEditor, () => { closeSceneEditorWindow(); 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(); warmNpcsEditorWindow(); return { project }; }); registerHandler(ipcChannels.project.open, async ({ projectId }) => { const project = await projectStore.openProjectById(projectId); sceneViewStore.reset(); sceneTokensSessionStore.reset(); emitSceneViewState(); emitSceneTokensSessionState(); emitSessionState(); warmNpcsEditorWindow(); return { project }; }); registerHandler(ipcChannels.project.close, async () => { closeNpcsEditorWindow(); await projectStore.closeOpenProject(); effectsStore.clear(); materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneDarknessStore.resetSession(); sceneTrapsStore.resetSession(); sceneViewStore.reset(); sceneTokensSessionStore.reset(); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); 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(); sceneViewStore.reset(); // Token moves persist for the whole play session (reset only on project open/close). const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); syncSceneTrapsForProject(project); } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); 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(); sceneViewStore.reset(); // Token moves persist for the whole play session (reset only on project open/close). const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); syncSceneTrapsForProject(project); } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); 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(); } if (project && project.currentSceneId === sceneId && patch.traps !== undefined) { syncSceneTrapsForProject(project); emitSceneTrapsState(); } 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 } : {}), }, (p) => emitMaterialUpsertProgress(p), ); 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.setMaterialLegend, async ({ materialId, legend }) => { const project = await projectStore.setMaterialLegend(materialId, legend); 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 }) => { 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 } : {}), }, (p) => emitNpcUpsertProgress(p), ); syncNpcsOverlayWithProject(project); emitNpcsOverlayState(); emitSessionState(); return { project }; }, ); registerHandler( ipcChannels.project.updateNpcFields, async ({ npcId, name, description, groupId }) => { const project = await projectStore.updateNpcFields(npcId, { ...(typeof name === 'string' ? { name } : {}), ...(typeof description === 'string' ? { description } : {}), ...(groupId !== undefined ? { groupId } : {}), }); emitSessionState(); return { project }; }, ); registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => { // Layout-only: Presentation/Control не читают npc.x/y; UI коммитит локально из ответа invoke. const project = await projectStore.updateNpcPosition(npcId, x, y); 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 }) => { // Layout-only: граф сцен живёт в editor (optimistic + invoke); session broadcast не нужен. const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y); 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 }) => { return projectStore.getProjectStorylines(projectId, labels); }); 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, async (cacheDir) => { const remap = await tokensStore!.importFromExportDir(cacheDir); emitTokensState(); return remap; }, ); 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, async (cacheDir) => { // Импорт из другого локального проекта: app-tokens в zip нет — только из внешнего архива. // Если в sourceCache нет app-tokens, remap пустой. const remap = await tokensStore!.importFromExportDir(cacheDir); emitTokensState(); return remap; }, ); 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, npcIds, 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, npcIds ?? [], dest, labels, (p) => { emitZipProgress({ kind: 'export', stage: p.stage, percent: p.percent, ...(p.detail ? { detail: p.detail } : null), }); }, async (tokenIds, exportRoot) => { await tokensStore!.packForExport(tokenIds, exportRoot); }, ); 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.sceneTraps.getState, () => { return { state: sceneTrapsStore.getState() }; }); registerHandler(ipcChannels.sceneTraps.dispatch, ({ event }) => { sceneTrapsStore.dispatch(event); emitSceneTrapsState(); return { ok: true }; }); registerHandler(ipcChannels.sceneView.getState, () => { return { state: sceneViewStore.getState() }; }); registerHandler(ipcChannels.sceneView.dispatch, ({ event }) => { sceneViewStore.dispatch(event); emitSceneViewState(); return { ok: true }; }); registerHandler(ipcChannels.tokens.list, async () => { await tokensStore!.ensureLoaded(); return { tokens: tokensStore!.list() }; }); registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => { const token = await tokensStore!.upsert({ id, name, filePath }); emitTokensState(); return { token }; }); registerHandler(ipcChannels.tokens.delete, async ({ id }) => { await tokensStore!.delete(id); // Убрать placements с текущей сцены. const project = projectStore.getOpenProject(); const sceneId = project?.currentSceneId; if (sceneId && project) { const scene = project.scenes[sceneId]; if (scene?.tokens?.some((t) => t.tokenId === id)) { await projectStore.updateScene(sceneId, { tokens: scene.tokens.filter((t) => t.tokenId !== id), }); emitSessionState(); } } emitTokensState(); return { ok: true }; }); registerHandler(ipcChannels.tokens.pickImage, 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.tokens.imageUrl, ({ id }) => { return { url: tokensStore!.getImageUrl(id) }; }); registerHandler(ipcChannels.sceneTokensSession.getState, () => { return { state: sceneTokensSessionStore.getState() }; }); registerHandler(ipcChannels.sceneTokensSession.dispatch, ({ event }) => { sceneTokensSessionStore.dispatch(event); emitSceneTokensSessionState(); 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(); } });