7c858ba633
Rename product to TTRPG Player (TTRPGPlayer / com.ttrpgplayer.app), use .ttrpg.zip for new saves while keeping .dnd.zip import, accept TTRPG- and DND- license keys on client, and remove sync-update-feed plus CI push to DndGamePlayerUpdates. Co-authored-by: Cursor <cursoragent@cursor.com>
139 lines
5.4 KiB
TypeScript
139 lines
5.4 KiB
TypeScript
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;
|
||
|
||
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<UpdaterCheckResponse> {
|
||
if (!app.isPackaged) {
|
||
return { outcome: 'not_packaged' };
|
||
}
|
||
if (!isLicensedForUpdates(licenseService)) {
|
||
return { outcome: 'no_license' };
|
||
}
|
||
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 };
|
||
}
|
||
}
|
||
|
||
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
|
||
if (!app.isPackaged) {
|
||
return { ok: false, message: 'NOT_PACKAGED' };
|
||
}
|
||
try {
|
||
suppressAutoInstallDialog = true;
|
||
await autoUpdater.downloadUpdate();
|
||
autoUpdater.quitAndInstall(false, true);
|
||
return { ok: true };
|
||
} catch (e) {
|
||
suppressAutoInstallDialog = false;
|
||
const message = e instanceof Error ? e.message : String(e);
|
||
return { ok: false, message };
|
||
}
|
||
}
|
||
|
||
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) {
|
||
autoUpdater.quitAndInstall(false, true);
|
||
}
|
||
});
|
||
});
|
||
|
||
autoUpdater.on('error', () => {
|
||
/* без console: в production main минифицируется с drop console */
|
||
});
|
||
|
||
addLicenseChangeListener(() => {
|
||
maybeCheckForUpdates(licenseService, false);
|
||
});
|
||
|
||
setTimeout(() => {
|
||
maybeCheckForUpdates(licenseService, true);
|
||
}, STARTUP_CHECK_DELAY_MS);
|
||
}
|