Files
DndGamePlayer/app/main/index.ts
T
Ivan Fontosh e39a72206d feat: boot-экран, стабильность Windows и оптимизация Pixi/пульта
- Экран загрузки (boot.html, bootWindow): статусы, ensureRoots и проверка лицензии, редактор после готовности; закрытие через destroy при closable:false.

- Упакованное приложение на Windows: disableHardwareAcceleration, sandbox выкл. вне dev, отложенный показ редактора, ensureWindowBecomesVisible, фокус на splash при second-instance.

- Vite: вход boot.html; eslint: игнор release/; тесты boot и maxFPS тикера.

- Пульт: позиция курсора кисти через ref/DOM без setState на каждый move; черновик эффекта через rAF; Pixi: maxFPS 32, resolution cap, antialias off, debounce ResizeObserver, меньше частиц poisonCloud, contain на хосте.

Made-with: Cursor
2026-04-20 12:12:01 +08:00

431 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import { EffectsStore } from './effects/effectsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
createBootWindow,
destroyBootWindow,
setBootWindowStatus,
waitForBootWindowReady,
} from './windows/bootWindow';
import {
applyDockIconIfNeeded,
closeMultiWindow,
createEditorWindowDeferred,
createWindows,
focusEditorWindow,
markAppQuitting,
openMultiWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
/**
* На части конфигураций Windows окно Electron с `file://` остаётся чёрным из‑за GPU/композитора.
* Отключаем аппаратное ускорение в упакованном приложении; отключить обход: `DND_DISABLE_GPU=0`.
*/
if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GPU !== '0') {
app.disableHardwareAcceleration();
}
if (process.platform === 'win32') {
app.setAppUserModelId('com.dndplayer.app');
}
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).
protocol.registerSchemesAsPrivileged([
{
scheme: 'dnd',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
]);
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
focusEditorWindow();
});
}
const projectStore = new ZipProjectStore();
const effectsStore = new EffectsStore();
const videoStore = new VideoPlaybackStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.effects.stateChanged, { state });
}
}
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.video.stateChanged, { state });
}
}
// Периодически чистим истёкшие эффекты (в основном молнии).
setInterval(() => {
if (effectsStore.pruneExpired()) {
emitEffectsState();
}
}, 500);
// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции).
setInterval(() => {
if (videoStore.getState().playing) {
emitVideoState();
}
}, 500);
function emitSessionState(): void {
const project = projectStore.getOpenProject();
const state: SessionState = {
project,
currentSceneId: project?.currentSceneId ?? null,
};
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.session.stateChanged, { state });
}
}
/**
* Упакованное приложение: экран загрузки → проверки → редактор.
* В 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();
emitVideoState();
return;
}
const splash = createBootWindow();
try {
await waitForBootWindowReady(splash);
} catch (err) {
console.error('[boot] splash load failed', err);
destroyBootWindow(splash);
createWindows();
emitSessionState();
emitEffectsState();
emitVideoState();
return;
}
splash.show();
setBootWindowStatus(splash, 'Инициализация…');
try {
setBootWindowStatus(splash, 'Подготовка данных…');
await projectStore.ensureRoots();
} catch (e) {
console.error('[boot] ensureRoots', e);
}
setBootWindowStatus(splash, 'Устанавливаем связь…');
setBootWindowStatus(splash, 'Проверка лицензии…');
try {
await licenseService.getStatus();
} catch (e) {
console.error('[boot] license getStatus', e);
}
setBootWindowStatus(splash, 'Загрузка редактора…');
const editor = createEditorWindowDeferred();
await waitForEditorWindowReady(editor);
setBootWindowStatus(splash, 'Готово');
destroyBootWindow(splash);
if (!editor.isDestroyed()) {
editor.show();
editor.focus();
}
emitSessionState();
emitEffectsState();
emitVideoState();
}
async function main() {
await app.whenReady();
const licenseService = new LicenseService(app.getPath('userData'));
setLicenseAssert(() => {
licenseService.assertForIpc();
});
Menu.setApplicationMenu(null);
registerDndAssetProtocol(projectStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
return { ok: true };
});
registerHandler(ipcChannels.app.getVersion, () => ({
version: getAppSemanticVersion(),
buildNumber: getOptionalBuildNumber(),
}));
registerHandler(ipcChannels.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, () => {
openMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
return {
projects: projects.map((p) => ({
id: p.id,
name: p.name,
updatedAt: p.updatedAt,
fileName: p.fileName,
})),
};
});
registerHandler(ipcChannels.project.create, async ({ name }) => {
const project = await projectStore.createProject(name);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.get, () => {
return { project: projectStore.getOpenProject() };
});
registerHandler(ipcChannels.project.saveNow, async () => {
await projectStore.saveNow();
return { ok: true };
});
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
emitEffectsState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => {
const open = projectStore.getOpenProject();
if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
await projectStore.updateProject((p) => ({
...p,
currentGraphNodeId: graphNodeId,
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
emitEffectsState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
currentGraphNodeId: p?.currentGraphNodeId ?? null,
currentSceneId: p?.currentSceneId ?? null,
};
});
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => {
const next = await projectStore.updateConnections(sceneId, connections);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || filePaths.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project, imported: [] };
}
const result = await projectStore.importMediaFiles(sceneId, filePaths);
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],
});
if (canceled || !filePaths[0]) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project };
}
const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
const project = await projectStore.clearScenePreview(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => {
const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => {
const project = await projectStore.addSceneGraphNode(sceneId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => {
const project = await projectStore.removeSceneGraphNode(nodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => {
const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => {
const project = await projectStore.removeSceneGraphEdge(edgeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
const project = await projectStore.renameOpenProject(name, fileBaseName);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importZip, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const project = await projectStore.importProjectFromExternalZip(filePaths[0]);
emitSessionState();
return { canceled: false as const, project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip')
? entry.fileName
: `${entry.fileName}.dnd.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
let dest = filePath;
const lower = dest.toLowerCase();
if (!lower.endsWith('.dnd.zip')) {
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
}
await projectStore.exportProjectZipToPath(projectId, dest);
return { canceled: false as const };
});
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
emitSessionState();
return { ok: true };
});
registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({
url: projectStore.getAssetFileUrl(assetId),
}));
registerHandler(ipcChannels.effects.getState, () => {
return { state: effectsStore.getState() };
});
registerHandler(ipcChannels.effects.dispatch, ({ event }) => {
effectsStore.dispatch(event);
emitEffectsState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
registerHandler(ipcChannels.video.dispatch, ({ event }) => {
videoStore.dispatch(event);
emitVideoState();
return { ok: true };
});
installIpcRouter();
applyDockIconIfNeeded();
await runStartupAfterHandlers(licenseService);
app.on('activate', () => {
focusEditorWindow();
});
}
if (gotTheLock) {
void main();
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});