Лицензия, редактор, пульт и сборка
- 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 { 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 { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
@@ -90,6 +91,10 @@ function emitSessionState(): void {
|
||||
|
||||
async function main() {
|
||||
await app.whenReady();
|
||||
const licenseService = new LicenseService(app.getPath('userData'));
|
||||
setLicenseAssert(() => {
|
||||
licenseService.assertForIpc();
|
||||
});
|
||||
Menu.setApplicationMenu(null);
|
||||
registerDndAssetProtocol(projectStore);
|
||||
registerHandler(ipcChannels.app.quit, () => {
|
||||
@@ -101,6 +106,10 @@ async function main() {
|
||||
version: getAppSemanticVersion(),
|
||||
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, () => {
|
||||
openMultiWindow();
|
||||
return { ok: true };
|
||||
|
||||
+26
-4
@@ -1,6 +1,6 @@
|
||||
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> = (
|
||||
payload: IpcInvokeMap[K]['req'],
|
||||
@@ -8,11 +8,33 @@ type Handler<K extends keyof IpcInvokeMap> = (
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => Promise<unknown>>();
|
||||
|
||||
export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handler: Handler<K>) {
|
||||
handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req']));
|
||||
let licenseAssert: (() => void) | undefined;
|
||||
|
||||
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()) {
|
||||
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 path from 'node:path';
|
||||
|
||||
import yauzl from 'yauzl';
|
||||
import { ZipFile } from 'yazl';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
|
||||
@@ -26,6 +25,7 @@ import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
|
||||
|
||||
type ProjectIndexEntry = {
|
||||
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. */
|
||||
function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } {
|
||||
const norm = rel.replace(/\\/gu, '/').toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user