20c838da7d
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики. - Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/. - Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика. - Окно управления: дочернее от окна просмотра (Z-order). - Типы эффектов, effectsStore prune, hit-test ластика. Made-with: Cursor
212 lines
6.2 KiB
TypeScript
212 lines
6.2 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
import { app, BrowserWindow, nativeImage, screen } from 'electron';
|
|
|
|
type WindowKind = 'editor' | 'presentation' | 'control';
|
|
|
|
const windows = new Map<WindowKind, BrowserWindow>();
|
|
|
|
let appQuitting = false;
|
|
|
|
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
|
export function markAppQuitting(): void {
|
|
appQuitting = true;
|
|
}
|
|
|
|
function quitAppFromEditorClose(): void {
|
|
markAppQuitting();
|
|
app.quit();
|
|
}
|
|
|
|
function isDev() {
|
|
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
|
|
}
|
|
|
|
function getRendererUrl(kind: WindowKind): string {
|
|
const dev = process.env.VITE_DEV_SERVER_URL;
|
|
if (dev) {
|
|
const page =
|
|
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
|
return new URL(page, dev).toString();
|
|
}
|
|
const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
|
return pathToFileURL(filePath).toString();
|
|
}
|
|
|
|
function getPreloadPath(): string {
|
|
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
|
|
}
|
|
|
|
/**
|
|
* PNG для иконки окна / дока: тот же растр, что electron-builder берёт из `build/icon.png`
|
|
* (копия в dist после сборки), затем окно 256px, затем dev-пути. SVG не используем для
|
|
* nativeImage на Windows — иначе пустая картинка и дефолтная иконка Electron вместо exe.
|
|
*/
|
|
function resolveBrandingPngPaths(): string[] {
|
|
const root = app.getAppPath();
|
|
return [
|
|
path.join(root, 'dist', 'renderer', 'app-pack-icon.png'),
|
|
path.join(root, 'dist', 'renderer', 'app-window-icon.png'),
|
|
path.join(root, 'build', 'icon.png'),
|
|
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
|
|
];
|
|
}
|
|
|
|
function resolveWindowIconPath(): string | undefined {
|
|
for (const p of resolveBrandingPngPaths()) {
|
|
try {
|
|
if (fs.existsSync(p)) return p;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
const root = app.getAppPath();
|
|
const svgFallback = [
|
|
path.join(root, 'dist', 'renderer', 'app-logo.svg'),
|
|
path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'),
|
|
];
|
|
for (const p of svgFallback) {
|
|
try {
|
|
if (fs.existsSync(p)) return p;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveWindowIcon(): Electron.NativeImage | undefined {
|
|
const tryPath = (filePath: string): Electron.NativeImage | undefined => {
|
|
try {
|
|
const img = nativeImage.createFromPath(filePath);
|
|
if (!img.isEmpty()) return img;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
if (process.platform === 'win32' || process.platform === 'linux') {
|
|
for (const p of resolveBrandingPngPaths()) {
|
|
if (!fs.existsSync(p)) continue;
|
|
const img = tryPath(p);
|
|
if (img) return img;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const p = resolveWindowIconPath();
|
|
if (!p) return undefined;
|
|
return tryPath(p);
|
|
}
|
|
|
|
/** macOS: в Dock показываем тот же PNG, что и у упакованного приложения на Windows (иконка exe). */
|
|
export function applyDockIconIfNeeded(): void {
|
|
if (process.platform !== 'darwin' || !app.dock) return;
|
|
for (const p of resolveBrandingPngPaths()) {
|
|
if (!fs.existsSync(p)) continue;
|
|
try {
|
|
const img = nativeImage.createFromPath(p);
|
|
if (img.isEmpty()) continue;
|
|
app.dock.setIcon(img);
|
|
return;
|
|
} catch {
|
|
/* try next */
|
|
}
|
|
}
|
|
}
|
|
|
|
type CreateWindowOpts = {
|
|
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
|
parent?: BrowserWindow;
|
|
};
|
|
|
|
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
|
const icon = resolveWindowIcon();
|
|
const win = new BrowserWindow({
|
|
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
|
height: 800,
|
|
show: false,
|
|
backgroundColor: '#09090B',
|
|
...(icon ? { icon } : {}),
|
|
...(opts?.parent ? { parent: opts.parent } : {}),
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
nodeIntegration: false,
|
|
devTools: isDev(),
|
|
preload: getPreloadPath(),
|
|
autoplayPolicy: 'no-user-gesture-required',
|
|
},
|
|
});
|
|
|
|
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
|
console.error(`[preload-error] ${preloadPath}:`, error);
|
|
});
|
|
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
|
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
|
});
|
|
|
|
win.once('ready-to-show', () => win.show());
|
|
void win.loadURL(getRendererUrl(kind));
|
|
if (kind === 'editor') {
|
|
win.on('close', (e) => {
|
|
if (appQuitting) return;
|
|
e.preventDefault();
|
|
quitAppFromEditorClose();
|
|
});
|
|
}
|
|
win.on('closed', () => windows.delete(kind));
|
|
windows.set(kind, win);
|
|
return win;
|
|
}
|
|
|
|
export function createWindows() {
|
|
if (!windows.has('editor')) {
|
|
createWindow('editor');
|
|
}
|
|
}
|
|
|
|
export function focusEditorWindow(): void {
|
|
const win = windows.get('editor');
|
|
if (win) {
|
|
if (win.isMinimized()) win.restore();
|
|
win.show();
|
|
win.focus();
|
|
} else {
|
|
createWindows();
|
|
}
|
|
}
|
|
|
|
export function openMultiWindow() {
|
|
let presentation = windows.get('presentation');
|
|
if (!presentation) {
|
|
const display = screen.getPrimaryDisplay();
|
|
const { x, y, width, height } = display.bounds;
|
|
presentation = createWindow('presentation');
|
|
presentation.setBounds({ x, y, width, height });
|
|
presentation.setMenuBarVisibility(false);
|
|
presentation.maximize();
|
|
}
|
|
if (!windows.has('control')) {
|
|
createWindow('control', { parent: presentation });
|
|
}
|
|
}
|
|
|
|
export function closeMultiWindow(): void {
|
|
const pres = windows.get('presentation');
|
|
const ctrl = windows.get('control');
|
|
if (pres) pres.close();
|
|
if (ctrl) ctrl.close();
|
|
}
|
|
|
|
export function togglePresentationFullscreen(): boolean {
|
|
const pres = windows.get('presentation');
|
|
if (!pres) return false;
|
|
const next = !pres.isFullScreen();
|
|
pres.setFullScreen(next);
|
|
return pres.isFullScreen();
|
|
}
|