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
21 changes: 10 additions & 11 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.6
default: v0.5.0-beta.7
preview_title:
description: Public prerelease title
required: true
type: string
default: Parlyn Engine v0.5.0 Beta 6 (unsigned)
default: Parlyn Engine v0.5.0 Beta 7 (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.6' }}
PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 6 (unsigned)' }}
PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.7' }}
PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 7 (unsigned)' }}

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

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.
This unsigned Windows beta adds safe project-asset management to Phase 2.
Imported assets can be selected, renamed and moved into validated nested
folders. Parlyn preserves file extensions, refuses existing targets and
blocks project-path or symbolic-link escapes.

**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 @@ -87,7 +86,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.6.md
https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md
"@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8

- name: Publish GitHub pre-release
Expand Down
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased — Foundation Stabilization

- Added safe project-asset selection, rename and move operations with guarded nested folders, extension preservation and no-overwrite behavior.
- 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.
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.6 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md).
repeatable [`v0.5.0-beta.7 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md).

### Code signing policy

Expand Down
2 changes: 2 additions & 0 deletions docs/ASSET-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ Preferred direction:
- extensible importers for additional formats

Marketplace compatibility is a technical goal only. The user remains responsible for ensuring that a third-party asset license permits use in Parlyn.

The Phase 2 editor can safely rename and move imported files inside a project's `assets/` directory. It creates validated subfolders, refuses overwrites and path escapes, ignores symbolic links during listing, and preserves the original file extension. Stable asset IDs, importer metadata, reimport and automatic reference migration remain Phase 4 work.
31 changes: 31 additions & 0 deletions docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Parlyn Engine v0.5.0 Beta 7 — Maintainer Acceptance

This pass verifies safe project-asset rename and move operations against the
published `v0.5.0-beta.7` Windows artifact.

## 1. Install and launch

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

## 2. Asset rename and move

1. Create or open a disposable Parlyn project.
2. Import at least two image, model or audio files.
3. Select one card in the **Assets** panel and confirm **Rename / Move** becomes available.
4. Rename that asset while preserving its file extension.
5. Move it to a nested path such as `assets/environment/trees/old-oak.png`.
6. Restart Parlyn, reopen the project and confirm the moved asset remains listed.
7. Attempt to move it onto the path of the second imported asset and confirm
Parlyn refuses without changing either file.
8. Attempt to change its extension and confirm Parlyn refuses the operation.
9. Confirm a failed operation remains visible in the error dialog.

## 3. Regression

Confirm project creation, Save, Close, Open, scene switching, scene rename/move,
multi-selection, Undo/Redo, duplication and hierarchy reparenting still work.

Beta 7 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 @@ -51,7 +51,7 @@ Completion evidence:
- durable, validated local Scene History — initial foundation complete;
- 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;
- rename and move project assets — initial safe editor workflow complete;
- unsaved-change protection;
- editor preferences;
- stronger error reporting;
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.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md)
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.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.6`; its repeatable checklist is documented in
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md).
`v0.5.0-beta.7`; its repeatable checklist is documented in
[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.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.6",
"version": "0.5.0-beta.7",
"private": true,
"description": "Parlyn Engine - open-source 2.5D-first game engine editor",
"author": "Parlyn Engine contributors",
Expand Down
43 changes: 43 additions & 0 deletions scripts/check-asset-files.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const assert = require('assert/strict');
const fs = require('fs/promises');
const os = require('os');
const path = require('path');
const { requireAssetPath, listAssets, moveAsset } = require('../src/main/assetFiles');

(async () => {
assert.equal(requireAssetPath('assets/trees/oak.png'), 'assets/trees/oak.png');
assert.throws(() => requireAssetPath('../oak.png'), /invalid segment/);
assert.throws(() => requireAssetPath('assets/../oak.png'), /invalid segment/);
assert.throws(() => requireAssetPath('assets\\oak.png'), /forward slashes/);
assert.throws(() => requireAssetPath('assets/no-extension'), /extension/);

const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-assets-'));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-assets-outside-'));
try {
await fs.mkdir(path.join(root, 'assets'));
await fs.writeFile(path.join(root, 'assets', 'oak.png'), 'oak');
await fs.writeFile(path.join(root, 'assets', 'taken.png'), 'taken');
assert.deepEqual((await listAssets(root)).map((asset) => asset.relativePath), ['assets/oak.png', 'assets/taken.png']);

const moved = await moveAsset(root, 'assets/oak.png', 'assets/trees/old-oak.png');
assert.equal(moved.relativePath, 'assets/trees/old-oak.png');
assert.equal(await fs.readFile(path.join(root, 'assets', 'trees', 'old-oak.png'), 'utf8'), 'oak');
await assert.rejects(fs.access(path.join(root, 'assets', 'oak.png')));
await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'assets/taken.png'), /already exists/);
await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'assets/trees/old-oak.glb'), /preserve its file extension/);
await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'scenes/old-oak.png'), /assets folder/);

try {
await fs.writeFile(path.join(outside, 'outside.png'), 'outside');
await fs.symlink(path.join(outside, 'outside.png'), path.join(root, 'assets', 'linked.png'));
await assert.rejects(moveAsset(root, 'assets/linked.png', 'assets/moved.png'), /symbolic link/);
assert.ok(!(await listAssets(root)).some((asset) => asset.relativePath === 'assets/linked.png'));
} catch (error) {
if (!['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) throw error;
}
} finally {
await fs.rm(root, { recursive:true, force:true });
await fs.rm(outside, { recursive:true, force:true });
}
console.log('Project asset listing and move contract check passed.');
})().catch((error) => { console.error(error); process.exitCode = 1; });
3 changes: 2 additions & 1 deletion scripts/check-desktop-boundaries.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP
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:move-asset'/);
assert.match(main, /secureHandle\('parlyn:project:close'/);
assert.match(main, /secureHandle\('parlyn:project:delete'/);
assert.match(main, /parlyn-scene-history/);
Expand All @@ -80,7 +81,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP
}
}, { filename:'src/main/preload.js' });
assert.ok(exposedHost, 'Preload must expose window.parlynHost.');
for (const method of ['getAppInfo','createProject','openProject','openProjectScene','createProjectScene','moveProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) {
for (const method of ['getAppInfo','createProject','openProject','openProjectScene','createProjectScene','moveProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets','moveProjectAsset']) {
assert.equal(typeof exposedHost[method], 'function', `Preload host is missing ${method}().`);
}

Expand Down
6 changes: 5 additions & 1 deletion scripts/check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const required = [
'src/main/documentFiles.mjs',
'src/main/ipcSecurity.js',
'src/main/projectPaths.js',
'src/main/assetFiles.js',
'src/main/ProjectSession.js',
'src/main/preload.js',
'src/renderer/index.html',
Expand All @@ -32,13 +33,15 @@ const required = [
'docs/WINDOWS-DISTRIBUTION.md',
'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md',
'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md',
'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.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-asset-files.cjs',
'scripts/check-electron-preload.cjs',
'scripts/check-windows-distribution.cjs',
'scripts/verify-windows-artifacts.ps1'
Expand All @@ -50,7 +53,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.6') throw new Error(`Unexpected package version: ${pkg.version}`);
if (pkg.version !== '0.5.0-beta.7') throw new Error(`Unexpected package version: ${pkg.version}`);

function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
Expand All @@ -70,6 +73,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-core-persisten
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-asset-files.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).`);
56 changes: 56 additions & 0 deletions src/main/assetFiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const fs = require('fs/promises');
const path = require('path');
const { validateRelativeProjectPath, resolveExistingProjectPath, resolveWritableProjectPathCreatingParents } = require('./projectPaths');

function requireAssetPath(value, label = 'Asset path') {
const relativePath = validateRelativeProjectPath(value, label);
if (!/^assets\/.+/.test(relativePath)) throw new Error(`${label} must stay inside the project's assets folder.`);
if (!path.posix.extname(relativePath)) throw new Error(`${label} must identify a file with an extension.`);
return relativePath;
}

async function listAssets(projectRoot) {
if (!projectRoot) return [];
let assetsRoot;
try { assetsRoot = await resolveExistingProjectPath(projectRoot, 'assets', 'Assets directory'); }
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
const result = [];
async function walk(directory) {
let entries;
try { entries = await fs.readdir(directory, { withFileTypes:true }); }
catch (error) { if (error.code === 'ENOENT') return; throw error; }
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) await walk(fullPath);
else if (entry.isFile()) result.push({ name:entry.name, relativePath:`assets/${path.relative(assetsRoot, fullPath).replace(/\\/g, '/')}`, extension:path.extname(entry.name).toLowerCase() });
}
}
await walk(assetsRoot);
return result.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}

async function moveAsset(projectRoot, sourceValue, targetValue) {
const sourcePath = requireAssetPath(sourceValue, 'Existing asset path');
const targetPath = requireAssetPath(targetValue, 'New asset path');
if (path.posix.extname(sourcePath).toLowerCase() !== path.posix.extname(targetPath).toLowerCase()) {
throw new Error('Renaming an asset must preserve its file extension. Reimport the file to change its format.');
}
const source = await resolveExistingProjectPath(projectRoot, sourcePath, 'Existing asset');
if (!(await fs.stat(source)).isFile()) throw new Error('The selected asset is not a regular file.');
if (sourcePath === targetPath) return { relativePath:sourcePath, assets:await listAssets(projectRoot) };
const target = await resolveWritableProjectPathCreatingParents(projectRoot, targetPath, 'New asset path');
try { await fs.link(source, target); }
catch (error) {
if (error.code === 'EEXIST') throw new Error('An asset already exists at that project path.');
throw error;
}
try { await fs.unlink(source); }
catch (error) {
await fs.unlink(target).catch(() => {});
throw error;
}
return { relativePath:targetPath, assets:await listAssets(projectRoot) };
}

module.exports = { requireAssetPath, listAssets, moveAsset };
32 changes: 7 additions & 25 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { pathToFileURL } = require('url');
const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableProjectPathCreatingParents } = require('./projectPaths');
const { assertTrustedIpcEvent, assertIpcPayload } = require('./ipcSecurity');
const { ProjectSession } = require('./ProjectSession');
const { listAssets, moveAsset } = require('./assetFiles');

const persistence = import('../engine/persistence/DocumentPersistence.mjs');
const documentFiles = import('./documentFiles.mjs');
Expand Down Expand Up @@ -75,31 +76,6 @@ const projectSession = new ProjectSession({
}
});

async function listAssets(projectRoot) {
if (!projectRoot) return [];
let assetsRoot;
try {
assetsRoot = await resolveExistingProjectPath(projectRoot, 'assets', 'Assets directory');
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const result = [];
async function walk(dir) {
let entries;
try { entries = await fs.readdir(dir, { withFileTypes:true }); }
catch (error) { if (error.code === 'ENOENT') return; throw error; }
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else result.push({ name:entry.name, relativePath:path.relative(projectRoot, full).replace(/\\/g,'/'), extension:path.extname(entry.name).toLowerCase() });
}
}
await walk(assetsRoot);
return result.sort((a,b)=>a.relativePath.localeCompare(b.relativePath));
}

async function listProjectScenes(projectRoot) {
if (!projectRoot) return [];
const scenesRoot = await resolveExistingProjectPath(projectRoot, 'scenes', 'Scenes directory');
Expand Down Expand Up @@ -332,6 +308,12 @@ secureHandle('parlyn:project:import-assets', async () => {
return { canceled:false, assets:await listAssets(activeProjectRoot) };
});

secureHandle('parlyn:project:move-asset', async (payload) => {
const activeProjectRoot = projectSession.activeProjectRoot;
if (!activeProjectRoot) throw new Error('Open a project before moving an asset.');
return { ok:true, ...await moveAsset(activeProjectRoot, payload?.sourcePath, payload?.targetPath) };
}, { payload:true });

app.whenReady().then(()=>{
createWindow();
app.on('activate',()=>{ if (BrowserWindow.getAllWindows().length===0) createWindow(); });
Expand Down
3 changes: 2 additions & 1 deletion src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ contextBridge.exposeInMainWorld('parlynHost', {
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')
importAssets:()=>ipcRenderer.invoke('parlyn:project:import-assets'),
moveProjectAsset:(payload)=>ipcRenderer.invoke('parlyn:project:move-asset',payload)
});
Loading
Loading