Лицензия, редактор, пульт и сборка
- 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:
+10
-1
@@ -3,7 +3,8 @@ import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
|||||||
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
|
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
|
||||||
|
|
||||||
import { EffectsStore } from './effects/effectsStore';
|
import { EffectsStore } from './effects/effectsStore';
|
||||||
import { installIpcRouter, registerHandler } from './ipc/router';
|
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
||||||
|
import { LicenseService } from './license/licenseService';
|
||||||
import { ZipProjectStore } from './project/zipStore';
|
import { ZipProjectStore } from './project/zipStore';
|
||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||||
@@ -90,6 +91,10 @@ function emitSessionState(): void {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await app.whenReady();
|
await app.whenReady();
|
||||||
|
const licenseService = new LicenseService(app.getPath('userData'));
|
||||||
|
setLicenseAssert(() => {
|
||||||
|
licenseService.assertForIpc();
|
||||||
|
});
|
||||||
Menu.setApplicationMenu(null);
|
Menu.setApplicationMenu(null);
|
||||||
registerDndAssetProtocol(projectStore);
|
registerDndAssetProtocol(projectStore);
|
||||||
registerHandler(ipcChannels.app.quit, () => {
|
registerHandler(ipcChannels.app.quit, () => {
|
||||||
@@ -101,6 +106,10 @@ async function main() {
|
|||||||
version: getAppSemanticVersion(),
|
version: getAppSemanticVersion(),
|
||||||
buildNumber: getOptionalBuildNumber(),
|
buildNumber: getOptionalBuildNumber(),
|
||||||
}));
|
}));
|
||||||
|
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
|
||||||
|
registerHandler(ipcChannels.license.setToken, ({ token }) => licenseService.setToken(token));
|
||||||
|
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||||
|
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||||
openMultiWindow();
|
openMultiWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
+26
-4
@@ -1,6 +1,6 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
import type { IpcInvokeMap } from '../../shared/ipc/contracts';
|
import { ipcChannels, type IpcInvokeMap } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
type Handler<K extends keyof IpcInvokeMap> = (
|
type Handler<K extends keyof IpcInvokeMap> = (
|
||||||
payload: IpcInvokeMap[K]['req'],
|
payload: IpcInvokeMap[K]['req'],
|
||||||
@@ -8,11 +8,33 @@ type Handler<K extends keyof IpcInvokeMap> = (
|
|||||||
|
|
||||||
const handlers = new Map<string, (payload: unknown) => Promise<unknown>>();
|
const handlers = new Map<string, (payload: unknown) => Promise<unknown>>();
|
||||||
|
|
||||||
export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handler: Handler<K>) {
|
let licenseAssert: (() => void) | undefined;
|
||||||
handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req']));
|
|
||||||
|
export function setLicenseAssert(fn: () => void): void {
|
||||||
|
licenseAssert = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installIpcRouter() {
|
function channelRequiresLicense(channel: string): boolean {
|
||||||
|
if (channel.startsWith('license.')) return false;
|
||||||
|
if (channel.startsWith('app.')) return false;
|
||||||
|
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||||
|
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handler: Handler<K>) {
|
||||||
|
const channelStr = channel as string;
|
||||||
|
const wrap = channelRequiresLicense(channelStr);
|
||||||
|
const inner = async (payload: unknown) => {
|
||||||
|
if (wrap) {
|
||||||
|
licenseAssert?.();
|
||||||
|
}
|
||||||
|
return handler(payload as IpcInvokeMap[K]['req']);
|
||||||
|
};
|
||||||
|
handlers.set(channelStr, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installIpcRouter(): void {
|
||||||
for (const [channel, handler] of handlers.entries()) {
|
for (const [channel, handler] of handlers.entries()) {
|
||||||
ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload));
|
ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { deviceIdPath } from './paths';
|
||||||
|
|
||||||
|
export function getOrCreateDeviceId(userData: string): string {
|
||||||
|
const p = deviceIdPath(userData);
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(p, 'utf8').trim();
|
||||||
|
if (existing.length >= 8) return existing;
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
fs.mkdirSync(userData, { recursive: true });
|
||||||
|
fs.writeFileSync(p, `${id}\n`, 'utf8');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { BrowserWindow, safeStorage } from 'electron';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
|
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
||||||
|
|
||||||
|
import { getOrCreateDeviceId } from './deviceId';
|
||||||
|
import { licenseEncryptedPath, preferencesPath } from './paths';
|
||||||
|
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||||
|
|
||||||
|
type Preferences = {
|
||||||
|
eulaAcceptedVersion?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPreferences(userData: string): Preferences {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
|
||||||
|
return JSON.parse(raw) as Preferences;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreferences(userData: string, prefs: Preferences): void {
|
||||||
|
fs.mkdirSync(userData, { recursive: true });
|
||||||
|
fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLicenseStatusChanged(): void {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(ipcChannels.license.statusChanged, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LicenseService {
|
||||||
|
private readonly userData: string;
|
||||||
|
private readonly deviceId: string;
|
||||||
|
private lastRemoteRevokeCheckMs = 0;
|
||||||
|
private lastRemoteRevoked = false;
|
||||||
|
|
||||||
|
constructor(userData: string) {
|
||||||
|
this.userData = userData;
|
||||||
|
this.deviceId = getOrCreateDeviceId(userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSkipLicense(): boolean {
|
||||||
|
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSealedToken(): string | null {
|
||||||
|
const p = licenseEncryptedPath(this.userData);
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
||||||
|
}
|
||||||
|
const buf = fs.readFileSync(p);
|
||||||
|
return safeStorage.decryptString(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeSealedToken(token: string): void {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
||||||
|
}
|
||||||
|
fs.mkdirSync(this.userData, { recursive: true });
|
||||||
|
const enc = safeStorage.encryptString(token);
|
||||||
|
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSealedTokenFile(): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(licenseEncryptedPath(this.userData));
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
||||||
|
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
|
if (!base) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastRemoteRevokeCheckMs < 60_000) return;
|
||||||
|
this.lastRemoteRevokeCheckMs = now;
|
||||||
|
try {
|
||||||
|
const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`);
|
||||||
|
u.searchParams.set('sub', payload.sub);
|
||||||
|
const res = await fetch(u, { method: 'GET', signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const j = (await res.json()) as { revoked?: boolean };
|
||||||
|
this.lastRemoteRevoked = Boolean(j.revoked);
|
||||||
|
} catch {
|
||||||
|
/* offline: не блокируем */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusSync(): LicenseSnapshot {
|
||||||
|
if (this.isSkipLicense()) {
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
devSkip: true,
|
||||||
|
reason: 'ok',
|
||||||
|
summary: null,
|
||||||
|
eulaAcceptedVersion: readPreferences(this.userData).eulaAcceptedVersion ?? null,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eulaAcceptedVersion = readPreferences(this.userData).eulaAcceptedVersion ?? null;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
let token: string | null = null;
|
||||||
|
try {
|
||||||
|
token = this.readSealedToken();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
devSkip: false,
|
||||||
|
reason: 'none',
|
||||||
|
summary: null,
|
||||||
|
eulaAcceptedVersion,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!token?.trim()) {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
devSkip: false,
|
||||||
|
reason: 'none',
|
||||||
|
summary: null,
|
||||||
|
eulaAcceptedVersion,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
|
||||||
|
if (!v.ok) {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
devSkip: false,
|
||||||
|
reason: v.reason,
|
||||||
|
summary: null,
|
||||||
|
eulaAcceptedVersion,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lastRemoteRevoked) {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
devSkip: false,
|
||||||
|
reason: 'revoked_remote',
|
||||||
|
summary: {
|
||||||
|
sub: v.payload.sub,
|
||||||
|
pid: v.payload.pid,
|
||||||
|
exp: v.payload.exp,
|
||||||
|
did: v.payload.did,
|
||||||
|
},
|
||||||
|
eulaAcceptedVersion,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
devSkip: false,
|
||||||
|
reason: 'ok',
|
||||||
|
summary: {
|
||||||
|
sub: v.payload.sub,
|
||||||
|
pid: v.payload.pid,
|
||||||
|
exp: v.payload.exp,
|
||||||
|
did: v.payload.did,
|
||||||
|
},
|
||||||
|
eulaAcceptedVersion,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<LicenseSnapshot> {
|
||||||
|
if (this.isSkipLicense()) return this.getStatusSync();
|
||||||
|
const base = this.getStatusSync();
|
||||||
|
if (!base.active || !base.summary) return base;
|
||||||
|
const token = this.readSealedToken();
|
||||||
|
if (!token?.trim()) return base;
|
||||||
|
const v = verifyLicenseToken(token, {
|
||||||
|
nowSec: Math.floor(Date.now() / 1000),
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
});
|
||||||
|
if (!v.ok) return this.getStatusSync();
|
||||||
|
await this.maybeRefreshRemoteRevocation(v.payload);
|
||||||
|
return this.getStatusSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(token: string): LicenseSnapshot {
|
||||||
|
if (this.isSkipLicense()) {
|
||||||
|
return this.getStatusSync();
|
||||||
|
}
|
||||||
|
const trimmed = token.trim();
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
||||||
|
if (!v.ok) {
|
||||||
|
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
||||||
|
}
|
||||||
|
this.writeSealedToken(trimmed);
|
||||||
|
this.lastRemoteRevoked = false;
|
||||||
|
emitLicenseStatusChanged();
|
||||||
|
return this.getStatusSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearToken(): LicenseSnapshot {
|
||||||
|
this.clearSealedTokenFile();
|
||||||
|
this.lastRemoteRevoked = false;
|
||||||
|
emitLicenseStatusChanged();
|
||||||
|
return this.getStatusSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptEula(version: number): { ok: true } {
|
||||||
|
if (version !== EULA_CURRENT_VERSION) {
|
||||||
|
throw new Error('EULA_BAD_VERSION');
|
||||||
|
}
|
||||||
|
const prefs = readPreferences(this.userData);
|
||||||
|
prefs.eulaAcceptedVersion = version;
|
||||||
|
writePreferences(this.userData, prefs);
|
||||||
|
emitLicenseStatusChanged();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertForIpc(): void {
|
||||||
|
if (this.isSkipLicense()) return;
|
||||||
|
const s = this.getStatusSync();
|
||||||
|
if (!s.active) {
|
||||||
|
throw new Error(`LICENSE_REQUIRED:${s.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export function licenseEncryptedPath(userData: string): string {
|
||||||
|
return path.join(userData, 'license.sealed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceIdPath(userData: string): string {
|
||||||
|
return path.join(userData, 'device.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferencesPath(userData: string): string {
|
||||||
|
return path.join(userData, 'preferences.json');
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { canonicalJson } from '../../shared/license/canonicalJson';
|
||||||
|
import { joinSignedLicenseToken } from '../../shared/license/tokenFormat';
|
||||||
|
|
||||||
|
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||||
|
|
||||||
|
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_test_1',
|
||||||
|
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 ok = verifyLicenseToken(token, {
|
||||||
|
nowSec: 1_700_000_000,
|
||||||
|
deviceId: 'any',
|
||||||
|
publicKeyOverrideSpkiDerB64: pubB64,
|
||||||
|
});
|
||||||
|
if (!ok.ok) assert.fail('expected ok');
|
||||||
|
assert.equal(ok.payload.sub, 'lic_test_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('verifyLicenseToken: неверное устройство', () => {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
const payload = {
|
||||||
|
v: 1 as const,
|
||||||
|
sub: 'lic_test_2',
|
||||||
|
pid: 'dnd_player',
|
||||||
|
iat: 100,
|
||||||
|
exp: 2_000_000_000,
|
||||||
|
did: 'device-a',
|
||||||
|
};
|
||||||
|
const body = canonicalJson(payload);
|
||||||
|
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
|
||||||
|
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
|
||||||
|
|
||||||
|
const bad = verifyLicenseToken(token, {
|
||||||
|
nowSec: 1_700_000_000,
|
||||||
|
deviceId: 'device-b',
|
||||||
|
publicKeyOverrideSpkiDerB64: pubB64,
|
||||||
|
});
|
||||||
|
if (bad.ok) assert.fail('expected failure');
|
||||||
|
assert.equal(bad.reason, 'wrong_device');
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
||||||
|
|
||||||
|
import { LICENSE_ED25519_SPKI_DER_B64 } from '../../shared/license/bundledPublicKey';
|
||||||
|
import { canonicalJson } from '../../shared/license/canonicalJson';
|
||||||
|
import { isLicensePayloadV1, type LicensePayloadV1 } from '../../shared/license/payloadV1';
|
||||||
|
import { splitSignedLicenseToken } from '../../shared/license/tokenFormat';
|
||||||
|
import type { LicenseVerifyFailure } from '../../shared/license/verifyReasons';
|
||||||
|
|
||||||
|
export type LicenseVerifyResult =
|
||||||
|
| { ok: true; payload: LicensePayloadV1 }
|
||||||
|
| { ok: false; reason: LicenseVerifyFailure };
|
||||||
|
|
||||||
|
let cachedPublicKey: ReturnType<typeof createPublicKey> | null = null;
|
||||||
|
|
||||||
|
function getBundledPublicKey() {
|
||||||
|
cachedPublicKey ??= createPublicKey({
|
||||||
|
key: Buffer.from(LICENSE_ED25519_SPKI_DER_B64, 'base64'),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
});
|
||||||
|
return cachedPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLicenseToken(
|
||||||
|
token: string,
|
||||||
|
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
|
||||||
|
): LicenseVerifyResult {
|
||||||
|
const parts = splitSignedLicenseToken(token);
|
||||||
|
if (!parts) return { ok: false, reason: 'malformed' };
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(parts.bodyUtf8) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'bad_payload' };
|
||||||
|
}
|
||||||
|
if (!isLicensePayloadV1(payload)) return { ok: false, reason: 'bad_payload' };
|
||||||
|
|
||||||
|
const body = canonicalJson(payload);
|
||||||
|
const msg = Buffer.from(body, 'utf8');
|
||||||
|
const publicKey =
|
||||||
|
opts.publicKeyOverrideSpkiDerB64 !== undefined
|
||||||
|
? createPublicKey({
|
||||||
|
key: Buffer.from(opts.publicKeyOverrideSpkiDerB64, 'base64'),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
})
|
||||||
|
: getBundledPublicKey();
|
||||||
|
const okSig = cryptoVerify(null, msg, publicKey, Buffer.from(parts.signature));
|
||||||
|
if (!okSig) return { ok: false, reason: 'bad_signature' };
|
||||||
|
|
||||||
|
if (payload.nbf !== undefined && opts.nowSec < payload.nbf) {
|
||||||
|
return { ok: false, reason: 'not_yet_valid' };
|
||||||
|
}
|
||||||
|
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
||||||
|
if (payload.did !== null && payload.did !== opts.deviceId) {
|
||||||
|
return { ok: false, reason: 'wrong_device' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, payload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import fssync from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import yauzl from 'yauzl';
|
||||||
|
|
||||||
|
import type { Project } from '../../shared/types';
|
||||||
|
|
||||||
|
export function unzipToDir(zipPath: string, outDir: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const zipFile = zip;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const safeClose = (): void => {
|
||||||
|
try {
|
||||||
|
zipFile.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishOk = (): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
safeClose();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishErr = (e: unknown): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
safeClose();
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
zipFile.readEntry();
|
||||||
|
zipFile.on('entry', (entry: yauzl.Entry) => {
|
||||||
|
if (settled) return;
|
||||||
|
const filePath = path.join(outDir, entry.fileName);
|
||||||
|
if (entry.fileName.endsWith('/')) {
|
||||||
|
fssync.mkdirSync(filePath, { recursive: true });
|
||||||
|
zipFile.readEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fssync.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
zipFile.openReadStream(entry, (streamErr, readStream) => {
|
||||||
|
if (streamErr) return finishErr(streamErr);
|
||||||
|
readStream.on('error', finishErr);
|
||||||
|
const writeStream = fssync.createWriteStream(filePath);
|
||||||
|
writeStream.on('error', finishErr);
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
writeStream.on('close', () => {
|
||||||
|
if (!settled) zipFile.readEntry();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
zipFile.on('end', () => finishOk());
|
||||||
|
zipFile.on('error', finishErr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает `project.json` из zip; всегда закрывает дескриптор yauzl (иначе на Windows возможен EMFILE). */
|
||||||
|
export async function readProjectJsonFromZip(zipPath: string): Promise<Project> {
|
||||||
|
return new Promise<Project>((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const zipFile = zip;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const safeClose = (): void => {
|
||||||
|
try {
|
||||||
|
zipFile.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishOk = (project: Project): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
safeClose();
|
||||||
|
resolve(project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishErr = (e: unknown): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
safeClose();
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
zipFile.readEntry();
|
||||||
|
zipFile.on('entry', (entry: yauzl.Entry) => {
|
||||||
|
if (settled) return;
|
||||||
|
if (entry.fileName !== 'project.json') {
|
||||||
|
zipFile.readEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zipFile.openReadStream(entry, (streamErr, readStream) => {
|
||||||
|
if (streamErr) return finishErr(streamErr);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
readStream.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
readStream.on('error', finishErr);
|
||||||
|
readStream.on('end', () => {
|
||||||
|
try {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
const parsed = JSON.parse(raw) as unknown as Project;
|
||||||
|
finishOk(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
finishErr(e instanceof Error ? e : new Error('Failed to parse project.json'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
zipFile.on('error', finishErr);
|
||||||
|
zipFile.on('end', () => {
|
||||||
|
finishErr(new Error('project.json not found in zip'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fssync from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ZipFile } from 'yazl';
|
||||||
|
|
||||||
|
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||||
|
|
||||||
|
import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||||
|
|
||||||
|
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
|
||||||
|
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||||
|
const minimal = {
|
||||||
|
id: 'p1',
|
||||||
|
meta: {
|
||||||
|
name: 'n',
|
||||||
|
fileBaseName: 'f',
|
||||||
|
createdAt: '2020-01-01',
|
||||||
|
updatedAt: '2020-01-01',
|
||||||
|
createdWithAppVersion: '1',
|
||||||
|
appVersion: '1',
|
||||||
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||||
|
},
|
||||||
|
scenes: {},
|
||||||
|
assets: {},
|
||||||
|
currentSceneId: null,
|
||||||
|
currentGraphNodeId: null,
|
||||||
|
sceneGraphNodes: [],
|
||||||
|
sceneGraphEdges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const zipfile = new ZipFile();
|
||||||
|
zipfile.addBuffer(Buffer.from(JSON.stringify(minimal), 'utf8'), 'project.json', { compressionLevel: 9 });
|
||||||
|
const out = fssync.createWriteStream(zipPath);
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
out.on('close', resolve);
|
||||||
|
out.on('error', reject);
|
||||||
|
});
|
||||||
|
zipfile.outputStream.pipe(out);
|
||||||
|
zipfile.end();
|
||||||
|
await done;
|
||||||
|
|
||||||
|
for (let i = 0; i < 150; i += 1) {
|
||||||
|
const p = await readProjectJsonFromZip(zipPath);
|
||||||
|
assert.equal(p.id, 'p1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
@@ -4,7 +4,6 @@ import fs from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import yauzl from 'yauzl';
|
|
||||||
import { ZipFile } from 'yazl';
|
import { ZipFile } from 'yazl';
|
||||||
|
|
||||||
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
|
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
|
||||||
@@ -26,6 +25,7 @@ import { getAppSemanticVersion } from '../versionInfo';
|
|||||||
|
|
||||||
import { reconcileAssetFiles } from './assetPrune';
|
import { reconcileAssetFiles } from './assetPrune';
|
||||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||||
|
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
|
||||||
|
|
||||||
type ProjectIndexEntry = {
|
type ProjectIndexEntry = {
|
||||||
id: ProjectId;
|
id: ProjectId;
|
||||||
@@ -1096,68 +1096,6 @@ async function atomicWriteFile(filePath: string, contents: string): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function unzipToDir(zipPath: string, outDir: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
const zipFile = zip;
|
|
||||||
zipFile.readEntry();
|
|
||||||
zipFile.on('entry', (entry: yauzl.Entry) => {
|
|
||||||
const filePath = path.join(outDir, entry.fileName);
|
|
||||||
if (entry.fileName.endsWith('/')) {
|
|
||||||
fssync.mkdirSync(filePath, { recursive: true });
|
|
||||||
zipFile.readEntry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fssync.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
||||||
zipFile.openReadStream(entry, (streamErr, readStream) => {
|
|
||||||
if (streamErr) return reject(streamErr);
|
|
||||||
readStream.on('error', reject);
|
|
||||||
const writeStream = fssync.createWriteStream(filePath);
|
|
||||||
writeStream.on('error', reject);
|
|
||||||
readStream.pipe(writeStream);
|
|
||||||
writeStream.on('close', () => zipFile.readEntry());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
zipFile.on('end', resolve);
|
|
||||||
zipFile.on('error', reject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readProjectJsonFromZip(zipPath: string): Promise<Project> {
|
|
||||||
return new Promise<Project>((resolve, reject) => {
|
|
||||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
const zipFile = zip;
|
|
||||||
zipFile.readEntry();
|
|
||||||
zipFile.on('entry', (entry: yauzl.Entry) => {
|
|
||||||
if (entry.fileName !== 'project.json') {
|
|
||||||
zipFile.readEntry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
zipFile.openReadStream(entry, (streamErr, readStream) => {
|
|
||||||
if (streamErr) return reject(streamErr);
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
readStream.on('data', (c: Buffer) => chunks.push(c));
|
|
||||||
readStream.on('error', reject);
|
|
||||||
readStream.on('end', () => {
|
|
||||||
try {
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8');
|
|
||||||
const parsed = JSON.parse(raw) as unknown as Project;
|
|
||||||
resolve(parsed);
|
|
||||||
} catch (e) {
|
|
||||||
reject(e instanceof Error ? e : new Error('Failed to parse project.json'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
zipFile.on('error', reject);
|
|
||||||
zipFile.on('end', () => reject(new Error('project.json not found in zip')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */
|
/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */
|
||||||
function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } {
|
function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } {
|
||||||
const norm = rel.replace(/\\/gu, '/').toLowerCase();
|
const norm = rel.replace(/\\/gu, '/').toLowerCase();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
import type { SessionState } from '../../shared/ipc/contracts';
|
import type { SessionState } from '../../shared/ipc/contracts';
|
||||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||||
@@ -31,6 +32,7 @@ export function ControlApp() {
|
|||||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
const [audioStateTick, setAudioStateTick] = useState(0);
|
||||||
const audioLoadRunRef = useRef(0);
|
const audioLoadRunRef = useRef(0);
|
||||||
|
const audioUnmountRef = useRef(false);
|
||||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const brushRef = useRef<{
|
const brushRef = useRef<{
|
||||||
@@ -80,6 +82,13 @@ export function ControlApp() {
|
|||||||
});
|
});
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
audioUnmountRef.current = false;
|
||||||
|
return () => {
|
||||||
|
audioUnmountRef.current = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const project = session?.project ?? null;
|
const project = session?.project ?? null;
|
||||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||||
const currentScene =
|
const currentScene =
|
||||||
@@ -102,21 +111,62 @@ export function ControlApp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioLoadRunRef.current += 1;
|
audioLoadRunRef.current += 1;
|
||||||
const runId = audioLoadRunRef.current;
|
const runId = audioLoadRunRef.current;
|
||||||
// Cleanup old audios on scene change.
|
|
||||||
const els = audioElsRef.current;
|
const oldEls = new Map(audioElsRef.current);
|
||||||
for (const el of els.values()) {
|
audioElsRef.current = new Map();
|
||||||
try {
|
|
||||||
el.pause();
|
|
||||||
el.currentTime = 0;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
els.clear();
|
|
||||||
audioMetaRef.current.clear();
|
audioMetaRef.current.clear();
|
||||||
setAudioStateTick((x) => x + 1);
|
setAudioStateTick((x) => x + 1);
|
||||||
|
|
||||||
if (!project || !currentScene) return;
|
const FADE_OUT_MS = 450;
|
||||||
|
const fadeOutCtl = { raf: 0, cancelled: false };
|
||||||
|
const finishFadeOut = (): void => {
|
||||||
|
for (const el of oldEls.values()) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (oldEls.size > 0) {
|
||||||
|
const startVol = new Map<string, number>();
|
||||||
|
for (const [id, el] of oldEls) {
|
||||||
|
startVol.set(id, el.volume);
|
||||||
|
}
|
||||||
|
const t0 = performance.now();
|
||||||
|
const tickOut = (now: number): void => {
|
||||||
|
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
|
||||||
|
finishFadeOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
|
||||||
|
for (const [id, el] of oldEls) {
|
||||||
|
try {
|
||||||
|
const v0 = startVol.get(id) ?? 1;
|
||||||
|
el.volume = v0 * (1 - u);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (u < 1) {
|
||||||
|
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||||||
|
} else {
|
||||||
|
finishFadeOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project || !currentScene) {
|
||||||
|
return () => {
|
||||||
|
fadeOutCtl.cancelled = true;
|
||||||
|
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_IN_MS = 550;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||||
for (const item of sceneAudioRefs) {
|
for (const item of sceneAudioRefs) {
|
||||||
@@ -126,6 +176,7 @@ export function ControlApp() {
|
|||||||
const el = new Audio(r.url);
|
const el = new Audio(r.url);
|
||||||
el.loop = item.loop;
|
el.loop = item.loop;
|
||||||
el.preload = 'auto';
|
el.preload = 'auto';
|
||||||
|
el.volume = item.autoplay ? 0 : 1;
|
||||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||||||
@@ -141,6 +192,7 @@ export function ControlApp() {
|
|||||||
try {
|
try {
|
||||||
el.pause();
|
el.pause();
|
||||||
el.currentTime = 0;
|
el.currentTime = 0;
|
||||||
|
el.volume = 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -157,9 +209,47 @@ export function ControlApp() {
|
|||||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||||
});
|
});
|
||||||
setAudioStateTick((x) => x + 1);
|
setAudioStateTick((x) => x + 1);
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tIn0 = performance.now();
|
||||||
|
const tickIn = (now: number): void => {
|
||||||
|
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
|
||||||
|
try {
|
||||||
|
el.volume = u;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (u < 1) window.requestAnimationFrame(tickIn);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(tickIn);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fadeOutCtl.cancelled = true;
|
||||||
|
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||||||
|
};
|
||||||
}, [api, currentScene, project, sceneAudioRefs]);
|
}, [api, currentScene, project, sceneAudioRefs]);
|
||||||
|
|
||||||
const anyPlaying = useMemo(() => {
|
const anyPlaying = useMemo(() => {
|
||||||
@@ -676,34 +766,8 @@ export function ControlApp() {
|
|||||||
setCursorN(p);
|
setCursorN(p);
|
||||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
if (tool.tool === 'eraser') {
|
if (tool.tool === 'eraser') {
|
||||||
const rN = tool.radiusN;
|
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||||
const nearest = (fxState?.instances ?? [])
|
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||||||
.map((inst) => {
|
|
||||||
if (inst.type === 'fog') {
|
|
||||||
const d = inst.points.reduce((best, q) => {
|
|
||||||
const dx = q.x - p.x;
|
|
||||||
const dy = q.y - p.y;
|
|
||||||
const dd = dx * dx + dy * dy;
|
|
||||||
return Math.min(best, dd);
|
|
||||||
}, Number.POSITIVE_INFINITY);
|
|
||||||
return { id: inst.id, dd: d };
|
|
||||||
}
|
|
||||||
if (inst.type === 'lightning') {
|
|
||||||
const dx = inst.end.x - p.x;
|
|
||||||
const dy = inst.end.y - p.y;
|
|
||||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
|
||||||
}
|
|
||||||
if (inst.type === 'freeze') {
|
|
||||||
const dx = inst.at.x - p.x;
|
|
||||||
const dy = inst.at.y - p.y;
|
|
||||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
|
||||||
}
|
|
||||||
return { id: inst.id, dd: Number.POSITIVE_INFINITY };
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.dd - b.dd)[0];
|
|
||||||
if (nearest && nearest.dd <= rN * rN) {
|
|
||||||
void fx.dispatch({ kind: 'instance.remove', id: nearest.id });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
brushRef.current = {
|
brushRef.current = {
|
||||||
@@ -714,10 +778,15 @@ export function ControlApp() {
|
|||||||
setDraftFxTick((x) => x + 1);
|
setDraftFxTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
const b = brushRef.current;
|
|
||||||
const p = toNPoint(e);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setCursorN(p);
|
setCursorN(p);
|
||||||
|
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||||||
|
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||||
|
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = brushRef.current;
|
||||||
if (!b?.points) return;
|
if (!b?.points) return;
|
||||||
const last = b.points[b.points.length - 1];
|
const last = b.points[b.points.length - 1];
|
||||||
if (!last) return;
|
if (!last) return;
|
||||||
|
|||||||
@@ -253,6 +253,37 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.licenseBlockTitle {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.licenseTextarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--text0);
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eulaScroll {
|
||||||
|
max-height: min(52vh, 420px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg0);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.fileSuffix {
|
.fileSuffix {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
|
import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
|
||||||
import { AppLogo } from '../shared/branding/AppLogo';
|
import { AppLogo } from '../shared/branding/AppLogo';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
@@ -12,6 +14,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|||||||
|
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
|
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
|
||||||
|
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||||
import { useProjectState } from './state/projectState';
|
import { useProjectState } from './state/projectState';
|
||||||
|
|
||||||
type SceneCard = {
|
type SceneCard = {
|
||||||
@@ -29,13 +32,22 @@ export function EditorApp() {
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
||||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||||
|
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [state, actions] = useProjectState();
|
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
||||||
|
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
|
||||||
|
const [eulaModalOpen, setEulaModalOpen] = useState(false);
|
||||||
|
const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
|
||||||
|
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
|
||||||
|
const licenseActive = licenseSnap?.active === true;
|
||||||
|
const [state, actions] = useProjectState(licenseActive);
|
||||||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const scenes = useMemo<SceneCard[]>(() => {
|
const scenes = useMemo<SceneCard[]>(() => {
|
||||||
const p = state.project;
|
const p = state.project;
|
||||||
if (!p) return [];
|
if (!p) return [];
|
||||||
@@ -134,6 +146,45 @@ export function EditorApp() {
|
|||||||
return () => window.removeEventListener('mousedown', onDown);
|
return () => window.removeEventListener('mousedown', onDown);
|
||||||
}, [projectMenuOpen]);
|
}, [projectMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsMenuOpen) return;
|
||||||
|
const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (r) {
|
||||||
|
setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
|
||||||
|
} else {
|
||||||
|
setSettingsMenuPos(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (!t) return;
|
||||||
|
if (t.closest('[data-settingsmenu-root="1"]')) return;
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => window.removeEventListener('mousedown', onDown);
|
||||||
|
}, [settingsMenuOpen]);
|
||||||
|
|
||||||
|
const reloadLicense = useCallback(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
|
||||||
|
setLicenseSnap(s);
|
||||||
|
} catch {
|
||||||
|
setLicenseSnap(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadLicense();
|
||||||
|
const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
|
||||||
|
reloadLicense();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [reloadLicense]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -148,28 +199,63 @@ export function EditorApp() {
|
|||||||
|
|
||||||
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
|
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
|
||||||
|
|
||||||
|
const bodyOverlay =
|
||||||
|
licenseSnap === null ? (
|
||||||
|
<div>
|
||||||
|
<div className={styles.licenseBlockTitle}>Проверка лицензии…</div>
|
||||||
|
<div className={styles.muted}>Подождите.</div>
|
||||||
|
</div>
|
||||||
|
) : !licenseSnap.active ? (
|
||||||
|
<div>
|
||||||
|
<div className={styles.licenseBlockTitle}>Требуется лицензия</div>
|
||||||
|
<div className={styles.muted}>
|
||||||
|
Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LayoutShell
|
<LayoutShell
|
||||||
|
bodyOverlay={bodyOverlay}
|
||||||
topBar={
|
topBar={
|
||||||
<div className={styles.topBarRow}>
|
<div className={styles.topBarRow}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.brandButton}
|
className={styles.brandButton}
|
||||||
onClick={() => void actions.closeProject()}
|
onClick={() => {
|
||||||
|
void actions.closeProject();
|
||||||
|
}}
|
||||||
title="К списку проектов"
|
title="К списку проектов"
|
||||||
>
|
>
|
||||||
<AppLogo className={styles.brandLogo} size={26} />
|
<AppLogo className={styles.brandLogo} size={26} />
|
||||||
<div className={styles.brandTitle}>DNDGamePlayer</div>
|
<div className={styles.brandTitle}>DNDGamePlayer</div>
|
||||||
</button>
|
</button>
|
||||||
<div className={styles.fileToolbar}>
|
<div className={styles.fileToolbar}>
|
||||||
|
<button
|
||||||
|
ref={settingsMenuBtnRef}
|
||||||
|
type="button"
|
||||||
|
data-settingsmenu-root="1"
|
||||||
|
className={styles.fileMenuTrigger}
|
||||||
|
onClick={() => {
|
||||||
|
setFileMenuOpen(false);
|
||||||
|
setProjectMenuOpen(false);
|
||||||
|
setSettingsMenuOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Настройки
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
ref={projectMenuBtnRef}
|
ref={projectMenuBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
data-projectmenu-root="1"
|
data-projectmenu-root="1"
|
||||||
className={styles.fileMenuTrigger}
|
className={styles.fileMenuTrigger}
|
||||||
|
disabled={!licenseActive}
|
||||||
|
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!licenseActive) return;
|
||||||
setFileMenuOpen(false);
|
setFileMenuOpen(false);
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
setProjectMenuOpen((v) => !v);
|
setProjectMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -181,8 +267,12 @@ export function EditorApp() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-filemenu-root="1"
|
data-filemenu-root="1"
|
||||||
className={styles.fileMenuTrigger}
|
className={styles.fileMenuTrigger}
|
||||||
|
disabled={!licenseActive}
|
||||||
|
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!licenseActive) return;
|
||||||
setProjectMenuOpen(false);
|
setProjectMenuOpen(false);
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
setFileMenuOpen((v) => !v);
|
setFileMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -200,10 +290,16 @@ export function EditorApp() {
|
|||||||
{state.project ? (
|
{state.project ? (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!graphStartGraphNodeId}
|
disabled={!licenseActive || !graphStartGraphNodeId}
|
||||||
title={graphStartSceneId ? undefined : 'Назначьте начальную сцену на графе (ПКМ по узлу)'}
|
title={
|
||||||
|
!licenseActive
|
||||||
|
? 'Доступно после активации лицензии'
|
||||||
|
: graphStartSceneId
|
||||||
|
? undefined
|
||||||
|
: 'Назначьте начальную сцену на графе (ПКМ по узлу)'
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!graphStartGraphNodeId) return;
|
if (!licenseActive || !graphStartGraphNodeId) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
|
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
|
||||||
graphNodeId: graphStartGraphNodeId,
|
graphNodeId: graphStartGraphNodeId,
|
||||||
@@ -319,6 +415,70 @@ export function EditorApp() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{settingsMenuOpen && settingsMenuPos
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
data-settingsmenu-root="1"
|
||||||
|
className={styles.fileMenu}
|
||||||
|
style={{ left: settingsMenuPos.left, top: settingsMenuPos.top }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
|
||||||
|
setLicenseKeyModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setOpenKeyAfterEula(true);
|
||||||
|
setEulaModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Указать ключ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
setAboutLicenseOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
О лицензии
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<LicenseTokenModal
|
||||||
|
open={licenseKeyModalOpen}
|
||||||
|
onClose={() => setLicenseKeyModalOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
reloadLicense();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EulaModal
|
||||||
|
open={eulaModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setEulaModalOpen(false);
|
||||||
|
setOpenKeyAfterEula(false);
|
||||||
|
}}
|
||||||
|
onAccepted={() => {
|
||||||
|
if (openKeyAfterEula) {
|
||||||
|
setLicenseKeyModalOpen(true);
|
||||||
|
}
|
||||||
|
setOpenKeyAfterEula(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LicenseAboutModal
|
||||||
|
open={aboutLicenseOpen}
|
||||||
|
onClose={() => setAboutLicenseOpen(false)}
|
||||||
|
snapshot={licenseSnap}
|
||||||
|
/>
|
||||||
{projectMenuOpen && projectMenuPos
|
{projectMenuOpen && projectMenuPos
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -327,6 +487,17 @@ export function EditorApp() {
|
|||||||
className={styles.fileMenu}
|
className={styles.fileMenu}
|
||||||
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
|
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setProjectMenuOpen(false);
|
||||||
|
void actions.closeProject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Начальный экран
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -372,7 +543,7 @@ export function EditorApp() {
|
|||||||
setRenameOpen(true);
|
setRenameOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Переименовать проект…
|
Переименовать проект
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -894,7 +1065,6 @@ function SceneInspector({
|
|||||||
/>
|
/>
|
||||||
<span className={styles.spanXs}>Цикл</span>
|
<span className={styles.spanXs}>Цикл</span>
|
||||||
</label>
|
</label>
|
||||||
<span>аудио</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Убрать из сцены"
|
title="Убрать из сцены"
|
||||||
@@ -921,7 +1091,7 @@ function SceneInspector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onUploadMedia}>Загрузить аудио</Button>
|
<Button onClick={onUploadMedia}>Загрузить</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
|
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||||
|
import { EULA_CURRENT_VERSION } from '../../../shared/license/eulaVersion';
|
||||||
|
import type { LicenseSnapshot } from '../../../shared/license/licenseSnapshot';
|
||||||
|
import { EULA_RU_MARKDOWN } from '../../legal/eulaRu';
|
||||||
|
import { getDndApi } from '../../shared/dndApi';
|
||||||
|
import { Button } from '../../shared/ui/controls';
|
||||||
|
import styles from '../EditorApp.module.css';
|
||||||
|
|
||||||
|
type LicenseTokenModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) {
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setToken('');
|
||||||
|
setSaving(false);
|
||||||
|
setError(null);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||||
|
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>Указать ключ</div>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>ЛИЦЕНЗИОННЫЙ ТОКЕН</div>
|
||||||
|
<textarea
|
||||||
|
className={styles.licenseTextarea}
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="Вставьте токен, выданный сервером лицензий…"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button onClick={onClose} disabled={saving}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving || !token.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await getDndApi().invoke(ipcChannels.license.setToken, { token: token.trim() });
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EulaModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAccepted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||||
|
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>Лицензионное соглашение</div>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.eulaScroll}>{EULA_RU_MARKDOWN}</div>
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button onClick={onClose} disabled={saving}>
|
||||||
|
Не принимаю
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await getDndApi().invoke(ipcChannels.license.acceptEula, {
|
||||||
|
version: EULA_CURRENT_VERSION,
|
||||||
|
});
|
||||||
|
onAccepted();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Принимаю условия
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseAboutModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
snapshot: LicenseSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function reasonLabel(reason: LicenseSnapshot['reason']): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'ok':
|
||||||
|
return 'Активна';
|
||||||
|
case 'none':
|
||||||
|
return 'Ключ не указан';
|
||||||
|
case 'expired':
|
||||||
|
return 'Срок действия истёк';
|
||||||
|
case 'bad_signature':
|
||||||
|
return 'Недействительная подпись';
|
||||||
|
case 'bad_payload':
|
||||||
|
return 'Неверный формат токена';
|
||||||
|
case 'malformed':
|
||||||
|
return 'Повреждённый токен';
|
||||||
|
case 'not_yet_valid':
|
||||||
|
return 'Ещё не действует';
|
||||||
|
case 'wrong_device':
|
||||||
|
return 'Другой привязанный компьютер';
|
||||||
|
case 'revoked_remote':
|
||||||
|
return 'Отозвана на сервере';
|
||||||
|
default:
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const expText =
|
||||||
|
snapshot?.summary?.exp != null
|
||||||
|
? new Date(snapshot.summary.exp * 1000).toLocaleString('ru-RU', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||||
|
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>О лицензии</div>
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{snapshot?.devSkip ? (
|
||||||
|
<div className={styles.fieldError}>
|
||||||
|
Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>СТАТУС</div>
|
||||||
|
<div>{snapshot ? reasonLabel(snapshot.reason) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
{snapshot?.summary ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>ПРОДУКТ</div>
|
||||||
|
<div>{snapshot.summary.pid}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>ID ЛИЦЕНЗИИ</div>
|
||||||
|
<div style={{ wordBreak: 'break-all' }}>{snapshot.summary.sub}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>ОКОНЧАНИЕ</div>
|
||||||
|
<div>{expText}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.fieldLabel}>УСТРОЙСТВО</div>
|
||||||
|
<div style={{ wordBreak: 'break-all' }}>{snapshot.deviceId}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.muted}>Нет данных лицензии.</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||||
@@ -54,9 +54,13 @@ function randomId(prefix: string): string {
|
|||||||
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectState(): readonly [State, Actions] {
|
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
|
||||||
const api = getDndApi();
|
const api = getDndApi();
|
||||||
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
|
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
|
||||||
|
const projectRef = useRef<Project | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
projectRef.current = state.project;
|
||||||
|
}, [state.project]);
|
||||||
|
|
||||||
const actions = useMemo<Actions>(() => {
|
const actions = useMemo<Actions>(() => {
|
||||||
const refreshProjects = async () => {
|
const refreshProjects = async () => {
|
||||||
@@ -77,11 +81,11 @@ export function useProjectState(): readonly [State, Actions] {
|
|||||||
|
|
||||||
const closeProject = async () => {
|
const closeProject = async () => {
|
||||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||||
await refreshProjects();
|
if (licenseActive) await refreshProjects();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createScene = async () => {
|
const createScene = async () => {
|
||||||
const p = state.project;
|
const p = projectRef.current;
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
const sceneId = randomId('scene') as SceneId;
|
const sceneId = randomId('scene') as SceneId;
|
||||||
const scene: Scene = {
|
const scene: Scene = {
|
||||||
@@ -307,16 +311,22 @@ export function useProjectState(): readonly [State, Actions] {
|
|||||||
exportProject,
|
exportProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
};
|
};
|
||||||
}, [api, state.project]);
|
}, [api, licenseActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!licenseActive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setState({ projects: [], project: null, selectedSceneId: null });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await actions.refreshProjects();
|
const listRes = await api.invoke(ipcChannels.project.list, {});
|
||||||
|
setState((s) => ({ ...s, projects: listRes.projects }));
|
||||||
const res = await api.invoke(ipcChannels.project.get, {});
|
const res = await api.invoke(ipcChannels.project.get, {});
|
||||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [licenseActive, api]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [state, actions] as const;
|
return [state, actions] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Текст для экрана принятия EULA (D9). Не заменяет консультацию юриста. */
|
||||||
|
export const EULA_RU_MARKDOWN = `
|
||||||
|
# Лицензионное соглашение с конечным пользователем (EULA)
|
||||||
|
|
||||||
|
Используя DNDGamePlayer («Программу»), вы соглашаетесь с условиями ниже.
|
||||||
|
|
||||||
|
## 1. Предоставление прав
|
||||||
|
Правообладатель предоставляет вам неисключическую, непередаваемую лицензию на использование Программы в пределах приобретённой лицензии (активации).
|
||||||
|
|
||||||
|
## 2. Активация, срок, устройства
|
||||||
|
Доступ к функциям может требовать онлайн- или офлайн-активации с помощью ключа. Лицензия может быть ограничена сроком действия и числом устройств. Подробности отображаются в разделе «О лицензии».
|
||||||
|
|
||||||
|
## 3. Отзыв
|
||||||
|
Правообладатель вправе отозвать лицензию при нарушении условий или по иным основаниям, предусмотренным офертой. После отзыва Программа может ограничить доступ к функциям без обновления установленного у вас клиента (проверка статуса при наличии сети).
|
||||||
|
|
||||||
|
## 4. Отказ от гарантий
|
||||||
|
Программа поставляется «как есть». По максимуму, допускаемому применимым правом, исключаются гарантии любого рода.
|
||||||
|
|
||||||
|
## 5. Ограничение ответственности
|
||||||
|
Ответственность ограничивается суммой, уплаченной за лицензию, если иное не установлено императивным правом.
|
||||||
|
|
||||||
|
## 6. Применимое право
|
||||||
|
Применимое право и разрешение споров — в соответствии с документами, сопровождающими вашу покупку, либо по выбору правообладателя, если отдельные документы не согласованы.
|
||||||
|
`.trim();
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
|
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -22,6 +23,22 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bodyOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(10, 10, 12, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.col {
|
.col {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ type Props = {
|
|||||||
left: React.ReactNode;
|
left: React.ReactNode;
|
||||||
center: React.ReactNode;
|
center: React.ReactNode;
|
||||||
right: React.ReactNode;
|
right: React.ReactNode;
|
||||||
|
/** Блокировка основной области (под хедером), например без лицензии. */
|
||||||
|
bodyOverlay?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LayoutShell({ topBar, left, center, right }: Props) {
|
export function LayoutShell({ topBar, left, center, right, bodyOverlay }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.topBar}>{topBar}</div>
|
<div className={styles.topBar}>{topBar}</div>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
|
{bodyOverlay ? <div className={styles.bodyOverlay}>{bodyOverlay}</div> : null}
|
||||||
<div className={styles.col}>{left}</div>
|
<div className={styles.col}>{left}</div>
|
||||||
<div className={styles.col}>{center}</div>
|
<div className={styles.col}>{center}</div>
|
||||||
<div className={styles.col}>{right}</div>
|
<div className={styles.col}>{right}</div>
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
AssetId,
|
AssetId,
|
||||||
EffectsEvent,
|
EffectsEvent,
|
||||||
@@ -61,6 +62,13 @@ export const ipcChannels = {
|
|||||||
dispatch: 'video.dispatch',
|
dispatch: 'video.dispatch',
|
||||||
stateChanged: 'video.stateChanged',
|
stateChanged: 'video.stateChanged',
|
||||||
},
|
},
|
||||||
|
license: {
|
||||||
|
getStatus: 'license.getStatus',
|
||||||
|
setToken: 'license.setToken',
|
||||||
|
clearToken: 'license.clearToken',
|
||||||
|
acceptEula: 'license.acceptEula',
|
||||||
|
statusChanged: 'license.statusChanged',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IpcInvokeMap = {
|
export type IpcInvokeMap = {
|
||||||
@@ -196,6 +204,22 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: VideoPlaybackEvent };
|
req: { event: VideoPlaybackEvent };
|
||||||
res: { ok: true };
|
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 = {
|
export type SessionState = {
|
||||||
@@ -207,6 +231,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
|
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScenePatch = {
|
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)', () => {
|
void test('package.json: конфиг electron-builder (mac/win)', () => {
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
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.ok(pkg.build);
|
||||||
assert.equal(pkg.build.appId, 'com.dndplayer.app');
|
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(Array.isArray(pkg.build.mac.target));
|
||||||
assert.ok(pkg.build.files.includes('dist/**/*'));
|
assert.ok(pkg.build.files.includes('dist/**/*'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Спецификация лицензирования DNDGamePlayer (этап D1)
|
||||||
|
|
||||||
|
Документ фиксирует модель **D1**: онлайн-активация, срок, число устройств, отзыв, и как это согласуется с клиентом и сервером лицензий.
|
||||||
|
|
||||||
|
Исходный код сервера вынесен в отдельный репозиторий: [DndGamePlayerLicenseServer](https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git).
|
||||||
|
|
||||||
|
## Модель
|
||||||
|
|
||||||
|
1. **Продуктовый ключ** — секрет покупателя, известен только ему и серверу. Обменивается на **лицензионный токен** через `POST /v1/activate` (онлайн-активация).
|
||||||
|
2. **Лицензионный токен** — публичная полезная нагрузка (`sub`, `pid`, `iat`, `exp`, `did`) + подпись **Ed25519**. Клиент хранит только токен и **публичный** ключ (вшит в приложение); подделать валидный токен без приватного ключа сервера невозможно.
|
||||||
|
3. **Срок** — поле `exp` (unix секунды). Клиент отклоняет истёкший токен без сети.
|
||||||
|
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`).
|
||||||
|
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
|
||||||
|
|
||||||
|
## Хранение на клиенте
|
||||||
|
|
||||||
|
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
|
||||||
|
|
||||||
|
## Юридическое (D9)
|
||||||
|
|
||||||
|
Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).
|
||||||
Generated
+877
-38
File diff suppressed because it is too large
Load Diff
+22
-8
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd_player",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "DNDGamePlayer — редактор сцен и режим презентации",
|
"description": "DNDGamePlayer — редактор и проигрыватель игр",
|
||||||
"main": "dist/main/index.cjs",
|
"main": "dist/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs --production",
|
||||||
|
"build:dev": "node scripts/build.mjs",
|
||||||
|
"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/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/shared/package.build.test.ts",
|
"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",
|
||||||
"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",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"@rollup/plugin-strip": "^3.0.4",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-unicorn": "^64.0.0",
|
"eslint-plugin-unicorn": "^64.0.0",
|
||||||
|
"javascript-obfuscator": "^4.2.2",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
@@ -75,11 +79,17 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"arch": ["x64", "arm64"]
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "zip",
|
"target": "zip",
|
||||||
"arch": ["x64", "arm64"]
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "build/icon.png"
|
"icon": "build/icon.png"
|
||||||
@@ -88,11 +98,15 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"arch": ["x64"]
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "zip",
|
"target": "zip",
|
||||||
"arch": ["x64"]
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "build/icon.png"
|
"icon": "build/icon.png"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @param {string[]} argv
|
||||||
|
* @param {NodeJS.ProcessEnv} env
|
||||||
|
*/
|
||||||
|
export function resolveIsProduction(argv = process.argv, env = process.env) {
|
||||||
|
return env.NODE_ENV === 'production' || argv.includes('--production');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string[]} argv */
|
||||||
|
export function resolveObfuscateMain(argv = process.argv) {
|
||||||
|
return argv.includes('--obfuscate');
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveIsProduction, resolveObfuscateMain } from './build-env.mjs';
|
||||||
|
|
||||||
|
void test('resolveIsProduction: argv --production', () => {
|
||||||
|
assert.equal(resolveIsProduction(['node', 'scripts/build.mjs', '--production'], {}), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveIsProduction: NODE_ENV=production', () => {
|
||||||
|
assert.equal(resolveIsProduction(['node', 'scripts/build.mjs'], { NODE_ENV: 'production' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveIsProduction: dev по умолчанию', () => {
|
||||||
|
assert.equal(resolveIsProduction(['node', 'scripts/build.mjs'], { NODE_ENV: 'development' }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveObfuscateMain: --obfuscate', () => {
|
||||||
|
assert.equal(resolveObfuscateMain(['node', 'build.mjs', '--production', '--obfuscate']), true);
|
||||||
|
assert.equal(resolveObfuscateMain(['node', 'build.mjs', '--production']), false);
|
||||||
|
});
|
||||||
+44
-12
@@ -5,39 +5,71 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import { build } from 'esbuild';
|
import { build } from 'esbuild';
|
||||||
|
|
||||||
|
import { resolveIsProduction, resolveObfuscateMain } from './build-env.mjs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const isProd = resolveIsProduction();
|
||||||
|
const obfuscateMain = resolveObfuscateMain();
|
||||||
|
|
||||||
|
/** Старые .map от dev-сборок не должны попадать в pack. */
|
||||||
|
function removeStaleNodeBundleMaps() {
|
||||||
|
for (const p of [
|
||||||
|
path.join(root, 'dist/main/index.cjs.map'),
|
||||||
|
path.join(root, 'dist/preload/index.cjs.map'),
|
||||||
|
]) {
|
||||||
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runViteBuild() {
|
function runViteBuild() {
|
||||||
execFileSync('npx vite build', {
|
const cmd = isProd ? 'npx vite build' : 'npx vite build --mode development';
|
||||||
|
execFileSync(cmd, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
shell: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: isProd ? 'production' : 'development',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildNodeTargets() {
|
async function buildNodeTargets() {
|
||||||
await build({
|
if (isProd) removeStaleNodeBundleMaps();
|
||||||
entryPoints: [path.join(root, 'app/main/index.ts')],
|
|
||||||
outfile: path.join(root, 'dist/main/index.cjs'),
|
const nodeEnvLiteral = JSON.stringify(isProd ? 'production' : 'development');
|
||||||
|
const common = {
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
bundle: true,
|
bundle: true,
|
||||||
sourcemap: true,
|
minify: isProd,
|
||||||
|
sourcemap: !isProd,
|
||||||
external: ['electron'],
|
external: ['electron'],
|
||||||
});
|
define: { 'process.env.NODE_ENV': nodeEnvLiteral },
|
||||||
|
drop: isProd ? ['console', 'debugger'] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainOut = path.join(root, 'dist/main/index.cjs');
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
|
...common,
|
||||||
|
entryPoints: [path.join(root, 'app/main/index.ts')],
|
||||||
|
outfile: mainOut,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isProd && obfuscateMain) {
|
||||||
|
const { obfuscateMainBundleFile } = await import('./obfuscate-main.mjs');
|
||||||
|
obfuscateMainBundleFile(mainOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
await build({
|
||||||
|
...common,
|
||||||
entryPoints: [path.join(root, 'app/preload/index.ts')],
|
entryPoints: [path.join(root, 'app/preload/index.ts')],
|
||||||
outfile: path.join(root, 'dist/preload/index.cjs'),
|
outfile: path.join(root, 'dist/preload/index.cjs'),
|
||||||
platform: 'node',
|
|
||||||
target: 'node22',
|
|
||||||
format: 'cjs',
|
|
||||||
bundle: true,
|
|
||||||
sourcemap: true,
|
|
||||||
external: ['electron'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -77,10 +77,20 @@ async function watchMainAndPreload() {
|
|||||||
|
|
||||||
const dispose = await watchMainAndPreload();
|
const dispose = await watchMainAndPreload();
|
||||||
const vite = spawnShell('npx vite dev --strictPort', {
|
const vite = spawnShell('npx vite dev --strictPort', {
|
||||||
env: { ...process.env, NODE_ENV: 'development', VITE_DEV_SERVER_URL: 'http://localhost:5173/' },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
|
||||||
|
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const electron = spawnShell('npx electron .', {
|
const electron = spawnShell('npx electron .', {
|
||||||
env: { ...process.env, NODE_ENV: 'development', VITE_DEV_SERVER_URL: 'http://localhost:5173/' },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
|
||||||
|
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import JavaScriptObfuscator from 'javascript-obfuscator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лёгкие настройки: без selfDefending / controlFlowFlattening — меньше шансов сломать Electron main.
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
export function obfuscateMainBundleFile(filePath) {
|
||||||
|
const code = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const obfuscated = JavaScriptObfuscator.obfuscate(code, {
|
||||||
|
compact: true,
|
||||||
|
controlFlowFlattening: false,
|
||||||
|
deadCodeInjection: false,
|
||||||
|
debugProtection: false,
|
||||||
|
disableConsoleOutput: false,
|
||||||
|
identifierNamesGenerator: 'hexadecimal',
|
||||||
|
renameGlobals: false,
|
||||||
|
selfDefending: false,
|
||||||
|
simplify: true,
|
||||||
|
stringArray: true,
|
||||||
|
stringArrayEncoding: [],
|
||||||
|
stringArrayThreshold: 0.75,
|
||||||
|
transformObjectKeys: false,
|
||||||
|
}).getObfuscatedCode();
|
||||||
|
fs.writeFileSync(filePath, obfuscated, 'utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { obfuscateMainBundleFile } from './obfuscate-main.mjs';
|
||||||
|
|
||||||
|
void test('obfuscateMainBundleFile: CJS после обфускации исполняется (кроссплатформенно)', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-obf-'));
|
||||||
|
const filePath = path.join(dir, 'stub.cjs');
|
||||||
|
fs.writeFileSync(filePath, 'exports.answer = 40 + 2;\n', 'utf8');
|
||||||
|
|
||||||
|
obfuscateMainBundleFile(filePath);
|
||||||
|
|
||||||
|
const out = fs.readFileSync(filePath, 'utf8');
|
||||||
|
assert.ok(out.length > 0);
|
||||||
|
assert.ok(!out.includes('exports.answer = 42'), 'исходный вид строки не должен сохраняться дословно');
|
||||||
|
|
||||||
|
const requireFromTest = createRequire(import.meta.url);
|
||||||
|
assert.equal(requireFromTest(filePath).answer, 42);
|
||||||
|
});
|
||||||
+37
-23
@@ -1,31 +1,45 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import strip from '@rollup/plugin-strip';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
root: path.resolve(__dirname, 'app/renderer'),
|
const isProd = mode === 'production';
|
||||||
plugins: [
|
|
||||||
react({
|
return {
|
||||||
babel: {
|
root: path.resolve(__dirname, 'app/renderer'),
|
||||||
plugins: [['babel-plugin-react-compiler', { target: '19' }]],
|
plugins: [
|
||||||
},
|
react({
|
||||||
} as Parameters<typeof react>[0]),
|
babel: {
|
||||||
],
|
plugins: [['babel-plugin-react-compiler', { target: '19' }]],
|
||||||
build: {
|
},
|
||||||
outDir: path.resolve(__dirname, 'dist/renderer'),
|
} as Parameters<typeof react>[0]),
|
||||||
emptyOutDir: true,
|
],
|
||||||
sourcemap: true,
|
build: {
|
||||||
rollupOptions: {
|
outDir: path.resolve(__dirname, 'dist/renderer'),
|
||||||
input: {
|
emptyOutDir: true,
|
||||||
editor: path.resolve(__dirname, 'app/renderer/editor.html'),
|
sourcemap: !isProd,
|
||||||
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
|
rollupOptions: {
|
||||||
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
plugins: isProd
|
||||||
|
? [
|
||||||
|
strip({
|
||||||
|
sourceMap: false,
|
||||||
|
debugger: true,
|
||||||
|
functions: ['console.*', 'assert.*'],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
input: {
|
||||||
|
editor: path.resolve(__dirname, 'app/renderer/editor.html'),
|
||||||
|
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
|
||||||
|
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
port: 5173,
|
||||||
port: 5173,
|
strictPort: true,
|
||||||
strictPort: true,
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user