e39a72206d
- Экран загрузки (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
431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
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();
|
||
}
|
||
});
|