feat: Electron one-click TTRPG Player release publisher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-01 17:19:04 +08:00
commit 7ca73f5871
15 changed files with 858 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+38
View File
@@ -0,0 +1,38 @@
# TTRPG Player Publisher
Десктопная утилита (Electron): одна кнопка для полного релиза после того, как вы положили Mac-файлы в папку.
## Что делает
1. `git pull` в репозитории `dnd_player` (версия уже должна быть поднята в git)
2. Проверяет Mac-артефакты в папке релиза и пересобирает `latest-mac.yml`
3. Запускает `release-all.ps1 -AfterMac -SkipGit` (сборка Windows + Linux через WSL, загрузка на VPS)
## Mac-файлы в папке релиза
Перед нажатием кнопки скопируйте в папку релиза (по умолчанию `D:\TTRPG-Release`):
- `TTRPGPlayer-x64.zip`, `TTRPGPlayer-arm64.zip`
- `TTRPGPlayer-x64.dmg`, `TTRPGPlayer-arm64.dmg`
Также в этой папке должны лежать скрипты `release-all.ps1`, `prepare-release.ps1`, `publish.ps1` и конфиги `release-config.json`, `publish-config.json` (как в `D:\TTRPG-Release`).
## Запуск
```bash
npm install
npm start
```
## Настройки
При первом запуске создаётся `%APPDATA%\ttrpg-player-publisher\config.json` с путями:
- `projectRoot` — каталог `dnd_player`
- `releaseDir` — папка с Mac-файлами и артефактами
- `scriptsDir` — где лежат PowerShell-скриплы релиза (обычно та же папка, что `releaseDir`)
## Требования
- Windows, Node.js 20+, git, OpenSSH (`ttrpg_updates_root`), WSL для Linux-сборки
- Закройте dev-сервер плеера (Vite/Electron) — приложение пытается остановить их само перед `npm ci`
+5
View File
@@ -0,0 +1,5 @@
{
"projectRoot": "D:\\Work\\my_projects\\dnd_project\\dnd_player",
"releaseDir": "D:\\TTRPG-Release",
"scriptsDir": "D:\\TTRPG-Release"
}
+161
View File
@@ -0,0 +1,161 @@
{
"name": "ttrpg-player-publisher",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ttrpg-player-publisher",
"version": "1.0.0",
"dependencies": {
"electron": "^41.2.0"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz",
"integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/get": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"progress": "^2.0.3",
"semver": "^7.6.3",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=22.12.0"
},
"optionalDependencies": {
"undici": "^7.24.4"
}
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/electron": {
"version": "41.9.2",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.9.2.tgz",
"integrity": "sha512-j8a6s8HDIeKMdsU8mLFl13ALuzORPjtoK/d9oKpmg8Qp/iTmKjPO6TRuFpHJzYXyTwG7ytCU/qjMbfhRkHVEfw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 22.12.0"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ttrpg-player-publisher",
"private": true,
"version": "1.0.0",
"description": "One-click TTRPG Player release publisher (Mac files in folder → build Win/Linux → upload)",
"type": "module",
"main": "src/main.js",
"scripts": {
"dev": "node src/run-electron.mjs",
"start": "node src/run-electron.mjs",
"test": "node --test src/release/*.test.mjs"
},
"dependencies": {
"electron": "^41.2.0"
}
}
+180
View File
@@ -0,0 +1,180 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTRPG Player Publisher</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1419;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.1);
--text: #e8eaed;
--muted: rgba(232, 234, 237, 0.72);
--accent: #7c9cff;
--accent-bg: rgba(124, 156, 255, 0.18);
--ok: #6ee7a0;
--err: #ff8a8a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.wrap {
max-width: 640px;
margin: 0 auto;
padding: 28px 24px 32px;
}
h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 650;
}
.subtitle {
margin: 0 0 24px;
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.card {
border: 1px solid var(--border);
background: var(--card);
border-radius: 14px;
padding: 20px;
}
.meta {
font-size: 13px;
color: var(--muted);
margin-bottom: 18px;
line-height: 1.5;
}
.meta strong {
color: var(--text);
font-weight: 600;
}
.path {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
word-break: break-all;
}
button.publish {
width: 100%;
appearance: none;
border: 1px solid rgba(124, 156, 255, 0.45);
background: var(--accent-bg);
color: var(--text);
font-size: 16px;
font-weight: 650;
padding: 14px 18px;
border-radius: 12px;
cursor: pointer;
transition: background 0.15s;
}
button.publish:hover:not(:disabled) {
background: rgba(124, 156, 255, 0.28);
}
button.publish:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.progress-wrap {
margin-top: 20px;
}
.progress-label {
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
min-height: 1.4em;
}
.progress-track {
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #5b7cfa, #7c9cff);
transition: width 0.25s ease;
}
.percent {
margin-top: 6px;
font-size: 12px;
color: var(--muted);
text-align: right;
}
.result {
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
font-size: 14px;
line-height: 1.45;
display: none;
}
.result.ok {
display: block;
background: rgba(110, 231, 160, 0.12);
border: 1px solid rgba(110, 231, 160, 0.35);
color: var(--ok);
}
.result.err {
display: block;
background: rgba(255, 138, 138, 0.1);
border: 1px solid rgba(255, 138, 138, 0.35);
color: var(--err);
white-space: pre-wrap;
}
.log {
margin-top: 14px;
max-height: 140px;
overflow: auto;
font-family: Consolas, monospace;
font-size: 11px;
color: var(--muted);
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 8px 10px;
display: none;
}
.log.visible {
display: block;
}
</style>
</head>
<body>
<div class="wrap">
<h1>TTRPG Player Publisher</h1>
<p class="subtitle">
Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git,
соберёт Windows и Linux и загрузит всё на сервер.
</p>
<div class="card">
<div class="meta">
<div><strong>Папка релиза (Mac-файлы):</strong></div>
<div id="releaseDir" class="path"></div>
<div style="margin-top: 10px"><strong>Версия в git:</strong> <span id="version"></span></div>
</div>
<button id="publishBtn" class="publish" type="button">Опубликовать новую версию</button>
<div class="progress-wrap">
<div id="progressLabel" class="progress-label">Готов к публикации</div>
<div class="progress-track">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="percent" class="percent">0%</div>
</div>
<div id="result" class="result"></div>
<div id="log" class="log"></div>
</div>
</div>
<script type="module" src="./renderer.js"></script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { app, BrowserWindow, ipcMain } from 'electron';
import { loadConfig, readProjectVersion } from './release/config.mjs';
import { runFullRelease } from './release/runRelease.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
/** @type {BrowserWindow | null} */
let mainWindow = null;
function send(channel, payload) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload);
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 720,
height: 520,
minWidth: 560,
minHeight: 420,
backgroundColor: '#0f1419',
title: 'TTRPG Player Publisher',
webPreferences: {
preload: path.join(here, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.removeMenu();
void mainWindow.loadFile(path.join(here, 'index.html'));
}
app.whenReady().then(() => {
createWindow();
ipcMain.handle('publisher.getInfo', async () => {
const config = loadConfig(app.getPath('userData'));
let version = null;
let versionError = null;
try {
version = readProjectVersion(config.projectRoot);
} catch (err) {
versionError = err instanceof Error ? err.message : String(err);
}
return { config, version, versionError };
});
ipcMain.handle('publisher.publish', async () => {
const config = loadConfig(app.getPath('userData'));
try {
const result = await runFullRelease(config, (ev) => {
send('publisher.progress', ev);
});
return { ok: true, version: result.version };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
send('publisher.progress', { percent: 0, label: message, logLine: message });
return { ok: false, error: message };
}
});
});
app.on('window-all-closed', () => {
app.quit();
});
+10
View File
@@ -0,0 +1,10 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('publisher', {
getInfo: () => ipcRenderer.invoke('publisher.getInfo'),
publish: () => ipcRenderer.invoke('publisher.publish'),
onProgress: (cb) => {
ipcRenderer.removeAllListeners('publisher.progress');
ipcRenderer.on('publisher.progress', (_e, payload) => cb(payload));
},
});
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
/** @typedef {{ projectRoot: string; releaseDir: string; scriptsDir: string }} PublisherConfig */
/** @param {string} userDataDir */
export function loadConfig(userDataDir) {
const userPath = path.join(userDataDir, 'config.json');
const defaults = JSON.parse(
fs.readFileSync(path.join(pkgRoot, 'default-config.json'), 'utf8'),
);
if (!fs.existsSync(userPath)) {
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(userPath, `${JSON.stringify(defaults, null, 2)}\n`, 'utf8');
return /** @type {PublisherConfig} */ ({ ...defaults });
}
const raw = JSON.parse(fs.readFileSync(userPath, 'utf8'));
return /** @type {PublisherConfig} */ ({
projectRoot: path.normalize(String(raw.projectRoot ?? defaults.projectRoot)),
releaseDir: path.normalize(String(raw.releaseDir ?? defaults.releaseDir)),
scriptsDir: path.normalize(String(raw.scriptsDir ?? defaults.scriptsDir)),
});
}
/** @param {string} projectRoot */
export function readProjectVersion(projectRoot) {
const pkgPath = path.join(projectRoot, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (!pkg.version) throw new Error(`В ${pkgPath} нет поля version`);
return String(pkg.version);
}
+47
View File
@@ -0,0 +1,47 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const MAC_ARTIFACTS = [
'TTRPGPlayer-x64.zip',
'TTRPGPlayer-arm64.zip',
'TTRPGPlayer-x64.dmg',
'TTRPGPlayer-arm64.dmg',
];
function hashFile(absPath) {
const buf = fs.readFileSync(absPath);
return {
sha512: crypto.createHash('sha512').update(buf).digest('base64'),
size: buf.length,
};
}
/** @param {string} releaseDir @param {string} version */
export function validateMacArtifacts(releaseDir) {
const missing = [];
for (const name of MAC_ARTIFACTS) {
if (!fs.existsSync(path.join(releaseDir, name))) {
missing.push(name);
}
}
if (missing.length > 0) {
throw new Error(
`В папке релиза не хватает Mac-файлов:\n${missing.map((n) => `${n}`).join('\n')}\n\nСкопируйте их с Mac в:\n${releaseDir}`,
);
}
}
/** @param {string} releaseDir @param {string} version */
export function writeLatestMacYml(releaseDir, version) {
validateMacArtifacts(releaseDir);
const entries = MAC_ARTIFACTS.map((url) => ({ url, ...hashFile(path.join(releaseDir, url)) }));
const primary = entries[0];
let yml = `version: ${version}\nfiles:\n`;
for (const e of entries) {
yml += ` - url: ${e.url}\n sha512: ${e.sha512}\n size: ${e.size}\n`;
}
yml += `path: ${primary.url}\nsha512: ${primary.sha512}\nreleaseDate: ${new Date().toISOString()}\n`;
fs.writeFileSync(path.join(releaseDir, 'latest-mac.yml'), yml, 'utf8');
return yml;
}
+34
View File
@@ -0,0 +1,34 @@
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 { writeLatestMacYml } from './macYml.mjs';
import { progressFromLogLine } from './pipeline.mjs';
void test('progressFromLogLine maps release script milestones', () => {
assert.equal(progressFromLogLine('>>> Phase 2/2: Publish to server'), 68);
assert.equal(progressFromLogLine('=== Full release completed ==='), 100);
assert.equal(progressFromLogLine('random log line'), null);
});
void test('writeLatestMacYml writes version and hashes', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttrpg-pub-'));
try {
for (const name of [
'TTRPGPlayer-x64.zip',
'TTRPGPlayer-arm64.zip',
'TTRPGPlayer-x64.dmg',
'TTRPGPlayer-arm64.dmg',
]) {
fs.writeFileSync(path.join(dir, name), `fake-${name}`);
}
writeLatestMacYml(dir, '9.9.9');
const yml = fs.readFileSync(path.join(dir, 'latest-mac.yml'), 'utf8');
assert.match(yml, /^version: 9\.9\.9/m);
assert.match(yml, /path: TTRPGPlayer-x64\.zip/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+104
View File
@@ -0,0 +1,104 @@
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/** Stop vite/electron dev servers that lock dnd_player node_modules during npm ci. */
export async function killBlockingDevProcesses(projectRoot) {
if (process.platform !== 'win32') return;
try {
const { stdout } = await execFileAsync(
'powershell',
[
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "name='node.exe'" | Where-Object { $_.CommandLine -match 'dnd_player' -and ($_.CommandLine -match 'vite dev|electron') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
],
{ windowsHide: true },
);
void stdout;
} catch {
// best effort
}
}
/**
* @param {string} cwd
* @param {string[]} args
* @param {(line: string) => void} onLine
*/
export function runPowerShellScript(cwd, scriptName, args, onLine) {
const scriptPath = `${cwd}\\${scriptName}`;
return new Promise((resolve, reject) => {
const psArgs = [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
...args,
];
const child = spawn('powershell.exe', psArgs, {
cwd,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const handle = (chunk) => {
const text = chunk.toString('utf8');
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trimEnd();
if (trimmed) onLine(trimmed);
}
};
child.stdout.on('data', handle);
child.stderr.on('data', handle);
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve(undefined);
else reject(new Error(`${scriptName} завершился с кодом ${code}`));
});
});
}
/** @param {string} projectRoot */
export async function gitPull(projectRoot, onLine) {
onLine('git pull…');
await new Promise((resolve, reject) => {
const child = spawn('git', ['-C', projectRoot, 'pull'], {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const handle = (chunk) => {
for (const line of chunk.toString('utf8').split(/\r?\n/)) {
if (line.trim()) onLine(line.trim());
}
};
child.stdout.on('data', handle);
child.stderr.on('data', handle);
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve(undefined);
else reject(new Error(`git pull завершился с кодом ${code}`));
});
});
}
/** @param {string} line @returns {number | null} */
export function progressFromLogLine(line) {
if (/Mac artifacts check/i.test(line)) return 12;
if (/Build Windows/i.test(line)) return 22;
if (/npm ci/i.test(line)) return 28;
if (/pack:win/i.test(line)) return 38;
if (/Build Linux/i.test(line)) return 48;
if (/wsl/i.test(line) && /pack-linux/i.test(line)) return 52;
if (/Copy artifacts/i.test(line)) return 62;
if (/Phase 2\/2/i.test(line)) return 68;
if (/=== Upload ===/i.test(line)) return 72;
if (/^\s*->\s+/i.test(line)) return 78;
if (/Setting www-data ownership/i.test(line)) return 92;
if (/Full release completed/i.test(line)) return 100;
if (/=== Done ===/i.test(line)) return 98;
return null;
}
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs';
import path from 'node:path';
import { readProjectVersion } from './config.mjs';
import { writeLatestMacYml } from './macYml.mjs';
import {
gitPull,
killBlockingDevProcesses,
progressFromLogLine,
runPowerShellScript,
} from './pipeline.mjs';
/**
* @param {import('./config.mjs').PublisherConfig} config
* @param {(ev: { percent: number; label: string; logLine?: string }) => void} emit
*/
export async function runFullRelease(config, emit) {
const { projectRoot, releaseDir, scriptsDir } = config;
if (!fs.existsSync(projectRoot)) {
throw new Error(`Не найден проект плеера:\n${projectRoot}`);
}
if (!fs.existsSync(releaseDir)) {
throw new Error(`Не найдена папка релиза:\n${releaseDir}`);
}
const releaseAll = path.join(scriptsDir, 'release-all.ps1');
if (!fs.existsSync(releaseAll)) {
throw new Error(`Не найден release-all.ps1:\n${releaseAll}`);
}
emit({ percent: 2, label: 'Останавливаем dev-серверы плеера…' });
await killBlockingDevProcesses(projectRoot);
emit({ percent: 6, label: 'Забираем изменения из git…' });
await gitPull(projectRoot, (logLine) => emit({ percent: 8, label: 'git pull', logLine }));
const version = readProjectVersion(projectRoot);
emit({ percent: 10, label: `Версия в git: v${version}` });
emit({ percent: 12, label: 'Проверяем Mac-файлы и обновляем latest-mac.yml…' });
writeLatestMacYml(releaseDir, version);
emit({ percent: 15, label: `latest-mac.yml готов для v${version}` });
emit({ percent: 18, label: 'Сборка Windows/Linux и загрузка на сервер…' });
let lastPercent = 18;
await runPowerShellScript(
scriptsDir,
'release-all.ps1',
['-AfterMac', '-SkipGit'],
(logLine) => {
const mapped = progressFromLogLine(logLine);
if (mapped != null && mapped > lastPercent) {
lastPercent = mapped;
}
emit({ percent: lastPercent, label: logLine, logLine });
},
);
emit({ percent: 100, label: `Готово! v${version} опубликована на updates.ttrpgplayer.ru` });
return { version };
}
+78
View File
@@ -0,0 +1,78 @@
const $publishBtn = document.getElementById('publishBtn');
const $releaseDir = document.getElementById('releaseDir');
const $version = document.getElementById('version');
const $progressLabel = document.getElementById('progressLabel');
const $progressBar = document.getElementById('progressBar');
const $percent = document.getElementById('percent');
const $result = document.getElementById('result');
const $log = document.getElementById('log');
let running = false;
function setProgress(percent, label) {
const p = Math.max(0, Math.min(100, percent));
$progressBar.style.width = `${p}%`;
$percent.textContent = `${p}%`;
if (label) $progressLabel.textContent = label;
}
function showResult(ok, text) {
$result.className = `result ${ok ? 'ok' : 'err'}`;
$result.textContent = text;
}
function appendLog(line) {
if (!line) return;
$log.classList.add('visible');
$log.textContent += `${line}\n`;
$log.scrollTop = $log.scrollHeight;
}
async function loadInfo() {
if (!window.publisher) {
showResult(false, 'Ошибка: preload не подключился. Перезапустите приложение.');
$publishBtn.disabled = true;
return;
}
const info = await window.publisher.getInfo();
$releaseDir.textContent = info.config.releaseDir;
if (info.version) {
$version.textContent = `v${info.version}`;
} else {
$version.textContent = info.versionError ?? 'не удалось прочитать';
}
}
window.publisher?.onProgress((ev) => {
setProgress(ev.percent ?? 0, ev.label ?? '');
if (ev.logLine) appendLog(ev.logLine);
});
$publishBtn.addEventListener('click', async () => {
if (running || !window.publisher) return;
running = true;
$publishBtn.disabled = true;
$result.className = 'result';
$result.textContent = '';
$log.textContent = '';
$log.classList.remove('visible');
setProgress(0, 'Запуск…');
const res = await window.publisher.publish();
running = false;
$publishBtn.disabled = false;
if (res.ok) {
setProgress(100, 'Готово!');
showResult(
true,
`Версия v${res.version} успешно опубликована на updates.ttrpgplayer.ru`,
);
void loadInfo();
} else {
setProgress(0, 'Ошибка');
showResult(false, res.error ?? 'Неизвестная ошибка');
}
});
void loadInfo();
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const electronBin = path.resolve(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron');
const child = spawn(electronBin, ['.'], {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('exit', (code) => {
process.exitCode = code ?? 0;
});