Files
DndGamePlayer/app/main/index.ts
T
Ivan Fontosh 877fbaa8e7 feat: save games and edit scene darkness
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:41:42 +08:00

2462 lines
92 KiB
TypeScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol, shell } from 'electron';
import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding';
import type { SystemDefinition } from '../shared/characterSheet/types';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
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 { canonicalGameSave, freezeTokenPathPlayback, pathForPlacement } from '../shared/gameSave/buildSnapshot';
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile } from '../shared/types/gameSave';
import { asNpcId, asSceneId, type MaterialId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import {
asPreviewRotationDeg,
previewRotationStepsCw,
} from '../shared/types/scenePreviewRotation';
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { GameSavesStore } from './gameSave/gameSavesStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
import { DiceRollSessionStore } from './diceRoll/diceRollSessionStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
import { PlaylistsStore } from './playlists/playlistsStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
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,
getPresentationContentSize,
getSceneDescriptionContent,
getTokenPathEditorTarget,
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openPlaylistsWindow,
openTimerWindow,
openInitiativeWindow,
openDiceWindow,
openMultiWindow,
openNpcsEditorWindow,
openSceneEditorWindow,
openTokenPathEditorWindow,
openNpcsWindow,
openSceneDescriptionWindow,
openInstructionsWindow,
getInstructionsSection,
closeMaterialsWindow,
closePlaylistsWindow,
closeTimerWindow,
closeInitiativeWindow,
closeDiceWindow,
closeNpcsEditorWindow,
closeSceneEditorWindow,
closeTokenPathEditorWindow,
closeNpcsWindow,
requestCloseMultiWindowConfirm,
sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
syncAllWindowChromeTitles,
togglePresentationFullscreen,
waitForEditorWindowReady,
warmNpcsEditorWindow,
} from './windows/createWindows';
import { listDisplayInfos } from './windows/presentationDisplay';
import { asPlaylistId } from '../shared/types/ids';
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
import { tokenPathTotalLength } from '../shared/types/tokenPath';
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
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 === 'darwin') {
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
}
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 sceneMarkerStore = new SceneMarkerStore();
const sceneTrapsStore = new SceneTrapsStore();
const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const timerSessionStore = new TimerSessionStore();
const initiativeSessionStore = new InitiativeSessionStore();
const diceRollSessionStore = new DiceRollSessionStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let gameSavesStore: GameSavesStore | null = null;
/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */
let lastSaveCanonical: string | null = null;
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null;
let playlistsStore: PlaylistsStore | null = null;
function emitTokenPathSessionState(): void {
const state = tokenPathSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokenPathSession.stateChanged, { state });
}
}
function seedTokenPathPlaybackForProject(project: Project | null): void {
tokenPathSessionStore.reset();
if (!project?.currentSceneId) {
emitTokenPathSessionState();
return;
}
const scene = project.scenes[project.currentSceneId];
if (!scene) {
emitTokenPathSessionState();
return;
}
const now = Date.now();
const seedOne = (kind: TokenPathTargetKind, placementId: string, path: NonNullable<(typeof scene.tokens)[number]['path']>) => {
const pathLength = tokenPathTotalLength(path);
if (pathLength <= 1e-9) return;
tokenPathSessionStore.dispatch({
kind: 'seedPlayback',
entry: {
kind,
placementId,
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: path.durationSec,
pathLength,
segmentStartedAtMs: now,
},
});
};
for (const t of scene.tokens ?? []) {
if (t.path && t.path.points.length >= 2) seedOne('token', String(t.id), t.path);
}
for (const t of scene.npcTokens ?? []) {
if (t.path && t.path.points.length >= 2) seedOne('npcToken', String(t.id), t.path);
}
emitTokenPathSessionState();
}
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 emitSessionTimerState(): void {
const state = timerSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sessionTimer.stateChanged, { state });
}
}
function dismissSessionTimerWhenWindowCloses(): void {
const st = timerSessionStore.getState();
let changed = false;
if (st.running) {
timerSessionStore.dispatch({ kind: 'pause' });
changed = true;
}
if (st.visibleToPlayers) {
timerSessionStore.dispatch({ kind: 'setVisibleToPlayers', visible: false });
changed = true;
}
if (changed) emitSessionTimerState();
}
setTimerWindowClosedListener(dismissSessionTimerWhenWindowCloses);
function emitInitiativeState(): void {
const state = initiativeSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(ipcChannels.initiative.stateChanged, { state });
} catch {
// Окно может умирать при закрытии пульта/сессии (дочернее initiative).
}
}
}
function emitDiceRollState(): void {
const state = diceRollSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(ipcChannels.diceRoll.stateChanged, { state });
} catch {
/* teardown */
}
}
}
function hideDiceOverlayWhenWindowCloses(): void {
const st = diceRollSessionStore.getState();
if (!st.visibleToPlayers || !st.active) return;
diceRollSessionStore.dispatch({ kind: 'hideOverlay' });
emitDiceRollState();
}
setDiceWindowClosedListener(hideDiceOverlayWhenWindowCloses);
function syncInitiativePresenceFromSession(): void {
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const npcSession = sceneNpcTokensSessionStore.getState();
const placements = listVisibleSceneNpcTokens(scene?.npcTokens, npcSession.spawned, sceneId);
const npcById = new Map((project?.npcs ?? []).map((n) => [String(n.id), n]));
const npcs = placements.map((p) => {
const npc = npcById.get(String(p.npcId));
return {
placementId: String(p.id),
npcId: String(p.npcId),
name: npc?.name?.trim() || 'NPC',
avatarAssetId: npc?.avatarAssetId ? String(npc.avatarAssetId) : null,
};
});
const playerSession = scenePlayerTokensSessionStore.getState();
const playersLib = playersStore?.listPlayers() ?? [];
const playerById = new Map(playersLib.map((p) => [String(p.id), p]));
const players = playerSession.selectedPlayerIds.map((id) => {
const p = playerById.get(String(id));
return {
playerId: String(id),
name: p?.name?.trim() || 'Player',
};
});
const before = initiativeSessionStore.getState().revision;
initiativeSessionStore.dispatch({
kind: 'syncPresence',
npcs,
players,
playersVisible: playerSession.visible,
});
if (initiativeSessionStore.getState().revision !== before) {
emitInitiativeState();
}
}
function clearAndResyncInitiative(): void {
initiativeSessionStore.clear();
syncInitiativePresenceFromSession();
emitInitiativeState();
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneDarkness.stateChanged, { state });
}
}
function emitSceneMarkerState(): void {
const state = sceneMarkerStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneMarker.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 });
}
}
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
const cacheDir = projectStore.getOpenCacheDir();
if (!tokensStore || !cacheDir) return null;
const remap = await tokensStore.importFromExportDir(cacheDir);
if (remap.size === 0) return null;
emitTokensState();
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
if (!needsRemap) return null;
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneTokensSession.stateChanged, { state });
}
}
function emitTokenGridSnapState(): void {
const { enabled } = tokenGridSnapSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokenGridSnap.stateChanged, { enabled });
}
}
function emitPlayersState(): void {
const players = playersStore?.listPlayers() ?? [];
const teams = playersStore?.listTeams() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.stateChanged, { players, teams });
}
}
function emitPlaylistsState(): void {
if (!playlistsStore) return;
const state = playlistsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.playlists.stateChanged, state);
}
}
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.upsertProgress, p);
}
}
function emitSceneNpcTokensSessionState(): void {
const state = sceneNpcTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneNpcTokensSession.stateChanged, { state });
}
}
let npcMapSpawnDragNpcId: string | null = null;
let npcMapSpawnHover: { nx: number; ny: number } | null = null;
function emitNpcMapSpawnDragState(): void {
const state = {
dragging: Boolean(npcMapSpawnDragNpcId),
npcId: npcMapSpawnDragNpcId,
hoverNx: npcMapSpawnHover?.nx ?? null,
hoverNy: npcMapSpawnHover?.ny ?? null,
};
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.npcMapSpawn.stateChanged, { state });
}
}
function clearNpcMapSpawnDrag(): void {
if (!npcMapSpawnDragNpcId && !npcMapSpawnHover) return;
npcMapSpawnDragNpcId = null;
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
}
function emitScenePlayerTokensSessionState(): void {
const state = scenePlayerTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.scenePlayerTokensSession.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' || scene?.previewAssetType === 'video');
sceneDarknessStore.switchScene(cacheKey, enabled, scene?.darknessStrokes ?? []);
}
function syncSceneEffectsForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
effectsStore.switchScene(cacheKey);
}
function captureGameSaveBody(project: Project): GameSaveBody {
const now = Date.now();
const timer = timerSessionStore.getState();
const players = scenePlayerTokensSessionStore.getState();
const initiative = initiativeSessionStore.getState();
const materials = materialsOverlayStore.getState();
const npcs = npcsOverlayStore.getState();
const camera = sceneViewStore.getState();
const npcTokens = sceneNpcTokensSessionStore.getState();
const paths = tokenPathSessionStore.getState();
return {
schemaVersion: GAME_SAVE_SCHEMA_VERSION,
projectId: String(project.id),
graphNodeId: project.currentGraphNodeId ? String(project.currentGraphNodeId) : null,
sceneId: project.currentSceneId ? String(project.currentSceneId) : null,
darknessByCard: sceneDarknessStore.exportByCard(),
markersByCard: sceneMarkerStore.exportByCard(),
trapsByCard: sceneTrapsStore.exportByCard(),
effectsByCard: effectsStore.exportByCard(),
sceneTokensByPlacementId: { ...sceneTokensSessionStore.getState().byPlacementId },
npcTokens: {
byPlacementId: { ...npcTokens.byPlacementId },
scale: npcTokens.scale,
spawned: { ...npcTokens.spawned },
},
tokenPaths: {
presentationVisible: { ...paths.presentationVisible },
playback: freezeTokenPathPlayback(
paths.playback,
(entry) => pathForPlacement(project, entry.kind, entry.placementId),
now,
),
},
timer: {
durationMs: timer.durationMs,
remainingMs: getSessionTimerRemainingMs(timer, now),
visibleToPlayers: timer.visibleToPlayers,
layout: { ...timer.layout },
},
playerTokens: {
selectedPlayerIds: [...players.selectedPlayerIds],
visible: players.visible,
byPlayerId: players.visible ? { ...players.byPlayerId } : {},
},
camera: { scale: camera.scale, ox: camera.ox, oy: camera.oy },
materials: {
activeMaterialIds: materials.activeMaterialIds.map(String),
layouts: { ...materials.layouts },
legendLayouts: { ...materials.legendLayouts },
focusMaterialId: materials.focusMaterialId ? String(materials.focusMaterialId) : null,
},
npcAvatars: {
activeNpcIds: npcs.activeNpcIds.map(String),
layouts: { ...npcs.layouts },
focusNpcId: npcs.focusNpcId ? String(npcs.focusNpcId) : null,
},
initiative: {
enabled: initiative.enabled,
entries: { ...initiative.entries },
order: [...initiative.order],
reserve: [...initiative.reserve],
activeId: initiative.activeId,
conditions: { ...initiative.conditions },
playersVisible: initiative.playersVisible,
},
tokenGridSnap: tokenGridSnapSessionStore.getState().enabled,
};
}
function sessionIsDirty(project: Project | null): boolean {
if (!project) return false;
if (lastSaveCanonical === null) return true;
return canonicalGameSave(captureGameSaveBody(project)) !== lastSaveCanonical;
}
async function applyGameSave(project: Project, file: GameSaveFile): Promise<Project> {
if (file.projectId !== String(project.id)) throw new Error('GAME_SAVE_PROJECT_MISMATCH');
let next = project;
if (file.graphNodeId) {
const gn = project.sceneGraphNodes.find((n) => String(n.id) === file.graphNodeId);
if (!gn) throw new Error('GAME_SAVE_CARD_MISSING');
next = projectStore.setSessionPointer(gn.id, gn.sceneId);
} else if (file.sceneId && project.scenes[asSceneId(file.sceneId)]) {
next = projectStore.setSessionPointer(null, asSceneId(file.sceneId));
} else if (file.sceneId) {
throw new Error('GAME_SAVE_CARD_MISSING');
}
sceneDarknessStore.importByCard(file.darknessByCard ?? {});
sceneMarkerStore.importByCard(file.markersByCard ?? {});
sceneTrapsStore.importByCard(file.trapsByCard ?? {});
effectsStore.importByCard(file.effectsByCard ?? {});
syncSceneDarknessForProject(next);
syncSceneMarkerForProject(next);
syncSceneTrapsForProject(next);
syncSceneEffectsForProject(next);
sceneTokensSessionStore.load(file.sceneTokensByPlacementId ?? {});
const npcIds = new Set((next.npcs ?? []).map((n) => String(n.id)));
const spawned: GameSaveBody['npcTokens']['spawned'] = {};
for (const [id, spawn] of Object.entries(file.npcTokens?.spawned ?? {})) {
if (npcIds.has(String(spawn.npcId))) spawned[id] = spawn;
}
sceneNpcTokensSessionStore.load({
byPlacementId: file.npcTokens?.byPlacementId ?? {},
scale: file.npcTokens?.scale ?? 1,
spawned,
});
let selected = [...(file.playerTokens?.selectedPlayerIds ?? [])];
if (playersStore) {
const known = new Set(playersStore.listPlayers().map((p) => String(p.id)));
selected = selected.filter((id) => known.has(id));
}
const byPlayerId: GameSaveBody['playerTokens']['byPlayerId'] = {};
if (file.playerTokens?.visible) {
for (const id of selected) {
const placement = file.playerTokens.byPlayerId?.[id];
if (placement) byPlayerId[id] = placement;
}
}
scenePlayerTokensSessionStore.load({
selectedPlayerIds: selected,
visible: Boolean(file.playerTokens?.visible) && selected.length > 0,
byPlayerId,
});
tokenPathSessionStore.load(file.tokenPaths?.playback ?? {}, file.tokenPaths?.presentationVisible ?? {});
timerSessionStore.loadPaused(file.timer);
sceneViewStore.dispatch({ kind: 'set', camera: file.camera });
const materialIds = new Set((next.materials ?? []).map((m) => String(m.id)));
const activeMaterials = (file.materials?.activeMaterialIds ?? []).filter((id) => materialIds.has(id));
materialsOverlayStore.load({
activeMaterialIds: activeMaterials as MaterialId[],
layouts: file.materials?.layouts ?? {},
legendLayouts: file.materials?.legendLayouts ?? {},
focusMaterialId:
file.materials?.focusMaterialId && materialIds.has(file.materials.focusMaterialId)
? (file.materials.focusMaterialId as MaterialId)
: null,
});
const activeNpcs = (file.npcAvatars?.activeNpcIds ?? []).filter((id) => npcIds.has(id));
npcsOverlayStore.load({
activeNpcIds: activeNpcs.map((id) => asNpcId(id)),
layouts: file.npcAvatars?.layouts ?? {},
focusNpcId:
file.npcAvatars?.focusNpcId && npcIds.has(file.npcAvatars.focusNpcId)
? asNpcId(file.npcAvatars.focusNpcId)
: null,
});
tokenGridSnapSessionStore.setEnabled(Boolean(file.tokenGridSnap));
if (file.initiative) initiativeSessionStore.load(file.initiative);
return next;
}
function syncSceneMarkerForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
sceneMarkerStore.switchScene(cacheKey);
}
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();
sendToAppWindows(ipcChannels.video.stateChanged, { state }, ['control', 'presentation']);
}
// Периодически чистим истёкшие эффекты (в основном молнии).
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();
if (USERS_BRANCH_FEATURES_ENABLED) {
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
}
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
gameSavesStore = new GameSavesStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore, tokensStore, playersStore, playlistsStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
return { ok: true };
});
registerHandler(ipcChannels.app.getVersion, () => ({
version: getAppSemanticVersion(),
buildNumber: getOptionalBuildNumber(),
packaged: app.isPackaged,
}));
registerHandler(ipcChannels.app.openExternal, async ({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false as const, error: 'Only http(s) URLs are allowed' };
}
await shell.openExternal(parsed.toString());
return { ok: true as const };
} catch (err) {
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
}
});
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, async (req) => {
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
tokenPathSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
effectsStore.resetSession();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
lastSaveCanonical = null;
const saveId = typeof req?.saveId === 'string' ? req.saveId.trim() : '';
let project = projectStore.getOpenProject();
if (saveId) {
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
const file = await gameSavesStore.read(String(project.id), saveId);
project = await applyGameSave(project, file);
} else if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
}
}
openMultiWindow();
project = projectStore.getOpenProject();
if (project && !saveId) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
syncSceneEffectsForProject(project);
seedTokenPathPlaybackForProject(project);
} else if (!project) {
emitTokenPathSessionState();
}
if (project && saveId) {
lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project));
}
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitTokenGridSnapState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneViewState();
emitSessionTimerState();
if (!saveId) syncInitiativePresenceFromSession();
emitInitiativeState();
emitDiceRollState();
emitTokenPathSessionState();
return { ok: true as const, project: projectStore.getOpenProject() };
});
registerHandler(ipcChannels.gameSave.list, async () => {
const project = projectStore.getOpenProject();
if (!project || !gameSavesStore) return { saves: [] };
return { saves: await gameSavesStore.list(String(project.id)) };
});
registerHandler(ipcChannels.gameSave.write, async ({ id, name }) => {
const project = projectStore.getOpenProject();
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
const body = captureGameSaveBody(project);
const save = await gameSavesStore.write(String(project.id), body, { id, name });
lastSaveCanonical = canonicalGameSave(body);
return { save };
});
registerHandler(ipcChannels.gameSave.isDirty, () => {
return { dirty: sessionIsDirty(projectStore.getOpenProject()) };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
tokenGridSnapSessionStore.reset();
emitSessionTimerState();
emitInitiativeState();
emitDiceRollState();
emitTokenGridSnapState();
return { ok: true };
});
registerHandler(ipcChannels.windows.requestCloseMultiWindowConfirm, () => {
requestCloseMultiWindowConfirm();
return { ok: true };
});
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
syncAllWindowChromeTitles(localeTag);
return { ok: true };
});
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen };
});
registerHandler(ipcChannels.windows.getMultiWindowState, () => {
return { open: isMultiWindowOpen() };
});
registerHandler(ipcChannels.windows.getPresentationContentSize, () => {
const size = getPresentationContentSize();
return size ?? { width: null, height: null };
});
registerHandler(ipcChannels.windows.listDisplays, () => {
return { displays: listDisplayInfos() };
});
registerHandler(ipcChannels.preferences.getPresentation, () => {
return { settings: readPresentationSettings(app.getPath('userData')) };
});
registerHandler(ipcChannels.preferences.setPresentation, ({ settings }) => {
const next = writePresentationSettings(app.getPath('userData'), settings);
return { settings: next };
});
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
openSceneDescriptionWindow(html);
return { ok: true };
});
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
openInstructionsWindow(section);
return { ok: true };
});
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
return { section: getInstructionsSection() };
});
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.openPlaylists, () => {
openPlaylistsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closePlaylists, () => {
closePlaylistsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openTimer, () => {
openTimerWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeTimer, () => {
closeTimerWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openInitiative, () => {
openInitiativeWindow({
onClosed: () => {
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
emitInitiativeState();
},
});
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: true });
syncInitiativePresenceFromSession();
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeInitiative, () => {
closeInitiativeWindow();
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.openDice, () => {
openDiceWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeDice, () => {
closeDiceWindow();
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.openTokenPathEditor, ({ kind, placementId }) => {
openTokenPathEditorWindow(kind, placementId);
return { ok: true };
});
registerHandler(ipcChannels.windows.closeTokenPathEditor, () => {
closeTokenPathEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.getTokenPathEditorTarget, () => getTokenPathEditorTarget());
registerHandler(ipcChannels.windows.openNpcs, (req) => {
openNpcsWindow();
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
if (npcId) {
npcsOverlayStore.dispatch({ kind: 'inspect.show', npcId });
emitNpcsOverlayState();
}
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.sessionTimer.getState, () => {
return { state: timerSessionStore.getState() };
});
registerHandler(ipcChannels.sessionTimer.dispatch, ({ event }) => {
timerSessionStore.dispatch(event);
timerSessionStore.settleIfExpired();
emitSessionTimerState();
return { state: timerSessionStore.getState() };
});
registerHandler(ipcChannels.initiative.getState, () => {
return { state: initiativeSessionStore.getState() };
});
registerHandler(ipcChannels.initiative.dispatch, ({ event }) => {
initiativeSessionStore.dispatch(event);
emitInitiativeState();
return { state: initiativeSessionStore.getState() };
});
registerHandler(ipcChannels.diceRoll.getState, () => {
return { state: diceRollSessionStore.getState() };
});
registerHandler(ipcChannels.diceRoll.dispatch, ({ event }) => {
diceRollSessionStore.dispatch(event);
emitDiceRollState();
return { state: diceRollSessionStore.getState() };
});
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 }) => {
let project = await projectStore.openProjectById(projectId);
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
effectsStore.resetSession();
lastSaveCanonical = null;
syncSceneMarkerForProject(project);
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSceneMarkerState();
emitSessionState();
warmNpcsEditorWindow();
return { project };
});
registerHandler(ipcChannels.project.close, async () => {
closeNpcsEditorWindow();
await projectStore.closeOpenProject();
effectsStore.resetSession();
lastSaveCanonical = null;
materialsOverlayStore.clear();
npcsOverlayStore.clear();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSessionTimerState();
emitInitiativeState();
emitDiceRollState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitTokenGridSnapState();
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 }));
const sceneProject = projectStore.getOpenProject();
if (sceneProject) syncSceneEffectsForProject(sceneProject);
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
clearAndResyncInitiative();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
}
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
seedTokenPathPlaybackForProject(project);
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;
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
const switched = projectStore.getOpenProject();
if (switched) syncSceneEffectsForProject(switched);
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
clearAndResyncInitiative();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
}
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
seedTokenPathPlaybackForProject(project);
emitSessionState();
const p = projectStore.getOpenProject();
return {
currentGraphNodeId: p?.currentGraphNodeId ?? null,
currentSceneId: p?.currentSceneId ?? null,
};
});
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const before = projectStore.getOpenProject()?.scenes[sceneId];
const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject();
if (
project?.currentSceneId === sceneId &&
(patch.darkenScene !== undefined || patch.darknessStrokes !== undefined)
) {
syncSceneDarknessForProject(project);
emitSceneDarknessState();
}
if (project && project.currentSceneId === sceneId && patch.traps !== undefined) {
syncSceneTrapsForProject(project);
emitSceneTrapsState();
}
if (
project?.currentSceneId === sceneId &&
patch.previewRotationDeg !== undefined &&
before
) {
const steps = previewRotationStepsCw(
asPreviewRotationDeg(before.previewRotationDeg),
asPreviewRotationDeg(patch.previewRotationDeg),
);
if (steps !== 0) {
sceneTokensSessionStore.rotateMapCwSteps(steps);
sceneNpcTokensSessionStore.rotateMapCwSteps(steps);
scenePlayerTokensSessionStore.rotateMapCwSteps(steps);
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
}
}
if (project && project.currentSceneId === sceneId && patch.previewRotationDeg !== undefined) {
// Trap ids stay the same; runtime statuses remain valid after coordinate remap in project.
syncSceneTrapsForProject(project);
emitSceneTrapsState();
}
if (project?.currentSceneId === sceneId && patch.npcTokens !== undefined) {
syncInitiativePresenceFromSession();
}
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.addGridTemplate, async ({ name, type, sizeN, color }) => {
const project = await projectStore.addGridTemplate({ name, type, sizeN, color });
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.renameGridTemplate, async ({ templateId, name }) => {
const project = await projectStore.renameGridTemplate(templateId, name);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteGridTemplate, async ({ templateId }) => {
const project = await projectStore.deleteGridTemplate(templateId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.applyGridTemplate, async ({ sceneId, templateId }) => {
const scene = await projectStore.applyGridTemplate(sceneId, templateId);
emitSessionState();
return { scene };
});
registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
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 } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
},
(p) => emitNpcUpsertProgress(p),
);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
},
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, externalUrl, groupId, ringColor, disposition, imageOffset, imageScale }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(typeof externalUrl === 'string' ? { externalUrl } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
});
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();
}
// Always clear optimizing UI — including abort on project close (changed: false).
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'done',
...(finalized.changed && finalized.project ? { 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: 'Копирование…' });
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
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();
if (playlistsStore) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
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();
if (playlistsStore) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
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: 'Копирование…' });
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
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, pendingAppPlaylists } = 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),
});
});
if (playlistsStore && pendingAppPlaylists.length > 0) {
for (const pl of pendingAppPlaylists) {
const created = await playlistsStore.createPlaylist(pl.name);
const paths = pl.tracks.map((t) => t.absPath);
if (paths.length > 0) {
await playlistsStore.addAudioFiles(created.id, paths);
}
}
emitPlaylistsState();
}
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, playlistIds }) => {
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);
},
async (exportRoot) => {
if (!playlistsStore || !playlistIds?.length) return;
await playlistsStore.packForExport(
playlistIds.map((id) => asPlaylistId(id)),
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: 'done', 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.sceneMarker.getState, () => {
return { state: sceneMarkerStore.getState() };
});
registerHandler(ipcChannels.sceneMarker.dispatch, ({ event }) => {
sceneMarkerStore.dispatch(event);
emitSceneMarkerState();
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({
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { 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.tokenPathSession.getState, () => {
return { state: tokenPathSessionStore.getState() };
});
registerHandler(ipcChannels.tokenPathSession.dispatch, ({ event }) => {
tokenPathSessionStore.dispatch(event);
emitTokenPathSessionState();
return { ok: true };
});
registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
const next = tokenGridSnapSessionStore.setEnabled(enabled);
emitTokenGridSnapState();
return next;
});
registerHandler(ipcChannels.players.list, async () => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { players: [], teams: [] };
}
await playersStore.ensureLoaded();
return { players: playersStore.listPlayers(), teams: playersStore.listTeams() };
});
registerHandler(
ipcChannels.players.upsert,
async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const player = await playersStore.upsert(
{
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { filePath } : {}),
...(teamId !== undefined ? { teamId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
},
(p) => emitPlayerUpsertProgress(p),
);
emitPlayersState();
return { player };
},
);
registerHandler(ipcChannels.players.delete, async ({ id }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { ok: true };
}
await playersStore.delete(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { players: [] };
}
const players = await playersStore.setPlayersOrder(playerIds);
emitPlayersState();
return { players };
});
registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const team = await playersStore.upsertTeam({
name,
...(id !== undefined ? { id } : {}),
...(color !== undefined ? { color } : {}),
});
emitPlayersState();
return { team };
});
registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { ok: true };
}
await playersStore.deleteTeam(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { teams: [] };
}
const teams = await playersStore.setTeamsOrder(teamIds);
emitPlayersState();
return { teams };
});
registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const player = await playersStore.assignPlayerTeam(playerId, teamId);
emitPlayersState();
return { player };
});
registerHandler(ipcChannels.players.pickImage, async () => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { canceled: true as const };
}
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.players.imageUrl, ({ id }) => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { url: null };
}
return { url: playersStore.getImageUrl(id) };
});
registerHandler(ipcChannels.gameSystems.list, async () => {
if (!gameSystemsStore) return { systems: [] };
return { systems: await gameSystemsStore.list() };
});
registerHandler(ipcChannels.gameSystems.importFromFile, async () => {
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: app.getLocale().toLowerCase().startsWith('ru') ? 'Системы (JSON)' : 'Systems (JSON)',
extensions: ['json'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as SystemDefinition;
const system = await gameSystemsStore.importSystem(parsed);
return { canceled: false as const, system };
});
registerHandler(ipcChannels.gameSystems.delete, async ({ id }) => {
if (!gameSystemsStore) return { ok: true as const };
await gameSystemsStore.delete(id);
if (playersStore) {
const cleared = await playersStore.clearSystemBindings(id);
if (cleared > 0) emitPlayersState();
}
return { ok: true as const };
});
registerHandler(ipcChannels.gameSystems.get, async ({ id }) => {
if (!gameSystemsStore) return { system: null };
return { system: await gameSystemsStore.get(id) };
});
registerHandler(ipcChannels.gameSystems.bindSnapshot, async ({ systemId }) => {
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
return gameSystemsStore.bindSnapshot(systemId);
});
registerHandler(ipcChannels.gameSystems.getSnapshot, async ({ hash }) => {
if (!gameSystemsStore) return { system: null };
return { system: await gameSystemsStore.getSnapshot(hash) };
});
registerHandler(ipcChannels.playlists.getState, async () => {
if (!playlistsStore) return { playlists: [], audios: [] };
await playlistsStore.ensureLoaded();
return playlistsStore.getState();
});
registerHandler(ipcChannels.playlists.create, async ({ name }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.createPlaylist(name);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.rename, async ({ id, name }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.renamePlaylist(id, name);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.duplicate, async ({ id }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.duplicatePlaylist(id);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.delete, async ({ id }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
await playlistsStore.deletePlaylist(id);
const state = playlistsStore.getState();
emitPlaylistsState();
return { state };
});
registerHandler(ipcChannels.playlists.setOrder, async ({ playlistIds }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
await playlistsStore.setPlaylistsOrder(playlistIds);
const state = playlistsStore.getState();
emitPlaylistsState();
return { state };
});
registerHandler(ipcChannels.playlists.setTracksOrder, async ({ playlistId, trackIds }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.setTracksOrder(playlistId, trackIds);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.addAudioFiles, async ({ playlistId, filePaths }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.addAudioFiles(playlistId, filePaths);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.pickAudioFiles, async () => {
const win = BrowserWindow.getFocusedWindow();
const { canceled, filePaths } = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled) return { filePaths: [] };
return { filePaths };
});
registerHandler(ipcChannels.playlists.removeTrack, async ({ playlistId, trackId }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.removeTrack(playlistId, trackId);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.renameAudio, async ({ audioId, title }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const audio = await playlistsStore.renameAudio(audioId, title);
const state = playlistsStore.getState();
emitPlaylistsState();
return { audio, state };
});
registerHandler(ipcChannels.playlists.audioUrl, ({ id }) => {
return { url: playlistsStore?.getAudioUrl(id) ?? null };
});
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
return { state: sceneNpcTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
sceneNpcTokensSessionStore.dispatch(event);
emitSceneNpcTokensSessionState();
if (event.kind === 'spawn' || event.kind === 'despawn' || event.kind === 'clear') {
syncInitiativePresenceFromSession();
}
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
return {
state: {
dragging: Boolean(npcMapSpawnDragNpcId),
npcId: npcMapSpawnDragNpcId,
hoverNx: npcMapSpawnHover?.nx ?? null,
hoverNy: npcMapSpawnHover?.ny ?? null,
},
};
});
registerHandler(ipcChannels.npcMapSpawn.beginDrag, ({ npcId }) => {
npcMapSpawnDragNpcId = String(npcId ?? '') || null;
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.setHover, ({ nx, ny }) => {
if (!npcMapSpawnDragNpcId) return { ok: true };
if (nx == null || ny == null || !Number.isFinite(nx) || !Number.isFinite(ny)) {
if (npcMapSpawnHover === null) return { ok: true };
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
return { ok: true };
}
const next = { nx: Math.max(0, Math.min(1, nx)), ny: Math.max(0, Math.min(1, ny)) };
if (npcMapSpawnHover && npcMapSpawnHover.nx === next.nx && npcMapSpawnHover.ny === next.ny) {
return { ok: true };
}
npcMapSpawnHover = next;
emitNpcMapSpawnDragState();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.cancel, () => {
clearNpcMapSpawnDrag();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.commit, () => {
const npcId = npcMapSpawnDragNpcId;
const hover = npcMapSpawnHover;
clearNpcMapSpawnDrag();
if (!npcId || !hover) return { spawned: false };
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId;
const npc = project?.npcs.find((item) => item.id === npcId);
if (!sceneId || !npc) return { spawned: false };
sceneNpcTokensSessionStore.dispatch({
kind: 'spawn',
npcId: npc.id,
sceneId,
nx: hover.nx,
ny: hover.ny,
disposition: npc.disposition,
});
emitSceneNpcTokensSessionState();
syncInitiativePresenceFromSession();
return { spawned: true };
});
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
return { state: scenePlayerTokensSessionStore.getState() };
});
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
scenePlayerTokensSessionStore.dispatch(event);
emitScenePlayerTokensSessionState();
if (
event.kind === 'setSelection' ||
event.kind === 'setVisible' ||
event.kind === 'show' ||
event.kind === 'clear' ||
event.kind === 'clearPlacements'
) {
syncInitiativePresenceFromSession();
}
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();
}
});