diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 79b71df..d50d95e 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -12,12 +12,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.3 + default: v0.5.0-beta.4 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 3 (unsigned) + default: Parlyn Engine v0.5.0 Beta 4 (unsigned) permissions: contents: write @@ -29,8 +29,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.3' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 3 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.4' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 4 (unsigned)' }} steps: - name: Checkout @@ -68,11 +68,12 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 3 + # Parlyn Engine v0.5.0 Beta 4 This unsigned Windows beta contains the completed automated Phase 1 foundation candidate. It restores the packaged Electron host bridge - required for project creation and saving, and includes atomic persistence, + required for project creation and saving. It adds safe project closing, + recoverable Recycle Bin deletion and explicit prerelease filenames alongside atomic persistence, hardened Node and Undo/Redo invariants, visible editor errors, tighter desktop trust boundaries and corrected packaged toolbar branding. @@ -87,7 +88,7 @@ jobs: remain pending. This release is not SignPath-signed. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release diff --git a/CHANGES.md b/CHANGES.md index c01a378..9688a51 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## Unreleased — Foundation Stabilization +- Added explicit Close Project behavior that clears the privileged project session and protects unsaved scene changes. +- Added exact-name-confirmed project deletion through the operating system Recycle Bin; Parlyn never permanently deletes the active project. +- Adopted full Semantic Version prerelease names in application metadata and Windows installer filenames. - Restored the sandboxed Electron preload bridge so packaged project creation and saving work again. - Added sandboxed preload contract and real Electron smoke tests that verify every desktop host method before Windows packaging. - Added a reproducible 64-bit Windows NSIS installer foundation. @@ -41,7 +44,7 @@ - Bounded loaded scenes to 10,000 nodes and 256 hierarchy levels. - Reworked plain-JSON validation to avoid recursive call-stack exhaustion. - Added automated Node hierarchy and Undo/Redo invariant checks. -- Added a repeatable Beta 3 maintainer acceptance checklist for the Phase 1 completion gate. +- Added a repeatable Beta 4 maintainer acceptance checklist for the Phase 1 completion gate. - Generalized the non-overwriting unsigned preview workflow for explicit beta releases. diff --git a/README.md b/README.md index bb594ca..5ab1f8f 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. The current Phase 1 acceptance candidate uses the repeatable -[`v0.5.0-beta.3 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md). +[`v0.5.0-beta.4 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md). ### Code signing policy diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md similarity index 75% rename from docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md rename to docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md index 387a8f6..806a185 100644 --- a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md @@ -1,7 +1,7 @@ -# Parlyn Engine v0.5.0 Beta 3 — Maintainer Acceptance +# Parlyn Engine v0.5.0 Beta 4 — Maintainer Acceptance This is the repeatable Phase 1 acceptance pass. Perform it on the published -`v0.5.0-beta.3` artifact, not on a development checkout or an older beta. +`v0.5.0-beta.4` artifact, not on a development checkout or an older beta. Smart App Control and normal Windows security settings remain enabled. This preview is intentionally unsigned while the SignPath Foundation application is @@ -19,11 +19,11 @@ Record these values in Issue #23 after the pass: ## 1. Download and integrity -1. Download the installer and `SHA256SUMS.txt` from the Beta 3 prerelease. +1. Download the installer and `SHA256SUMS.txt` from the Beta 4 prerelease. 2. In PowerShell, run: ```powershell - Get-FileHash "$env:USERPROFILE\Downloads\Parlyn-Engine-Setup-0.5.0-x64.exe" -Algorithm SHA256 + Get-FileHash "$env:USERPROFILE\Downloads\Parlyn-Engine-Setup-0.5.0-beta.4-x64.exe" -Algorithm SHA256 ``` 3. Confirm the displayed hash matches `SHA256SUMS.txt`. @@ -37,12 +37,16 @@ Record these values in Issue #23 after the pass: ## 3. Project and persistence -1. Create a project named `Parlyn Beta 3 Test` in a new empty parent folder. +1. Create a project named `Parlyn Beta 4 Test` in a new empty parent folder. 2. Add one 2.5D Sprite and one 3D Mesh. 3. Rename both nodes and change their positions. 4. Save the scene, close Parlyn, reopen the project and confirm names and positions survived. 5. Open **World**, confirm the project world name and seed appear, then use **Save World**. 6. Open the bundled `samples/Parlyn-Test-Project` and confirm its hierarchy and imported asset appear. +7. Make an unsaved change, choose **Close**, then cancel the warning and confirm the project remains open. +8. Choose **Close** again, use **Save & Continue**, then reopen the project and confirm the last change survived. +9. Create a disposable project named `Parlyn Delete Test`, choose **Delete**, confirm the button remains disabled for a wrong name, then enter the exact name. +10. Confirm the editor returns to `Loose Scene`, the project folder leaves its original location and can be found in the Windows Recycle Bin. ## 4. Hierarchy, transforms and history @@ -68,8 +72,8 @@ Record these values in Issue #23 after the pass: ## 7. Uninstall preservation -1. Close Parlyn and uninstall Beta 3 normally. -2. Confirm the separately created `Parlyn Beta 3 Test` project still exists with its files intact. +1. Close Parlyn and uninstall Beta 4 normally. +2. Confirm the separately created `Parlyn Beta 4 Test` project still exists with its files intact. Phase 1 passes only when every required section succeeds or a discovered defect is fixed and the affected section is repeated against a new artifact. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dae3237..645c18c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,7 +41,7 @@ Already present: Completion gate: -- complete the repeatable `v0.5.0-beta.3` maintainer test pass and record the result in Issue #23. +- complete the repeatable `v0.5.0-beta.4` maintainer test pass and record the result in Issue #23. ## Phase 2 — Editor foundation diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 9c17260..11d4afa 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 Phase 1 release-candidate acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md) and record the result in Issue #23. 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/docs/WINDOWS-DISTRIBUTION.md b/docs/WINDOWS-DISTRIBUTION.md index db3ab27..6d1b509 100644 --- a/docs/WINDOWS-DISTRIBUTION.md +++ b/docs/WINDOWS-DISTRIBUTION.md @@ -132,6 +132,6 @@ the maintainer's Windows machine. Public trusted-signing acceptance remains pending separately from the resolved development-binary blocker. The full Phase 1 functional acceptance candidate is the unsigned -`v0.5.0-beta.3` prerelease. Its repeatable checklist is documented in -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md), +`v0.5.0-beta.4` prerelease. Its repeatable checklist is documented in +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md), and the result is recorded in Issue #23 before Phase 1 can be marked complete. diff --git a/package-lock.json b/package-lock.json index c8394ac..82d46de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0", + "version": "0.5.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0", + "version": "0.5.0-beta.4", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index 941cd01..006c62e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0", + "version": "0.5.0-beta.4", "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 898a80e..ec6c32a 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -57,6 +57,8 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s assert.match(main, /will-navigate/); assert.match(main, /secureHandle\('parlyn:app:get-info'/); assert.match(main, /secureHandle\('parlyn:project:open'/); + assert.match(main, /secureHandle\('parlyn:project:close'/); + assert.match(main, /secureHandle\('parlyn:project:delete'/); let exposedHost = null; vm.runInNewContext(preload, { @@ -69,7 +71,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s } }, { filename:'src/main/preload.js' }); assert.ok(exposedHost, 'Preload must expose window.parlynHost.'); - for (const method of ['getAppInfo','createProject','openProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) { + for (const method of ['getAppInfo','createProject','openProject','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) { assert.equal(typeof exposedHost[method], 'function', `Preload host is missing ${method}().`); } diff --git a/scripts/check-electron-preload.cjs b/scripts/check-electron-preload.cjs index da2f3ca..2e9a777 100644 --- a/scripts/check-electron-preload.cjs +++ b/scripts/check-electron-preload.cjs @@ -6,6 +6,8 @@ const methods = [ 'getAppInfo', 'createProject', 'openProject', + 'closeProject', + 'deleteProject', 'saveProjectScene', 'saveProjectWorld', 'saveSceneAs', diff --git a/scripts/check-project-session.cjs b/scripts/check-project-session.cjs new file mode 100644 index 0000000..6738d66 --- /dev/null +++ b/scripts/check-project-session.cjs @@ -0,0 +1,61 @@ +const assert = require('assert/strict'); +const path = require('path'); +const { ProjectSession } = require('../src/main/ProjectSession'); + +(async () => { + const projectRoot = path.resolve('test-project'); + const trashed = []; + let loadFailure = null; + let trashFailure = null; + const session = new ProjectSession({ + async loadProject(root) { + if (loadFailure) throw loadFailure; + assert.equal(root, projectRoot); + return { format:'parlyn-project', version:1, name:'Safety Test' }; + }, + async trashItem(root) { if (trashFailure) throw trashFailure; trashed.push(root); }, + isProtectedRoot:root => root === path.resolve('protected-user-folder') + }); + + assert.deepEqual(session.close(), { ok:false, reason:'no-project' }); + assert.deepEqual(await session.moveToTrash('Safety Test'), { ok:false, reason:'no-project' }); + assert.throws(() => session.activate('relative/project'), /absolute path/); + + session.activate(projectRoot); + assert.equal(session.activeProjectRoot, projectRoot); + const closed = session.close(); + assert.deepEqual(closed, { ok:true, projectRoot }); + assert.equal(session.activeProjectRoot, null); + + session.activate(projectRoot); + await assert.rejects(session.moveToTrash('wrong name'), /exact project name/); + assert.equal(session.activeProjectRoot, projectRoot); + assert.deepEqual(trashed, []); + + loadFailure = new Error('Invalid project file'); + await assert.rejects(session.moveToTrash('Safety Test'), /Invalid project file/); + assert.equal(session.activeProjectRoot, projectRoot); + assert.deepEqual(trashed, []); + + loadFailure = null; + trashFailure = new Error('Recycle Bin unavailable'); + await assert.rejects(session.moveToTrash('Safety Test'), /Recycle Bin unavailable/); + assert.equal(session.activeProjectRoot, projectRoot); + assert.deepEqual(trashed, []); + + trashFailure = null; + const deleted = await session.moveToTrash('Safety Test'); + assert.deepEqual(deleted, { ok:true, projectRoot, projectName:'Safety Test' }); + assert.equal(session.activeProjectRoot, null); + assert.deepEqual(trashed, [projectRoot]); + + session.activate(path.parse(projectRoot).root); + await assert.rejects(session.moveToTrash('Safety Test'), /protected system or user folder/); + session.activate(path.resolve('protected-user-folder')); + await assert.rejects(session.moveToTrash('Safety Test'), /protected system or user folder/); + + console.log('Project session close and Recycle Bin contract check passed.'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/check.cjs b/scripts/check.cjs index 6d47e84..9641705 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -13,6 +13,7 @@ const required = [ 'src/main/documentFiles.mjs', 'src/main/ipcSecurity.js', 'src/main/projectPaths.js', + 'src/main/ProjectSession.js', 'src/main/preload.js', 'src/renderer/index.html', 'src/renderer/app.mjs', @@ -28,13 +29,14 @@ const required = [ 'docs/SMART-SYSTEMS.md', 'docs/AUTHENTICATION.md', 'docs/WINDOWS-DISTRIBUTION.md', - 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.3.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md', 'build/icon.ico', '.github/workflows/windows-installer.yml', 'scripts/check-smart-systems.mjs', 'scripts/check-core-persistence.mjs', 'scripts/check-core-invariants.mjs', 'scripts/check-desktop-boundaries.cjs', + 'scripts/check-project-session.cjs', 'scripts/check-electron-preload.cjs', 'scripts/check-windows-distribution.cjs', 'scripts/verify-windows-artifacts.ps1' @@ -46,7 +48,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') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.4') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -65,6 +67,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-smart-systems. cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-core-persistence.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-core-invariants.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-desktop-boundaries.cjs')], { stdio: 'inherit' }); +cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-project-session.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' }); console.log(`Parlyn structure check passed (${codeFiles.length} JavaScript modules checked).`); diff --git a/src/main/ProjectSession.js b/src/main/ProjectSession.js new file mode 100644 index 0000000..d49b09e --- /dev/null +++ b/src/main/ProjectSession.js @@ -0,0 +1,51 @@ +const path = require('path'); + +class ProjectSession { + #activeProjectRoot = null; + + constructor({ loadProject, trashItem, isProtectedRoot = () => false }) { + if (typeof loadProject !== 'function') throw new TypeError('ProjectSession requires loadProject().'); + if (typeof trashItem !== 'function') throw new TypeError('ProjectSession requires trashItem().'); + if (typeof isProtectedRoot !== 'function') throw new TypeError('ProjectSession isProtectedRoot must be a function.'); + this.loadProject = loadProject; + this.trashItem = trashItem; + this.isProtectedRoot = isProtectedRoot; + } + + get activeProjectRoot() { + return this.#activeProjectRoot; + } + + activate(projectRoot) { + if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) { + throw new TypeError('Active project root must be an absolute path.'); + } + this.#activeProjectRoot = projectRoot; + return projectRoot; + } + + close() { + if (!this.#activeProjectRoot) return { ok:false, reason:'no-project' }; + const projectRoot = this.#activeProjectRoot; + this.#activeProjectRoot = null; + return { ok:true, projectRoot }; + } + + async moveToTrash(confirmationName) { + const projectRoot = this.#activeProjectRoot; + if (!projectRoot) return { ok:false, reason:'no-project' }; + if (path.parse(projectRoot).root === projectRoot || this.isProtectedRoot(projectRoot)) { + throw new Error('Parlyn refuses to move a protected system or user folder to the Recycle Bin.'); + } + const project = await this.loadProject(projectRoot); + if (typeof confirmationName !== 'string' || confirmationName !== project.name) { + throw new Error(`Type the exact project name "${project.name}" to move it to the Recycle Bin.`); + } + if (this.#activeProjectRoot !== projectRoot) throw new Error('The active project changed before deletion. Try again.'); + await this.trashItem(projectRoot); + if (this.#activeProjectRoot === projectRoot) this.#activeProjectRoot = null; + return { ok:true, projectRoot, projectName:project.name }; + } +} + +module.exports = { ProjectSession }; diff --git a/src/main/main.js b/src/main/main.js index 6e96f96..d0792be 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -1,11 +1,11 @@ -const { app, BrowserWindow, dialog, ipcMain } = require('electron'); +const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron'); const fs = require('fs/promises'); const path = require('path'); const { pathToFileURL } = require('url'); const { resolveExistingProjectPath, resolveWritableProjectPath } = require('./projectPaths'); const { assertTrustedIpcEvent, assertIpcPayload } = require('./ipcSecurity'); +const { ProjectSession } = require('./ProjectSession'); -let activeProjectRoot = null; const persistence = import('../engine/persistence/DocumentPersistence.mjs'); const documentFiles = import('./documentFiles.mjs'); const EDITOR_FILE = path.join(__dirname, '..', 'renderer', 'index.html'); @@ -32,6 +32,26 @@ async function writeDocumentAtomic(filePath, document, expectedFormat, label = ' return writeDocumentFileAtomic(filePath, document, expectedFormat, label); } +const projectSession = new ProjectSession({ + async loadProject(projectRoot) { + const projectFile = await resolveExistingProjectPath(projectRoot, 'parlyn.project.json', 'Parlyn project file'); + return readDocument(projectFile, 'parlyn-project', 'Parlyn project file'); + }, + trashItem:projectRoot => shell.trashItem(projectRoot), + isProtectedRoot(projectRoot) { + const normalize = value => { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + const target = normalize(projectRoot); + const protectedUserFolders = ['home', 'desktop', 'documents', 'downloads'].map(name => normalize(app.getPath(name))); + if (protectedUserFolders.includes(target)) return true; + + const protectedApplicationRoots = [app.getAppPath(), process.resourcesPath].filter(Boolean).map(normalize); + return protectedApplicationRoots.some(root => target === root || target.startsWith(`${root}${path.sep}`)); + } +}); + async function listAssets(projectRoot) { if (!projectRoot) return []; let assetsRoot; @@ -134,7 +154,7 @@ secureHandle('parlyn:project:create', async (payload) => { await fs.rm(projectRoot, { recursive:true, force:true }).catch(() => {}); throw error; } - activeProjectRoot=await fs.realpath(projectRoot); + const activeProjectRoot=projectSession.activate(await fs.realpath(projectRoot)); return { canceled:false, projectRoot:activeProjectRoot, project, world, assets:await listAssets(activeProjectRoot) }; }, { payload:true }); @@ -148,11 +168,16 @@ secureHandle('parlyn:project:open', async () => { const worldPath=await resolveExistingProjectPath(projectRoot,project.world,'World document'); const scene=await readDocument(scenePath, 'parlyn-scene', 'Startup scene'); const world=await readDocument(worldPath, 'parlyn-world', 'World document'); - activeProjectRoot=projectRoot; + projectSession.activate(projectRoot); return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot) }; }); +secureHandle('parlyn:project:close', async () => projectSession.close()); + +secureHandle('parlyn:project:delete', async (payload) => projectSession.moveToTrash(payload?.confirmationName), { payload:true }); + secureHandle('parlyn:project:save-scene', async (payload) => { + const activeProjectRoot=projectSession.activeProjectRoot; if (!activeProjectRoot) return { ok:false, reason:'no-project' }; const relativePath=payload?.relativePath || 'scenes/Main.parlyn-scene.json'; const target=await resolveWritableProjectPath(activeProjectRoot,relativePath,'Project scene path'); @@ -165,6 +190,7 @@ secureHandle('parlyn:project:save-scene', async (payload) => { }, { payload:true }); secureHandle('parlyn:project:save-world', async (payload) => { + const activeProjectRoot=projectSession.activeProjectRoot; if (!activeProjectRoot) return { ok:false, reason:'no-project' }; const relativePath=payload?.relativePath || 'worlds/Main.parlyn-world.json'; const target=await resolveWritableProjectPath(activeProjectRoot,relativePath,'Project world path'); @@ -177,6 +203,7 @@ secureHandle('parlyn:project:save-world', async (payload) => { }, { payload:true }); secureHandle('parlyn:project:import-assets', async () => { + const activeProjectRoot=projectSession.activeProjectRoot; if (!activeProjectRoot) return { canceled:false, reason:'no-project', assets:[] }; const choose=await dialog.showOpenDialog({ title:'Import Assets into Parlyn Project', diff --git a/src/main/preload.js b/src/main/preload.js index 7e85e82..fe84e61 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld('parlynHost', { openScene:()=>ipcRenderer.invoke('parlyn:scene:open'), createProject:(payload)=>ipcRenderer.invoke('parlyn:project:create',payload), openProject:()=>ipcRenderer.invoke('parlyn:project:open'), + closeProject:()=>ipcRenderer.invoke('parlyn:project:close'), + deleteProject:(payload)=>ipcRenderer.invoke('parlyn:project:delete',payload), saveProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:save-scene',payload), saveProjectWorld:(payload)=>ipcRenderer.invoke('parlyn:project:save-world',payload), importAssets:()=>ipcRenderer.invoke('parlyn:project:import-assets') diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index bc4633e..c416922 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -24,6 +24,7 @@ async function bootstrap() { let assets = []; let inspectorStartSnapshot = null; let dirty = false; + let pendingUnsavedDecision = null; let gizmoStartSnapshot = null; let transformMode = "select"; const moduleEvents = new EventTarget(); @@ -114,6 +115,40 @@ async function bootstrap() { if (errorDialog.open) errorDialog.close(); errorDialog.showModal(); } + function resetProjectWorkspace() { + currentProject = null; + currentProjectRoot = null; + currentSceneRelativePath = null; + currentFilePath = null; + currentWorld = null; + assets = []; + scene = new SceneDocument("Untitled Scene"); + history.clear(); + renderer.rebuild(scene); + clearSelection(); + updateHistoryButtons(); + updateProjectUI(); + renderAssets(); + setDirty(false); + } + function resolveUnsavedDecision(decision) { + const resolve = pendingUnsavedDecision; + pendingUnsavedDecision = null; + if ($("unsaved-dialog").open) $("unsaved-dialog").close(); + resolve?.(decision); + } + function askAboutUnsavedChanges() { + if (!dirty) return Promise.resolve("continue"); + if (pendingUnsavedDecision) return Promise.resolve("cancel"); + $("unsaved-dialog").showModal(); + return new Promise((resolve) => { pendingUnsavedDecision = resolve; }); + } + async function mayCloseProject() { + const decision = await askAboutUnsavedChanges(); + if (decision === "cancel") return false; + if (decision === "save") return saveScene(); + return true; + } function clearSelection() { selected = null; renderer.selectNode(null); @@ -359,18 +394,20 @@ async function bootstrap() { currentFilePath = result2.filePath; setDirty(false); status.textContent = `Saved project scene: ${currentSceneRelativePath || currentProject.startupScene}`; - return; + return true; } const result = await host.saveSceneAs({ name: scene.name, scene: scene.toJSON() }); if (result.canceled) { status.textContent = "Save canceled."; - return; + return false; } currentFilePath = result.filePath; setDirty(false); status.textContent = `Saved: ${shortPath(currentFilePath)}`; + return true; } catch (error) { showError("Scene save failed", error); + return false; } } async function openScene() { @@ -436,9 +473,49 @@ async function bootstrap() { showError("Project open failed", error); } } + async function closeProject() { + if (!currentProject || !await mayCloseProject()) return; + const projectName = currentProject.name; + try { + const result = await host.closeProject(); + if (!result.ok) throw new Error("No active project could be closed."); + resetProjectWorkspace(); + status.textContent = `Project closed: ${projectName}`; + } catch (error) { + showError("Project close failed", error); + } + } + function showDeleteProjectDialog() { + if (!currentProject) return; + $("delete-project-name").textContent = currentProject.name; + $("delete-project-path").textContent = currentProjectRoot; + $("delete-unsaved-warning").hidden = !dirty; + $("delete-project-confirmation").value = ""; + $("confirm-delete-project").disabled = true; + $("delete-project-dialog").showModal(); + $("delete-project-confirmation").focus(); + } + async function deleteProject() { + if (!currentProject) return; + const confirmationName = $("delete-project-confirmation").value; + const projectName = currentProject.name; + $("confirm-delete-project").disabled = true; + try { + const result = await host.deleteProject({ confirmationName }); + if (!result.ok) throw new Error("No active project could be moved to the Recycle Bin."); + $("delete-project-dialog").close(); + resetProjectWorkspace(); + status.textContent = `Moved to Recycle Bin: ${projectName}`; + } catch (error) { + showError("Project deletion failed", error); + $("confirm-delete-project").disabled = confirmationName !== currentProject?.name; + } + } function updateProjectUI() { $("project-name").textContent = currentProject?.name ?? "Loose Scene"; $("project-name").title = currentProjectRoot ?? ""; + $("close-project").disabled = !currentProject; + $("delete-project").disabled = !currentProject; } function assetIcon(ext) { if ([".png", ".jpg", ".jpeg", ".webp", ".svg"].includes(ext)) return "\u25A7"; @@ -518,6 +595,17 @@ async function bootstrap() { $("save-world").addEventListener("click", saveWorld); $("close-modules").addEventListener("click", () => $("module-dialog").close()); $("close-error").addEventListener("click", () => $("error-dialog").close()); + $("close-project").addEventListener("click", closeProject); + $("delete-project").addEventListener("click", showDeleteProjectDialog); + $("cancel-delete-project").addEventListener("click", () => $("delete-project-dialog").close()); + $("confirm-delete-project").addEventListener("click", deleteProject); + $("delete-project-confirmation").addEventListener("input", () => { + $("confirm-delete-project").disabled = $("delete-project-confirmation").value !== currentProject?.name; + }); + $("cancel-unsaved").addEventListener("click", () => resolveUnsavedDecision("cancel")); + $("discard-unsaved").addEventListener("click", () => resolveUnsavedDecision("discard")); + $("save-unsaved").addEventListener("click", () => resolveUnsavedDecision("save")); + $("unsaved-dialog").addEventListener("cancel", (event) => { event.preventDefault(); resolveUnsavedDecision("cancel"); }); moduleEvents.addEventListener("module-changed", renderModules); $("view-25").addEventListener("click", () => { renderer.setView("2.5d"); @@ -609,13 +697,15 @@ async function bootstrap() { selectById(scene.root.children[1].id); updateHistoryButtons(); setDirty(false); - let appVersion = "0.5.0"; + let appVersion = "unknown"; try { const appInfo = await host.getAppInfo(); if (appInfo?.version) appVersion = appInfo.version; } catch (error) { console.warn("Could not read Parlyn application information:", error); } + $("brand-version").textContent = `${appVersion} GitHub Preview`; + $("footer-version").textContent = `v${appVersion}`; status.textContent = `Ready \xB7 Parlyn ${appVersion} \xB7 THREE renderer backend`; } bootstrap().catch((error) => { diff --git a/src/renderer/index.html b/src/renderer/index.html index 8547150..6cbf634 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -10,7 +10,7 @@
- 0.5.0 GitHub Preview + Loading version…