Compare commits
10 Commits
8ec830cdb5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a96e1f8465 | |||
| 80103a00e7 | |||
| b017155eaf | |||
| 5706355c5f | |||
| 428fa09224 | |||
| 1cda87fe13 | |||
| 10de99bb06 | |||
| 0ae3c39333 | |||
| d07dcae626 | |||
| dd0dd646f6 |
@@ -1,4 +1,4 @@
|
|||||||
import { app, dialog } from 'electron';
|
import { app, BrowserWindow, dialog } from 'electron';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
|
||||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ipcChannels,
|
ipcChannels,
|
||||||
type UpdaterCheckResponse,
|
type UpdaterCheckResponse,
|
||||||
type UpdaterDownloadResponse,
|
type UpdaterDownloadResponse,
|
||||||
|
type UpdaterProgressEvent,
|
||||||
} from '../../shared/ipc/contracts';
|
} from '../../shared/ipc/contracts';
|
||||||
import type { IpcRegisterHandler } from '../ipc/router';
|
import type { IpcRegisterHandler } from '../ipc/router';
|
||||||
import { addLicenseChangeListener } from '../license/licenseService';
|
import { addLicenseChangeListener } from '../license/licenseService';
|
||||||
@@ -14,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
|
|||||||
const STARTUP_CHECK_DELAY_MS = 12_000;
|
const STARTUP_CHECK_DELAY_MS = 12_000;
|
||||||
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
||||||
const RE_CHECK_COOLDOWN_MS = 30_000;
|
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;
|
let lastCheckAt = 0;
|
||||||
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
||||||
let suppressAutoInstallDialog = false;
|
let suppressAutoInstallDialog = false;
|
||||||
|
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
|
||||||
|
let downloadedUpdateVersion: string | null = null;
|
||||||
|
|
||||||
type RegisterFn = IpcRegisterHandler;
|
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 {
|
function isLicensedForUpdates(licenseService: LicenseService): boolean {
|
||||||
const snap = licenseService.getStatusSync();
|
const snap = licenseService.getStatusSync();
|
||||||
return snap.active;
|
return snap.active;
|
||||||
@@ -32,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
||||||
lastCheckAt = now;
|
lastCheckAt = now;
|
||||||
|
emitUpdaterProgress({ phase: 'checking' });
|
||||||
void autoUpdater.checkForUpdates().catch(() => undefined);
|
void autoUpdater.checkForUpdates().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,37 +94,126 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
|
|||||||
if (!isLicensedForUpdates(licenseService)) {
|
if (!isLicensedForUpdates(licenseService)) {
|
||||||
return { outcome: 'no_license' };
|
return { outcome: 'no_license' };
|
||||||
}
|
}
|
||||||
|
const prevAutoDownload = autoUpdater.autoDownload;
|
||||||
|
autoUpdater.autoDownload = false;
|
||||||
|
emitUpdaterProgress({ phase: 'checking' });
|
||||||
try {
|
try {
|
||||||
const result = await autoUpdater.checkForUpdates();
|
const result = await autoUpdater.checkForUpdates();
|
||||||
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
||||||
|
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
|
||||||
return { outcome: '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() };
|
return { outcome: 'current', currentVersion: app.getVersion() };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
emitUpdaterProgress({ phase: 'error', message });
|
||||||
return { outcome: 'error', message };
|
return { outcome: 'error', message };
|
||||||
|
} finally {
|
||||||
|
autoUpdater.autoDownload = prevAutoDownload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
|
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
|
||||||
|
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return { ok: false, message: 'NOT_PACKAGED' };
|
return { ok: false, message: 'NOT_PACKAGED' };
|
||||||
}
|
}
|
||||||
|
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
|
||||||
try {
|
try {
|
||||||
suppressAutoInstallDialog = true;
|
suppressAutoInstallDialog = true;
|
||||||
await autoUpdater.downloadUpdate();
|
if (process.platform === 'darwin') {
|
||||||
autoUpdater.quitAndInstall(false, true);
|
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 };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
suppressAutoInstallDialog = false;
|
suppressAutoInstallDialog = false;
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = formatUpdaterError(e);
|
||||||
|
emitUpdaterProgress({ phase: 'error', message });
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit;
|
||||||
|
}
|
||||||
return { ok: false, message };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
||||||
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
|
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
|
||||||
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,8 +232,6 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
|||||||
autoUpdater.setFeedURL({ provider: 'generic', url });
|
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();
|
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
|
||||||
autoUpdater.disableDifferentialDownload = !(
|
autoUpdater.disableDifferentialDownload = !(
|
||||||
enableDiff === '1' ||
|
enableDiff === '1' ||
|
||||||
@@ -103,34 +242,7 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
|||||||
autoUpdater.autoDownload = true;
|
autoUpdater.autoDownload = true;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
wireAutoUpdaterEvents(licenseService);
|
||||||
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(() => {
|
setTimeout(() => {
|
||||||
maybeCheckForUpdates(licenseService, true);
|
maybeCheckForUpdates(licenseService, true);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
|
||||||
import { getAppSemanticVersion } from '../versionInfo';
|
import { getAppSemanticVersion } from '../versionInfo';
|
||||||
|
|
||||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||||
@@ -64,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
win.setTitle(windowChromeTitle('boot', app.getLocale()));
|
||||||
|
|
||||||
bootSplashRef = win;
|
bootSplashRef = win;
|
||||||
win.once('closed', () => {
|
win.once('closed', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { app, BrowserWindow, screen } from 'electron';
|
import { app, BrowserWindow, screen } from 'electron';
|
||||||
|
|
||||||
|
import { windowChromeTitle } from '../../shared/appBranding';
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
import { getBootSplashWindow } from './bootWindow';
|
import { getBootSplashWindow } from './bootWindow';
|
||||||
@@ -140,6 +141,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||||
|
|
||||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
<title>DnD Player — Control</title>
|
<title>TTRPG - Control</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
<title>DnD Player — Editor</title>
|
<title>TTRPG - Editor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { ipcChannels, type UpdaterCheckResponse } from '../../shared/ipc/contracts';
|
import {
|
||||||
|
ipcChannels,
|
||||||
|
type UpdaterCheckResponse,
|
||||||
|
type UpdaterProgressEvent,
|
||||||
|
} from '../../shared/ipc/contracts';
|
||||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
||||||
@@ -965,31 +969,76 @@ type CheckUpdatesModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatUpdaterStageLabel(
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||||
|
ev: UpdaterProgressEvent | null,
|
||||||
|
): string {
|
||||||
|
if (!ev) return t('updates.stage.checking');
|
||||||
|
const percentSuffix =
|
||||||
|
ev.phase === 'downloading' && ev.percent !== undefined
|
||||||
|
? t('updates.stagePercent', { percent: ev.percent })
|
||||||
|
: '';
|
||||||
|
switch (ev.phase) {
|
||||||
|
case 'checking':
|
||||||
|
return t('updates.stage.checking');
|
||||||
|
case 'available':
|
||||||
|
return t('updates.stage.available', { version: ev.version ?? '?' });
|
||||||
|
case 'not-available':
|
||||||
|
return t('updates.stage.not-available');
|
||||||
|
case 'downloading':
|
||||||
|
return t('updates.stage.downloading', { percent: percentSuffix });
|
||||||
|
case 'installing':
|
||||||
|
return t('updates.stage.installing');
|
||||||
|
case 'error':
|
||||||
|
return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error');
|
||||||
|
default:
|
||||||
|
return t('updates.stage.checking');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
|
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
|
||||||
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
|
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
|
||||||
const [downloadBusy, setDownloadBusy] = useState(false);
|
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<UpdaterProgressEvent | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setPhase('checking');
|
setPhase('checking');
|
||||||
setRes(null);
|
setRes(null);
|
||||||
|
setProgress({ phase: 'checking' });
|
||||||
|
setDownloadBusy(false);
|
||||||
});
|
});
|
||||||
void getDndApi()
|
void getDndApi()
|
||||||
.invoke(ipcChannels.updater.check, {})
|
.invoke(ipcChannels.updater.check, {})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setRes(r);
|
setRes(r);
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
|
if (r.outcome === 'available') {
|
||||||
|
setProgress({ phase: 'available', version: r.version });
|
||||||
|
} else if (r.outcome === 'current') {
|
||||||
|
setProgress({ phase: 'not-available', version: r.currentVersion });
|
||||||
|
} else if (r.outcome === 'error') {
|
||||||
|
setProgress({ phase: 'error', message: r.message });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
setRes({ outcome: 'error', message });
|
setRes({ outcome: 'error', message });
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
|
setProgress({ phase: 'error', message });
|
||||||
});
|
});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
return getDndApi().on(ipcChannels.updater.progress, (ev) => {
|
||||||
|
setProgress(ev);
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -1001,6 +1050,8 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) });
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
phase === 'checking' || res === null ? (
|
phase === 'checking' || res === null ? (
|
||||||
<div className={styles.muted}>{t('updates.checking')}</div>
|
<div className={styles.muted}>{t('updates.checking')}</div>
|
||||||
@@ -1042,7 +1093,10 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fieldGrid}>{body}</div>
|
<div className={styles.fieldGrid}>
|
||||||
|
{body}
|
||||||
|
<div className={styles.muted}>{stageLine}</div>
|
||||||
|
</div>
|
||||||
<div className={styles.modalFooter}>
|
<div className={styles.modalFooter}>
|
||||||
{showUpdateIdle ? (
|
{showUpdateIdle ? (
|
||||||
<>
|
<>
|
||||||
@@ -1053,21 +1107,23 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={downloadBusy}
|
disabled={downloadBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (res?.outcome !== 'available') return;
|
||||||
setDownloadBusy(true);
|
setDownloadBusy(true);
|
||||||
|
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
|
||||||
void getDndApi()
|
void getDndApi()
|
||||||
.invoke(ipcChannels.updater.downloadAndRestart, {})
|
.invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setDownloadBusy(false);
|
setDownloadBusy(false);
|
||||||
setRes({ outcome: 'error', message: r.message });
|
setRes({ outcome: 'error', message: r.message });
|
||||||
|
setProgress({ phase: 'error', message: r.message });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
setDownloadBusy(false);
|
setDownloadBusy(false);
|
||||||
setRes({
|
setRes({ outcome: 'error', message });
|
||||||
outcome: 'error',
|
setProgress({ phase: 'error', message });
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1076,7 +1132,7 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
|||||||
</>
|
</>
|
||||||
) : showUpdateBusy ? (
|
) : showUpdateBusy ? (
|
||||||
<Button variant="primary" disabled>
|
<Button variant="primary" disabled>
|
||||||
{t('updates.downloading')}
|
{progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
|
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'updates.noLicense': 'Нужна активная лицензия.',
|
'updates.noLicense': 'Нужна активная лицензия.',
|
||||||
'updates.download': 'Обновить',
|
'updates.download': 'Обновить',
|
||||||
'updates.downloading': 'Загрузка…',
|
'updates.downloading': 'Загрузка…',
|
||||||
|
'updates.stageLine': 'Этап: {stage}',
|
||||||
|
'updates.stage.checking': 'проверка обновлений',
|
||||||
|
'updates.stage.available': 'доступна версия {version}',
|
||||||
|
'updates.stage.not-available': 'актуальная версия',
|
||||||
|
'updates.stage.downloading': 'загрузка{percent}',
|
||||||
|
'updates.stage.installing': 'установка и перезапуск',
|
||||||
|
'updates.stage.error': 'ошибка',
|
||||||
|
'updates.stagePercent': ' ({percent}%)',
|
||||||
|
|
||||||
'projectMenu.home': 'Начальный экран',
|
'projectMenu.home': 'Начальный экран',
|
||||||
'projectMenu.import': 'Импорт',
|
'projectMenu.import': 'Импорт',
|
||||||
@@ -359,6 +367,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'updates.noLicense': 'An active license is required.',
|
'updates.noLicense': 'An active license is required.',
|
||||||
'updates.download': 'Update',
|
'updates.download': 'Update',
|
||||||
'updates.downloading': 'Downloading…',
|
'updates.downloading': 'Downloading…',
|
||||||
|
'updates.stageLine': 'Stage: {stage}',
|
||||||
|
'updates.stage.checking': 'checking for updates',
|
||||||
|
'updates.stage.available': 'version {version} available',
|
||||||
|
'updates.stage.not-available': 'up to date',
|
||||||
|
'updates.stage.downloading': 'downloading{percent}',
|
||||||
|
'updates.stage.installing': 'installing and restarting',
|
||||||
|
'updates.stage.error': 'error',
|
||||||
|
'updates.stagePercent': ' ({percent}%)',
|
||||||
|
|
||||||
'projectMenu.home': 'Home',
|
'projectMenu.home': 'Home',
|
||||||
'projectMenu.import': 'Import',
|
'projectMenu.import': 'Import',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
<title>DnD Player — Presentation</title>
|
<title>TTRPG - Presentation</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -11,3 +11,21 @@ export function appDisplayNameForLocale(localeTag: string): string {
|
|||||||
if (tag.startsWith('ru')) return APP_DISPLAY_NAME_RU;
|
if (tag.startsWith('ru')) return APP_DISPLAY_NAME_RU;
|
||||||
return APP_DISPLAY_NAME_EN;
|
return APP_DISPLAY_NAME_EN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Префикс заголовка окон: `TTRPG - Редактор`. */
|
||||||
|
export const APP_WINDOW_BRAND = 'TTRPG';
|
||||||
|
|
||||||
|
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot';
|
||||||
|
|
||||||
|
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||||
|
editor: { ru: 'Редактор', en: 'Editor' },
|
||||||
|
presentation: { ru: 'Презентация', en: 'Presentation' },
|
||||||
|
control: { ru: 'Пульт', en: 'Control' },
|
||||||
|
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
|
||||||
|
const tag = localeTag.trim().toLowerCase();
|
||||||
|
const suffix = tag.startsWith('ru') ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
|
||||||
|
return `${APP_WINDOW_BRAND} - ${suffix}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const ipcChannels = {
|
|||||||
updater: {
|
updater: {
|
||||||
check: 'updater.check',
|
check: 'updater.check',
|
||||||
downloadAndRestart: 'updater.downloadAndRestart',
|
downloadAndRestart: 'updater.downloadAndRestart',
|
||||||
|
progress: 'updater.progress',
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
list: 'project.list',
|
list: 'project.list',
|
||||||
@@ -97,6 +98,22 @@ export type UpdaterCheckResponse =
|
|||||||
|
|
||||||
export type UpdaterDownloadResponse = { ok: true } | { ok: false; message: string };
|
export type UpdaterDownloadResponse = { ok: true } | { ok: false; message: string };
|
||||||
|
|
||||||
|
export type UpdaterProgressPhase =
|
||||||
|
| 'checking'
|
||||||
|
| 'available'
|
||||||
|
| 'not-available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'installing'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type UpdaterProgressEvent = {
|
||||||
|
phase: UpdaterProgressPhase;
|
||||||
|
/** 0..100 при phase=downloading */
|
||||||
|
percent?: number;
|
||||||
|
version?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type IpcEventMap = {
|
export type IpcEventMap = {
|
||||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||||
@@ -105,6 +122,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||||
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
||||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||||
|
[ipcChannels.updater.progress]: UpdaterProgressEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IpcInvokeMap = {
|
export type IpcInvokeMap = {
|
||||||
@@ -121,7 +139,7 @@ export type IpcInvokeMap = {
|
|||||||
res: UpdaterCheckResponse;
|
res: UpdaterCheckResponse;
|
||||||
};
|
};
|
||||||
[ipcChannels.updater.downloadAndRestart]: {
|
[ipcChannels.updater.downloadAndRestart]: {
|
||||||
req: Record<string, never>;
|
req: { version: string };
|
||||||
res: UpdaterDownloadResponse;
|
res: UpdaterDownloadResponse;
|
||||||
};
|
};
|
||||||
[ipcChannels.project.list]: {
|
[ipcChannels.project.list]: {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
## Сборка на Mac
|
## Сборка на Mac
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
FFMPEG_BINARIES_URL=https://cdn.npmmirror.com/binaries/ffmpeg-static npm ci
|
||||||
npm run pack:mac
|
npm run pack:mac
|
||||||
```
|
```
|
||||||
|
|
||||||
`pack:mac` сам вызывает `build` и `scripts/release-mac-prep.mjs` — подтягивает **оба** набора нативных бинарников sharp (x64 и arm64). Без этого x64-сборка с Apple Silicon падает при старте с ошибкой `Could not load the "sharp" module using the darwin-x64 runtime`.
|
`pack:mac` сам вызывает `build` и `scripts/release-mac-prep.mjs` — подтягивает **оба** набора нативных бинарников sharp (x64 и arm64). Без этого x64-сборка с Apple Silicon падает при старте с ошибкой `Could not load the "sharp" module using the darwin-x64 runtime`.
|
||||||
|
Переменная `FFMPEG_BINARIES_URL` нужна только чтобы `ffmpeg-static` не падал из-за временных 5xx на GitHub при `npm ci`.
|
||||||
|
|
||||||
В `release/` (имена **без версии**):
|
В `release/` (имена **без версии**):
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "TTRPGPlayer",
|
"name": "TTRPGPlayer",
|
||||||
"version": "1.0.18",
|
"version": "1.0.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "TTRPGPlayer",
|
"name": "TTRPGPlayer",
|
||||||
"version": "1.0.18",
|
"version": "1.0.20",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TTRPGPlayer",
|
"name": "TTRPGPlayer",
|
||||||
"version": "1.0.18",
|
"version": "1.0.22",
|
||||||
"description": "TTRPG Player — редактор и проигрыватель НРИ",
|
"description": "TTRPG Player — редактор и проигрыватель НРИ",
|
||||||
"main": "dist/main/index.cjs",
|
"main": "dist/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Generate TTRPG-License-Key-Instructions.docx (run: python scripts/generate-license-key-docx.py)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.shared import Pt
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUT_PATHS = [
|
||||||
|
ROOT / "docs" / "TTRPG-License-Key-Instructions.docx",
|
||||||
|
Path(r"D:\TTRPG-Release\TTRPG-License-Key-Instructions.docx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def add_bullet(doc: Document, text: str, level: int = 0) -> None:
|
||||||
|
style = "List Bullet" if level == 0 else "List Bullet 2"
|
||||||
|
doc.add_paragraph(text, style=style)
|
||||||
|
|
||||||
|
|
||||||
|
def add_step(doc: Document, n: int, title: str, body: str) -> None:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run(f"Шаг {n}. {title}. ").bold = True
|
||||||
|
p.add_run(body)
|
||||||
|
|
||||||
|
|
||||||
|
def add_code(doc: Document, text: str) -> None:
|
||||||
|
for line in text.strip().splitlines():
|
||||||
|
p = doc.add_paragraph(line)
|
||||||
|
p.style = "No Spacing"
|
||||||
|
for run in p.runs:
|
||||||
|
run.font.name = "Consolas"
|
||||||
|
run.font.size = Pt(9)
|
||||||
|
|
||||||
|
|
||||||
|
def build_document() -> Document:
|
||||||
|
doc = Document()
|
||||||
|
normal = doc.styles["Normal"]
|
||||||
|
normal.font.name = "Calibri"
|
||||||
|
normal.font.size = Pt(11)
|
||||||
|
|
||||||
|
title = doc.add_heading("TTRPG Player — выдача нового лицензионного ключа", level=0)
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
|
||||||
|
doc.add_paragraph(
|
||||||
|
"Инструкция для администратора: добавление нового продуктового ключа в базу "
|
||||||
|
"сервера лицензий. Пользователь вводит этот ключ в приложении; сервер выдаёт "
|
||||||
|
"подписанный лицензионный токен при активации."
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_heading("Термины", level=1)
|
||||||
|
doc_terms = [
|
||||||
|
(
|
||||||
|
"Продуктовый ключ",
|
||||||
|
"строка вида TTRPG-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX — выдаётся покупателю, "
|
||||||
|
"хранится в data.json на сервере.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Лицензионный токен",
|
||||||
|
"выдаётся автоматически при активации в приложении; вручную создавать не нужно.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sub",
|
||||||
|
"внутренний идентификатор лицензии на сервере (отзыв, учёт устройств).",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for term, desc in doc_terms:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run(f"{term} — ").bold = True
|
||||||
|
p.add_run(desc)
|
||||||
|
|
||||||
|
doc.add_heading("Где лежит сервер (продакшен)", level=1)
|
||||||
|
srv = doc.add_table(rows=6, cols=2)
|
||||||
|
srv.style = "Table Grid"
|
||||||
|
srv.rows[0].cells[0].text = "Параметр"
|
||||||
|
srv.rows[0].cells[1].text = "Значение"
|
||||||
|
server_rows = [
|
||||||
|
("Домен", "https://license.mailib.ru/"),
|
||||||
|
("VPS", "185.173.94.234"),
|
||||||
|
("Папка проекта", "/var/www/license_mailib_ru"),
|
||||||
|
("Файл ключей", "/var/www/license_mailib_ru/data.json"),
|
||||||
|
("Пользователь Linux", "dndlicense"),
|
||||||
|
]
|
||||||
|
for i, (k, v) in enumerate(server_rows, start=1):
|
||||||
|
srv.rows[i].cells[0].text = k
|
||||||
|
srv.rows[i].cells[1].text = v
|
||||||
|
|
||||||
|
doc.add_heading("Редактирование data.json (удобный редактор)", level=1)
|
||||||
|
doc.add_paragraph(
|
||||||
|
"Не используйте nano, если нужны выделение мышью и привычное копирование. "
|
||||||
|
"Рекомендуется Cursor или VS Code с расширением Remote - SSH."
|
||||||
|
)
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
1,
|
||||||
|
"Настроить SSH",
|
||||||
|
"В %USERPROFILE%\\.ssh\\config добавьте хост (ключ ttrpg_updates_root — тот же, "
|
||||||
|
"что для публикации обновлений):",
|
||||||
|
)
|
||||||
|
add_code(
|
||||||
|
doc,
|
||||||
|
"""Host mailib-vps
|
||||||
|
HostName 185.173.94.234
|
||||||
|
User root
|
||||||
|
IdentityFile C:\\Users\\Administrator\\.ssh\\ttrpg_updates_root""",
|
||||||
|
)
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
2,
|
||||||
|
"Подключиться",
|
||||||
|
"F1 → Remote-SSH: Connect to Host… → mailib-vps.",
|
||||||
|
)
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
3,
|
||||||
|
"Открыть папку",
|
||||||
|
"File → Open Folder → /var/www/license_mailib_ru → открыть data.json.",
|
||||||
|
)
|
||||||
|
add_bullet(doc, "Альтернатива: WinSCP (SFTP) → правый клик по data.json → Edit.")
|
||||||
|
add_bullet(
|
||||||
|
doc,
|
||||||
|
"Альтернатива: scp скачать файл на ПК, править в Cursor, scp залить обратно.",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_heading("Выдача нового ключа", level=1)
|
||||||
|
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
1,
|
||||||
|
"Резервная копия",
|
||||||
|
"На сервере (SSH или терминал в Remote SSH):",
|
||||||
|
)
|
||||||
|
add_code(
|
||||||
|
doc,
|
||||||
|
"cd /var/www/license_mailib_ru\n"
|
||||||
|
"cp data.json \"data.json.bak.$(date +%F-%H%M%S)\"",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
2,
|
||||||
|
"Сгенерировать запись (Node.js на сервере)",
|
||||||
|
"Выполнить в каталоге /var/www/license_mailib_ru. Скрипт добавит запись в "
|
||||||
|
"productKeys и выведет новый ключ в консоль. Срок и лимит устройств — в начале скрипта.",
|
||||||
|
)
|
||||||
|
add_code(
|
||||||
|
doc,
|
||||||
|
r"""node -e "
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
const dataPath = process.env.DND_LICENSE_DATA_PATH || './data.json';
|
||||||
|
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
|
||||||
|
|
||||||
|
const maxDevices = 3;
|
||||||
|
const expiresAtSec = Math.floor(new Date('2027-12-31T23:59:59Z').getTime() / 1000);
|
||||||
|
|
||||||
|
const key = 'TTRPG-' + crypto.randomUUID().toUpperCase();
|
||||||
|
const sub = 'lic_' + crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
data.productKeys ??= [];
|
||||||
|
data.productKeys.push({
|
||||||
|
key,
|
||||||
|
sub,
|
||||||
|
pid: 'dnd_player',
|
||||||
|
maxDevices,
|
||||||
|
expiresAtSec
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2) + '\n');
|
||||||
|
console.log('NEW PRODUCT KEY:', key);
|
||||||
|
console.log('sub:', sub);
|
||||||
|
console.log('expiresAtSec:', expiresAtSec);
|
||||||
|
console.log('maxDevices:', maxDevices);
|
||||||
|
"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
doc.add_paragraph(
|
||||||
|
"Сохраните выведенный NEW PRODUCT KEY — его передаёте пользователю. "
|
||||||
|
"Формат ключа должен соответствовать шаблону TTRPG-… (заглавные hex-символы)."
|
||||||
|
)
|
||||||
|
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
3,
|
||||||
|
"Или добавить вручную в data.json",
|
||||||
|
"В массив productKeys добавьте объект (пример):",
|
||||||
|
)
|
||||||
|
add_code(
|
||||||
|
doc,
|
||||||
|
"""{
|
||||||
|
"key": "TTRPG-12345678-1234-1234-1234-123456789ABC",
|
||||||
|
"sub": "lic_unique_id",
|
||||||
|
"pid": "dnd_player",
|
||||||
|
"maxDevices": 3,
|
||||||
|
"expiresAtSec": 1830268799
|
||||||
|
}""",
|
||||||
|
)
|
||||||
|
fields = doc.add_table(rows=6, cols=2)
|
||||||
|
fields.style = "Table Grid"
|
||||||
|
fields.rows[0].cells[0].text = "Поле"
|
||||||
|
fields.rows[0].cells[1].text = "Описание"
|
||||||
|
field_rows = [
|
||||||
|
("key", "Продуктовый ключ для пользователя (уникальный)"),
|
||||||
|
("sub", "Уникальный ID лицензии на сервере"),
|
||||||
|
("pid", "Обычно dnd_player"),
|
||||||
|
("maxDevices", "Сколько разных deviceId можно активировать"),
|
||||||
|
("expiresAtSec", "Срок действия (Unix time, секунды)"),
|
||||||
|
]
|
||||||
|
for i, (k, v) in enumerate(field_rows, start=1):
|
||||||
|
fields.rows[i].cells[0].text = k
|
||||||
|
fields.rows[i].cells[1].text = v
|
||||||
|
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
4,
|
||||||
|
"Права на файл (если правили от root)",
|
||||||
|
"chown dndlicense:dndlicense /var/www/license_mailib_ru/data.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_step(
|
||||||
|
doc,
|
||||||
|
5,
|
||||||
|
"Перезапуск сервиса",
|
||||||
|
"После изменения data.json:",
|
||||||
|
)
|
||||||
|
add_code(doc, "sudo -u dndlicense pm2 restart dnd-license")
|
||||||
|
|
||||||
|
doc.add_heading("Проверка", level=1)
|
||||||
|
add_bullet(doc, "Сервер жив: curl https://license.mailib.ru/health → {\"ok\":true}")
|
||||||
|
add_bullet(
|
||||||
|
doc,
|
||||||
|
"В приложении TTRPG Player: «Указать ключ» → вставить продуктовый ключ → активация.",
|
||||||
|
)
|
||||||
|
add_bullet(
|
||||||
|
doc,
|
||||||
|
"При ошибке unknown_product_key — ключ не в data.json или опечатка; "
|
||||||
|
"too_many_devices — превышен maxDevices.",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_heading("Отзыв лицензии", level=1)
|
||||||
|
doc.add_paragraph(
|
||||||
|
"Чтобы отозвать лицензию по sub, добавьте sub в массив revokedSubs в data.json "
|
||||||
|
"и перезапустите pm2. Либо POST /v1/admin/revoke с Bearer-токеном администратора "
|
||||||
|
"(токен хранится только на сервере, в эту инструкцию не включён)."
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_heading("Локальная разработка", level=1)
|
||||||
|
add_bullet(
|
||||||
|
doc,
|
||||||
|
"Исходники сервера на ПК: D:\\Work\\my_projects\\dnd_project\\DndGamePlayerLicenseServer",
|
||||||
|
)
|
||||||
|
add_bullet(doc, "Локальный data.json: скопировать из data.example.json")
|
||||||
|
add_bullet(doc, "Запуск: npm start (нужен LICENSE_PRIVATE_KEY_PEM)")
|
||||||
|
add_bullet(doc, "Порт по умолчанию: 3847")
|
||||||
|
|
||||||
|
doc.add_heading("Частые проблемы", level=1)
|
||||||
|
problems = [
|
||||||
|
(
|
||||||
|
"Ключ не принимается в приложении",
|
||||||
|
"Проверьте формат TTRPG-… (8-4-4-4-12 hex), что запись есть в productKeys, "
|
||||||
|
"сервер перезапущен после правки data.json.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"JSON сломан после правки",
|
||||||
|
"Восстановите из data.json.bak.*; проверьте запятые и кавычки.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Нет прав на запись",
|
||||||
|
"Правьте от root или chown на dndlicense; файл не должен быть только для чтения.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for prob, fix in problems:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run(f"{prob}. ").bold = True
|
||||||
|
p.add_run(fix)
|
||||||
|
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
p.add_run(
|
||||||
|
"Репозиторий сервера: git.mailib.ru/ifontosh/DndGamePlayerLicenseServer"
|
||||||
|
).italic = True
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
doc = build_document()
|
||||||
|
for path in OUT_PATHS:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
doc.save(str(path))
|
||||||
|
print(f"Wrote {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -53,7 +53,8 @@ def build_document() -> Document:
|
|||||||
add_step(
|
add_step(
|
||||||
doc,
|
doc,
|
||||||
"Шаг 2. Собрать macOS (только на Mac)",
|
"Шаг 2. Собрать macOS (только на Mac)",
|
||||||
"npm ci && npm run build && npm run pack:mac",
|
"FFMPEG_BINARIES_URL=https://cdn.npmmirror.com/binaries/ffmpeg-static npm ci && "
|
||||||
|
"npm run build && npm run pack:mac",
|
||||||
)
|
)
|
||||||
add_step(
|
add_step(
|
||||||
doc,
|
doc,
|
||||||
@@ -159,6 +160,10 @@ def build_document() -> Document:
|
|||||||
"WSL Linux build / Node 18",
|
"WSL Linux build / Node 18",
|
||||||
"Нужен nvm и Node 22 (scripts/wsl-pack-linux.sh).",
|
"Нужен nvm и Node 22 (scripts/wsl-pack-linux.sh).",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"npm ci / ffmpeg-static / GitHub 5xx",
|
||||||
|
"Win-скрипт повторит npm ci через FFMPEG_BINARIES_URL; на Mac используйте эту переменную вручную.",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"AfterMac: version mismatch",
|
"AfterMac: version mismatch",
|
||||||
"Версии в package.json и latest-mac.yml должны совпадать до запуска release-all.",
|
"Версии в package.json и latest-mac.yml должны совпадать до запуска release-all.",
|
||||||
|
|||||||
@@ -45,6 +45,53 @@ function Write-Fail([string]$text) {
|
|||||||
Write-Host " [!!] $text" -ForegroundColor Red
|
Write-Host " [!!] $text" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$FfmpegStaticMirror = 'https://cdn.npmmirror.com/binaries/ffmpeg-static'
|
||||||
|
|
||||||
|
function Test-IsNpmCi([string[]]$NpmArgs) {
|
||||||
|
return ($NpmArgs.Count -eq 1 -and $NpmArgs[0] -eq 'ci')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-NpmRaw {
|
||||||
|
param(
|
||||||
|
[string[]]$NpmArgs,
|
||||||
|
[string]$WorkingDirectory
|
||||||
|
)
|
||||||
|
$prevEap = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
Push-Location $WorkingDirectory
|
||||||
|
try {
|
||||||
|
& npm @NpmArgs 2>&1 | ForEach-Object {
|
||||||
|
if ($_ -is [System.Management.Automation.ErrorRecord]) {
|
||||||
|
Write-Host $_.ToString()
|
||||||
|
} else {
|
||||||
|
Write-Host ([string]$_)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
$ErrorActionPreference = $prevEap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-NpmCiWithFfmpegMirror {
|
||||||
|
param(
|
||||||
|
[string]$WorkingDirectory
|
||||||
|
)
|
||||||
|
$prevMirror = $env:FFMPEG_BINARIES_URL
|
||||||
|
$env:FFMPEG_BINARIES_URL = $FfmpegStaticMirror
|
||||||
|
try {
|
||||||
|
Write-Host " > npm ci (retry with FFMPEG_BINARIES_URL=$FfmpegStaticMirror)"
|
||||||
|
return (Invoke-NpmRaw -NpmArgs @('ci') -WorkingDirectory $WorkingDirectory)
|
||||||
|
} finally {
|
||||||
|
if ($null -eq $prevMirror) {
|
||||||
|
Remove-Item Env:\FFMPEG_BINARIES_URL -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
$env:FFMPEG_BINARIES_URL = $prevMirror
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-Npm {
|
function Invoke-Npm {
|
||||||
param(
|
param(
|
||||||
[string]$Label,
|
[string]$Label,
|
||||||
@@ -52,14 +99,13 @@ function Invoke-Npm {
|
|||||||
[string]$WorkingDirectory
|
[string]$WorkingDirectory
|
||||||
)
|
)
|
||||||
Write-Host " > $Label"
|
Write-Host " > $Label"
|
||||||
Push-Location $WorkingDirectory
|
$exitCode = Invoke-NpmRaw -NpmArgs $NpmArgs -WorkingDirectory $WorkingDirectory
|
||||||
try {
|
if ($exitCode -ne 0 -and (Test-IsNpmCi $NpmArgs)) {
|
||||||
& npm @NpmArgs
|
Write-Host " [--] npm ci failed (exit $exitCode). Retrying via ffmpeg-static mirror..." -ForegroundColor Yellow
|
||||||
if ($LASTEXITCODE -ne 0) {
|
$exitCode = Invoke-NpmCiWithFfmpegMirror $WorkingDirectory
|
||||||
throw "$Label failed (exit $LASTEXITCODE)"
|
}
|
||||||
}
|
if ($exitCode -ne 0) {
|
||||||
} finally {
|
throw "$Label failed (exit $exitCode)"
|
||||||
Pop-Location
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +160,33 @@ function Invoke-Git {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-GitTextOutput {
|
||||||
|
param(
|
||||||
|
[string]$ProjectRoot,
|
||||||
|
[string[]]$GitArgs
|
||||||
|
)
|
||||||
|
$prevEap = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
$output = & git -C $ProjectRoot @GitArgs 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE)"
|
||||||
|
}
|
||||||
|
if ($null -eq $output) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return (@($output | ForEach-Object {
|
||||||
|
if ($_ -is [System.Management.Automation.ErrorRecord]) {
|
||||||
|
$_.ToString()
|
||||||
|
} else {
|
||||||
|
[string]$_
|
||||||
|
}
|
||||||
|
}) -join "`n").Trim()
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $prevEap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Write-GitLines($lines) {
|
function Write-GitLines($lines) {
|
||||||
foreach ($line in $lines) {
|
foreach ($line in $lines) {
|
||||||
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
||||||
@@ -141,7 +214,7 @@ function Invoke-GitPush {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Push-Git([string]$ProjectRoot, [string]$Remote, [string]$McpConfigPath) {
|
function Push-Git([string]$ProjectRoot, [string]$Remote, [string]$McpConfigPath) {
|
||||||
$branch = (git -C $ProjectRoot rev-parse --abbrev-ref HEAD).Trim()
|
$branch = Get-GitTextOutput $ProjectRoot @('rev-parse', '--abbrev-ref', 'HEAD')
|
||||||
Write-Host " > git push $Remote $branch"
|
Write-Host " > git push $Remote $branch"
|
||||||
$exitCode = Invoke-GitPush $ProjectRoot @($Remote, $branch)
|
$exitCode = Invoke-GitPush $ProjectRoot @($Remote, $branch)
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
@@ -332,7 +405,7 @@ if ($SkipGit) {
|
|||||||
Invoke-Git $projectRoot @('add', 'package-lock.json')
|
Invoke-Git $projectRoot @('add', 'package-lock.json')
|
||||||
}
|
}
|
||||||
$commitMsg = "chore: release v$newVersion"
|
$commitMsg = "chore: release v$newVersion"
|
||||||
$status = (git -C $projectRoot status --porcelain).Trim()
|
$status = Get-GitTextOutput $projectRoot @('status', '--porcelain')
|
||||||
if ($status) {
|
if ($status) {
|
||||||
Invoke-Git $projectRoot @('commit', '-m', $commitMsg)
|
Invoke-Git $projectRoot @('commit', '-m', $commitMsg)
|
||||||
Write-Ok "committed: $commitMsg"
|
Write-Ok "committed: $commitMsg"
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Using $(node -v) ($(command -v node))"
|
echo "Using $(node -v) ($(command -v node))"
|
||||||
|
export FFMPEG_BINARIES_URL="${FFMPEG_BINARIES_URL:-https://cdn.npmmirror.com/binaries/ffmpeg-static}"
|
||||||
|
echo "Using ffmpeg-static mirror: ${FFMPEG_BINARIES_URL}"
|
||||||
npm ci
|
npm ci
|
||||||
npm run pack:linux
|
npm run pack:linux
|
||||||
|
|||||||
Reference in New Issue
Block a user