diff --git a/app/main/update/installAutoUpdater.ts b/app/main/update/installAutoUpdater.ts index 3211d9c..697d4b9 100644 --- a/app/main/update/installAutoUpdater.ts +++ b/app/main/update/installAutoUpdater.ts @@ -1,4 +1,4 @@ -import { app, dialog } from 'electron'; +import { app, BrowserWindow, dialog } from 'electron'; import { autoUpdater } from 'electron-updater'; import { appDisplayNameForLocale } from '../../shared/appBranding'; @@ -6,6 +6,7 @@ import { ipcChannels, type UpdaterCheckResponse, type UpdaterDownloadResponse, + type UpdaterProgressEvent, } from '../../shared/ipc/contracts'; import type { IpcRegisterHandler } from '../ipc/router'; import { addLicenseChangeListener } from '../license/licenseService'; @@ -55,9 +56,22 @@ function formatUpdaterError(e: unknown): string { let lastCheckAt = 0; /** Ручная установка: не показывать второй диалог из `update-downloaded`. */ let suppressAutoInstallDialog = false; +/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */ +let downloadedUpdateVersion: string | null = null; type RegisterFn = IpcRegisterHandler; +function emitUpdaterProgress(ev: UpdaterProgressEvent): void { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed() || win.webContents.isDestroyed()) continue; + try { + win.webContents.send(ipcChannels.updater.progress, ev); + } catch { + /* окно закрылось */ + } + } +} + function isLicensedForUpdates(licenseService: LicenseService): boolean { const snap = licenseService.getStatusSync(); return snap.active; @@ -69,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo const now = Date.now(); if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return; lastCheckAt = now; + emitUpdaterProgress({ phase: 'checking' }); void autoUpdater.checkForUpdates().catch(() => undefined); } @@ -81,21 +96,29 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise { +function isUpdateAlreadyDownloaded(targetVersion: string): boolean { + return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion; +} + +async function runManualDownloadAndRestart(targetVersion: string): Promise { if (!app.isPackaged) { return { ok: false, message: 'NOT_PACKAGED' }; } @@ -105,25 +128,92 @@ async function runManualDownloadAndRestart(): Promise { if (process.platform === 'darwin') { autoUpdater.autoInstallOnAppQuit = false; } - await withTimeout( - autoUpdater.downloadUpdate(), - MANUAL_DOWNLOAD_TIMEOUT_MS, - 'UPDATE_DOWNLOAD_TIMEOUT', - ); + + if (!isUpdateAlreadyDownloaded(targetVersion)) { + emitUpdaterProgress({ phase: 'downloading', version: targetVersion, percent: 0 }); + await withTimeout( + autoUpdater.downloadUpdate(), + MANUAL_DOWNLOAD_TIMEOUT_MS, + 'UPDATE_DOWNLOAD_TIMEOUT', + ); + } + + emitUpdaterProgress({ phase: 'installing', version: targetVersion }); quitAndInstallForPlatform(); return { ok: true }; } catch (e) { suppressAutoInstallDialog = false; + const message = formatUpdaterError(e); + emitUpdaterProgress({ phase: 'error', message }); if (process.platform === 'darwin') { autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit; } - return { ok: false, message: formatUpdaterError(e) }; + return { ok: false, message }; } } function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void { register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService)); - register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart()); + register(ipcChannels.updater.downloadAndRestart, (req: { version: string }) => + runManualDownloadAndRestart(req.version), + ); +} + +function wireAutoUpdaterEvents(licenseService: LicenseService): void { + autoUpdater.on('checking-for-update', () => { + emitUpdaterProgress({ phase: 'checking' }); + }); + + autoUpdater.on('update-available', (info) => { + emitUpdaterProgress({ phase: 'available', version: info.version }); + }); + + autoUpdater.on('update-not-available', (info) => { + emitUpdaterProgress({ phase: 'not-available', version: info.version }); + }); + + autoUpdater.on('download-progress', (progress) => { + const percent = + typeof progress.percent === 'number' && Number.isFinite(progress.percent) + ? Math.round(progress.percent) + : undefined; + const ev: UpdaterProgressEvent = { phase: 'downloading' }; + if (percent !== undefined) ev.percent = percent; + emitUpdaterProgress(ev); + }); + + autoUpdater.on('update-downloaded', (info) => { + downloadedUpdateVersion = info.version; + emitUpdaterProgress({ phase: 'downloading', version: info.version, percent: 100 }); + if (suppressAutoInstallDialog) { + suppressAutoInstallDialog = false; + return; + } + void dialog + .showMessageBox({ + type: 'info', + title: appDisplayNameForLocale(app.getLocale()), + message: `Доступна новая версия ${info.version}. Установить и перезапустить?`, + buttons: ['Перезапустить сейчас', 'Позже'], + defaultId: 0, + cancelId: 1, + }) + .then((r) => { + if (r.response === 0) { + emitUpdaterProgress({ phase: 'installing', version: info.version }); + quitAndInstallForPlatform(); + } + }); + }); + + autoUpdater.on('error', (e) => { + const message = e instanceof Error ? e.message : String(e); + emitUpdaterProgress({ phase: 'error', message }); + }); + + addLicenseChangeListener(() => { + maybeCheckForUpdates(licenseService, false); + }); } /** @@ -142,8 +232,6 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg autoUpdater.setFeedURL({ provider: 'generic', url }); } - // Дифференциальное обновление (multi-Range по blockmap) часто ломается на Gitea raw за nginx (HTTP 400); - // electron-updater тогда и так падает на полный файл — отключаем лишний шум и лишний round-trip. const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase(); autoUpdater.disableDifferentialDownload = !( enableDiff === '1' || @@ -154,34 +242,7 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.on('update-downloaded', (info) => { - if (suppressAutoInstallDialog) { - suppressAutoInstallDialog = false; - return; - } - void dialog - .showMessageBox({ - type: 'info', - title: appDisplayNameForLocale(app.getLocale()), - message: `Доступна новая версия ${info.version}. Установить и перезапустить?`, - buttons: ['Перезапустить сейчас', 'Позже'], - defaultId: 0, - cancelId: 1, - }) - .then((r) => { - if (r.response === 0) { - quitAndInstallForPlatform(); - } - }); - }); - - autoUpdater.on('error', () => { - /* без console: в production main минифицируется с drop console */ - }); - - addLicenseChangeListener(() => { - maybeCheckForUpdates(licenseService, false); - }); + wireAutoUpdaterEvents(licenseService); setTimeout(() => { maybeCheckForUpdates(licenseService, true); diff --git a/app/main/windows/bootWindow.ts b/app/main/windows/bootWindow.ts index e46671c..2c005a9 100644 --- a/app/main/windows/bootWindow.ts +++ b/app/main/windows/bootWindow.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { app, BrowserWindow } from 'electron'; -import { appDisplayNameForLocale } from '../../shared/appBranding'; +import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding'; import { getAppSemanticVersion } from '../versionInfo'; import { loadBrandingWindowIcon } from './brandingIcon'; @@ -64,6 +64,7 @@ export function createBootWindow(): BrowserWindow { /* ignore */ } } + win.setTitle(windowChromeTitle('boot', app.getLocale())); bootSplashRef = win; win.once('closed', () => { diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index ece893b..3c0b142 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { app, BrowserWindow, screen } from 'electron'; +import { windowChromeTitle } from '../../shared/appBranding'; import { ipcChannels } from '../../shared/ipc/contracts'; import { getBootSplashWindow } from './bootWindow'; @@ -140,6 +141,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow } } + win.setTitle(windowChromeTitle(kind, app.getLocale())); + win.webContents.on('preload-error', (_event, preloadPath, error) => { console.error(`[preload-error] ${preloadPath}:`, error); }); diff --git a/app/renderer/control.html b/app/renderer/control.html index d41d20f..5211676 100644 --- a/app/renderer/control.html +++ b/app/renderer/control.html @@ -4,7 +4,7 @@ - DnD Player — Control + TTRPG - Control
diff --git a/app/renderer/editor.html b/app/renderer/editor.html index 7ede2ff..dacbe86 100644 --- a/app/renderer/editor.html +++ b/app/renderer/editor.html @@ -4,7 +4,7 @@ - DnD Player — Editor + TTRPG - Editor
diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 2464ff1..2a51d1e 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1,7 +1,11 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ipcChannels, type UpdaterCheckResponse } from '../../shared/ipc/contracts'; +import { + ipcChannels, + type UpdaterCheckResponse, + type UpdaterProgressEvent, +} from '../../shared/ipc/contracts'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; @@ -965,31 +969,76 @@ type CheckUpdatesModalProps = { onClose: () => void; }; +function formatUpdaterStageLabel( + t: (key: string, vars?: Record) => string, + ev: UpdaterProgressEvent | null, +): string { + if (!ev) return t('updates.stage.checking'); + const percentSuffix = + ev.phase === 'downloading' && ev.percent !== undefined + ? t('updates.stagePercent', { percent: ev.percent }) + : ''; + switch (ev.phase) { + case 'checking': + return t('updates.stage.checking'); + case 'available': + return t('updates.stage.available', { version: ev.version ?? '?' }); + case 'not-available': + return t('updates.stage.not-available'); + case 'downloading': + return t('updates.stage.downloading', { percent: percentSuffix }); + case 'installing': + return t('updates.stage.installing'); + case 'error': + return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error'); + default: + return t('updates.stage.checking'); + } +} + function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { const { t } = useEditorI18n(); const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle'); const [res, setRes] = useState(null); const [downloadBusy, setDownloadBusy] = useState(false); + const [progress, setProgress] = useState(null); useEffect(() => { if (!open) return; startTransition(() => { setPhase('checking'); setRes(null); + setProgress({ phase: 'checking' }); + setDownloadBusy(false); }); void getDndApi() .invoke(ipcChannels.updater.check, {}) .then((r) => { setRes(r); setPhase('done'); + if (r.outcome === 'available') { + setProgress({ phase: 'available', version: r.version }); + } else if (r.outcome === 'current') { + setProgress({ phase: 'not-available', version: r.currentVersion }); + } else if (r.outcome === 'error') { + setProgress({ phase: 'error', message: r.message }); + } }) .catch((e: unknown) => { const message = e instanceof Error ? e.message : String(e); setRes({ outcome: 'error', message }); setPhase('done'); + setProgress({ phase: 'error', message }); }); }, [open]); + useEffect(() => { + if (!open) return; + return getDndApi().on(ipcChannels.updater.progress, (ev) => { + setProgress(ev); + }); + }, [open]); + useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { @@ -1001,6 +1050,8 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { if (!open) return null; + const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) }); + const body = phase === 'checking' || res === null ? (
{t('updates.checking')}
@@ -1042,7 +1093,10 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { × -
{body}
+
+ {body} +
{stageLine}
+
{showUpdateIdle ? ( <> @@ -1053,21 +1107,23 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { variant="primary" disabled={downloadBusy} onClick={() => { + if (res?.outcome !== 'available') return; setDownloadBusy(true); + setProgress({ phase: 'downloading', version: res.version, percent: 0 }); void getDndApi() - .invoke(ipcChannels.updater.downloadAndRestart, {}) + .invoke(ipcChannels.updater.downloadAndRestart, { version: res.version }) .then((r) => { if (!r.ok) { setDownloadBusy(false); setRes({ outcome: 'error', message: r.message }); + setProgress({ phase: 'error', message: r.message }); } }) .catch((e: unknown) => { + const message = e instanceof Error ? e.message : String(e); setDownloadBusy(false); - setRes({ - outcome: 'error', - message: e instanceof Error ? e.message : String(e), - }); + setRes({ outcome: 'error', message }); + setProgress({ phase: 'error', message }); }); }} > @@ -1076,7 +1132,7 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { ) : showUpdateBusy ? ( ) : (