Files
DndGamePlayer/app/main/update/installAutoUpdater.ts
T
     Фонтош Иван Сергеевич 80103a00e7 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>
2026-05-19 11:07:19 +08:00

251 lines
9.2 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 { app, BrowserWindow, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import { appDisplayNameForLocale } from '../../shared/appBranding';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterDownloadResponse,
type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import type { IpcRegisterHandler } from '../ipc/router';
import { addLicenseChangeListener } from '../license/licenseService';
import type { LicenseService } from '../license/licenseService';
const STARTUP_CHECK_DELAY_MS = 12_000;
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
const RE_CHECK_COOLDOWN_MS = 30_000;
/** Ручная загрузка из модалки — не зависать бесконечно на «Загрузка…». */
const MANUAL_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
function withTimeout<T>(promise: Promise<T>, ms: number, code: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(code)), ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e: unknown) => {
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
);
});
}
/**
* На macOS Squirrel.Mac может уже получить update-downloaded к моменту quitAndInstall.
* При autoInstallOnAppQuit=true MacUpdater не вызывает checkForUpdates повторно и зависает.
*/
function quitAndInstallForPlatform(): void {
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
autoUpdater.quitAndInstall(false, true);
}
function formatUpdaterError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw === 'UPDATE_DOWNLOAD_TIMEOUT') {
return 'Превышено время ожидания загрузки обновления';
}
return raw;
}
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;
}
function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: boolean): void {
if (!app.isPackaged) return;
if (!isLicensedForUpdates(licenseService)) return;
const now = Date.now();
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
lastCheckAt = now;
emitUpdaterProgress({ phase: 'checking' });
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' };
}
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;
}
}
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' };
}
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
try {
suppressAutoInstallDialog = true;
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
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 };
}
}
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
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);
});
}
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
*/
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
registerUpdaterHandlers(register, licenseService);
if (!app.isPackaged) return;
const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim();
if (feedOverride) {
const url = feedOverride.endsWith('/') ? feedOverride : `${feedOverride}/`;
autoUpdater.setFeedURL({ provider: 'generic', url });
}
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
autoUpdater.disableDifferentialDownload = !(
enableDiff === '1' ||
enableDiff === 'true' ||
enableDiff === 'yes'
);
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
wireAutoUpdaterEvents(licenseService);
setTimeout(() => {
maybeCheckForUpdates(licenseService, true);
}, STARTUP_CHECK_DELAY_MS);
}