Files
DndGamePlayer/app/main/windows/createWindows.ts
T
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00

319 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { app, BrowserWindow, nativeImage, screen } from 'electron';
import { ipcChannels } from '../../shared/ipc/contracts';
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));
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { 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<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')) {
// 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 });
}
const open = true;
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
}
export function closeMultiWindow(): void {
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 togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
const next = !pres.isFullScreen();
pres.setFullScreen(next);
return pres.isFullScreen();
}