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

- 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
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { minDistSqEffectToPoint, pickEraseTargetId } from './effectEraserHitTest';
import type { EffectInstance } from './types/effects';
const base = { seed: 1, createdAtMs: 0 };
void test('pickEraseTargetId: fire/rain по штриху как туман', () => {
const fire: EffectInstance = {
...base,
id: 'f1',
type: 'fire',
points: [{ x: 0.5, y: 0.5, tMs: 0 }],
radiusN: 0.05,
opacity: 1,
lifetimeMs: null,
};
const id = pickEraseTargetId([fire], { x: 0.51, y: 0.5 }, 0.05);
assert.equal(id, 'f1');
});
void test('minDistSqEffectToPoint: молния — расстояние до отрезка', () => {
const bolt: EffectInstance = {
...base,
id: 'L1',
type: 'lightning',
start: { x: 0, y: 0 },
end: { x: 1, y: 0 },
widthN: 0.02,
intensity: 1,
lifetimeMs: 500,
};
const mid = minDistSqEffectToPoint(bolt, { x: 0.5, y: 0.1 });
assert.ok(Math.abs(mid - 0.01) < 1e-9);
const end = minDistSqEffectToPoint(bolt, { x: 1, y: 0 });
assert.equal(end, 0);
});
void test('pickEraseTargetId: scorch с учётом inst.radiusN', () => {
const sc: EffectInstance = {
...base,
id: 's1',
type: 'scorch',
at: { x: 0.5, y: 0.5 },
radiusN: 0.08,
opacity: 1,
lifetimeMs: 1000,
};
const id = pickEraseTargetId([sc], { x: 0.59, y: 0.5 }, 0.02);
assert.equal(id, 's1');
});
+83
View File
@@ -0,0 +1,83 @@
import type { EffectInstance } from './types/effects';
function distSqPointToSegment(
px: number,
py: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) {
const ex = px - x1;
const ey = py - y1;
return ex * ex + ey * ey;
}
let t = ((px - x1) * dx + (py - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const qx = x1 + t * dx;
const qy = y1 + t * dy;
const ex = px - qx;
const ey = py - qy;
return ex * ex + ey * ey;
}
/** Минимальная квадрат дистанции от точки (норм. координаты) до «тела» эффекта — для ластика. */
export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y: number }): number {
switch (inst.type) {
case 'fog':
case 'fire':
case 'rain': {
let best = Number.POSITIVE_INFINITY;
for (const q of inst.points) {
const dx = q.x - p.x;
const dy = q.y - p.y;
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
case 'lightning':
return distSqPointToSegment(p.x, p.y, inst.start.x, inst.start.y, inst.end.x, inst.end.y);
case 'freeze': {
const dx = inst.at.x - p.x;
const dy = inst.at.y - p.y;
return dx * dx + dy * dy;
}
case 'scorch':
case 'ice': {
const dx = inst.at.x - p.x;
const dy = inst.at.y - p.y;
return dx * dx + dy * dy;
}
default:
return Number.POSITIVE_INFINITY;
}
}
function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number {
if (inst.type === 'scorch' || inst.type === 'ice') {
const r = toolRadiusN + inst.radiusN;
return r * r;
}
return toolRadiusN * toolRadiusN;
}
/** Ближайший эффект в пределах радиуса ластика, иначе `null`. */
export function pickEraseTargetId(
instances: readonly EffectInstance[],
p: { x: number; y: number },
toolRadiusN: number,
): string | null {
let best: { id: string; dd: number } | null = null;
for (const inst of instances) {
const dd = minDistSqEffectToPoint(inst, p);
const th = eraseHitThresholdSq(inst, toolRadiusN);
if (dd <= th && (!best || dd < best.dd)) {
best = { id: inst.id, dd };
}
}
return best?.id ?? null;
}
+25
View File
@@ -1,3 +1,4 @@
import type { LicenseSnapshot } from '../license/licenseSnapshot';
import type {
AssetId,
EffectsEvent,
@@ -61,6 +62,13 @@ export const ipcChannels = {
dispatch: 'video.dispatch',
stateChanged: 'video.stateChanged',
},
license: {
getStatus: 'license.getStatus',
setToken: 'license.setToken',
clearToken: 'license.clearToken',
acceptEula: 'license.acceptEula',
statusChanged: 'license.statusChanged',
},
} as const;
export type IpcInvokeMap = {
@@ -196,6 +204,22 @@ export type IpcInvokeMap = {
req: { event: VideoPlaybackEvent };
res: { ok: true };
};
[ipcChannels.license.getStatus]: {
req: Record<string, never>;
res: LicenseSnapshot;
};
[ipcChannels.license.setToken]: {
req: { token: string };
res: LicenseSnapshot;
};
[ipcChannels.license.clearToken]: {
req: Record<string, never>;
res: LicenseSnapshot;
};
[ipcChannels.license.acceptEula]: {
req: { version: number };
res: { ok: true };
};
};
export type SessionState = {
@@ -207,6 +231,7 @@ export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
};
export type ScenePatch = {
+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';
+2 -1
View File
@@ -8,10 +8,11 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.
void test('package.json: конфиг electron-builder (mac/win)', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
build: { appId: string; mac: { target: unknown }; files: string[] };
build: { appId: string; asar: boolean; mac: { target: unknown }; files: string[] };
};
assert.ok(pkg.build);
assert.equal(pkg.build.appId, 'com.dndplayer.app');
assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe');
assert.ok(Array.isArray(pkg.build.mac.target));
assert.ok(pkg.build.files.includes('dist/**/*'));
});