fix(mac): updater progress UI and skip redundant download

Show update stage in the modal, forward electron-updater progress over IPC,
and install immediately when the build is already cached. Rename window titles to TTRPG - *.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
     Фонтош Иван Сергеевич
2026-05-19 11:07:19 +08:00
parent b017155eaf
commit 80103a00e7
10 changed files with 225 additions and 52 deletions
+100 -39
View File
@@ -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<Up
}
const prevAutoDownload = autoUpdater.autoDownload;
autoUpdater.autoDownload = false;
emitUpdaterProgress({ phase: 'checking' });
try {
const result = await autoUpdater.checkForUpdates();
if (result && result.isUpdateAvailable && result.updateInfo.version) {
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
return { outcome: 'available', version: result.updateInfo.version };
}
emitUpdaterProgress({ phase: 'not-available', version: app.getVersion() });
return { outcome: 'current', currentVersion: app.getVersion() };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
emitUpdaterProgress({ phase: 'error', message });
return { outcome: 'error', message };
} finally {
autoUpdater.autoDownload = prevAutoDownload;
}
}
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
}
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
if (!app.isPackaged) {
return { ok: false, message: 'NOT_PACKAGED' };
}
@@ -105,25 +128,92 @@ async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
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);
+2 -1
View File
@@ -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', () => {
+3
View File
@@ -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);
});