diff --git a/app/main/windows/bootWindow.ts b/app/main/windows/bootWindow.ts index 8be096c..1578412 100644 --- a/app/main/windows/bootWindow.ts +++ b/app/main/windows/bootWindow.ts @@ -4,6 +4,8 @@ import { app, BrowserWindow } from 'electron'; import { getAppSemanticVersion } from '../versionInfo'; +import { loadBrandingWindowIcon } from './brandingIcon'; + let bootSplashRef: BrowserWindow | null = null; export function getBootSplashWindow(): BrowserWindow | null { @@ -37,6 +39,7 @@ function bootWebPreferences(): Electron.WebPreferences { * Показывать после `waitForBootWindowReady`. */ export function createBootWindow(): BrowserWindow { + const icon = loadBrandingWindowIcon(); const win = new BrowserWindow({ width: 440, height: 420, @@ -50,8 +53,16 @@ export function createBootWindow(): BrowserWindow { transparent: false, backgroundColor: '#09090B', roundedCorners: true, + ...(icon ? { icon } : {}), webPreferences: bootWebPreferences(), }); + if (icon) { + try { + win.setIcon(icon); + } catch { + /* ignore */ + } + } bootSplashRef = win; win.once('closed', () => { diff --git a/app/main/windows/brandingIcon.ts b/app/main/windows/brandingIcon.ts new file mode 100644 index 0000000..f66b252 --- /dev/null +++ b/app/main/windows/brandingIcon.ts @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { app, nativeImage } from 'electron'; + +let resolved = false; +let cached: Electron.NativeImage | undefined; + +/** ICO рядом с exe (вне asar): надёжно для `nativeImage` / панели задач на Windows. */ +function getPackagedBrandingIcoPath(): string | undefined { + if (!app.isPackaged) return undefined; + const p = path.join(process.resourcesPath, 'branding', 'icon.ico'); + try { + if (fs.existsSync(p)) return p; + } catch { + /* ignore */ + } + return undefined; +} + +function brandingPngPaths(): string[] { + const root = app.getAppPath(); + const relPack = path.join('dist', 'renderer', 'app-pack-icon.png'); + const relWindow = path.join('dist', 'renderer', 'app-window-icon.png'); + const paths: string[] = []; + if (app.isPackaged) { + const unpacked = path.join(process.resourcesPath, 'app.asar.unpacked'); + paths.push(path.join(unpacked, relPack), path.join(unpacked, relWindow)); + } + paths.push( + path.join(root, relPack), + path.join(root, relWindow), + path.join(root, 'build', 'icon.png'), + path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'), + ); + return paths; +} + +function tryLoadImageFile(filePath: string): Electron.NativeImage | undefined { + try { + const buf = fs.readFileSync(filePath); + const fromBuf = nativeImage.createFromBuffer(buf); + if (!fromBuf.isEmpty()) return fromBuf; + } catch { + /* ignore */ + } + try { + const fromPath = nativeImage.createFromPath(filePath); + if (!fromPath.isEmpty()) return fromPath; + } catch { + /* ignore */ + } + return undefined; +} + +function tryLoadSvgFile(filePath: string): Electron.NativeImage | undefined { + try { + const fromPath = nativeImage.createFromPath(filePath); + if (!fromPath.isEmpty()) return fromPath; + } catch { + /* ignore */ + } + return undefined; +} + +function tryDarwinSvgPaths(): Electron.NativeImage | undefined { + if (process.platform !== 'darwin') return undefined; + const root = app.getAppPath(); + for (const p of [ + path.join(root, 'dist', 'renderer', 'app-logo.svg'), + path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'), + ]) { + if (!fs.existsSync(p)) continue; + const img = tryLoadSvgFile(p); + if (img) return img; + } + return undefined; +} + +/** + * Иконка окна / дока. Сначала ICO из `extraResources` (реальный путь на диске), затем PNG + * через буфер — `createFromPath` к файлам внутри `app.asar` на Windows часто даёт пустой `NativeImage`. + */ +export function loadBrandingWindowIcon(): Electron.NativeImage | undefined { + if (resolved) return cached; + resolved = true; + + const ico = getPackagedBrandingIcoPath(); + if (ico) { + const img = tryLoadImageFile(ico); + if (img) { + cached = img; + return cached; + } + } + + for (const p of brandingPngPaths()) { + if (!fs.existsSync(p)) continue; + const img = tryLoadImageFile(p); + if (img) { + cached = img; + return cached; + } + } + + const svgIcon = tryDarwinSvgPaths(); + if (svgIcon) { + cached = svgIcon; + return cached; + } + + cached = undefined; + return undefined; +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 9bc0c52..f9fc254 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -22,10 +22,11 @@ void test('createWindows: закрытие редактора завершает void test('createWindows: иконка окна (pack PNG, затем window PNG; SVG только вне win32)', () => { const src = readCreateWindows(); - assert.ok(src.includes('resolveWindowIconPath')); - assert.ok(src.includes('app-pack-icon.png')); - assert.ok(src.includes('app-window-icon.png')); - assert.ok(src.includes('app-logo.svg')); + assert.ok(src.includes('loadBrandingWindowIcon')); + const branding = fs.readFileSync(path.join(here, 'brandingIcon.ts'), 'utf8'); + assert.ok(branding.includes('app-pack-icon.png')); + assert.ok(branding.includes('app-window-icon.png')); + assert.ok(branding.includes('tryDarwinSvgPaths')); }); void test('createWindows: пульт поверх экрана просмотра (дочернее окно)', () => { diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index dade0a5..ece893b 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -1,11 +1,11 @@ -import fs from 'node:fs'; import path from 'node:path'; -import { app, BrowserWindow, nativeImage, screen } from 'electron'; +import { app, BrowserWindow, screen } from 'electron'; import { ipcChannels } from '../../shared/ipc/contracts'; import { getBootSplashWindow } from './bootWindow'; +import { loadBrandingWindowIcon } from './brandingIcon'; type WindowKind = 'editor' | 'presentation' | 'control'; @@ -69,91 +69,15 @@ function getPreloadPath(): string { return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs'); } -/** - * PNG для иконки окна / дока: копия `build/icon.png` в dist после сборки, затем окно 256px. - * В упакованном приложении файлы под `dist/renderer/*.png` вынесены в `app.asar.unpacked` - * — `nativeImage` на Windows с путём только внутри asar часто даёт пустую иконку. - * SVG не используем для nativeImage на Windows — иначе пустая картинка и дефолт Electron. - */ -function resolveBrandingPngPaths(): string[] { - const root = app.getAppPath(); - const relPack = path.join('dist', 'renderer', 'app-pack-icon.png'); - const relWindow = path.join('dist', 'renderer', 'app-window-icon.png'); - const paths: string[] = []; - if (app.isPackaged) { - const unpacked = path.join(process.resourcesPath, 'app.asar.unpacked'); - paths.push(path.join(unpacked, relPack), path.join(unpacked, relWindow)); - } - paths.push( - path.join(root, relPack), - path.join(root, relWindow), - path.join(root, 'build', 'icon.png'), - path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'), - ); - return paths; -} - -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). */ +/** macOS: в Dock — тот же растр, что и у окон (ICO/PNG из brandingIcon). */ 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 */ - } + const icon = loadBrandingWindowIcon(); + if (!icon || icon.isEmpty()) return; + try { + app.dock.setIcon(icon); + } catch { + /* ignore */ } } @@ -189,7 +113,7 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void { function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { const deferEditor = kind === 'editor' && opts?.deferVisibility === true; - const icon = resolveWindowIcon(); + const icon = loadBrandingWindowIcon(); const win = new BrowserWindow({ width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280, height: 800, @@ -208,6 +132,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL), }, }); + if (icon) { + try { + win.setIcon(icon); + } catch { + /* ignore */ + } + } win.webContents.on('preload-error', (_event, preloadPath, error) => { console.error(`[preload-error] ${preloadPath}:`, error); diff --git a/app/shared/package.build.test.ts b/app/shared/package.build.test.ts index 014506e..119f40c 100644 --- a/app/shared/package.build.test.ts +++ b/app/shared/package.build.test.ts @@ -12,6 +12,7 @@ void test('package.json: конфиг electron-builder (mac/win)', () => { appId: string; asar: boolean; asarUnpack: string[]; + extraResources: { from: string; to: string }[]; mac: { target: unknown }; files: string[]; }; @@ -21,6 +22,17 @@ void test('package.json: конфиг electron-builder (mac/win)', () => { assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe'); assert.ok(Array.isArray(pkg.build.asarUnpack)); assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload'))); + assert.ok(Array.isArray(pkg.build.extraResources)); + assert.ok( + pkg.build.extraResources.some( + (e: unknown) => + typeof e === 'object' && + e !== null && + 'to' in e && + typeof (e as { to: unknown }).to === 'string' && + (e as { to: string }).to.includes('branding'), + ), + ); assert.ok(Array.isArray(pkg.build.mac.target)); assert.ok(pkg.build.files.includes('dist/**/*')); }); diff --git a/package.json b/package.json index 7ecb2be..51b7d75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DndGamePlayer", - "version": "1.0.5", + "version": "1.0.6", "description": "DNDGamePlayer — редактор и проигрыватель игр", "main": "dist/main/index.cjs", "scripts": { @@ -76,6 +76,12 @@ "output": "release", "buildResources": "build" }, + "extraResources": [ + { + "from": "build/icon.ico", + "to": "branding/icon.ico" + } + ], "files": [ "dist/**/*", "package.json"