commit 7ca73f58719e163e400b2ca6daa2367cff75712a Author: Ivan Fontosh Date: Wed Jul 1 17:19:04 2026 +0800 feat: Electron one-click TTRPG Player release publisher Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..f86822d --- /dev/null +++ b/README.md @@ -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` diff --git a/default-config.json b/default-config.json new file mode 100644 index 0000000..5928d2f --- /dev/null +++ b/default-config.json @@ -0,0 +1,5 @@ +{ + "projectRoot": "D:\\Work\\my_projects\\dnd_project\\dnd_player", + "releaseDir": "D:\\TTRPG-Release", + "scriptsDir": "D:\\TTRPG-Release" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0da462a --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e1ecca1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..a2092da --- /dev/null +++ b/src/index.html @@ -0,0 +1,180 @@ + + + + + + TTRPG Player Publisher + + + +
+

TTRPG Player Publisher

+

+ Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git, + соберёт Windows и Linux и загрузит всё на сервер. +

+
+
+
Папка релиза (Mac-файлы):
+
+
Версия в git:
+
+ +
+
Готов к публикации
+
+
+
+
0%
+
+
+
+
+
+ + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b15285e --- /dev/null +++ b/src/main.js @@ -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(); +}); diff --git a/src/preload.cjs b/src/preload.cjs new file mode 100644 index 0000000..39c67a8 --- /dev/null +++ b/src/preload.cjs @@ -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)); + }, +}); diff --git a/src/release/config.mjs b/src/release/config.mjs new file mode 100644 index 0000000..5da641a --- /dev/null +++ b/src/release/config.mjs @@ -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); +} diff --git a/src/release/macYml.mjs b/src/release/macYml.mjs new file mode 100644 index 0000000..d0a6513 --- /dev/null +++ b/src/release/macYml.mjs @@ -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; +} diff --git a/src/release/macYml.test.mjs b/src/release/macYml.test.mjs new file mode 100644 index 0000000..7613120 --- /dev/null +++ b/src/release/macYml.test.mjs @@ -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 }); + } +}); diff --git a/src/release/pipeline.mjs b/src/release/pipeline.mjs new file mode 100644 index 0000000..8558f68 --- /dev/null +++ b/src/release/pipeline.mjs @@ -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; +} diff --git a/src/release/runRelease.mjs b/src/release/runRelease.mjs new file mode 100644 index 0000000..4f4fb38 --- /dev/null +++ b/src/release/runRelease.mjs @@ -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 }; +} diff --git a/src/renderer.js b/src/renderer.js new file mode 100644 index 0000000..7b42bb5 --- /dev/null +++ b/src/renderer.js @@ -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(); diff --git a/src/run-electron.mjs b/src/run-electron.mjs new file mode 100644 index 0000000..4e900d0 --- /dev/null +++ b/src/run-electron.mjs @@ -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; +});