подготовка к билду
This commit is contained in:
+1
-1
@@ -107,7 +107,7 @@ async function main() {
|
|||||||
buildNumber: getOptionalBuildNumber(),
|
buildNumber: getOptionalBuildNumber(),
|
||||||
}));
|
}));
|
||||||
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
|
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
|
||||||
registerHandler(ipcChannels.license.setToken, ({ token }) => licenseService.setToken(token));
|
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
|
||||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
|||||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
||||||
|
import { isDndProductKey } from '../../shared/license/productKey';
|
||||||
|
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||||
|
|
||||||
import { getOrCreateDeviceId } from './deviceId';
|
import { getOrCreateDeviceId } from './deviceId';
|
||||||
import { licenseEncryptedPath, preferencesPath } from './paths';
|
import { licenseEncryptedPath, preferencesPath } from './paths';
|
||||||
@@ -77,6 +79,40 @@ export class LicenseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||||
|
private resolveLicenseActivateBaseUrl(): string {
|
||||||
|
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
|
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
||||||
|
return 'https://license.mailib.ru/';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateWithProductKey(productKey: string): Promise<string> {
|
||||||
|
const base = this.resolveLicenseActivateBaseUrl();
|
||||||
|
const url = new URL('v1/activate', base);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`LICENSE_ACTIVATE_FAILED:${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const obj = parsed as { token?: string; error?: string; message?: string };
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`LICENSE_ACTIVATE_FAILED:${obj.error ?? obj.message ?? String(res.status)}`);
|
||||||
|
}
|
||||||
|
const token = obj.token;
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
||||||
|
}
|
||||||
|
return normalizeLicenseTokenInput(token);
|
||||||
|
}
|
||||||
|
|
||||||
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
||||||
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
|
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
if (!base) return;
|
if (!base) return;
|
||||||
@@ -191,11 +227,14 @@ export class LicenseService {
|
|||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
setToken(token: string): LicenseSnapshot {
|
async setToken(token: string): Promise<LicenseSnapshot> {
|
||||||
if (this.isSkipLicense()) {
|
if (this.isSkipLicense()) {
|
||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
}
|
}
|
||||||
const trimmed = token.trim();
|
let trimmed = normalizeLicenseTokenInput(token);
|
||||||
|
if (isDndProductKey(trimmed)) {
|
||||||
|
trimmed = await this.activateWithProductKey(trimmed);
|
||||||
|
}
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
||||||
if (!v.ok) {
|
if (!v.ok) {
|
||||||
|
|||||||
@@ -54,3 +54,29 @@ void test('verifyLicenseToken: неверное устройство', () => {
|
|||||||
if (bad.ok) assert.fail('expected failure');
|
if (bad.ok) assert.fail('expected failure');
|
||||||
assert.equal(bad.reason, 'wrong_device');
|
assert.equal(bad.reason, 'wrong_device');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
const licensePayload = {
|
||||||
|
v: 1 as const,
|
||||||
|
sub: 'lic_wrap',
|
||||||
|
pid: 'dnd_player',
|
||||||
|
iat: 100,
|
||||||
|
exp: 2_000_000_000,
|
||||||
|
did: null as string | null,
|
||||||
|
};
|
||||||
|
const body = canonicalJson(licensePayload);
|
||||||
|
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
|
||||||
|
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
|
||||||
|
const mid = Math.max(1, Math.floor(token.length / 2));
|
||||||
|
const messy = `${token.slice(0, mid)}\n${token.slice(mid, mid + 3)} \r\n ${token.slice(mid + 3)}`;
|
||||||
|
|
||||||
|
const ok = verifyLicenseToken(messy, {
|
||||||
|
nowSec: 1_700_000_000,
|
||||||
|
deviceId: 'any',
|
||||||
|
publicKeyOverrideSpkiDerB64: pubB64,
|
||||||
|
});
|
||||||
|
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
|
||||||
|
assert.equal(ok.payload.sub, 'lic_wrap');
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fieldGrid}>
|
<div className={styles.fieldGrid}>
|
||||||
<div className={styles.fieldLabel}>ЛИЦЕНЗИОННЫЙ ТОКЕН</div>
|
<div className={styles.fieldLabel}>КЛЮЧ</div>
|
||||||
<textarea
|
<textarea
|
||||||
className={styles.licenseTextarea}
|
className={styles.licenseTextarea}
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder="Вставьте токен, выданный сервером лицензий…"
|
placeholder="Продуктовый ключ DND-..."
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
* Публичный ключ Ed25519 (SPKI DER, base64). Должен соответствовать приватному ключу на сервере лицензий.
|
* Публичный ключ Ed25519 (SPKI DER, base64). Должен соответствовать приватному ключу на сервере лицензий.
|
||||||
* Репозиторий сервера: https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git
|
* Репозиторий сервера: https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git
|
||||||
*/
|
*/
|
||||||
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEAd7zvdjqeYW/fUvG5RX1/L1SCTZL1xzh+kr4rlNLQJbY=';
|
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEA0KNHmwh7cjUtHh0V5XApTav9z/mee9iWLSS4MFbVDq8=';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { isDndProductKey } from './productKey';
|
||||||
|
|
||||||
|
void test('isDndProductKey: пример пользователя', () => {
|
||||||
|
assert.equal(isDndProductKey('DND-CEBEC1BF-AD0B-4312-BFDD-675AFF5955FD'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('isDndProductKey: токен с точкой — нет', () => {
|
||||||
|
assert.equal(isDndProductKey('eyJ.xxx'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('isDndProductKey: пробелы по краям', () => {
|
||||||
|
assert.equal(isDndProductKey(' DND-CEBEC1BF-AD0B-4312-BFDD-675AFF5955FD '), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Продуктовый ключ активации (не путать с лицензионным токеном `base64.base64`). */
|
||||||
|
const DND_PRODUCT_KEY_RE = /^DND-[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}$/iu;
|
||||||
|
|
||||||
|
export function isDndProductKey(s: string): boolean {
|
||||||
|
return DND_PRODUCT_KEY_RE.test(s.trim());
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
/** Убирает переносы/неразрывные пробелы из вставки из почты и мессенджеров (иначе `malformed`). */
|
||||||
|
export function normalizeLicenseTokenInput(token: string): string {
|
||||||
|
return token.replace(/[\s\u00a0\u200b-\u200d\ufeff\u2028\u2029]+/gu, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
const B64URL = {
|
const B64URL = {
|
||||||
encode(bytes: Uint8Array): string {
|
encode(bytes: Uint8Array): string {
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
|
||||||
|
}
|
||||||
let bin = '';
|
let bin = '';
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
bin += String.fromCharCode(byte);
|
bin += String.fromCharCode(byte);
|
||||||
@@ -8,6 +16,13 @@ const B64URL = {
|
|||||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
|
||||||
},
|
},
|
||||||
decode(s: string): Uint8Array {
|
decode(s: string): Uint8Array {
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
try {
|
||||||
|
return new Uint8Array(Buffer.from(s, 'base64url'));
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
||||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
||||||
const bin = atob(b64);
|
const bin = atob(b64);
|
||||||
@@ -19,7 +34,7 @@ const B64URL = {
|
|||||||
|
|
||||||
/** Тело UTF-8 + подпись Ed25519 (64 байта), разделитель «.». */
|
/** Тело UTF-8 + подпись Ed25519 (64 байта), разделитель «.». */
|
||||||
export function splitSignedLicenseToken(token: string): { bodyUtf8: string; signature: Uint8Array } | null {
|
export function splitSignedLicenseToken(token: string): { bodyUtf8: string; signature: Uint8Array } | null {
|
||||||
const t = token.trim();
|
const t = normalizeLicenseTokenInput(token);
|
||||||
const dot = t.indexOf('.');
|
const dot = t.indexOf('.');
|
||||||
if (dot <= 0) return null;
|
if (dot <= 0) return null;
|
||||||
const a = t.slice(0, dot);
|
const a = t.slice(0, dot);
|
||||||
|
|||||||
@@ -6,12 +6,16 @@
|
|||||||
|
|
||||||
## Модель
|
## Модель
|
||||||
|
|
||||||
1. **Продуктовый ключ** — секрет покупателя, известен только ему и серверу. Обменивается на **лицензионный токен** через `POST /v1/activate` (онлайн-активация).
|
1. **Продуктовый ключ** — секрет покупателя, известен только ему и серверу. Обменивается на **лицензионный токен** через `POST /v1/activate` (онлайн-активация). В поле «Указать ключ» приложение принимает **продуктовый ключ** `DND-…` (клиент сам вызывает `POST /v1/activate` с `deviceId`) или уже готовый **токен** (две части base64url через одну точку).
|
||||||
2. **Лицензионный токен** — публичная полезная нагрузка (`sub`, `pid`, `iat`, `exp`, `did`) + подпись **Ed25519**. Клиент хранит только токен и **публичный** ключ (вшит в приложение); подделать валидный токен без приватного ключа сервера невозможно.
|
2. **Лицензионный токен** — публичная полезная нагрузка (`sub`, `pid`, `iat`, `exp`, `did`) + подпись **Ed25519**. Клиент хранит только токен и **публичный** ключ (вшит в приложение); подделать валидный токен без приватного ключа сервера невозможно.
|
||||||
3. **Срок** — поле `exp` (unix секунды). Клиент отклоняет истёкший токен без сети.
|
3. **Срок** — поле `exp` (unix секунды). Клиент отклоняет истёкший токен без сети.
|
||||||
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`).
|
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`).
|
||||||
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
|
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
|
||||||
|
|
||||||
|
## Продакшен-сборка
|
||||||
|
|
||||||
|
Скрипт `npm run build` / `node scripts/build.mjs --production` подставляет в main-бандл базовый URL **`https://license.mailib.ru/`** как `process.env.DND_LICENSE_STATUS_URL` (если при сборке переменная не задана). Другой хост: `DND_LICENSE_STATUS_URL=https://example.com npm run build`. В dev по-прежнему можно выставить переменную окружения при запуске Electron без пересборки.
|
||||||
|
|
||||||
## Хранение на клиенте
|
## Хранение на клиенте
|
||||||
|
|
||||||
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
|
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
|
||||||
|
|||||||
+4
-4
@@ -10,14 +10,14 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.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/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.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/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/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"release:info": "node scripts/print-release-info.mjs",
|
"release:info": "node scripts/print-release-info.mjs",
|
||||||
"pack": "npm run build && electron-builder",
|
"pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder",
|
||||||
"pack:dir": "npm run build && electron-builder --dir",
|
"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 && electron-builder --mac",
|
||||||
"pack:win": "npm run build && electron-builder --win"
|
"pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
+15
-1
@@ -14,6 +14,9 @@ const root = path.resolve(__dirname, '..');
|
|||||||
const isProd = resolveIsProduction();
|
const isProd = resolveIsProduction();
|
||||||
const obfuscateMain = resolveObfuscateMain();
|
const obfuscateMain = resolveObfuscateMain();
|
||||||
|
|
||||||
|
/** База для `GET …/v1/status` (отзыв). Прод: по умолчанию публичный сервер; переопределение: `DND_LICENSE_STATUS_URL=… npm run build`. */
|
||||||
|
const DEFAULT_LICENSE_STATUS_BASE = 'https://license.mailib.ru/';
|
||||||
|
|
||||||
/** Старые .map от dev-сборок не должны попадать в pack. */
|
/** Старые .map от dev-сборок не должны попадать в pack. */
|
||||||
function removeStaleNodeBundleMaps() {
|
function removeStaleNodeBundleMaps() {
|
||||||
for (const p of [
|
for (const p of [
|
||||||
@@ -41,6 +44,17 @@ async function buildNodeTargets() {
|
|||||||
if (isProd) removeStaleNodeBundleMaps();
|
if (isProd) removeStaleNodeBundleMaps();
|
||||||
|
|
||||||
const nodeEnvLiteral = JSON.stringify(isProd ? 'production' : 'development');
|
const nodeEnvLiteral = JSON.stringify(isProd ? 'production' : 'development');
|
||||||
|
const licenseStatusFromEnv = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
|
const licenseStatusBase =
|
||||||
|
isProd && (licenseStatusFromEnv || DEFAULT_LICENSE_STATUS_BASE)
|
||||||
|
? (licenseStatusFromEnv || DEFAULT_LICENSE_STATUS_BASE).replace(/\/+$/u, '') + '/'
|
||||||
|
: null;
|
||||||
|
const define = {
|
||||||
|
'process.env.NODE_ENV': nodeEnvLiteral,
|
||||||
|
...(licenseStatusBase
|
||||||
|
? { 'process.env.DND_LICENSE_STATUS_URL': JSON.stringify(licenseStatusBase) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
const common = {
|
const common = {
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
@@ -49,7 +63,7 @@ async function buildNodeTargets() {
|
|||||||
minify: isProd,
|
minify: isProd,
|
||||||
sourcemap: !isProd,
|
sourcemap: !isProd,
|
||||||
external: ['electron'],
|
external: ['electron'],
|
||||||
define: { 'process.env.NODE_ENV': nodeEnvLiteral },
|
define,
|
||||||
drop: isProd ? ['console', 'debugger'] : [],
|
drop: isProd ? ['console', 'debugger'] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Перед `electron-builder` на Windows: снять блокировку `release/win-unpacked/resources/app.asar`
|
||||||
|
* (часто из‑за запущенного exe из прошлой распаковки).
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const winUnpacked = path.join(root, 'release', 'win-unpacked');
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryKillDndPlayer() {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
try {
|
||||||
|
execFileSync('taskkill', ['/F', '/IM', 'DNDGamePlayer.exe', '/T'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* процесс не найден — нормально */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRmWinUnpacked() {
|
||||||
|
if (!fs.existsSync(winUnpacked)) return;
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(winUnpacked, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
'[release-win-prep] Не удалось удалить release/win-unpacked. Закройте DNDGamePlayer и повторите pack.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tryKillDndPlayer();
|
||||||
|
await tryRmWinUnpacked();
|
||||||
@@ -8,6 +8,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
const isProd = mode === 'production';
|
const isProd = mode === 'production';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/** Иначе в упакованном Electron `file://` запросы идут в `/assets/...` с корня диска — чёрный экран. */
|
||||||
|
base: isProd ? './' : '/',
|
||||||
root: path.resolve(__dirname, 'app/renderer'),
|
root: path.resolve(__dirname, 'app/renderer'),
|
||||||
plugins: [
|
plugins: [
|
||||||
react({
|
react({
|
||||||
|
|||||||
Reference in New Issue
Block a user