5 Commits

Author SHA1 Message Date
Ivan Fontosh 1840227be6 fix(win): restore tray/taskbar and installer icons; release 1.0.5
Release / release (push) Successful in 5m8s
- Generate build/icon.ico for electron-builder (exe/NSIS/Programs list).
- Unpack branding PNGs from asar so nativeImage loads on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 08:04:04 +08:00
Ivan Fontosh a15adfc3b1 ci(release): unpack win32 sharp package without npm install
Release / release (push) Successful in 5m9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 07:45:58 +08:00
Ivan Fontosh 82baef2b04 ci(release): install @img/sharp-win32-x64 with --os win32 --cpu x64 on Linux
Release / release (push) Failing after 28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 07:43:03 +08:00
Ivan Fontosh e4b989c60f Release 1.0.3: Windows sharp in CI, manual update check, updater IPC
Release / release (push) Failing after 29s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 07:30:03 +08:00
Ivan Fontosh e165a41180 chore: release 1.0.2
Release / release (push) Successful in 4m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:22:47 +08:00
11 changed files with 1212 additions and 22 deletions
+13
View File
@@ -67,6 +67,19 @@ jobs:
- run: npm ci - run: npm ci
# Не используем `npm install`: на Linux npm падает с EBADPLATFORM для win32-пакета.
- name: sharp (@img/sharp-win32-x64) для Windows-артефакта при сборке на Linux
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp -d)"
npm pack @img/sharp-win32-x64@0.34.5 --pack-destination "$tmp"
mkdir -p node_modules/@img/sharp-win32-x64
tar -xzf "$tmp/img-sharp-win32-x64-0.34.5.tgz" -C "$tmp"
cp -a "$tmp/package/." node_modules/@img/sharp-win32-x64/
test -f node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
rm -rf "$tmp"
- run: npm run build - run: npm run build
- name: electron-builder (win) - name: electron-builder (win)
+2 -1
View File
@@ -252,6 +252,7 @@ async function main() {
registerHandler(ipcChannels.app.getVersion, () => ({ registerHandler(ipcChannels.app.getVersion, () => ({
version: getAppSemanticVersion(), version: getAppSemanticVersion(),
buildNumber: getOptionalBuildNumber(), buildNumber: getOptionalBuildNumber(),
packaged: app.isPackaged,
})); }));
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus()); registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
@@ -526,9 +527,9 @@ async function main() {
return { ok: true }; return { ok: true };
}); });
installAutoUpdater(licenseService, registerHandler);
installIpcRouter(); installIpcRouter();
applyDockIconIfNeeded(); applyDockIconIfNeeded();
installAutoUpdater(licenseService);
await runStartupAfterHandlers(licenseService); await runStartupAfterHandlers(licenseService);
app.on('activate', () => { app.on('activate', () => {
+2
View File
@@ -36,6 +36,8 @@ export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handle
handlers.set(channelStr, inner); handlers.set(channelStr, inner);
} }
export type IpcRegisterHandler = typeof registerHandler;
export function installIpcRouter(): void { export function installIpcRouter(): void {
for (const [channel, handler] of handlers.entries()) { for (const [channel, handler] of handlers.entries()) {
ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload)); ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload));
+57 -1
View File
@@ -1,6 +1,12 @@
import { app, dialog } from 'electron'; import { app, dialog } from 'electron';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterDownloadResponse,
} from '../../shared/ipc/contracts';
import type { IpcRegisterHandler } from '../ipc/router';
import { addLicenseChangeListener } from '../license/licenseService'; import { addLicenseChangeListener } from '../license/licenseService';
import type { LicenseService } from '../license/licenseService'; import type { LicenseService } from '../license/licenseService';
@@ -9,6 +15,10 @@ const STARTUP_CHECK_DELAY_MS = 12_000;
const RE_CHECK_COOLDOWN_MS = 30_000; const RE_CHECK_COOLDOWN_MS = 30_000;
let lastCheckAt = 0; let lastCheckAt = 0;
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
let suppressAutoInstallDialog = false;
type RegisterFn = IpcRegisterHandler;
function isLicensedForUpdates(licenseService: LicenseService): boolean { function isLicensedForUpdates(licenseService: LicenseService): boolean {
const snap = licenseService.getStatusSync(); const snap = licenseService.getStatusSync();
@@ -24,11 +34,53 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
void autoUpdater.checkForUpdates().catch(() => undefined); void autoUpdater.checkForUpdates().catch(() => undefined);
} }
async function runManualUpdaterCheck(licenseService: LicenseService): Promise<UpdaterCheckResponse> {
if (!app.isPackaged) {
return { outcome: 'not_packaged' };
}
if (!isLicensedForUpdates(licenseService)) {
return { outcome: 'no_license' };
}
try {
const result = await autoUpdater.checkForUpdates();
if (result && result.isUpdateAvailable && result.updateInfo.version) {
return { outcome: 'available', version: result.updateInfo.version };
}
return { outcome: 'current', currentVersion: app.getVersion() };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { outcome: 'error', message };
}
}
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
if (!app.isPackaged) {
return { ok: false, message: 'NOT_PACKAGED' };
}
try {
suppressAutoInstallDialog = true;
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall(false, true);
return { ok: true };
} catch (e) {
suppressAutoInstallDialog = false;
const message = e instanceof Error ? e.message : String(e);
return { ok: false, message };
}
}
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
}
/** /**
* Проверка обновлений: только упакованное приложение, только при активной лицензии. * Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки). * Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
*/ */
export function installAutoUpdater(licenseService: LicenseService): void { export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
registerUpdaterHandlers(register, licenseService);
if (!app.isPackaged) return; if (!app.isPackaged) return;
const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim(); const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim();
@@ -41,6 +93,10 @@ export function installAutoUpdater(licenseService: LicenseService): void {
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
if (suppressAutoInstallDialog) {
suppressAutoInstallDialog = false;
return;
}
void dialog void dialog
.showMessageBox({ .showMessageBox({
type: 'info', type: 'info',
+16 -7
View File
@@ -70,18 +70,27 @@ function getPreloadPath(): string {
} }
/** /**
* PNG для иконки окна / дока: тот же растр, что electron-builder берёт из `build/icon.png` * PNG для иконки окна / дока: копия `build/icon.png` в dist после сборки, затем окно 256px.
* (копия в dist после сборки), затем окно 256px, затем dev-пути. SVG не используем для * В упакованном приложении файлы под `dist/renderer/*.png` вынесены в `app.asar.unpacked`
* nativeImage на Windows — иначе пустая картинка и дефолтная иконка Electron вместо exe. * — `nativeImage` на Windows с путём только внутри asar часто даёт пустую иконку.
* SVG не используем для nativeImage на Windows — иначе пустая картинка и дефолт Electron.
*/ */
function resolveBrandingPngPaths(): string[] { function resolveBrandingPngPaths(): string[] {
const root = app.getAppPath(); const root = app.getAppPath();
return [ const relPack = path.join('dist', 'renderer', 'app-pack-icon.png');
path.join(root, 'dist', 'renderer', 'app-pack-icon.png'), const relWindow = path.join('dist', 'renderer', 'app-window-icon.png');
path.join(root, '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, 'build', 'icon.png'),
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'), path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
]; );
return paths;
} }
function resolveWindowIconPath(): string | undefined { function resolveWindowIconPath(): string | undefined {
+150 -2
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels, type UpdaterCheckResponse } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { AssetId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; import type { AssetId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
@@ -76,6 +76,8 @@ export function EditorApp() {
const [previewBusy, setPreviewBusy] = useState(false); const [previewBusy, setPreviewBusy] = useState(false);
const [presentationOpen, setPresentationOpen] = useState(false); const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null); const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false);
const [appPackaged, setAppPackaged] = useState(false);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false); const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false); const [eulaModalOpen, setEulaModalOpen] = useState(false);
const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false); const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
@@ -305,6 +307,7 @@ export function EditorApp() {
const r = await getDndApi().invoke(ipcChannels.app.getVersion, {}); const r = await getDndApi().invoke(ipcChannels.app.getVersion, {});
const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`; const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`;
setAppVersionText(label); setAppVersionText(label);
setAppPackaged(r.packaged);
} catch { } catch {
setAppVersionText(null); setAppVersionText(null);
} }
@@ -647,6 +650,20 @@ export function EditorApp() {
> >
{t('menu.aboutLicense')} {t('menu.aboutLicense')}
</button> </button>
{licenseActive && appPackaged ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setCheckUpdatesOpen(true);
}}
>
{t('menu.checkUpdates')}
</button>
) : null}
<div className={styles.fileMenuSubHost} role="presentation"> <div className={styles.fileMenuSubHost} role="presentation">
<button <button
type="button" type="button"
@@ -819,6 +836,7 @@ export function EditorApp() {
await actions.exportProject(projectId); await actions.exportProject(projectId);
}} }}
/> />
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<SimpleMessageModal <SimpleMessageModal
open={appNotice !== null} open={appNotice !== null}
title={appNotice?.title ?? t('common.message')} title={appNotice?.title ?? t('common.message')}
@@ -941,6 +959,136 @@ function ExportProjectModal({
); );
} }
type CheckUpdatesModalProps = {
open: boolean;
onClose: () => void;
};
function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
const { t } = useEditorI18n();
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
useEffect(() => {
if (!open) return;
startTransition(() => {
setPhase('checking');
setRes(null);
});
void getDndApi()
.invoke(ipcChannels.updater.check, {})
.then((r) => {
setRes(r);
setPhase('done');
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setRes({ outcome: 'error', message });
setPhase('done');
});
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !downloadBusy) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [downloadBusy, onClose, open]);
if (!open) return null;
const body =
phase === 'checking' || res === null ? (
<div className={styles.muted}>{t('updates.checking')}</div>
) : res.outcome === 'available' ? (
<div className={styles.muted}>{t('updates.available', { version: res.version })}</div>
) : res.outcome === 'current' ? (
<div className={styles.muted}>{t('updates.current', { version: res.currentVersion })}</div>
) : res.outcome === 'error' ? (
<div className={styles.muted}>{t('updates.error', { message: res.message })}</div>
) : res.outcome === 'not_packaged' ? (
<div className={styles.muted}>{t('updates.notPackaged')}</div>
) : (
<div className={styles.muted}>{t('updates.noLicense')}</div>
);
const showUpdateIdle = phase === 'done' && res !== null && res.outcome === 'available' && !downloadBusy;
const showUpdateBusy = phase === 'done' && res !== null && res.outcome === 'available' && downloadBusy;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!downloadBusy) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('updates.dialogTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
disabled={downloadBusy}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>{body}</div>
<div className={styles.modalFooter}>
{showUpdateIdle ? (
<>
<Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
<Button
variant="primary"
disabled={downloadBusy}
onClick={() => {
setDownloadBusy(true);
void getDndApi()
.invoke(ipcChannels.updater.downloadAndRestart, {})
.then((r) => {
if (!r.ok) {
setDownloadBusy(false);
setRes({ outcome: 'error', message: r.message });
}
})
.catch((e: unknown) => {
setDownloadBusy(false);
setRes({
outcome: 'error',
message: e instanceof Error ? e.message : String(e),
});
});
}}
>
{t('updates.download')}
</Button>
</>
) : showUpdateBusy ? (
<Button variant="primary" disabled>
{t('updates.downloading')}
</Button>
) : (
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</>,
document.body,
);
}
type SimpleMessageModalProps = { type SimpleMessageModalProps = {
open: boolean; open: boolean;
title?: string; title?: string;
@@ -114,10 +114,21 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.enterKey': 'Указать ключ', 'menu.enterKey': 'Указать ключ',
'menu.aboutLicense': 'О лицензии', 'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления',
'menu.language': 'Язык', 'menu.language': 'Язык',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'updates.dialogTitle': 'Обновления',
'updates.checking': 'Проверка наличия обновлений…',
'updates.available': 'Доступна новая версия {version}.',
'updates.current': 'У вас установлена актуальная версия ({version}).',
'updates.error': 'Не удалось проверить обновления: {message}',
'updates.notPackaged': 'Проверка доступна только в установленной версии приложения.',
'updates.noLicense': 'Нужна активная лицензия.',
'updates.download': 'Обновить',
'updates.downloading': 'Загрузка…',
'projectMenu.home': 'Начальный экран', 'projectMenu.home': 'Начальный экран',
'projectMenu.import': 'Импорт', 'projectMenu.import': 'Импорт',
'projectMenu.export': 'Экспорт', 'projectMenu.export': 'Экспорт',
@@ -330,10 +341,21 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.enterKey': 'Enter license key', 'menu.enterKey': 'Enter license key',
'menu.aboutLicense': 'About license', 'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates',
'menu.language': 'Language', 'menu.language': 'Language',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'updates.dialogTitle': 'Updates',
'updates.checking': 'Checking for updates…',
'updates.available': 'A new version is available: {version}.',
'updates.current': 'You have the latest version ({version}).',
'updates.error': 'Could not check for updates: {message}',
'updates.notPackaged': 'Updates can only be checked in the installed application.',
'updates.noLicense': 'An active license is required.',
'updates.download': 'Update',
'updates.downloading': 'Downloading…',
'projectMenu.home': 'Home', 'projectMenu.home': 'Home',
'projectMenu.import': 'Import', 'projectMenu.import': 'Import',
'projectMenu.export': 'Export', 'projectMenu.export': 'Export',
+22 -1
View File
@@ -18,6 +18,10 @@ export const ipcChannels = {
quit: 'app.quit', quit: 'app.quit',
getVersion: 'app.getVersion', getVersion: 'app.getVersion',
}, },
updater: {
check: 'updater.check',
downloadAndRestart: 'updater.downloadAndRestart',
},
project: { project: {
list: 'project.list', list: 'project.list',
create: 'project.create', create: 'project.create',
@@ -84,6 +88,15 @@ export type ZipProgressEvent = {
detail?: string; detail?: string;
}; };
export type UpdaterCheckResponse =
| { outcome: 'not_packaged' }
| { outcome: 'no_license' }
| { outcome: 'current'; currentVersion: string }
| { outcome: 'available'; version: string }
| { outcome: 'error'; message: string };
export type UpdaterDownloadResponse = { ok: true } | { ok: false; message: string };
export type IpcEventMap = { export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState }; [ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState }; [ipcChannels.effects.stateChanged]: { state: EffectsState };
@@ -101,7 +114,15 @@ export type IpcInvokeMap = {
}; };
[ipcChannels.app.getVersion]: { [ipcChannels.app.getVersion]: {
req: Record<string, never>; req: Record<string, never>;
res: { version: string; buildNumber: string | null }; res: { version: string; buildNumber: string | null; packaged: boolean };
};
[ipcChannels.updater.check]: {
req: Record<string, never>;
res: UpdaterCheckResponse;
};
[ipcChannels.updater.downloadAndRestart]: {
req: Record<string, never>;
res: UpdaterDownloadResponse;
}; };
[ipcChannels.project.list]: { [ipcChannels.project.list]: {
req: Record<string, never>; req: Record<string, never>;
+906 -7
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "DndGamePlayer", "name": "DndGamePlayer",
"version": "1.0.1", "version": "1.0.5",
"description": "DNDGamePlayer — редактор и проигрыватель игр", "description": "DNDGamePlayer — редактор и проигрыватель игр",
"main": "dist/main/index.cjs", "main": "dist/main/index.cjs",
"scripts": { "scripts": {
@@ -60,6 +60,7 @@
"javascript-obfuscator": "^4.2.2", "javascript-obfuscator": "^4.2.2",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"to-ico": "^1.1.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
@@ -82,6 +83,8 @@
"asar": true, "asar": true,
"asarUnpack": [ "asarUnpack": [
"dist/preload/**", "dist/preload/**",
"dist/renderer/app-pack-icon.png",
"dist/renderer/app-window-icon.png",
"node_modules/sharp/**", "node_modules/sharp/**",
"node_modules/@img/**", "node_modules/@img/**",
"node_modules/ffmpeg-static/**" "node_modules/ffmpeg-static/**"
@@ -121,7 +124,7 @@
] ]
} }
], ],
"icon": "build/icon.png" "icon": "build/icon.ico"
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
+17 -1
View File
@@ -1,5 +1,6 @@
/** /**
* Растеризует app-logo.svg в app-window-icon.png для nativeImage (Windows). * Растеризует app-logo.svg в app-window-icon.png для nativeImage (Windows).
* Пишет build/icon.png (macOS / fallback) и build/icon.ico — для exe/NSIS и «Программы и компоненты».
* Запуск: node scripts/gen-window-icon.mjs * Запуск: node scripts/gen-window-icon.mjs
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
@@ -7,6 +8,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js'; import { Resvg } from '@resvg/resvg-js';
import toIco from 'to-ico';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..'); const root = path.join(__dirname, '..');
@@ -29,4 +31,18 @@ const resvg512 = new Resvg(svg, {
}); });
const packOut = resvg512.render(); const packOut = resvg512.render();
fs.writeFileSync(packIconPath, packOut.asPng()); fs.writeFileSync(packIconPath, packOut.asPng());
console.log('wrote', packIconPath, packOut.width, 'x', packOut.height, '(electron-builder)'); console.log('wrote', packIconPath, packOut.width, 'x', packOut.height, '(electron-builder mac / fallback)');
function renderPngForWidth(width) {
const r = new Resvg(svg, {
fitTo: { mode: 'width', value: width },
});
return Buffer.from(r.render().asPng());
}
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
const icoPngs = icoSizes.map((w) => renderPngForWidth(w));
const icoBuf = await toIco(icoPngs);
const icoPath = path.join(buildDir, 'icon.ico');
fs.writeFileSync(icoPath, icoBuf);
console.log('wrote', icoPath, '(electron-builder win)');