diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts new file mode 100644 index 0000000..faa3e85 --- /dev/null +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +void test('zipStore: deleteProjectById removes legacy sibling copies by entry.fileName', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + assert.match(src, /async deleteProjectById/); + assert.match(src, /for \(const legacyRoot of getLegacyProjectsRootDirs\(\)\)/); + assert.match(src, /path\.join\(legacyRoot, entry\.fileName\)/); + assert.match(src, /rmWithRetries\(fs\.rm, legacyZipPath, \{ force: true \}\)/); +}); + +void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are not resurrected', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + assert.match(src, /migrateLegacyProjectZipsIfNeeded/); + assert.match(src, /if \(destZips\.has\(name\)\) continue/); + assert.match(src, /await fs\.rename\(from, to\)/); + assert.match(src, /await fs\.copyFile\(from, to\)/); + assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/); + assert.match(src, /не «возрождались»/); +}); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 89fbeaf..e2e4d31 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -100,7 +100,21 @@ export class ZipProjectStore { try { const st = await fs.stat(from); if (!st.isFile()) continue; - await fs.copyFile(from, to); + // Переносим (а не копируем), чтобы: + // - не было дублей между разными appName + // - удалённые пользователем проекты не «возрождались» при следующем ensureRoots() + try { + await fs.rename(from, to); + } catch { + await fs.copyFile(from, to); + try { + await rmWithRetries(fs.rm, from, { force: true }); + } catch { + // best effort: если zip уже скопирован в dest, миграцию считаем успешной; + // legacy-копия может остаться (например из-за lock/AV), но удаление проекта + // затем чистит legacy по fileName. + } + } destZips.add(name); } catch { /* ignore */ @@ -795,6 +809,18 @@ export class ZipProjectStore { await rmWithRetries(fs.rm, zipPath, { force: true }); await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true }); + + // Если проект подтянулся миграцией из legacy userData (другое имя приложения), + // то после удаления из текущей папки он может снова появиться при следующем ensureRoots(). + // Поэтому удаляем и legacy-копии архива. + for (const legacyRoot of getLegacyProjectsRootDirs()) { + const legacyZipPath = path.join(legacyRoot, entry.fileName); + try { + await rmWithRetries(fs.rm, legacyZipPath, { force: true }); + } catch { + /* ignore */ + } + } } private randomId(): string { diff --git a/package.json b/package.json index 4df68aa..d7a324c 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/editor/state/projectState.race.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/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.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/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs",