Лицензия, редактор, пульт и сборка
- 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:
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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=';
|
||||
@@ -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}');
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
|
||||
export const EULA_CURRENT_VERSION = 1;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type LicenseVerifyFailure =
|
||||
| 'malformed'
|
||||
| 'bad_signature'
|
||||
| 'bad_payload'
|
||||
| 'not_yet_valid'
|
||||
| 'expired'
|
||||
| 'wrong_device';
|
||||
@@ -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/**/*'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user