From 3d0474fe979736652c0b9f387e6def8015b5570d Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 10 Sep 2026 18:09:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=D0=BD=D0=B5=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8C=20=D1=81=20EPERM=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D1=8C=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=BD=D0=B0=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перед записью zip больше не вызываем mkdir для E:\ / C:\ — на Windows это даёт operation not permitted. Co-authored-by: Cursor --- app/main/project/ensureParentDir.test.ts | 26 +++++++++++++++++++ app/main/project/ensureParentDir.ts | 14 ++++++++++ .../project/zipStore.legacyContract.test.ts | 10 +++++++ app/main/project/zipStore.ts | 23 ++++++++-------- package.json | 2 +- 5 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 app/main/project/ensureParentDir.test.ts create mode 100644 app/main/project/ensureParentDir.ts diff --git a/app/main/project/ensureParentDir.test.ts b/app/main/project/ensureParentDir.test.ts new file mode 100644 index 0000000..bb5e130 --- /dev/null +++ b/app/main/project/ensureParentDir.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { ensureParentDir } from './ensureParentDir'; + +void test('ensureParentDir: creates nested parent', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-')); + try { + const file = path.join(root, 'a', 'b', 'out.ttrpg.zip'); + await ensureParentDir(file); + const st = await fs.stat(path.dirname(file)); + assert.equal(st.isDirectory(), true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +void test('ensureParentDir: drive root is a no-op (Windows)', async () => { + if (process.platform !== 'win32') return; + // Must not throw EPERM the way fs.mkdir('E:\\') often does. + await ensureParentDir('E:\\campaign.ttrpg.zip'); + await ensureParentDir('C:\\file.ttrpg.zip'); +}); diff --git a/app/main/project/ensureParentDir.ts b/app/main/project/ensureParentDir.ts new file mode 100644 index 0000000..416409b --- /dev/null +++ b/app/main/project/ensureParentDir.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Создаёт родительский каталог для файла. + * На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем. + */ +export async function ensureParentDir(filePath: string): Promise { + const dir = path.dirname(filePath); + if (!dir || dir === '.') return; + const root = path.parse(path.resolve(filePath)).root; + if (root && path.resolve(dir) === path.resolve(root)) return; + await fs.mkdir(dir, { recursive: true }); +} diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index fc03535..10ff587 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -139,6 +139,16 @@ void test('main index: full zip import loads app-tokens like playlists', () => { assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/); }); +void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + assert.match(src, /from '\.\/ensureParentDir'/); + const i = src.indexOf('async function zipDir('); + assert.ok(i >= 0); + const block = src.slice(i, i + 800); + assert.match(block, /await ensureParentDir\(outZipPath\)/); + assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/); +}); + void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const i = src.indexOf('export async function replaceFileAtomic'); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 4839f72..222f8b3 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -112,6 +112,7 @@ import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace'; +import { ensureParentDir } from './ensureParentDir'; import { rmWithRetries } from './fsRetry'; import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; @@ -475,7 +476,7 @@ export class ZipProjectStore { const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); - await fs.mkdir(path.dirname(abs), { recursive: true }); + await ensureParentDir(abs); await fs.writeFile(abs, buf); const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length); @@ -1165,7 +1166,7 @@ export class ZipProjectStore { const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); - await fs.mkdir(path.dirname(abs), { recursive: true }); + await ensureParentDir(abs); await fs.copyFile(filePath, abs); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); } @@ -1226,7 +1227,7 @@ export class ZipProjectStore { const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); - await fs.mkdir(path.dirname(abs), { recursive: true }); + await ensureParentDir(abs); await fs.copyFile(filePath, abs); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); } @@ -1316,7 +1317,7 @@ export class ZipProjectStore { const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); - await fs.mkdir(path.dirname(abs), { recursive: true }); + await ensureParentDir(abs); await fs.writeFile(abs, buf); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); nextAssetId = id; @@ -1551,7 +1552,7 @@ export class ZipProjectStore { const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); - await fs.mkdir(path.dirname(abs), { recursive: true }); + await ensureParentDir(abs); await fs.writeFile(abs, buf); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); nextAssetId = id; @@ -2038,7 +2039,7 @@ export class ZipProjectStore { private async packZipFromCache(cacheDir: string, zipPath: string): Promise { const tmpPath = `${zipPath}.tmp`; - await fs.mkdir(path.dirname(zipPath), { recursive: true }); + await ensureParentDir(zipPath); await zipDir(cacheDir, tmpPath); const st = await fs.stat(tmpPath).catch(() => null); if (!st?.isFile() || st.size < 22) { @@ -2178,7 +2179,7 @@ export class ZipProjectStore { } const src = path.join(getProjectsRootDir(), entry.fileName); const dest = path.resolve(destinationPath); - await fs.mkdir(path.dirname(dest), { recursive: true }); + await ensureParentDir(dest); if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' }); await copyFileWithProgress(src, dest, (pct) => { if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' }); @@ -2291,7 +2292,7 @@ export class ZipProjectStore { if (!a) continue; const srcAbs = path.join(snap.cacheDir, a.relPath); const destAbs = path.join(exportCache, a.relPath); - await fs.mkdir(path.dirname(destAbs), { recursive: true }); + await ensureParentDir(destAbs); await fs.copyFile(srcAbs, destAbs); if (onProgress) { const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75); @@ -2428,7 +2429,7 @@ export class ZipProjectStore { if (!srcAsset || !tgtMeta) continue; const srcAbs = path.join(sourceCache, srcAsset.relPath); const destAbs = path.join(targetCache, tgtMeta.relPath); - await fs.mkdir(path.dirname(destAbs), { recursive: true }); + await ensureParentDir(destAbs); await fs.copyFile(srcAbs, destAbs); if (onProgress) { const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90); @@ -2894,7 +2895,7 @@ async function copyFileWithProgress( onPercent(100); return; } - await fs.mkdir(path.dirname(dest), { recursive: true }); + await ensureParentDir(dest); await new Promise((resolve, reject) => { let done = 0; const rs = fssync.createReadStream(src); @@ -3027,7 +3028,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise { } zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel)); } - await fs.mkdir(path.dirname(outZipPath), { recursive: true }); + await ensureParentDir(outZipPath); const out = fssync.createWriteStream(outZipPath); const done = new Promise((resolve, reject) => { out.on('close', resolve); diff --git a/package.json b/package.json index 7a8fcb7..b49e686 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",