Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions .github/workflows/publish-unsigned-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ on:
description: New prerelease tag; an existing release is never overwritten
required: true
type: string
default: v0.5.0-beta.5
default: v0.5.0-beta.6
preview_title:
description: Public prerelease title
required: true
type: string
default: Parlyn Engine v0.5.0 Beta 5 (unsigned)
default: Parlyn Engine v0.5.0 Beta 6 (unsigned)

permissions:
contents: write
Expand All @@ -29,8 +29,8 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
GH_TOKEN: ${{ github.token }}
PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.5' }}
PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 5 (unsigned)' }}
PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.6' }}
PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 6 (unsigned)' }}

steps:
- name: Checkout
Expand Down Expand Up @@ -68,14 +68,13 @@ jobs:
shell: pwsh
run: |
@"
# Parlyn Engine v0.5.0 Beta 5
# Parlyn Engine v0.5.0 Beta 6

This unsigned Windows beta begins Phase 2 of the Parlyn editor. It adds
deep node duplication, safe hierarchy reparenting, recursive hierarchy
display and durable validated Scene History. Undo/Redo now survives
project close and a complete application restart when the saved history
matches the loaded Scene. Beta 4 project persistence, safe Recycle Bin
deletion and hardened desktop boundaries remain intact.
This unsigned Windows beta expands Phase 2 with hierarchy and viewport
multi-selection, safe one-step bulk deletion and the first complete
Multi-Scene Project Browser workflow. Project scenes can be validated,
opened, created, renamed and moved within the scenes directory while
preserving unsaved-change and project-path protections.

**Important:** This beta is unsigned and may be blocked by Windows
Smart App Control. It is published to document and validate the packaged
Expand All @@ -88,7 +87,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.5.md
https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md
"@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8

- name: Publish GitHub pre-release
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased — Foundation Stabilization

- Added hierarchy and viewport multi-selection with Undo/Redo-safe bulk deletion.
- Added a validated Multi-Scene Project Browser with scene creation, switching, rename and move operations.
- Added guarded creation of scene subfolders without allowing path or symbolic-link escapes.
- Started Phase 2 with editor commands for deep node duplication and safe reparenting.
- Added recursive hierarchy rendering and fixed subtree deletion in the viewport.
- Added a bounded, validated local Scene History that preserves Undo/Redo through project close and application restart.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,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.5 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md).
repeatable [`v0.5.0-beta.6 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md).

### Code signing policy

Expand Down
40 changes: 40 additions & 0 deletions docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Parlyn Engine v0.5.0 Beta 6 — Maintainer Acceptance

This pass verifies the Phase 2 multi-selection and Multi-Scene Project Browser
against the published `v0.5.0-beta.6` Windows artifact.

## 1. Install and launch

1. Download the Beta 6 installer and `SHA256SUMS.txt`.
2. Verify the installer checksum.
3. Install normally and launch Parlyn without weakening Windows security.

## 2. Multi-selection

1. Open a project with several scene nodes.
2. Use **Ctrl+click** to select separate nodes in the Hierarchy and viewport.
3. Use **Shift+click** in the Hierarchy to select a contiguous range.
4. Confirm every selected node is highlighted.
5. Delete several selected nodes and confirm one Undo restores the complete set.
6. Select a parent and child together, delete them and confirm no orphan remains.

## 3. Multi-Scene Project Browser

1. Confirm the startup scene appears in the **Scenes** panel.
2. Create `Village` at `scenes/Village.parlyn-scene.json`.
3. Create `Forest Path` at `scenes/travel/Forest-Path.parlyn-scene.json`.
4. Modify and save both scenes, then switch between them from the panel.
5. Make an unsaved edit and confirm switching offers Cancel, Discard and Save.
6. Rename `Village` to `Oak Village` and move it to
`scenes/locations/Oak-Village.parlyn-scene.json`.
7. Restart Parlyn, reopen the project and confirm all scenes and contents remain.
8. Attempt to move a scene onto an existing scene path and confirm Parlyn refuses
without changing either file.

## 4. Regression

Confirm project creation, Save, Close, Open, Recycle Bin deletion, Undo/Redo,
duplication and hierarchy reparenting still work.

Beta 6 passes only when all required checks succeed or a defect is corrected and
the affected check is repeated against a new artifact.
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Completion evidence:
- duplication and reparenting — initial editor commands complete;
- durable, validated local Scene History — initial foundation complete;
- multi-selection where appropriate — initial hierarchy and viewport selection complete;
- multi-scene project browser — initial validated scene listing and switching complete;
- multi-scene project browser — validated listing, switching, creation, rename and move complete;
- rename and move project assets;
- unsaved-change protection;
- editor preferences;
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTING-v0.5.0.md
Original file line number Diff line number Diff line change
@@ -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.5.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md)
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md)
and record the result in Issue #29. 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/WINDOWS-DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,5 @@ pending separately from the resolved development-binary blocker.

Phase 1 functional acceptance completed with the unsigned `v0.5.0-beta.4`
prerelease and Issue #23. The current Phase 2 editor candidate is
`v0.5.0-beta.5`; its repeatable checklist is documented in
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md).
`v0.5.0-beta.6`; its repeatable checklist is documented in
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parlyn-engine",
"version": "0.5.0-beta.5",
"version": "0.5.0-beta.6",
"private": true,
"description": "Parlyn Engine - open-source 2.5D-first game engine editor",
"author": "Parlyn Engine contributors",
Expand Down
9 changes: 7 additions & 2 deletions scripts/check-desktop-boundaries.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const os = require('os');
const path = require('path');
const vm = require('vm');
const { assertTrustedIpcEvent, assertIpcPayload } = require('../src/main/ipcSecurity');
const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../src/main/projectPaths');
const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableProjectPathCreatingParents } = require('../src/main/projectPaths');

(async () => {
const editorUrl = 'file:///parlyn/src/renderer/index.html';
Expand All @@ -24,6 +24,9 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s
const realScenePath = await fs.realpath(scenePath);
assert.equal(await resolveExistingProjectPath(temporaryRoot, 'scenes/Main.parlyn-scene.json'), realScenePath);
assert.equal(await resolveWritableProjectPath(temporaryRoot, 'scenes/Main.parlyn-scene.json'), realScenePath);
const nestedScenePath = await resolveWritableProjectPathCreatingParents(temporaryRoot, 'scenes/chapters/Intro.parlyn-scene.json');
const realNestedParent = await fs.realpath(path.join(temporaryRoot, 'scenes', 'chapters'));
assert.equal(nestedScenePath, path.join(realNestedParent, 'Intro.parlyn-scene.json'));

try {
await fs.symlink(outsideRoot, path.join(temporaryRoot, 'escaped'), 'dir');
Expand Down Expand Up @@ -58,6 +61,8 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s
assert.match(main, /secureHandle\('parlyn:app:get-info'/);
assert.match(main, /secureHandle\('parlyn:project:open'/);
assert.match(main, /secureHandle\('parlyn:project:open-scene'/);
assert.match(main, /secureHandle\('parlyn:project:create-scene'/);
assert.match(main, /secureHandle\('parlyn:project:move-scene'/);
assert.match(main, /secureHandle\('parlyn:project:close'/);
assert.match(main, /secureHandle\('parlyn:project:delete'/);
assert.match(main, /parlyn-scene-history/);
Expand All @@ -75,7 +80,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','openProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) {
for (const method of ['getAppInfo','createProject','openProject','openProjectScene','createProjectScene','moveProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) {
assert.equal(typeof exposedHost[method], 'function', `Preload host is missing ${method}().`);
}

Expand Down
2 changes: 2 additions & 0 deletions scripts/check-electron-preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const methods = [
'createProject',
'openProject',
'openProjectScene',
'createProjectScene',
'moveProjectScene',
'closeProject',
'deleteProject',
'saveProjectScene',
Expand Down
2 changes: 1 addition & 1 deletion scripts/check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,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.5') throw new Error(`Unexpected package version: ${pkg.version}`);
if (pkg.version !== '0.5.0-beta.6') throw new Error(`Unexpected package version: ${pkg.version}`);

function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
Expand Down
60 changes: 52 additions & 8 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableProjectPathCreatingParents } = require('./projectPaths');
const { assertTrustedIpcEvent, assertIpcPayload } = require('./ipcSecurity');
const { ProjectSession } = require('./ProjectSession');

Expand Down Expand Up @@ -103,18 +103,29 @@ async function listAssets(projectRoot) {
async function listProjectScenes(projectRoot) {
if (!projectRoot) return [];
const scenesRoot = await resolveExistingProjectPath(projectRoot, 'scenes', 'Scenes directory');
const entries = await fs.readdir(scenesRoot, { withFileTypes:true });
const scenes = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.parlyn-scene.json')) continue;
const relativePath = `scenes/${entry.name}`;
const filePath = await resolveExistingProjectPath(projectRoot, relativePath, 'Project scene');
const scene = await readDocument(filePath, 'parlyn-scene', 'Project scene');
scenes.push({ name:scene.name, relativePath });
async function walk(directory) {
for (const entry of await fs.readdir(directory, { withFileTypes:true })) {
if (entry.isSymbolicLink()) continue;
const fullPath=path.join(directory,entry.name);
if (entry.isDirectory()) await walk(fullPath);
else if (entry.isFile() && entry.name.endsWith('.parlyn-scene.json')) {
const relativePath=path.relative(projectRoot,fullPath).replace(/\\/g,'/');
const filePath=await resolveExistingProjectPath(projectRoot,relativePath,'Project scene');
const scene=await readDocument(filePath,'parlyn-scene','Project scene');
scenes.push({ name:scene.name, relativePath });
}
}
}
await walk(scenesRoot);
return scenes.sort((a,b) => a.relativePath.localeCompare(b.relativePath));
}

function requireScenePath(value) {
if (typeof value !== 'string' || !/^scenes\/.+\.parlyn-scene\.json$/.test(value)) throw new Error('Scene paths must stay inside scenes/ and end with .parlyn-scene.json.');
return value;
}

function createWindow() {
const win = new BrowserWindow({
width:1500,
Expand Down Expand Up @@ -222,6 +233,39 @@ secureHandle('parlyn:project:open-scene', async (payload) => {
return { ok:true, relativePath, scene, history:sceneHistory.history, historyWarning:sceneHistory.warning };
}, { payload:true });

secureHandle('parlyn:project:create-scene', async (payload) => {
const activeProjectRoot=projectSession.activeProjectRoot;
if (!activeProjectRoot) throw new Error('Open a project before creating a project scene.');
const relativePath=requireScenePath(payload?.relativePath);
const target=await resolveWritableProjectPathCreatingParents(activeProjectRoot,relativePath,'New project scene');
try { await fs.access(target); throw new Error('A scene already exists at that project path.'); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
await writeDocumentAtomic(target,payload?.scene,'parlyn-scene','New project scene');
return { ok:true, relativePath, scenes:await listProjectScenes(activeProjectRoot) };
}, { payload:true });

secureHandle('parlyn:project:move-scene', async (payload) => {
const activeProjectRoot=projectSession.activeProjectRoot;
if (!activeProjectRoot) throw new Error('Open a project before moving a project scene.');
const sourcePath=requireScenePath(payload?.sourcePath);
const targetPath=requireScenePath(payload?.targetPath);
if (sourcePath === targetPath) return { ok:true, relativePath:sourcePath, scenes:await listProjectScenes(activeProjectRoot) };
const source=await resolveExistingProjectPath(activeProjectRoot,sourcePath,'Existing project scene');
const target=await resolveWritableProjectPathCreatingParents(activeProjectRoot,targetPath,'New project scene path');
try { await fs.access(target); throw new Error('A scene already exists at that project path.'); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
const scene=await readDocument(source,'parlyn-scene','Existing project scene');
if (typeof payload?.name === 'string' && payload.name.trim()) scene.name=payload.name.trim();
await writeDocumentAtomic(target,scene,'parlyn-scene','Moved project scene');
const projectFile=await resolveWritableProjectPath(activeProjectRoot,'parlyn.project.json','Parlyn project file');
const project=await readDocument(projectFile,'parlyn-project','Parlyn project file');
if (project.startupScene === sourcePath) project.startupScene=targetPath;
project.updatedAt=new Date().toISOString();
await writeDocumentAtomic(projectFile,project,'parlyn-project','Parlyn project file');
await fs.unlink(source);
return { ok:true, relativePath:targetPath, project, scene, scenes:await listProjectScenes(activeProjectRoot) };
}, { payload:true });

secureHandle('parlyn:project:close', async () => projectSession.close());

secureHandle('parlyn:project:delete', async (payload) => projectSession.moveToTrash(payload?.confirmationName), { payload:true });
Expand Down
2 changes: 2 additions & 0 deletions src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld('parlynHost', {
createProject:(payload)=>ipcRenderer.invoke('parlyn:project:create',payload),
openProject:()=>ipcRenderer.invoke('parlyn:project:open'),
openProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:open-scene',payload),
createProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:create-scene',payload),
moveProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:move-scene',payload),
closeProject:()=>ipcRenderer.invoke('parlyn:project:close'),
deleteProject:(payload)=>ipcRenderer.invoke('parlyn:project:delete',payload),
saveProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:save-scene',payload),
Expand Down
22 changes: 21 additions & 1 deletion src/main/projectPaths.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,24 @@ async function resolveWritableProjectPath(projectRoot, relativePath, label = 'Pr
return path.join(realParent, path.basename(target));
}

module.exports = { validateRelativeProjectPath, resolveProjectPath, resolveExistingProjectPath, resolveWritableProjectPath };
async function resolveWritableProjectPathCreatingParents(projectRoot, relativePath, label = 'Project path') {
const target = resolveProjectPath(projectRoot, relativePath, label);
const realRoot = await fs.realpath(projectRoot);
const parentParts = path.relative(path.resolve(projectRoot), path.dirname(target)).split(path.sep).filter(Boolean);
let current = realRoot;
for (const part of parentParts) {
const next = path.join(current, part);
try {
const info = await fs.lstat(next);
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`${label} contains an unsafe parent.`);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
await fs.mkdir(next);
}
current = await fs.realpath(next);
if (!isInsideOrEqual(realRoot, current)) throw new Error(`${label} escapes the project root.`);
}
return resolveWritableProjectPath(projectRoot, relativePath, label);
}

module.exports = { validateRelativeProjectPath, resolveProjectPath, resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableProjectPathCreatingParents };
Loading
Loading