Compare commits
15 Commits
ece48fe53d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a96e1f8465 | |||
| 80103a00e7 | |||
| b017155eaf | |||
| 5706355c5f | |||
| 428fa09224 | |||
| 1cda87fe13 | |||
| 10de99bb06 | |||
| 0ae3c39333 | |||
| d07dcae626 | |||
| dd0dd646f6 | |||
| 8ec830cdb5 | |||
| 02b3131f19 | |||
| cfa067519d | |||
| 4e5d320c36 | |||
| 411ac634f4 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, dialog } from 'electron';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ipcChannels,
|
||||
type UpdaterCheckResponse,
|
||||
type UpdaterDownloadResponse,
|
||||
type UpdaterProgressEvent,
|
||||
} from '../../shared/ipc/contracts';
|
||||
import type { IpcRegisterHandler } from '../ipc/router';
|
||||
import { addLicenseChangeListener } from '../license/licenseService';
|
||||
@@ -14,13 +15,63 @@ 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<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;
|
||||
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
||||
let suppressAutoInstallDialog = false;
|
||||
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
|
||||
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 {
|
||||
const snap = licenseService.getStatusSync();
|
||||
return snap.active;
|
||||
@@ -32,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
|
||||
const now = Date.now();
|
||||
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
||||
lastCheckAt = now;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
void autoUpdater.checkForUpdates().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -42,37 +94,126 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
|
||||
if (!isLicensedForUpdates(licenseService)) {
|
||||
return { outcome: 'no_license' };
|
||||
}
|
||||
const prevAutoDownload = autoUpdater.autoDownload;
|
||||
autoUpdater.autoDownload = false;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
||||
emitUpdaterProgress({ phase: '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() };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
emitUpdaterProgress({ phase: '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) {
|
||||
return { ok: false, message: 'NOT_PACKAGED' };
|
||||
}
|
||||
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
|
||||
try {
|
||||
suppressAutoInstallDialog = true;
|
||||
await autoUpdater.downloadUpdate();
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
if (process.platform === 'darwin') {
|
||||
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 };
|
||||
} catch (e) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Дифференциальное обновление (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' ||
|
||||
@@ -103,34 +242,7 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
||||
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);
|
||||
});
|
||||
wireAutoUpdaterEvents(licenseService);
|
||||
|
||||
setTimeout(() => {
|
||||
maybeCheckForUpdates(licenseService, true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
@@ -64,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
win.setTitle(windowChromeTitle('boot', app.getLocale()));
|
||||
|
||||
bootSplashRef = win;
|
||||
win.once('closed', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { windowChromeTitle } from '../../shared/appBranding';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
|
||||
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) => {
|
||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Control</title>
|
||||
<title>TTRPG - Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Editor</title>
|
||||
<title>TTRPG - Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
||||
@@ -965,28 +969,73 @@ type CheckUpdatesModalProps = {
|
||||
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) {
|
||||
const { t } = useEditorI18n();
|
||||
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
|
||||
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
|
||||
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||
const [progress, setProgress] = useState<UpdaterProgressEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
startTransition(() => {
|
||||
setPhase('checking');
|
||||
setRes(null);
|
||||
setProgress({ phase: 'checking' });
|
||||
setDownloadBusy(false);
|
||||
});
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.updater.check, {})
|
||||
.then((r) => {
|
||||
setRes(r);
|
||||
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) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setRes({ outcome: 'error', message });
|
||||
setPhase('done');
|
||||
setProgress({ phase: 'error', message });
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return getDndApi().on(ipcChannels.updater.progress, (ev) => {
|
||||
setProgress(ev);
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
@@ -1001,6 +1050,8 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) });
|
||||
|
||||
const body =
|
||||
phase === 'checking' || res === null ? (
|
||||
<div className={styles.muted}>{t('updates.checking')}</div>
|
||||
@@ -1042,7 +1093,10 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>{body}</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
{body}
|
||||
<div className={styles.muted}>{stageLine}</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
{showUpdateIdle ? (
|
||||
<>
|
||||
@@ -1053,21 +1107,23 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
||||
variant="primary"
|
||||
disabled={downloadBusy}
|
||||
onClick={() => {
|
||||
if (res?.outcome !== 'available') return;
|
||||
setDownloadBusy(true);
|
||||
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.updater.downloadAndRestart, {})
|
||||
.invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
setDownloadBusy(false);
|
||||
setRes({ outcome: 'error', message: r.message });
|
||||
setProgress({ phase: 'error', message: r.message });
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setDownloadBusy(false);
|
||||
setRes({
|
||||
outcome: 'error',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
setRes({ outcome: 'error', message });
|
||||
setProgress({ phase: 'error', message });
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -1076,7 +1132,7 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
|
||||
</>
|
||||
) : showUpdateBusy ? (
|
||||
<Button variant="primary" disabled>
|
||||
{t('updates.downloading')}
|
||||
{progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
|
||||
|
||||
@@ -130,6 +130,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'updates.noLicense': 'Нужна активная лицензия.',
|
||||
'updates.download': 'Обновить',
|
||||
'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.import': 'Импорт',
|
||||
@@ -359,6 +367,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'updates.noLicense': 'An active license is required.',
|
||||
'updates.download': 'Update',
|
||||
'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.import': 'Import',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Presentation</title>
|
||||
<title>TTRPG - Presentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -11,3 +11,21 @@ export function appDisplayNameForLocale(localeTag: string): string {
|
||||
if (tag.startsWith('ru')) return APP_DISPLAY_NAME_RU;
|
||||
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: {
|
||||
check: 'updater.check',
|
||||
downloadAndRestart: 'updater.downloadAndRestart',
|
||||
progress: 'updater.progress',
|
||||
},
|
||||
project: {
|
||||
list: 'project.list',
|
||||
@@ -97,6 +98,22 @@ export type UpdaterCheckResponse =
|
||||
|
||||
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 = {
|
||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||
@@ -105,6 +122,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.updater.progress]: UpdaterProgressEvent;
|
||||
};
|
||||
|
||||
export type IpcInvokeMap = {
|
||||
@@ -121,7 +139,7 @@ export type IpcInvokeMap = {
|
||||
res: UpdaterCheckResponse;
|
||||
};
|
||||
[ipcChannels.updater.downloadAndRestart]: {
|
||||
req: Record<string, never>;
|
||||
req: { version: string };
|
||||
res: UpdaterDownloadResponse;
|
||||
};
|
||||
[ipcChannels.project.list]: {
|
||||
|
||||
@@ -13,7 +13,7 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
|
||||
asar: boolean;
|
||||
asarUnpack: string[];
|
||||
extraResources: { from: string; to: string }[];
|
||||
mac: { target: unknown };
|
||||
mac: { target: unknown; artifactName?: string };
|
||||
linux: { target: unknown };
|
||||
appImage?: { artifactName?: string };
|
||||
nsis?: { artifactName?: string };
|
||||
@@ -39,6 +39,8 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
|
||||
),
|
||||
);
|
||||
assert.ok(Array.isArray(pkg.build.mac.target));
|
||||
assert.equal(pkg.build.mac.artifactName, '${productName}-${arch}.${ext}');
|
||||
assert.ok(!pkg.build.mac.artifactName?.includes('version'));
|
||||
assert.equal(pkg.build.toolsets?.appimage, '1.0.2');
|
||||
assert.ok(Array.isArray(pkg.build.linux.target));
|
||||
const linuxTargets = pkg.build.linux.target as { target: string; arch: string[] }[];
|
||||
@@ -50,4 +52,15 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
|
||||
assert.ok(!pkg.build.appImage?.artifactName?.includes('version'));
|
||||
assert.ok(!pkg.build.nsis?.artifactName?.includes('version'));
|
||||
assert.ok(pkg.build.files.includes('dist/**/*'));
|
||||
assert.ok(
|
||||
pkg.build.asarUnpack.some((p) => p.includes('@img')),
|
||||
'sharp native binaries live under node_modules/@img',
|
||||
);
|
||||
});
|
||||
|
||||
void test('package.json: pack:mac runs release-mac-prep before electron-builder', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
||||
scripts: { 'pack:mac': string };
|
||||
};
|
||||
assert.match(pkg.scripts['pack:mac'], /release-mac-prep\.mjs/);
|
||||
});
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
## Сборка на Mac
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
FFMPEG_BINARIES_URL=https://cdn.npmmirror.com/binaries/ffmpeg-static npm ci
|
||||
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`.
|
||||
Переменная `FFMPEG_BINARIES_URL` нужна только чтобы `ffmpeg-static` не падал из-за временных 5xx на GitHub при `npm ci`.
|
||||
|
||||
В `release/` (имена **без версии**):
|
||||
|
||||
- `latest-mac.yml`
|
||||
- `TTRPGPlayer-x64.dmg`
|
||||
- `TTRPGPlayer-arm64.dmg` (если собирали arm64)
|
||||
- `latest-mac.yml` — сгенерирован electron-builder, не копируйте старый с Windows
|
||||
- `TTRPGPlayer-x64.zip` и `TTRPGPlayer-arm64.zip` — **нужны для автообновления** (поле `path` в yml)
|
||||
- `TTRPGPlayer-x64.dmg`, `TTRPGPlayer-arm64.dmg` — опционально, для ручной установки
|
||||
|
||||
Скопируйте эти файлы на Windows в общую папку релиза (вместе с Win/Linux) и залейте на VPS вместе с остальными.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.20",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.22",
|
||||
"description": "TTRPG Player — редактор и проигрыватель НРИ",
|
||||
"main": "dist/main/index.cjs",
|
||||
"scripts": {
|
||||
@@ -10,14 +10,14 @@
|
||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||
"format": "prettier . --check",
|
||||
"format:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
"release:info": "node scripts/print-release-info.mjs",
|
||||
"pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder",
|
||||
"pack:dir": "npm run build && node scripts/release-win-prep.mjs && electron-builder --dir",
|
||||
"pack:mac": "npm run build && electron-builder --mac",
|
||||
"pack:mac": "npm run build && node scripts/release-mac-prep.mjs && electron-builder --mac",
|
||||
"pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win",
|
||||
"pack:linux": "node scripts/release-linux-pack.mjs",
|
||||
"release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release.ps1",
|
||||
@@ -105,6 +105,7 @@
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.games",
|
||||
"artifactName": "${productName}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Generate TTRPG-Release-Instructions.docx (run: python scripts/generate-release-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-Release-Instructions.docx",
|
||||
Path(r"D:\TTRPG-Release\TTRPG-Release-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, title: str, body: str) -> None:
|
||||
p = doc.add_paragraph()
|
||||
p.add_run(f"{title}. ").bold = True
|
||||
p.add_run(body)
|
||||
|
||||
|
||||
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(
|
||||
"Одна версия на все платформы. Сначала вручную поднимается версия и собирается macOS; "
|
||||
"затем на Windows скрипт собирает Win/Linux без повторного bump и выкладывает на "
|
||||
"https://updates.mailib.ru/"
|
||||
)
|
||||
|
||||
doc.add_heading("Рекомендуемый порядок", level=1)
|
||||
|
||||
add_step(
|
||||
doc,
|
||||
"Шаг 1. Поднять версию",
|
||||
"В каталоге проекта dnd_player: bump-version.cmd (D:\\TTRPG-Release) "
|
||||
"или npm version patch --no-git-tag-version. "
|
||||
"Закоммитить package.json при необходимости до сборки на Mac.",
|
||||
)
|
||||
add_step(
|
||||
doc,
|
||||
"Шаг 2. Собрать macOS (только на Mac)",
|
||||
"FFMPEG_BINARIES_URL=https://cdn.npmmirror.com/binaries/ffmpeg-static npm ci && "
|
||||
"npm run build && npm run pack:mac",
|
||||
)
|
||||
add_step(
|
||||
doc,
|
||||
"Шаг 3. Скопировать Mac-артефакты в D:\\TTRPG-Release",
|
||||
"latest-mac.yml (только свежий с Mac), TTRPGPlayer-x64.zip, TTRPGPlayer-arm64.zip; "
|
||||
"опционально TTRPGPlayer-x64.dmg, TTRPGPlayer-arm64.dmg. "
|
||||
"Версия в latest-mac.yml должна совпадать с package.json.",
|
||||
)
|
||||
add_step(
|
||||
doc,
|
||||
"Шаг 4. Сборка Win/Linux и публикация (Windows)",
|
||||
"release-all.cmd — по умолчанию режим -AfterMac: без bump, проверка Mac-файлов, "
|
||||
"сборка Windows и Linux (WSL), копирование в D:\\TTRPG-Release, upload на сервер.",
|
||||
)
|
||||
|
||||
doc.add_heading("Скрипты (D:\\TTRPG-Release)", level=1)
|
||||
table = doc.add_table(rows=7, cols=2)
|
||||
table.style = "Table Grid"
|
||||
table.rows[0].cells[0].text = "Команда"
|
||||
table.rows[0].cells[1].text = "Назначение"
|
||||
rows = [
|
||||
("bump-version.cmd", "Только patch в package.json (шаг 1)"),
|
||||
("release-all.cmd", "Шаг 4: Win/Linux + publish, без bump (-AfterMac)"),
|
||||
("prepare-release.cmd -AfterMac", "Только сборка Win/Linux и копирование"),
|
||||
("publish.cmd", "Только проверка и upload на VPS"),
|
||||
("publish.cmd -SkipMac", "Выложить Win/Linux, если Mac ещё не готов"),
|
||||
("release-all-bump.cmd", "Устаревший режим: bump в скрипте, Mac потом"),
|
||||
]
|
||||
for i, (cmd, desc) in enumerate(rows, start=1):
|
||||
table.rows[i].cells[0].text = cmd
|
||||
table.rows[i].cells[1].text = desc
|
||||
|
||||
doc.add_heading("Флаги prepare-release", level=2)
|
||||
for line in [
|
||||
"-AfterMac — не менять версию; требовать Mac-файлы в папке релиза (по умолчанию в release-all)",
|
||||
"-Bump — поднять patch и собрать (старый порядок)",
|
||||
"-SkipGit, -SkipLinux, -NoBump, -Version X.Y.Z, -Minor",
|
||||
]:
|
||||
add_bullet(doc, line)
|
||||
|
||||
doc.add_heading("Флаги publish", level=2)
|
||||
add_bullet(doc, "-CheckOnly — проверить файлы, без upload")
|
||||
add_bullet(doc, "-SkipMac — не проверять и не заливать macOS")
|
||||
|
||||
doc.add_heading("Артефакты (имена без версии)", level=1)
|
||||
art = doc.add_table(rows=4, cols=2)
|
||||
art.style = "Table Grid"
|
||||
art.rows[0].cells[0].text = "Платформа"
|
||||
art.rows[0].cells[1].text = "Файлы"
|
||||
artifacts = [
|
||||
(
|
||||
"Windows",
|
||||
"latest.yml, TTRPGPlayer-Setup.exe, TTRPGPlayer-Setup.exe.blockmap",
|
||||
),
|
||||
(
|
||||
"Linux",
|
||||
"latest-linux.yml, latest-linux-arm64.yml, "
|
||||
"TTRPGPlayer-x64.AppImage, TTRPGPlayer-arm64.AppImage",
|
||||
),
|
||||
(
|
||||
"macOS",
|
||||
"latest-mac.yml, TTRPGPlayer-x64.zip, TTRPGPlayer-arm64.zip "
|
||||
"(zip — автообновление; dmg — опционально, вручную)",
|
||||
),
|
||||
]
|
||||
for i, (plat, files) in enumerate(artifacts, start=1):
|
||||
art.rows[i].cells[0].text = plat
|
||||
art.rows[i].cells[1].text = files
|
||||
|
||||
doc.add_heading("Требования", level=1)
|
||||
for line in [
|
||||
"Windows: Node.js 20.19+ (или 22.12+), git, OpenSSH, WSL2 с nvm (Node 22, .nvmrc в проекте)",
|
||||
"Проект: D:\\Work\\my_projects\\dnd_project\\dnd_player",
|
||||
"Папка релиза: D:\\TTRPG-Release",
|
||||
"SSH: %USERPROFILE%\\.ssh\\ttrpg_updates_root → root@185.173.94.234",
|
||||
"Mac: Node 20+, сборка только на macOS",
|
||||
]:
|
||||
add_bullet(doc, line)
|
||||
|
||||
doc.add_heading("Проверка после выкладки", level=1)
|
||||
for url in [
|
||||
"https://updates.mailib.ru/latest.yml",
|
||||
"https://updates.mailib.ru/latest-linux.yml",
|
||||
"https://updates.mailib.ru/latest-mac.yml",
|
||||
]:
|
||||
add_bullet(doc, url)
|
||||
|
||||
doc.add_heading("Частые проблемы", level=1)
|
||||
problems = [
|
||||
(
|
||||
"Win/Linux на версию выше Mac",
|
||||
"Не использовать release-all-bump.cmd. Сначала bump + Mac, потом release-all.cmd.",
|
||||
),
|
||||
(
|
||||
"macOS: missing TTRPGPlayer-1.0.xx-mac.zip",
|
||||
"Устаревший latest-mac.yml — пересобрать на Mac или publish.cmd -SkipMac.",
|
||||
),
|
||||
(
|
||||
"git push Failed to authenticate",
|
||||
"Скрипт повторит push с токеном Gitea; или настроить credentials для git.mailib.ru.",
|
||||
),
|
||||
(
|
||||
"WSL Linux build / Node 18",
|
||||
"Нужен nvm и Node 22 (scripts/wsl-pack-linux.sh).",
|
||||
),
|
||||
(
|
||||
"npm ci / ffmpeg-static / GitHub 5xx",
|
||||
"Win-скрипт повторит npm ci через FFMPEG_BINARIES_URL; на Mac используйте эту переменную вручную.",
|
||||
),
|
||||
(
|
||||
"AfterMac: version mismatch",
|
||||
"Версии в package.json и latest-mac.yml должны совпадать до запуска release-all.",
|
||||
),
|
||||
]
|
||||
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("Конфиг: release-config.json, publish-config.json в D:\\TTRPG-Release").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()
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Перед `electron-builder --mac`: npm ставит sharp только под CPU хоста.
|
||||
* В release идут x64 и arm64 — в .app должны быть оба набора @img/sharp-darwin-*.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MAC_SHARP_IMG_PACKAGES = [
|
||||
'@img/sharp-darwin-arm64',
|
||||
'@img/sharp-libvips-darwin-arm64',
|
||||
'@img/sharp-darwin-x64',
|
||||
'@img/sharp-libvips-darwin-x64',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} root
|
||||
* @returns {string[]} entries like `@img/sharp-darwin-x64@0.34.5` missing under node_modules
|
||||
*/
|
||||
export function macSharpImgPackagesToInstall(root) {
|
||||
const sharpPkgPath = path.join(root, 'node_modules', 'sharp', 'package.json');
|
||||
if (!fs.existsSync(sharpPkgPath)) {
|
||||
throw new Error('[release-mac-prep] sharp is not installed — run npm ci first');
|
||||
}
|
||||
|
||||
const sharpPkg = JSON.parse(fs.readFileSync(sharpPkgPath, 'utf8'));
|
||||
const versions = sharpPkg.optionalDependencies ?? {};
|
||||
const missing = [];
|
||||
|
||||
for (const name of MAC_SHARP_IMG_PACKAGES) {
|
||||
const version = versions[name];
|
||||
if (!version) {
|
||||
throw new Error(`[release-mac-prep] sharp optionalDependency missing: ${name}`);
|
||||
}
|
||||
if (!fs.existsSync(path.join(root, 'node_modules', name))) {
|
||||
missing.push(`${name}@${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root
|
||||
* @param {{ runInstall?: boolean }} [opts]
|
||||
*/
|
||||
export function ensureMacSharpBinaries(root, opts = {}) {
|
||||
const { runInstall = true } = opts;
|
||||
const toInstall = macSharpImgPackagesToInstall(root);
|
||||
|
||||
if (toInstall.length === 0) {
|
||||
console.log('[release-mac-prep] all macOS sharp binaries present');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[release-mac-prep] installing', toInstall.join(', '));
|
||||
if (!runInstall) return;
|
||||
|
||||
execFileSync('npm', ['install', '--no-save', '--force', ...toInstall], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
if (process.platform !== 'darwin') {
|
||||
console.warn('[release-mac-prep] skipped: not macOS (no dual-arch sharp prep needed here)');
|
||||
process.exit(0);
|
||||
}
|
||||
ensureMacSharpBinaries(root);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { macSharpImgPackagesToInstall } from './release-mac-prep.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
void test('macSharpImgPackagesToInstall: empty when all four @img darwin packages exist', () => {
|
||||
const missing = macSharpImgPackagesToInstall(root);
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
void test('macSharpImgPackagesToInstall: lists missing darwin-x64 on arm64-only tree', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-sharp-prep-'));
|
||||
try {
|
||||
const sharpOpt = {
|
||||
'@img/sharp-darwin-arm64': '0.34.5',
|
||||
'@img/sharp-libvips-darwin-arm64': '1.2.4',
|
||||
'@img/sharp-darwin-x64': '0.34.5',
|
||||
'@img/sharp-libvips-darwin-x64': '1.2.4',
|
||||
};
|
||||
fs.mkdirSync(path.join(tmp, 'node_modules', 'sharp'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'node_modules', 'sharp', 'package.json'),
|
||||
JSON.stringify({ optionalDependencies: sharpOpt }),
|
||||
);
|
||||
for (const name of ['@img/sharp-darwin-arm64', '@img/sharp-libvips-darwin-arm64']) {
|
||||
const dir = path.join(tmp, 'node_modules', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
|
||||
}
|
||||
|
||||
const missing = macSharpImgPackagesToInstall(tmp);
|
||||
assert.deepEqual(missing, ['@img/sharp-darwin-x64@0.34.5', '@img/sharp-libvips-darwin-x64@1.2.4']);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,2 +1,45 @@
|
||||
Copy publish.ps1, publish.cmd, publish-config.json to D:\TTRPG-Release\
|
||||
when updating the release publisher tool.
|
||||
TTRPG Release folder
|
||||
|
||||
====================
|
||||
|
||||
|
||||
|
||||
RECOMMENDED WORKFLOW (same version on all platforms)
|
||||
|
||||
----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
1) bump-version.cmd
|
||||
|
||||
OR in project: npm version patch --no-git-tag-version
|
||||
|
||||
|
||||
|
||||
2) On Mac: npm ci && npm run build && npm run pack:mac
|
||||
|
||||
Copy to D:\TTRPG-Release:
|
||||
|
||||
latest-mac.yml, TTRPGPlayer-x64.zip, TTRPGPlayer-arm64.zip (+ dmg optional)
|
||||
|
||||
|
||||
|
||||
3) release-all.cmd
|
||||
|
||||
- does NOT bump version (default -AfterMac)
|
||||
|
||||
- checks Mac files match package.json version
|
||||
|
||||
- builds Windows + Linux, copies artifacts, publishes to updates.mailib.ru
|
||||
|
||||
|
||||
|
||||
LEGACY (Win/Linux ahead of Mac)
|
||||
|
||||
-------------------------------
|
||||
|
||||
release-all-bump.cmd - bumps patch in script, build Win/Linux, publish with -SkipMac or add Mac later
|
||||
|
||||
|
||||
|
||||
SCRIPTS
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
REM Bump patch in package.json only (no git tag). Run BEFORE Mac build.
|
||||
cd /d "D:\Work\my_projects\dnd_project\dnd_player"
|
||||
call npm version patch --no-git-tag-version
|
||||
if errorlevel 1 pause & exit /b 1
|
||||
echo.
|
||||
echo Version bumped. Next:
|
||||
echo 1) npm run pack:mac on Mac
|
||||
echo 2) Copy latest-mac.yml + TTRPGPlayer-*.zip to D:\TTRPG-Release
|
||||
echo 3) release-all.cmd
|
||||
pause
|
||||
@@ -1,17 +1,26 @@
|
||||
# Prepare TTRPG release: bump version, git push, build Win/Linux, copy to release folder.
|
||||
# Prepare TTRPG release: build Win/Linux, copy to release folder.
|
||||
# Run: prepare-release.cmd
|
||||
#
|
||||
# Recommended (Mac first, same version on all platforms):
|
||||
# 1) Bump version in package.json manually (npm version patch --no-git-tag-version)
|
||||
# 2) pack:mac on Mac, copy latest-mac.yml + *.zip to release folder
|
||||
# 3) prepare-release.cmd -AfterMac (or release-all.cmd)
|
||||
#
|
||||
# Options:
|
||||
# -Version 1.0.17 explicit version (skip auto bump)
|
||||
# -Patch bump patch (default)
|
||||
# -Minor bump minor
|
||||
# -AfterMac do not bump; require Mac files in release folder (default in release-all)
|
||||
# -Bump bump patch before build (Mac can be added later)
|
||||
# -Version 1.0.17 set explicit version (with -Bump workflow)
|
||||
# -Minor bump minor (with -Bump)
|
||||
# -SkipGit skip commit/push
|
||||
# -SkipLinux skip Linux build (WSL)
|
||||
# -NoBump do not change package.json version
|
||||
# -NoBump do not change package.json (same as -AfterMac without Mac check)
|
||||
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Patch,
|
||||
[switch]$Minor,
|
||||
[switch]$Bump,
|
||||
[switch]$AfterMac,
|
||||
[switch]$SkipGit,
|
||||
[switch]$SkipLinux,
|
||||
[switch]$NoBump
|
||||
@@ -36,6 +45,53 @@ function Write-Fail([string]$text) {
|
||||
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 {
|
||||
param(
|
||||
[string]$Label,
|
||||
@@ -43,14 +99,13 @@ function Invoke-Npm {
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
Write-Host " > $Label"
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& npm @NpmArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Label failed (exit $LASTEXITCODE)"
|
||||
$exitCode = Invoke-NpmRaw -NpmArgs $NpmArgs -WorkingDirectory $WorkingDirectory
|
||||
if ($exitCode -ne 0 -and (Test-IsNpmCi $NpmArgs)) {
|
||||
Write-Host " [--] npm ci failed (exit $exitCode). Retrying via ffmpeg-static mirror..." -ForegroundColor Yellow
|
||||
$exitCode = Invoke-NpmCiWithFfmpegMirror $WorkingDirectory
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$Label failed (exit $exitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +119,11 @@ function Convert-ToWslPath([string]$winPath) {
|
||||
return $p
|
||||
}
|
||||
|
||||
function Get-WslLinuxBuildCommand([string]$WslProjectPath) {
|
||||
# WSL Debian often has node 18 on PATH; scripts/wsl-pack-linux.sh uses nvm + .nvmrc.
|
||||
return "cd '$WslProjectPath' && bash scripts/wsl-pack-linux.sh"
|
||||
}
|
||||
|
||||
function Read-PackageVersion([string]$packageJsonPath) {
|
||||
$json = Get-Content -LiteralPath $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
return [string]$json.version
|
||||
@@ -100,24 +160,116 @@ 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) {
|
||||
foreach ($line in $lines) {
|
||||
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
||||
Write-Host $line.ToString()
|
||||
} else {
|
||||
Write-Host ([string]$line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-GitPush {
|
||||
param(
|
||||
[string]$ProjectRoot,
|
||||
[string[]]$PushArgs
|
||||
)
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$output = & git -C $ProjectRoot push @PushArgs 2>&1
|
||||
Write-GitLines $output
|
||||
return $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
& git -C $ProjectRoot push $Remote $branch 2>&1 | ForEach-Object { Write-Host $_ }
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$exitCode = Invoke-GitPush $ProjectRoot @($Remote, $branch)
|
||||
if ($exitCode -eq 0) {
|
||||
return
|
||||
}
|
||||
$pushUrl = Get-GiteaPushUrl $McpConfigPath
|
||||
if (-not $pushUrl) {
|
||||
throw "git push failed and no Gitea token in mcp config"
|
||||
throw "git push failed (exit $exitCode) and no Gitea token in mcp config ($McpConfigPath)"
|
||||
}
|
||||
Write-Host " > git push via Gitea token URL"
|
||||
& git -C $ProjectRoot push $pushUrl "HEAD:${branch}" 2>&1 | ForEach-Object { Write-Host $_ }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git push failed (exit $LASTEXITCODE)"
|
||||
$exitCode = Invoke-GitPush $ProjectRoot @($pushUrl, "HEAD:${branch}")
|
||||
if ($exitCode -ne 0) {
|
||||
throw "git push failed (exit $exitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-YmlField([string]$ymlPath, [string]$fieldName) {
|
||||
$content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8
|
||||
$m = [regex]::Match($content, "(?m)^${fieldName}:\s*(\S+)\s*$")
|
||||
if ($m.Success) {
|
||||
return $m.Groups[1].Value.Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-MacReleaseReady {
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$ExpectedVersion
|
||||
)
|
||||
$macYml = Join-Path $ReleaseDir 'latest-mac.yml'
|
||||
if (-not (Test-Path -LiteralPath $macYml)) {
|
||||
throw @"
|
||||
AfterMac: missing latest-mac.yml in $ReleaseDir
|
||||
1) Bump version in package.json (now $ExpectedVersion)
|
||||
2) npm run pack:mac on Mac
|
||||
3) Copy latest-mac.yml + TTRPGPlayer-*.zip into release folder
|
||||
4) Run prepare-release.cmd -AfterMac again
|
||||
"@
|
||||
}
|
||||
$macVersion = Get-YmlField $macYml 'version'
|
||||
if ($macVersion -and $macVersion -ne $ExpectedVersion) {
|
||||
throw "AfterMac: latest-mac.yml is v$macVersion but package.json is v$ExpectedVersion. Align versions before building Win/Linux."
|
||||
}
|
||||
$primary = Get-YmlField $macYml 'path'
|
||||
if (-not $primary) {
|
||||
throw 'AfterMac: latest-mac.yml has no path: entry'
|
||||
}
|
||||
$primaryPath = Join-Path $ReleaseDir $primary
|
||||
if (-not (Test-Path -LiteralPath $primaryPath)) {
|
||||
throw "AfterMac: missing Mac update file $primary (path in latest-mac.yml). Copy zip from Mac build."
|
||||
}
|
||||
Write-Ok "Mac feed v$ExpectedVersion, primary: $primary"
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param(
|
||||
[string]$BuildReleaseDir,
|
||||
@@ -197,12 +349,36 @@ Write-Host '=== TTRPG Prepare Release ===' -ForegroundColor Cyan
|
||||
Write-Host "Project: $projectRoot"
|
||||
Write-Host "Release folder: $releaseDir"
|
||||
|
||||
# Step 1 - version
|
||||
Write-Step 1 'Bump version'
|
||||
$currentVersion = Read-PackageVersion $packageJson
|
||||
$newVersion = $currentVersion
|
||||
if ($AfterMac) {
|
||||
$NoBump = $true
|
||||
}
|
||||
if ($Bump -and $AfterMac) {
|
||||
throw 'Use either -Bump or -AfterMac, not both'
|
||||
}
|
||||
if ($Bump) {
|
||||
$Patch = $true
|
||||
}
|
||||
|
||||
if ($NoBump) {
|
||||
$currentVersion = Read-PackageVersion $packageJson
|
||||
|
||||
if ($AfterMac) {
|
||||
Write-Step 0 'Mac artifacts check'
|
||||
Test-MacReleaseReady $releaseDir $currentVersion
|
||||
}
|
||||
|
||||
# Step 1 - version
|
||||
if ($AfterMac) {
|
||||
Write-Step 1 'Version (unchanged, Mac-first workflow)'
|
||||
$newVersion = $currentVersion
|
||||
Write-Ok "building Win/Linux at v$newVersion (same as Mac feed)"
|
||||
} else {
|
||||
Write-Step 1 'Bump version'
|
||||
$newVersion = $currentVersion
|
||||
}
|
||||
|
||||
if ($AfterMac) {
|
||||
# version step done above
|
||||
} elseif ($NoBump) {
|
||||
Write-Ok "version unchanged: $currentVersion"
|
||||
$newVersion = $currentVersion
|
||||
} elseif ($Version) {
|
||||
@@ -229,7 +405,7 @@ if ($SkipGit) {
|
||||
Invoke-Git $projectRoot @('add', 'package-lock.json')
|
||||
}
|
||||
$commitMsg = "chore: release v$newVersion"
|
||||
$status = (git -C $projectRoot status --porcelain).Trim()
|
||||
$status = Get-GitTextOutput $projectRoot @('status', '--porcelain')
|
||||
if ($status) {
|
||||
Invoke-Git $projectRoot @('commit', '-m', $commitMsg)
|
||||
Write-Ok "committed: $commitMsg"
|
||||
@@ -252,7 +428,7 @@ if ($SkipLinux) {
|
||||
} else {
|
||||
Write-Step 4 'Build Linux via WSL (npm ci + pack:linux)'
|
||||
$wslProject = Convert-ToWslPath $projectRoot
|
||||
$wslCmd = "cd '$wslProject' && npm ci && npm run pack:linux"
|
||||
$wslCmd = Get-WslLinuxBuildCommand $wslProject
|
||||
$wslArgs = @()
|
||||
if ($wslDistro) {
|
||||
$wslArgs += '-d', $wslDistro
|
||||
@@ -279,8 +455,12 @@ Write-Host '=== Prepare release done ===' -ForegroundColor Green
|
||||
Write-Host "Version: $newVersion"
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:' -ForegroundColor Yellow
|
||||
Write-Host ' 1) Copy Mac files (latest-mac.yml, *.dmg) from Mac into release folder if needed'
|
||||
Write-Host ' 2) Run publish.cmd to validate and upload to updates.mailib.ru'
|
||||
if ($AfterMac) {
|
||||
Write-Host ' Run publish.cmd (release-all continues to publish automatically)'
|
||||
} else {
|
||||
Write-Host ' 1) Copy Mac files (latest-mac.yml, TTRPGPlayer-*.zip) from Mac into release folder'
|
||||
Write-Host ' 2) Run release-all.cmd (default -AfterMac) or publish.cmd'
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Copy of D:\TTRPG-Release\publish.ps1 - keep in sync when updating the release folder tool.
|
||||
|
||||
param(
|
||||
[switch]$CheckOnly
|
||||
[switch]$CheckOnly,
|
||||
[switch]$SkipMac
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -38,6 +39,24 @@ function Write-Warn([string]$text) {
|
||||
Write-Host " [--] $text" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Get-YmlVersion([string]$ymlPath) {
|
||||
$content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8
|
||||
$m = [regex]::Match($content, '(?m)^version:\s*(\S+)\s*$')
|
||||
if ($m.Success) {
|
||||
return $m.Groups[1].Value.Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-YmlPrimaryPath([string]$ymlPath) {
|
||||
$content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8
|
||||
$m = [regex]::Match($content, '(?m)^path:\s*(\S+)\s*$')
|
||||
if ($m.Success) {
|
||||
return $m.Groups[1].Value.Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-YmlReferencedFiles([string]$ymlPath) {
|
||||
$names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8
|
||||
@@ -47,6 +66,20 @@ function Get-YmlReferencedFiles([string]$ymlPath) {
|
||||
return @($names)
|
||||
}
|
||||
|
||||
function Get-YmlOptionalUrls([string]$ymlPath) {
|
||||
$primary = Get-YmlPrimaryPath $ymlPath
|
||||
$names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8
|
||||
foreach ($m in [regex]::Matches($content, '(?m)^\s*-\s*url:\s*(\S+)\s*$')) {
|
||||
$name = $m.Groups[1].Value.Trim()
|
||||
if ($primary -and $name.Equals($primary, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
continue
|
||||
}
|
||||
[void]$names.Add($name)
|
||||
}
|
||||
return @($names)
|
||||
}
|
||||
|
||||
function Resolve-ReleaseFile([string]$name) {
|
||||
$direct = Join-Path $ReleaseDir $name
|
||||
if (Test-Path -LiteralPath $direct) {
|
||||
@@ -149,17 +182,50 @@ if ($linuxYmls.Count -eq 0) {
|
||||
|
||||
Write-Title 'macOS (if latest-mac.yml present)'
|
||||
$macYml = Join-Path $ReleaseDir 'latest-mac.yml'
|
||||
if (-not (Test-Path -LiteralPath $macYml)) {
|
||||
if ($SkipMac) {
|
||||
Write-Warn 'macOS skipped (-SkipMac)'
|
||||
} elseif (-not (Test-Path -LiteralPath $macYml)) {
|
||||
Write-Warn 'No latest-mac.yml - skipping macOS'
|
||||
} else {
|
||||
Write-Ok 'latest-mac.yml'
|
||||
[void]$uploadFiles.Add($macYml)
|
||||
foreach ($name in (Get-YmlReferencedFiles $macYml)) {
|
||||
|
||||
$macVersion = Get-YmlVersion $macYml
|
||||
$winYml = Join-Path $ReleaseDir 'latest.yml'
|
||||
$winVersion = $null
|
||||
if (Test-Path -LiteralPath $winYml) {
|
||||
$winVersion = Get-YmlVersion $winYml
|
||||
}
|
||||
if ($macVersion -and $winVersion -and $macVersion -ne $winVersion) {
|
||||
$errors.Add(
|
||||
"macOS: latest-mac.yml is v$macVersion but latest.yml is v$winVersion (stale Mac feed). " +
|
||||
'On Mac run: npm run pack:mac, then copy latest-mac.yml + TTRPGPlayer-*.zip (+ optional *.dmg). ' +
|
||||
'Or publish Win/Linux only: publish.cmd -SkipMac'
|
||||
)
|
||||
}
|
||||
|
||||
$primaryName = Get-YmlPrimaryPath $macYml
|
||||
if (-not $primaryName) {
|
||||
$errors.Add('macOS: latest-mac.yml has no path: entry')
|
||||
} else {
|
||||
$primaryFile = Resolve-ReleaseFile $primaryName
|
||||
if ($null -eq $primaryFile) {
|
||||
$errors.Add(
|
||||
"macOS: missing primary update file $primaryName (path in latest-mac.yml). " +
|
||||
'electron-updater on macOS needs the .zip from pack:mac, not only .dmg. Rebuild on Mac or use -SkipMac.'
|
||||
)
|
||||
} else {
|
||||
Write-Ok "primary update: $($primaryFile.Name)"
|
||||
Add-FileToUploadSet $uploadFiles $primaryFile
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($name in (Get-YmlOptionalUrls $macYml)) {
|
||||
$file = Resolve-ReleaseFile $name
|
||||
if ($null -eq $file) {
|
||||
$errors.Add("macOS: missing file $name (from latest-mac.yml)")
|
||||
$warnings.Add("macOS: optional file missing (not uploaded): $name")
|
||||
} else {
|
||||
Write-Ok $name
|
||||
Write-Ok "optional: $($file.Name)"
|
||||
Add-FileToUploadSet $uploadFiles $file
|
||||
}
|
||||
}
|
||||
@@ -213,4 +279,11 @@ if ($LASTEXITCODE -ne 0) {
|
||||
Write-Title 'Done'
|
||||
Write-Host 'Verify:'
|
||||
Write-Host " ${feedUrl}latest.yml"
|
||||
if (Test-Path -LiteralPath (Join-Path $ReleaseDir 'latest-linux.yml')) {
|
||||
Write-Host " ${feedUrl}latest-linux.yml"
|
||||
}
|
||||
if (Test-Path -LiteralPath (Join-Path $ReleaseDir 'latest-mac.yml')) {
|
||||
Write-Host " ${feedUrl}latest-mac.yml"
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
REM Legacy: bump version in script, then Win/Linux (Mac files added later).
|
||||
cd /d "%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0release-all.ps1" -Bump %*
|
||||
if errorlevel 1 pause
|
||||
@@ -1,13 +1,17 @@
|
||||
# Copy of D:\TTRPG-Release\release-all.ps1
|
||||
# Full release: prepare (build Win/Linux, copy) then publish (validate + upload).
|
||||
# Run: release-all.cmd (default: -AfterMac, version already set, Mac files in release folder)
|
||||
# release-all-bump.cmd (bump version first, Mac later)
|
||||
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Patch,
|
||||
[switch]$Bump,
|
||||
[switch]$AfterMac,
|
||||
[switch]$Minor,
|
||||
[switch]$SkipGit,
|
||||
[switch]$SkipLinux,
|
||||
[switch]$NoBump,
|
||||
[switch]$CheckOnlyPublish
|
||||
[switch]$CheckOnlyPublish,
|
||||
[switch]$SkipMac
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -24,9 +28,18 @@ if (-not (Test-Path -LiteralPath $PublishScript)) {
|
||||
throw "Missing publish.ps1: $PublishScript"
|
||||
}
|
||||
|
||||
$useAfterMac = $AfterMac
|
||||
if ($Bump -and $AfterMac) {
|
||||
throw 'Use either -Bump or -AfterMac, not both'
|
||||
}
|
||||
if (-not $Bump -and -not $PSBoundParameters.ContainsKey('AfterMac')) {
|
||||
$useAfterMac = $true
|
||||
}
|
||||
|
||||
$prepareArgs = @()
|
||||
if ($Version) { $prepareArgs += '-Version', $Version }
|
||||
if ($Patch) { $prepareArgs += '-Patch' }
|
||||
if ($Bump) { $prepareArgs += '-Bump' }
|
||||
if ($useAfterMac) { $prepareArgs += '-AfterMac' }
|
||||
if ($Minor) { $prepareArgs += '-Minor' }
|
||||
if ($SkipGit) { $prepareArgs += '-SkipGit' }
|
||||
if ($SkipLinux) { $prepareArgs += '-SkipLinux' }
|
||||
@@ -34,6 +47,7 @@ if ($NoBump) { $prepareArgs += '-NoBump' }
|
||||
|
||||
$publishArgs = @()
|
||||
if ($CheckOnlyPublish) { $publishArgs += '-CheckOnly' }
|
||||
if ($SkipMac) { $publishArgs += '-SkipMac' }
|
||||
|
||||
Write-Host '=== TTRPG Full Release ===' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export NVM_DIR="${HOME}/.nvm"
|
||||
if [[ -s "${NVM_DIR}/nvm.sh" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "${NVM_DIR}/nvm.sh"
|
||||
nvm install
|
||||
nvm use
|
||||
else
|
||||
echo "nvm not found at ${NVM_DIR}/nvm.sh (install nvm or use Node 20.19+ / 22.12+ for Vite)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 run pack:linux
|
||||
Reference in New Issue
Block a user