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
300 lines
9.4 KiB
TypeScript
300 lines
9.4 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
/** Вне 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<void> {
|
||
return new Promise<void>((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<void>((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();
|
||
}
|