Лицензия, редактор, пульт и сборка

- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест
- Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay
- Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены
- Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 20:11:24 +08:00
parent 5e7dc5ea19
commit 2fa20da94d
40 changed files with 2629 additions and 211 deletions
+5
View File
@@ -0,0 +1,5 @@
/**
* Публичный ключ Ed25519 (SPKI DER, base64). Должен соответствовать приватному ключу на сервере лицензий.
* Репозиторий сервера: https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git
*/
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEAd7zvdjqeYW/fUvG5RX1/L1SCTZL1xzh+kr4rlNLQJbY=';
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalJson } from './canonicalJson';
void test('canonicalJson: стабильный порядок ключей', () => {
const a = canonicalJson({ b: 2, a: 1 });
const b = canonicalJson({ a: 1, b: 2 });
assert.equal(a, b);
assert.equal(a, '{"a":1,"b":2}');
});
+19
View File
@@ -0,0 +1,19 @@
/** Детерминированная JSON-сериализация для подписи (совпадает с `lib/canonicalJson.mjs` в DndGamePlayerLicenseServer). */
export function canonicalJson(value: unknown): string {
if (value === null) return 'null';
const t = typeof value;
if (t === 'number') {
if (!Number.isFinite(value as number)) throw new TypeError('non-finite number in license payload');
return JSON.stringify(value);
}
if (t === 'string' || t === 'boolean') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((x) => canonicalJson(x)).join(',')}]`;
}
if (t === 'object') {
const o = value as Record<string, unknown>;
const keys = Object.keys(o).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
}
throw new TypeError(`unsupported type in license payload: ${t}`);
}
+2
View File
@@ -0,0 +1,2 @@
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
export const EULA_CURRENT_VERSION = 1;
+17
View File
@@ -0,0 +1,17 @@
import type { LicenseVerifyFailure } from './verifyReasons';
export type LicenseSnapshotReason = 'ok' | LicenseVerifyFailure | 'revoked_remote' | 'none';
export type LicenseSnapshot = {
active: boolean;
devSkip: boolean;
reason: LicenseSnapshotReason;
summary: {
sub: string;
pid: string;
exp: number;
did: string | null;
} | null;
eulaAcceptedVersion: number | null;
deviceId: string;
};
+33
View File
@@ -0,0 +1,33 @@
/** Полезная нагрузка лицензии v1 (подписывается Ed25519 на сервере). */
export type LicensePayloadV1 = {
v: 1;
/** Стабильный id лицензии (для отзыва и учёта). */
sub: string;
/** Идентификатор продукта. */
pid: string;
/** Unix-время выдачи (сек). */
iat: number;
/** Unix-время окончания (сек). */
exp: number;
/** Не раньше этого времени (сек), опционально. */
nbf?: number;
/** Привязка к устройству: null — любое устройство. */
did: string | null;
};
export function isLicensePayloadV1(x: unknown): x is LicensePayloadV1 {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return (
o.v === 1 &&
typeof o.sub === 'string' &&
o.sub.length > 0 &&
typeof o.pid === 'string' &&
o.pid.length > 0 &&
typeof o.iat === 'number' &&
Number.isFinite(o.iat) &&
typeof o.exp === 'number' &&
Number.isFinite(o.exp) &&
(o.did === null || typeof o.did === 'string')
);
}
+41
View File
@@ -0,0 +1,41 @@
const B64URL = {
encode(bytes: Uint8Array): string {
let bin = '';
for (const byte of bytes) {
bin += String.fromCharCode(byte);
}
const b64 = btoa(bin);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
},
decode(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) & 0xff;
return out;
},
};
/** Тело UTF-8 + подпись Ed25519 (64 байта), разделитель «.». */
export function splitSignedLicenseToken(token: string): { bodyUtf8: string; signature: Uint8Array } | null {
const t = token.trim();
const dot = t.indexOf('.');
if (dot <= 0) return null;
const a = t.slice(0, dot);
const b = t.slice(dot + 1);
if (!a || !b) return null;
try {
const bodyBytes = B64URL.decode(a);
const sigBytes = B64URL.decode(b);
const bodyUtf8 = new TextDecoder().decode(bodyBytes);
return { bodyUtf8, signature: sigBytes };
} catch {
return null;
}
}
export function joinSignedLicenseToken(bodyUtf8: string, signature: Uint8Array): string {
const bodyBytes = new TextEncoder().encode(bodyUtf8);
return `${B64URL.encode(bodyBytes)}.${B64URL.encode(signature)}`;
}
+7
View File
@@ -0,0 +1,7 @@
export type LicenseVerifyFailure =
| 'malformed'
| 'bad_signature'
| 'bad_payload'
| 'not_yet_valid'
| 'expired'
| 'wrong_device';