import { app, dialog } from 'electron'; import { autoUpdater } from 'electron-updater'; import { appDisplayNameForLocale } from '../../shared/appBranding'; import { ipcChannels, type UpdaterCheckResponse, type UpdaterDownloadResponse, } 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(promise: Promise, ms: number, code: string): Promise { return new Promise((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; type RegisterFn = IpcRegisterHandler; 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; void autoUpdater.checkForUpdates().catch(() => undefined); } async function runManualUpdaterCheck(licenseService: LicenseService): Promise { if (!app.isPackaged) { return { outcome: 'not_packaged' }; } if (!isLicensedForUpdates(licenseService)) { return { outcome: 'no_license' }; } const prevAutoDownload = autoUpdater.autoDownload; autoUpdater.autoDownload = false; 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 }; } finally { autoUpdater.autoDownload = prevAutoDownload; } } async function runManualDownloadAndRestart(): Promise { if (!app.isPackaged) { return { ok: false, message: 'NOT_PACKAGED' }; } const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit; try { suppressAutoInstallDialog = true; if (process.platform === 'darwin') { autoUpdater.autoInstallOnAppQuit = false; } await withTimeout( autoUpdater.downloadUpdate(), MANUAL_DOWNLOAD_TIMEOUT_MS, 'UPDATE_DOWNLOAD_TIMEOUT', ); quitAndInstallForPlatform(); return { ok: true }; } catch (e) { suppressAutoInstallDialog = false; if (process.platform === 'darwin') { autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit; } return { ok: false, message: formatUpdaterError(e) }; } } function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void { register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService)); register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart()); } /** * Проверка обновлений: только упакованное приложение, только при активной лицензии. * Канал и 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 }); } // Дифференциальное обновление (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' || enableDiff === 'true' || enableDiff === 'yes' ); 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); }); setTimeout(() => { maybeCheckForUpdates(licenseService, true); }, STARTUP_CHECK_DELAY_MS); }