From 9c745ccfd3865991c3449472d672dee208dc63ea Mon Sep 17 00:00:00 2001 From: WebCrew Date: Sun, 13 Sep 2026 07:31:30 +0200 Subject: [PATCH] Beta 20: isolate saved project scene Undo/Redo histories --- .../workflows/publish-unsigned-preview.yml | 30 ++++----- CHANGES.md | 8 +++ README.md | 10 +-- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md | 4 ++ docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md | 27 ++++++++ docs/PROJECT-FORMAT.md | 9 ++- docs/ROADMAP.md | 1 + docs/TESTING-v0.5.0.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/check-desktop-boundaries.cjs | 6 +- scripts/check-scene-history-files.mjs | 65 +++++++++++++++++++ scripts/check-windows-distribution.cjs | 2 +- scripts/check.cjs | 6 +- src/main/main.js | 27 +------- src/main/sceneHistoryFiles.mjs | 45 +++++++++++++ 16 files changed, 196 insertions(+), 52 deletions(-) create mode 100644 docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md create mode 100644 scripts/check-scene-history-files.mjs create mode 100644 src/main/sceneHistoryFiles.mjs diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index af28131..a2f20a6 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -7,12 +7,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.19 + default: v0.5.0-beta.20 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 19 (unsigned) + default: Parlyn Engine v0.5.0 Beta 20 (unsigned) permissions: contents: write @@ -24,8 +24,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.19' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 19 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.20' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 20 (unsigned)' }} steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: throw "Expected exactly one Parlyn installer." } - $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.19-x64.zip" + $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.20-x64.zip" if (-not (Test-Path -LiteralPath "release/win-unpacked/Parlyn Engine.exe")) { throw "Packaged Parlyn executable was not found for the portable build." } @@ -72,14 +72,14 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 19 - - This unsigned Windows beta adds optional scene and world bounds as - world-space authoring guides through the Bounds command. Scene bounds - use normal saving and Undo/Redo; world bounds are explicitly saved. - Bounds do not constrain objects, cameras or runtime physics. - Beta 18 passed the maintainer's full editor regression test; Beta 19 - awaits human Windows acceptance. Older editors discard bounds on save. + # Parlyn Engine v0.5.0 Beta 20 + + This unsigned Windows beta fixes saved Undo/Redo disappearing after + switching project scenes. History is stored independently per scene. + It retains Beta 19's optional scene and world authoring bounds. + Beta 19 was not accepted due to the history defect; Beta 20 awaits + human Windows acceptance. Previously overwritten history cannot be + reconstructed. Older editors discard bounds on save. The portable ZIP creates no Windows installation or uninstall entry and remains the preferred unsigned test path. @@ -96,7 +96,7 @@ jobs: other public certificate provider. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release @@ -114,6 +114,6 @@ jobs: --notes-file release/PREVIEW-NOTES.md ` --prerelease ` "$($installer[0].FullName)" ` - "release/Parlyn-Engine-Portable-0.5.0-beta.19-x64.zip" ` + "release/Parlyn-Engine-Portable-0.5.0-beta.20-x64.zip" ` "release/latest.yml" ` "release/SHA256SUMS.txt" diff --git a/CHANGES.md b/CHANGES.md index 5595dfb..a94dd28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,14 @@ ## Unreleased — Foundation Stabilization +- Beta 20: fixed project scenes overwriting one shared history file. Saved + Undo/Redo now uses a separate hashed path per scene, with read-only legacy + fallback and unchanged stale-snapshot/size validation. +- Added a real file-system regression test for A/B switching, same-name scenes + in different folders, saved Redo, legacy history and corrupt/stale rejection. +- Beta 19 remains unaccepted following the maintainer's history defect report; + Beta 20 awaits re-testing. Already overwritten history cannot be recovered. + - Beta 19: added optional validated scene/world authoring bounds, independent of viewport size, with separate blue/amber guides and explicit world saving. - Added scene bounds Undo/Redo and round-trip/validation/helper-lifecycle tests. diff --git a/README.md b/README.md index 3f1f3bb..05e1be0 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ # Parlyn Engine -Beta 19 adds optional scene/world bounds through **Bounds** in the View command +Beta 20 fixes saved per-scene Undo/Redo history after a scene switch. It retains +Beta 19's optional scene/world bounds through **Bounds** in the View command group: blue scene guides and amber world guides in world coordinates. They do not restrict camera movement, objects or physics. Scene edits use Undo/Redo and -normal Save; world edits use explicit Save World Bounds. Human Beta 19 Windows -acceptance is pending. Beta 18 passed the full maintainer editor regression test. +normal Save; world edits use explicit Save World Bounds. Beta 19 was not accepted +due to the history defect; Beta 20 human Windows acceptance is pending. +Beta 18 passed the full maintainer editor regression test. **Parlyn Engine** is a free and open-source game engine designed primarily for **modern 2.5D development**, while keeping real 3D available wherever a project benefits from it. @@ -235,7 +237,7 @@ See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. Phase 1 acceptance is complete. The current Phase 2 editor candidate uses the -repeatable [`v0.5.0-beta.19 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md). +repeatable [`v0.5.0-beta.20 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md). ### Windows release integrity diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md index a263002..d0345c3 100644 --- a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md @@ -3,6 +3,10 @@ Status: pending human Windows acceptance. Beta 18 passed the maintainer's full editor regression test on 2026-09-12. +Beta 19 was not accepted: the maintainer found saved Undo history disappearing +after switching project scenes. Beta 20 addresses the shared-history-file cause +and requires the dedicated per-scene history re-test before acceptance. + 1. Open an existing project. Bounds are initially absent. Open **Bounds** in the View command group; check tooltips, keyboard focus and readable labels. 2. Enable Current Scene bounds, enter Min (-10,-3,-10), Max (10,8,10), and diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md new file mode 100644 index 0000000..3830ab1 --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md @@ -0,0 +1,27 @@ +# Parlyn Engine v0.5.0 Beta 20 — Maintainer Acceptance + +Status: pending human Windows acceptance. Beta 19 was not accepted because +saving a second project scene overwrote the first scene's local history. +Beta 20 separates saved history by project-relative scene path. + +Use a disposable copy of a project with two scenes. + +1. In scene A enable/change Bounds, Apply and Save. Undo must remain enabled. +2. Switch to B, enable different Bounds, Apply and Save. +3. Switch back to A. Undo must still be enabled and undo A's Bounds edit only. +4. Redo restores A's exact values. Save A, switch to B: B's Undo must still work + independently. Repeat A/B switching several times. +5. Undo an edit, Save, switch away and return: Redo must remain available if no + new edit was made. A new edit after Undo must clear only that scene's Redo. +6. Save both scenes, close the project and Engine, restart and reopen. Each + saved scene must retain its own Undo/Redo state. Include transform edits. +7. Test scene names that are identical but live in different subfolders. +8. Repeat the Bounds checks in [Beta 19's checklist](MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md), including loose-scene persistence and World Bounds. +9. Re-test unsaved Cancel/Discard/Save & Continue, snapping, Ground/End, + Frame Selected/F, multi-selection and panel restoration. + +History reflects its last successful save, not unsaved actions intentionally +discarded during switching. Already overwritten Beta 19 history cannot be +reconstructed by this fix. A matching legacy history remains readable until +the next save writes a separate per-scene file. Renamed/moved scene paths +have separate history identities; stale history is never attached blindly. diff --git a/docs/PROJECT-FORMAT.md b/docs/PROJECT-FORMAT.md index 713fbe9..3469fd0 100644 --- a/docs/PROJECT-FORMAT.md +++ b/docs/PROJECT-FORMAT.md @@ -58,8 +58,13 @@ Contains source assets imported by the developer. External asset formats are not ## `.parlyn/` Reserved for generated caches, import metadata and other project-local internal -data. Beta 5 stores the validated startup Scene History here as -`startup-scene.parlyn-history.json`. Developers should not rely on internal +data. Beta 20 stores validated per-scene History under +`scene-history/.parlyn-history.json`. +The previous shared `startup-scene.parlyn-history.json` is read-only legacy +fallback, accepted only for its matching scene and snapshot. New saves never +overwrite that legacy file. History remains limited to 100 entries and the +existing size limits. Renamed/moved paths have separate history identities. +Developers should not rely on internal paths as a public API; the history document itself remains explicitly versioned. ## Compatibility contract v1 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 11769e7..1e7ab6b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,6 +49,7 @@ Completion evidence: - duplication and reparenting — initial editor commands complete; - durable, validated local Scene History — initial foundation complete; + Beta 20 corrects saved per-scene history isolation; human re-test pending; - multi-selection where appropriate — initial hierarchy and viewport selection complete; - multi-scene project browser — validated listing, switching, creation, rename and move complete; - rename and move project assets — initial safe editor workflow complete; diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 4ad4626..de149e1 100644 --- a/docs/TESTING-v0.5.0.md +++ b/docs/TESTING-v0.5.0.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Test Guide For the current Phase 2 editor acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md) The guide below remains the detailed feature reference. This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. diff --git a/package-lock.json b/package-lock.json index d087d8a..a46302f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.19", + "version": "0.5.0-beta.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.19", + "version": "0.5.0-beta.20", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index 437e29f..e6bb634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.19", + "version": "0.5.0-beta.20", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index 23d36aa..040eaa8 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -100,7 +100,11 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(main, /secureHandle\('parlyn:project:move-asset'/); assert.match(main, /secureHandle\('parlyn:project:close'/); assert.match(main, /secureHandle\('parlyn:project:delete'/); - assert.match(main, /parlyn-scene-history/); + const sceneHistoryFiles = await fs.readFile(path.join(__dirname, '../src/main/sceneHistoryFiles.mjs'), 'utf8'); + assert.match(main, /import\('\.\/sceneHistoryFiles\.mjs'\)/); + assert.match(main, /\.loadSceneHistory\(projectRoot, sceneRelativePath, currentScene\)/); + assert.match(main, /\.saveSceneHistory\(activeProjectRoot, relativePath, payload\.scene, payload\.history\)/); + assert.match(sceneHistoryFiles, /parlyn-scene-history/); assert.match(renderer, /history\.exportState\(/); assert.match(renderer, /history\.restoreState\(result\.history\)/); assert.match(renderer, /normalizeWorkspaceLayout/); diff --git a/scripts/check-scene-history-files.mjs b/scripts/check-scene-history-files.mjs new file mode 100644 index 0000000..ae0cfde --- /dev/null +++ b/scripts/check-scene-history-files.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { SceneDocument } from '../src/engine/scene/SceneDocument.mjs'; +import { History } from '../src/engine/history/History.mjs'; +import { sceneHistoryRelativePath, saveSceneHistory, loadSceneHistory } from '../src/main/sceneHistoryFiles.mjs'; +import { readDocumentFile, writeDocumentFileAtomic } from '../src/main/documentFiles.mjs'; + +const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-per-scene-history-')); +try { + const emptyRoot = path.join(root, 'empty'); + await fs.mkdir(emptyRoot); + assert.deepEqual(await loadSceneHistory(emptyRoot, 'scenes/new.json', new SceneDocument().toJSON()), { history:null, warning:null }); + const entries = ['scenes/first/Demo.parlyn-scene.json', 'scenes/second/Demo.parlyn-scene.json'].map((scenePath, index) => { + const scene = new SceneDocument(`Demo ${index}`); + const before = scene.toJSON(); + scene.bounds = { min:{ x:-10-index, y:-3, z:-10 }, max:{ x:10+index, y:8, z:10 } }; + const history = new History(); history.push(before, 'Change Scene Bounds'); + return { scenePath, scene, before, history }; + }); + assert.notEqual(sceneHistoryRelativePath(entries[0].scenePath), sceneHistoryRelativePath(entries[1].scenePath)); + for (const entry of entries) { + const file = path.join(root, entry.scenePath); + await fs.mkdir(path.dirname(file), { recursive:true }); + await writeDocumentFileAtomic(file, entry.scene.toJSON(), 'parlyn-scene'); + await saveSceneHistory(root, entry.scenePath, entry.scene.toJSON(), entry.history.exportState()); + } + // Save A, save B, reopen A, reopen B: each scene retains its own Undo. + for (const entry of entries) { + const scene = await readDocumentFile(path.join(root, entry.scenePath), 'parlyn-scene'); + const result = await loadSceneHistory(root, entry.scenePath, scene); + assert.equal(result.warning, null); + const restored = new History(); restored.restoreState(result.history); + assert.equal(restored.canUndo, true); + assert.deepEqual(restored.undo(scene).snapshot, entry.before); + assert.deepEqual(restored.redo(entry.before).snapshot, scene); + } + // Persist an Undo followed by a switch/reopen: Redo is retained too. + const first = entries[0]; + first.history.undo(first.scene.toJSON()); + await saveSceneHistory(root, first.scenePath, first.before, first.history.exportState()); + const redoResult = await loadSceneHistory(root, first.scenePath, first.before); + const redoHistory = new History(); redoHistory.restoreState(redoResult.history); + assert.equal(redoHistory.canRedo, true); + assert.deepEqual(redoHistory.redo(first.before).snapshot, first.scene.toJSON()); + assert.equal((await loadSceneHistory(root, first.scenePath, first.scene.toJSON())).history, null, 'Stale history remains rejected.'); + assert.throws(() => sceneHistoryRelativePath('../outside.json')); + + // Legacy history is read-only, accepted only when path and snapshot match. + const legacy = path.join(root, '.parlyn/startup-scene.parlyn-history.json'); + await writeDocumentFileAtomic(legacy, { format:'parlyn-scene-history', version:1, + scenePath:'scenes/legacy.json', currentScene:first.before, history:new History().exportState(), updatedAt:new Date().toISOString() }, 'parlyn-scene-history'); + assert.notEqual((await loadSceneHistory(root, 'scenes/legacy.json', first.before)).history, null); + assert.equal((await loadSceneHistory(root, 'scenes/other.json', first.before)).history, null); + const legacyBefore = await fs.readFile(legacy, 'utf8'); + await saveSceneHistory(root, 'scenes/legacy.json', first.before, new History().exportState()); + assert.equal(await fs.readFile(legacy, 'utf8'), legacyBefore, 'New saves must not overwrite legacy history.'); + const primary = path.join(root, sceneHistoryRelativePath('scenes/legacy.json')); + await fs.writeFile(primary, '{broken', 'utf8'); + assert.equal((await loadSceneHistory(root, 'scenes/legacy.json', first.before)).history, null, 'Corrupt primary must not fall back to an older legacy snapshot.'); +} finally { + await fs.rm(root, { recursive:true, force:true }); +} +console.log('Per-scene saved Undo/Redo history regression check passed.'); diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs index 3c38dcb..8b75034 100644 --- a/scripts/check-windows-distribution.cjs +++ b/scripts/check-windows-distribution.cjs @@ -37,7 +37,7 @@ requireValue(workflow.includes('Verify signed Windows artifacts'), 'Signed outpu requireValue(workflow.includes('require_signing'), 'Windows workflow must distinguish signed and unsigned preflight builds.'); requireValue(!workflow.includes('BEGIN PRIVATE KEY'), 'Signing material must never be embedded in the workflow.'); requireValue(previewWorkflow.includes('Compress-Archive'), 'Unsigned previews must create a portable ZIP test artifact.'); -requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.19-x64.zip'), 'Portable preview artifact has an unexpected name.'); +requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.20-x64.zip'), 'Portable preview artifact has an unexpected name.'); requireValue(previewWorkflow.includes('release/win-unpacked/Parlyn Engine.exe'), 'Portable preview must verify its packaged executable.'); const gitignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); diff --git a/scripts/check.cjs b/scripts/check.cjs index 7308131..c12cb16 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -64,6 +64,9 @@ const required = [ 'scripts/check-transform-space.mjs', 'scripts/check-frame-selected.mjs', 'scripts/check-document-bounds.mjs', + 'scripts/check-scene-history-files.mjs', + 'src/main/sceneHistoryFiles.mjs', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md', 'src/engine/core/DocumentBounds.mjs', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md', 'scripts/check-asset-files.cjs', @@ -78,7 +81,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.5.0-beta.19') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.20') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -105,6 +108,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-transform-snap cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-transform-space.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-frame-selected.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-document-bounds.mjs')], { stdio: 'inherit' }); +cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-scene-history-files.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-asset-files.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' }); diff --git a/src/main/main.js b/src/main/main.js index 5706a27..7949f86 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -9,10 +9,9 @@ const { listAssets, moveAsset } = require('./assetFiles'); const persistence = import('../engine/persistence/DocumentPersistence.mjs'); const documentFiles = import('./documentFiles.mjs'); +const sceneHistoryFiles = import('./sceneHistoryFiles.mjs'); const EDITOR_FILE = path.join(__dirname, '..', 'renderer', 'index.html'); const EDITOR_URL = pathToFileURL(EDITOR_FILE).href; -const STARTUP_HISTORY_PATH = '.parlyn/startup-scene.parlyn-history.json'; -const MAX_HISTORY_FILE_BYTES = 32 * 1024 * 1024; const approvedWindowClosures = new WeakSet(); const readyEditorWindows = new WeakSet(); @@ -38,24 +37,7 @@ async function writeDocumentAtomic(filePath, document, expectedFormat, label = ' } async function loadSceneHistory(projectRoot, sceneRelativePath, currentScene) { - let historyFile; - try { - historyFile = await resolveExistingProjectPath(projectRoot, STARTUP_HISTORY_PATH, 'Scene history file'); - } catch (error) { - if (error.code === 'ENOENT') return { history:null, warning:null }; - return { history:null, warning:error.message }; - } - try { - const info = await fs.stat(historyFile); - if (info.size > MAX_HISTORY_FILE_BYTES) return { history:null, warning:'Saved scene history exceeded the 32 MiB safety limit and was ignored.' }; - const document = await readDocument(historyFile, 'parlyn-scene-history', 'Parlyn scene history'); - if (document.scenePath !== sceneRelativePath || JSON.stringify(document.currentScene) !== JSON.stringify(currentScene)) { - return { history:null, warning:'Saved scene history did not match the current scene and was safely ignored.' }; - } - return { history:document.history, warning:null }; - } catch (error) { - return { history:null, warning:error.message }; - } + return (await sceneHistoryFiles).loadSceneHistory(projectRoot, sceneRelativePath, currentScene); } const projectSession = new ProjectSession({ @@ -287,10 +269,7 @@ secureHandle('parlyn:project:save-scene', async (payload) => { let historyWarning = null; if (payload?.history) { try { - await fs.mkdir(path.join(activeProjectRoot,'.parlyn'), { recursive:true }); - const historyTarget=await resolveWritableProjectPath(activeProjectRoot,STARTUP_HISTORY_PATH,'Scene history path'); - const historyDocument={ format:'parlyn-scene-history', version:1, scenePath:relativePath, currentScene:payload.scene, history:payload.history, updatedAt:new Date().toISOString() }; - await writeDocumentAtomic(historyTarget, historyDocument, 'parlyn-scene-history', 'Parlyn scene history'); + await (await sceneHistoryFiles).saveSceneHistory(activeProjectRoot, relativePath, payload.scene, payload.history); } catch (error) { historyWarning=error.message; } diff --git a/src/main/sceneHistoryFiles.mjs b/src/main/sceneHistoryFiles.mjs new file mode 100644 index 0000000..5c16887 --- /dev/null +++ b/src/main/sceneHistoryFiles.mjs @@ -0,0 +1,45 @@ +import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import projectPaths from './projectPaths.js'; +import { readDocumentFile, writeDocumentFileAtomic } from './documentFiles.mjs'; + +const LEGACY_PATH = '.parlyn/startup-scene.parlyn-history.json'; +const MAX_HISTORY_FILE_BYTES = 32 * 1024 * 1024; +const { validateRelativeProjectPath, resolveExistingProjectPath, resolveWritableProjectPathCreatingParents } = projectPaths; + +export function sceneHistoryRelativePath(scenePath) { + validateRelativeProjectPath(scenePath, 'Scene history scene path'); + const key = createHash('sha256').update(scenePath, 'utf8').digest('hex'); + return `.parlyn/scene-history/${key}.parlyn-history.json`; +} + +export async function loadSceneHistory(projectRoot, scenePath, currentScene) { + try { + let file; + try { + file = await resolveExistingProjectPath(projectRoot, sceneHistoryRelativePath(scenePath), 'Scene history file'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + // Legacy files remain read-only. They are accepted only for their own scene. + try { file = await resolveExistingProjectPath(projectRoot, LEGACY_PATH, 'Legacy scene history file'); } + catch (legacyError) { + if (legacyError.code === 'ENOENT') return { history:null, warning:null }; + throw legacyError; + } + } + if ((await fs.stat(file)).size > MAX_HISTORY_FILE_BYTES) return { history:null, warning:'Saved scene history exceeded the 32 MiB safety limit and was ignored.' }; + const document = await readDocumentFile(file, 'parlyn-scene-history', 'Parlyn scene history'); + if (document.scenePath !== scenePath || JSON.stringify(document.currentScene) !== JSON.stringify(currentScene)) { + return { history:null, warning:'Saved scene history did not match the current scene and was safely ignored.' }; + } + return { history:document.history, warning:null }; + } catch (error) { + return { history:null, warning:error.message }; + } +} + +export async function saveSceneHistory(projectRoot, scenePath, currentScene, history) { + const target = await resolveWritableProjectPathCreatingParents(projectRoot, sceneHistoryRelativePath(scenePath), 'Scene history path'); + const document = { format:'parlyn-scene-history', version:1, scenePath, currentScene, history, updatedAt:new Date().toISOString() }; + await writeDocumentFileAtomic(target, document, 'parlyn-scene-history', 'Parlyn scene history'); +}