feat: Electron one-click TTRPG Player release publisher
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projectRoot": "D:\\Work\\my_projects\\dnd_project\\dnd_player",
|
||||||
|
"releaseDir": "D:\\TTRPG-Release",
|
||||||
|
"scriptsDir": "D:\\TTRPG-Release"
|
||||||
|
}
|
||||||
Generated
+161
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user