import { app, BrowserWindow } from 'electron' import { autoUpdater } from 'electron-updater' import { IPC } from '@shared/ipc' import type { UpdaterStatus } from '@shared/types' import { log } from './logger' let currentStatus: UpdaterStatus = { kind: 'idle' } let lastCheckedAt: number | undefined let wired = false let checkInterval: NodeJS.Timeout | null = null // User-initiated checks surface errors. Background checks stay quiet to avoid // the red banner on transient network blips (504s, DNS, captive portals). let silentMode = false const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour const BOOT_DELAY_MS = 5_000 // Boot retry: if the first check fails (e.g. network not yet up), retry a few // times with exponential backoff before giving up until the hourly tick. const BOOT_RETRY_DELAYS = [30_000, 120_000, 300_000] // 30s, 2min, 5min export function getUpdaterStatus(): UpdaterStatus { return currentStatus } function setStatus(s: UpdaterStatus): void { // Preserve lastCheckedAt across status transitions where applicable. if (s.kind === 'not-available' || s.kind === 'idle') { if (lastCheckedAt && !('lastCheckedAt' in s)) { const withTs = s as { lastCheckedAt?: number } withTs.lastCheckedAt = lastCheckedAt } } currentStatus = s for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s) } } export function initUpdater(): void { if (wired) return wired = true // In dev (electron not packaged) there's no signature / no release feed — // skip silently. The UI still shows "не поддерживается в dev-режиме". if (!app.isPackaged) { setStatus({ kind: 'unsupported', reason: 'Auto-update недоступен в dev-режиме' }) return } autoUpdater.autoDownload = false // user-confirmed download autoUpdater.autoInstallOnAppQuit = true autoUpdater.allowDowngrade = false autoUpdater.on('checking-for-update', () => { // Don't replace the prior status with "checking" during silent polls — the // UI would briefly flicker for users opening Settings during a tick. if (!silentMode) setStatus({ kind: 'checking' }) }) autoUpdater.on('update-available', (info) => { lastCheckedAt = Date.now() setStatus({ kind: 'available', version: info.version, releaseDate: typeof info.releaseDate === 'string' ? info.releaseDate : undefined }) }) autoUpdater.on('update-not-available', () => { lastCheckedAt = Date.now() setStatus({ kind: 'not-available', currentVersion: app.getVersion(), lastCheckedAt }) }) autoUpdater.on('download-progress', (p) => { setStatus({ kind: 'downloading', percent: p.percent, transferred: p.transferred, total: p.total, bytesPerSecond: p.bytesPerSecond }) }) autoUpdater.on('update-downloaded', (info) => { setStatus({ kind: 'downloaded', version: info.version }) }) autoUpdater.on('error', (err) => { const message = err instanceof Error ? err.message : String(err) if (silentMode) { // Background check failed — keep previous status, don't show red banner. // Will retry on the next hourly tick. log.warn('[updater] silent check failed', message) return } setStatus({ kind: 'error', message }) }) // First check on boot with retry-on-failure. setTimeout(() => { void bootCheckWithRetry() }, BOOT_DELAY_MS) // Periodic re-check (silent). checkInterval = setInterval(() => { void checkForUpdates({ silent: true }) }, CHECK_INTERVAL_MS) } async function bootCheckWithRetry(): Promise { for (let attempt = 0; attempt <= BOOT_RETRY_DELAYS.length; attempt++) { await checkForUpdates({ silent: true }) if ( currentStatus.kind === 'available' || currentStatus.kind === 'not-available' || currentStatus.kind === 'downloaded' ) { return // success } const delay = BOOT_RETRY_DELAYS[attempt] if (delay === undefined) { // Исчерпали ретраи — раньше сдавались молча. Логируем, чтобы при // диагностике было видно «boot-check так и не достучался». Следующая // попытка — на ближайшем hourly-тике. log.warn( '[updater] boot check exhausted retries — will retry on hourly tick' ) return } await new Promise((r) => setTimeout(r, delay)) } } export function stopUpdater(): void { if (checkInterval) { clearInterval(checkInterval) checkInterval = null } } export async function checkForUpdates( opts: { silent?: boolean } = {} ): Promise { if (!app.isPackaged) return currentStatus silentMode = opts.silent ?? false try { await autoUpdater.checkForUpdates() } catch (err) { const message = err instanceof Error ? err.message : String(err) if (silentMode) { log.warn('[updater] silent check failed (sync)', message) } else { setStatus({ kind: 'error', message }) } } finally { silentMode = false } return currentStatus } export async function downloadUpdate(): Promise { if (!app.isPackaged) return try { await autoUpdater.downloadUpdate() } catch (err) { setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }) } } export function quitAndInstall(): void { if (!app.isPackaged) return // (isSilent=true, isForceRunAfter=true): // - isSilent: NSIS работает без UI-диалогов установки → restart занимает // ~1-2 сек вместо ~5-10 (без чёрного окна установщика на половину экрана). // - isForceRunAfter: гарантируем что после установки приложение запустится // автоматически, даже если в NSIS-конфиге runAfterFinish был выключен // для этого сценария. Без этого пользователь нажал «Рестарт» — и остался // без открытого приложения. autoUpdater.quitAndInstall(true, true) }