80103a00e7
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>
251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
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);
|
||
}
|