feat: i18n control, Gitea auto-update CI, license-gated updater, fixes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-05-11 22:20:14 +08:00
parent 36776f4c5d
commit f462e65581
23 changed files with 2049 additions and 440 deletions
+2
View File
@@ -7,6 +7,7 @@ import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/route
import { LicenseService } from './license/licenseService';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
@@ -527,6 +528,7 @@ async function main() {
installIpcRouter();
applyDockIconIfNeeded();
installAutoUpdater(licenseService);
await runStartupAfterHandlers(licenseService);
app.on('activate', () => {
+23
View File
@@ -17,6 +17,28 @@ type Preferences = {
eulaAcceptedVersion?: number;
};
type LicenseChangeListener = () => void;
const licenseChangeListeners = new Set<LicenseChangeListener>();
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
export function addLicenseChangeListener(fn: LicenseChangeListener): () => void {
licenseChangeListeners.add(fn);
return () => {
licenseChangeListeners.delete(fn);
};
}
function notifyLicenseChangeListeners(): void {
for (const fn of licenseChangeListeners) {
try {
fn();
} catch (err) {
console.error('[license] change listener failed', err);
}
}
}
function readPreferences(userData: string): Preferences {
try {
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
@@ -35,6 +57,7 @@ function emitLicenseStatusChanged(): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.license.statusChanged, {});
}
notifyLicenseChangeListeners();
}
export class LicenseService {
+71
View File
@@ -0,0 +1,71 @@
import { app, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
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;
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);
}
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
*/
export function installAutoUpdater(licenseService: LicenseService): void {
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 });
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', (info) => {
void dialog
.showMessageBox({
type: 'info',
title: 'DNDGamePlayer',
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);
}
+15 -7
View File
@@ -13,6 +13,19 @@ const windows = new Map<WindowKind, BrowserWindow>();
let appQuitting = false;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed()) continue;
if (w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
@@ -212,9 +225,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
broadcastMultiWindowStateChanged(open);
});
windows.set(kind, win);
return win;
@@ -292,10 +303,7 @@ export function openMultiWindow() {
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
const open = true;
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
broadcastMultiWindowStateChanged(true);
}
export function closeMultiWindow(): void {