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

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

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 20:11:24 +08:00
parent 5e7dc5ea19
commit 2fa20da94d
40 changed files with 2629 additions and 211 deletions
+18
View File
@@ -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;
}
+235
View File
@@ -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}`);
}
}
}
+13
View File
@@ -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');
});
+61
View File
@@ -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 };
}