import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; import { ipcChannels, type SessionState } from '../shared/ipc/contracts'; import { EffectsStore } from './effects/effectsStore'; import { installIpcRouter, registerHandler } from './ipc/router'; import { ZipProjectStore } from './project/zipStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; import { VideoPlaybackStore } from './video/videoPlaybackStore'; import { closeMultiWindow, createWindows, focusEditorWindow, markAppQuitting, openMultiWindow, togglePresentationFullscreen, } from './windows/createWindows'; 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(); 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 }); } } async function main() { await app.whenReady(); Menu.setApplicationMenu(null); registerDndAssetProtocol(projectStore); registerHandler(ipcChannels.app.quit, () => { markAppQuitting(); app.quit(); return { ok: true }; }); registerHandler(ipcChannels.app.getVersion, () => ({ version: getAppSemanticVersion(), buildNumber: getOptionalBuildNumber(), })); 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.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.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 project = await projectStore.importProjectFromExternalZip(filePaths[0]); 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`; } await projectStore.exportProjectZipToPath(projectId, dest); 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(); createWindows(); emitSessionState(); emitEffectsState(); emitVideoState(); app.on('activate', () => { focusEditorWindow(); }); } if (gotTheLock) { void main(); } app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } });