Files
DndGamePlayer/app/main/index.ts
T
Ivan Fontosh 61446dacfc fix(ui): hide Electron menu bar and polish editor chrome
Drop the system View menu across windows, show the open project name above Game properties, and keep scene list kebab visible with truncated titles and overflow tooltips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:21:26 +08:00

1313 lines
47 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, 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<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'));
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();
}
});