import fs from 'node:fs'; import path from 'node:path'; import { app, BrowserWindow, nativeImage, screen } from 'electron'; import { getBootSplashWindow } from './bootWindow'; type WindowKind = 'editor' | 'presentation' | 'control'; const windows = new Map(); 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; } /** Вне dev-сервера на Windows с `loadFile` + preload иногда ломается sandbox; оставляем изоляцию через preload/contextBridge. */ function shouldUseRendererSandbox(): boolean { if (process.env.VITE_DEV_SERVER_URL) return true; return process.platform !== 'win32'; } function getRendererHtmlPath(kind: WindowKind): string { return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`); } /** * В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран; * `loadFile` корректно открывает HTML из asar и на Windows, и на macOS. */ function loadWindowPage(win: BrowserWindow, kind: WindowKind): void { const dev = process.env.VITE_DEV_SERVER_URL; if (dev) { const page = kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html'; void win.loadURL(new URL(page, dev).toString()); return; } void win.loadFile(getRendererHtmlPath(kind)); } 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; /** Только редактор: не показывать окно до `show()` (экран загрузки). */ deferVisibility?: boolean; }; /** * Только `ready-to-show` на части систем (первый холодный старт Windows) не приходит вовремя — * окно остаётся с `show: false` и кажется «зависшим». Дублируем показ по `did-finish-load` и таймауту. */ function ensureWindowBecomesVisible(win: BrowserWindow): void { let shown = false; const showOnce = (): void => { if (shown) return; if (win.isDestroyed()) return; shown = true; win.show(); }; win.once('ready-to-show', showOnce); win.webContents.once('did-finish-load', () => { showOnce(); }); const safetyTimer = setTimeout(showOnce, 8000); win.once('closed', () => { clearTimeout(safetyTimer); }); } function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { const deferEditor = kind === 'editor' && opts?.deferVisibility === true; 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: shouldUseRendererSandbox(), nodeIntegration: false, devTools: isDev() || process.env.DND_OPEN_DEVTOOLS === '1', preload: getPreloadPath(), autoplayPolicy: 'no-user-gesture-required', // file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим. webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL), }, }); 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.webContents.on('render-process-gone', (_event, details) => { console.error('[render-process-gone]', details.reason, details.exitCode); }); if (!deferEditor) { ensureWindowBecomesVisible(win); } loadWindowPage(win, 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 createEditorWindowDeferred(): BrowserWindow { const existing = windows.get('editor'); if (existing) { return existing; } return createWindow('editor', { deferVisibility: true }); } /** Дождаться первой отрисовки редактора (готовность к показу без чёрного экрана). */ export function waitForEditorWindowReady(win: BrowserWindow): Promise { return new Promise((resolve) => { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined); } }, 35000); const finish = (): void => { if (settled) return; settled = true; clearTimeout(timer); resolve(undefined); }; win.once('ready-to-show', finish); win.webContents.once('did-finish-load', finish); }).then( () => new Promise((r) => { setTimeout(r, 120); }), ); } export function focusEditorWindow(): void { const splash = getBootSplashWindow(); if (splash && !splash.isDestroyed()) { splash.focus(); return; } 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(); }