import path from 'node:path'; import { app, BrowserWindow, screen } from 'electron'; import { windowChromeTitle } from '../../shared/appBranding'; import { ipcChannels } from '../../shared/ipc/contracts'; import { safeConsoleError } from '../safeConsole'; import { getBootSplashWindow } from './bootWindow'; import { loadBrandingWindowIcon } from './brandingIcon'; export type WindowKind = | 'editor' | 'presentation' | 'control' | 'sceneDescription' | 'materials' | 'npcsEditor' | 'npcs'; /** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ 'presentation', 'control', 'materials', 'npcs', 'npcsEditor', ] as const; const windows = new Map(); let appQuitting = false; let pendingSceneDescriptionHtml = ''; /** Окно материалов — только колонка списка. */ const MATERIALS_WINDOW_WIDTH = 300; const MATERIALS_WINDOW_HEIGHT = 720; /** Редактор НПС — как основной редактор, шире инспектор. */ const NPCS_EDITOR_WINDOW_WIDTH = 1400; const NPCS_EDITOR_WINDOW_HEIGHT = 860; /** Пульт НПС: деталь слева + список справа. */ const NPCS_WINDOW_WIDTH = 720; const NPCS_WINDOW_HEIGHT = 720; /** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */ function broadcastMultiWindowStateChanged(open: boolean): void { for (const w of BrowserWindow.getAllWindows()) { if (w.isDestroyed()) continue; if (w.webContents.isDestroyed()) continue; try { w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open }); } catch { /* окно могло закрыться между проверкой и send */ } } } function sendSceneDescriptionContent(win: BrowserWindow, html: string): void { if (win.isDestroyed() || win.webContents.isDestroyed()) return; try { win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html }); } catch { /* ignore */ } } /** Разрешает реальное закрытие окна редактора (выход из приложения). */ export function markAppQuitting(): void { appQuitting = true; } /** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */ export function sendToAppWindows( channel: string, payload: unknown, kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS, ): void { for (const kind of kinds) { const win = windows.get(kind); if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue; try { win.webContents.send(channel, payload); } catch { /* окно могло закрыться между проверкой и send */ } } } 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`); } function pageNameForKind(kind: WindowKind): string { switch (kind) { case 'editor': return 'editor.html'; case 'presentation': return 'presentation.html'; case 'control': return 'control.html'; case 'sceneDescription': return 'sceneDescription.html'; case 'materials': return 'materials.html'; case 'npcsEditor': return 'npcsEditor.html'; case 'npcs': return 'npcs.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) { void win.loadURL(new URL(pageNameForKind(kind), dev).toString()); return; } void win.loadFile(getRendererHtmlPath(kind)); } function getPreloadPath(): string { return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs'); } /** macOS: в Dock — тот же растр, что и у окон (ICO/PNG из brandingIcon). */ export function applyDockIconIfNeeded(): void { if (process.platform !== 'darwin' || !app.dock) return; const icon = loadBrandingWindowIcon(); if (!icon || icon.isEmpty()) return; try { app.dock.setIcon(icon); } catch { /* ignore */ } } 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 windowSizeForKind(kind: WindowKind): { width: number; height: number } { if (kind === 'editor') return { width: 1280, height: 800 }; if (kind === 'control') return { width: 1200, height: 800 }; if (kind === 'sceneDescription') return { width: 720, height: 640 }; if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT }; if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT }; return { width: 1280, height: 800 }; } function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { const deferEditor = kind === 'editor' && opts?.deferVisibility === true; const icon = loadBrandingWindowIcon(); const size = windowSizeForKind(kind); const win = new BrowserWindow({ width: size.width, height: size.height, ...(kind === 'sceneDescription' ? { minWidth: 520, minHeight: 420, autoHideMenuBar: true, } : {}), ...(kind === 'materials' ? { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT, minWidth: 260, maxWidth: 360, minHeight: 480, autoHideMenuBar: true, } : {}), ...(kind === 'npcsEditor' ? { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT, minWidth: 1100, minHeight: 640, autoHideMenuBar: true, } : {}), ...(kind === 'npcs' ? { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT, minWidth: 560, maxWidth: 900, minHeight: 480, autoHideMenuBar: true, } : {}), 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), }, }); if (icon) { try { win.setIcon(icon); } catch { /* ignore */ } } win.setTitle(windowChromeTitle(kind, app.getLocale())); if ( kind === 'sceneDescription' || kind === 'materials' || kind === 'npcsEditor' || kind === 'npcs' ) { win.setMenuBarVisibility(false); } win.webContents.on('preload-error', (_event, preloadPath, error) => { safeConsoleError(`[preload-error] ${preloadPath}:`, error); }); win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { // -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим. if (errorCode === -3) return; if (!isMainFrame) return; safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); }); win.webContents.on('render-process-gone', (_event, details) => { safeConsoleError('[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)); win.on('closed', () => { if (kind !== 'presentation' && kind !== 'control') return; const open = windows.has('presentation') || windows.has('control'); if (!open) { closeSceneDescriptionWindow(); closeMaterialsWindow(); closeNpcsWindow(); } broadcastMultiWindowStateChanged(open); }); 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')) { // macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want). // Keep control window independent on darwin. createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation }); } broadcastMultiWindowStateChanged(true); } export function closeMultiWindow(): void { closeSceneDescriptionWindow(); closeMaterialsWindow(); closeNpcsWindow(); const pres = windows.get('presentation'); const ctrl = windows.get('control'); if (pres) pres.close(); if (ctrl) ctrl.close(); } export function isMultiWindowOpen(): boolean { return windows.has('presentation') || windows.has('control'); } export function closeSceneDescriptionWindow(): void { const win = windows.get('sceneDescription'); if (win && !win.isDestroyed()) { win.close(); } } export function closeMaterialsWindow(): void { const win = windows.get('materials'); if (win && !win.isDestroyed()) { win.close(); } } export function closeNpcsEditorWindow(): void { const win = windows.get('npcsEditor'); if (win && !win.isDestroyed()) { win.close(); } } export function closeNpcsWindow(): void { const win = windows.get('npcs'); if (win && !win.isDestroyed()) { win.close(); } } export function getSceneDescriptionContent(): string { return pendingSceneDescriptionHtml; } /** Одно окно описания: переиспользовать существующее или создать новое. */ export function openSceneDescriptionWindow(html: string): void { pendingSceneDescriptionHtml = html; const existing = windows.get('sceneDescription'); if (existing && !existing.isDestroyed()) { if (existing.isMinimized()) existing.restore(); existing.show(); existing.focus(); existing.moveTop(); sendSceneDescriptionContent(existing, html); return; } // Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра. const parent = windows.get('control') ?? windows.get('presentation'); const win = createWindow('sceneDescription', parent ? { parent } : undefined); const { width, height } = win.getBounds(); const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); const { x, y, width: dw, height: dh } = display.workArea; win.setBounds({ x: Math.round(x + (dw - width) / 2), y: Math.round(y + (dh - height) / 2), width, height, }); win.webContents.once('did-finish-load', () => { sendSceneDescriptionContent(win, pendingSceneDescriptionHtml); if (!win.isDestroyed()) { win.show(); win.focus(); win.moveTop(); } }); } /** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */ export function openMaterialsWindow(): void { const existing = windows.get('materials'); if (existing && !existing.isDestroyed()) { if (existing.isMinimized()) existing.restore(); const b = existing.getBounds(); existing.setBounds({ x: b.x, y: b.y, width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT, }); existing.show(); existing.focus(); existing.moveTop(); return; } const parent = windows.get('control') ?? windows.get('presentation'); const win = createWindow('materials', parent ? { parent } : undefined); const { width, height } = win.getBounds(); const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); const { x, y, width: dw, height: dh } = display.workArea; win.setBounds({ x: Math.round(x + Math.max(0, dw - width - 24)), y: Math.round(y + (dh - height) / 2), width, height, }); win.webContents.once('did-finish-load', () => { if (!win.isDestroyed()) { win.show(); win.focus(); win.moveTop(); } }); } /** Редактор НПС: отдельное окно с графом и инспектором. */ export function openNpcsEditorWindow(): void { const existing = windows.get('npcsEditor'); if (existing && !existing.isDestroyed()) { if (existing.isMinimized()) existing.restore(); existing.show(); existing.focus(); existing.moveTop(); return; } const parent = windows.get('editor'); const win = createWindow('npcsEditor', parent ? { parent } : undefined); const { width, height } = win.getBounds(); const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); const { x, y, width: dw, height: dh } = display.workArea; win.setBounds({ x: Math.round(x + Math.max(0, (dw - width) / 2)), y: Math.round(y + Math.max(0, (dh - height) / 2)), width, height, }); win.webContents.once('did-finish-load', () => { if (!win.isDestroyed()) { win.show(); win.focus(); win.moveTop(); } }); } /** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */ export function openNpcsWindow(): void { const existing = windows.get('npcs'); if (existing && !existing.isDestroyed()) { if (existing.isMinimized()) existing.restore(); const b = existing.getBounds(); existing.setBounds({ x: b.x, y: b.y, width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)), height: Math.max(NPCS_WINDOW_HEIGHT, b.height), }); existing.show(); existing.focus(); existing.moveTop(); return; } const parent = windows.get('control') ?? windows.get('presentation'); const win = createWindow('npcs', parent ? { parent } : undefined); const { width, height } = win.getBounds(); const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); const { x, y, width: dw, height: dh } = display.workArea; win.setBounds({ x: Math.round(x + Math.max(0, dw - width - 24)), y: Math.round(y + (dh - height) / 2), width, height, }); win.webContents.once('did-finish-load', () => { if (!win.isDestroyed()) { win.show(); win.focus(); win.moveTop(); } }); } export function togglePresentationFullscreen(): boolean { const pres = windows.get('presentation'); if (!pres) return false; const next = !pres.isFullScreen(); pres.setFullScreen(next); return pres.isFullScreen(); }