Лицензия, редактор, пульт и сборка
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест - Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay - Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены - Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,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