diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts new file mode 100644 index 0000000..91ba259 --- /dev/null +++ b/electron/main/artifact-registry-service.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + classifyAssetLibraryCandidate, + listWorkspaceAssetLibrary, + normalizeWorkspaceAssetPath, + openWorkspaceAssetLibraryEntry, + readWorkspaceAssetLibraryEntry, + registerWorkspaceAssetLibraryIpcHandlers, +} from './artifact-registry-service.ts' + +async function withWorkspace(run: (workspaceDir: string) => Promise) { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'modly-library-')) + try { + await run(workspaceDir) + } finally { + await rm(workspaceDir, { recursive: true, force: true }) + } +} + +test('normalizes only workspace-relative paths under allowed Workflows and Exports roots', () => withWorkspace(async (workspaceDir) => { + assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Workflows/checkpoints/hero.glb').workspacePath, 'Workflows/checkpoints/hero.glb') + assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Exports/hero.glb').workspacePath, 'Exports/hero.glb') + assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, '../secret.glb'), /traversal|escape|relative/i) + assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, '/tmp/secret.glb'), /absolute/i) + assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, 'Workflows/%2e%2e/secret.glb'), /encoded/i) + assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, 'Collections/hero.glb'), /allowed workspace library roots/i) +})) + +test('classifies supported assets by capability instead of extension category', () => { + assert.deepEqual(classifyAssetLibraryCandidate({ workspacePath: 'Workflows/a.glb' }), { + capability: 'mesh', state: 'ready', previewKind: '3d-model', openable: true, + }) + assert.deepEqual(classifyAssetLibraryCandidate({ workspacePath: 'Workflows/rigged.gltf', hasRigMetadata: true }), { + capability: 'rigged-mesh', state: 'ready', previewKind: '3d-model', openable: true, + }) + assert.equal(classifyAssetLibraryCandidate({ workspacePath: 'Workflows/motion.bvh' }).capability, 'animation-motion') + assert.equal(classifyAssetLibraryCandidate({ workspacePath: 'Workflows/scan.splat' }).openable, false) + assert.equal(classifyAssetLibraryCandidate({ workspacePath: 'Workflows/notes.txt' }).state, 'unsupported') +}) + +test('lists Workflows and Exports assets while skipping hidden, cache, and internal files', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await mkdir(path.join(workspaceDir, 'Workflows/.hidden'), { recursive: true }) + await mkdir(path.join(workspaceDir, 'Workflows/cache'), { recursive: true }) + await mkdir(path.join(workspaceDir, 'Exports'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.rigmeta.json'), '{}') + await writeFile(path.join(workspaceDir, 'Workflows/.hidden/private.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/cache/temp.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/exported.ply'), 'ply') + + const result = await listWorkspaceAssetLibrary({ workspaceDir }) + assert.equal(result.success, true) + assert.deepEqual(result.success && result.entries.map((entry) => entry.workspacePath), [ + 'Exports/exported.ply', + 'Workflows/checkpoints/hero.glb', + ]) + assert.equal(result.success && result.entries.find((entry) => entry.workspacePath.endsWith('hero.glb'))?.capability, 'rigged-mesh') + assert.equal(result.success && result.entries.find((entry) => entry.workspacePath.endsWith('exported.ply'))?.openable, false) +})) + +test('reads and opens only safe GLB/GLTF workspace assets', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await mkdir(path.join(workspaceDir, 'Exports'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply') + + const read = await readWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/hero.glb' }) + assert.equal(read.success, true) + assert.equal(read.success && read.preview.kind, '3d-model') + const opened = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/hero.glb' }) + assert.equal(opened.success, true) + assert.equal(opened.success && opened.entry.openable, true) + const blocked = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Exports/static.ply' }) + assert.equal(blocked.success, false) + assert.equal(!blocked.success && blocked.error.code, 'not-openable') + const unsafe = await readWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: '../secret.glb' }) + assert.equal(unsafe.success, false) + assert.equal(!unsafe.success && unsafe.error.code, 'unsafe-path') +})) + +test('Electron read/open boundary rejects Windows absolute and UNC workspace paths', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + + const unsafeWorkspacePaths = [ + 'C:\\Users\\x\\asset.glb', + 'C:/Users/x/asset.glb', + '\\\\server\\share\\asset.glb', + ] + + for (const workspacePath of unsafeWorkspacePaths) { + const read = await readWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath }) + const opened = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath }) + + assert.equal(read.success, false, `${workspacePath} should be rejected for read`) + assert.equal(!read.success && read.error.code, 'unsafe-path') + assert.equal(opened.success, false, `${workspacePath} should be rejected for open`) + assert.equal(!opened.success && opened.error.code, 'unsafe-path') + } +})) + +test('Electron read/open boundary rejects Windows absolute and UNC sourceWorkspacePath values', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.landmarks.v1.json'), JSON.stringify({ + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + })) + + const unsafeSourcePaths = [ + 'C:\\Users\\x\\asset.glb', + 'C:/Users/x/asset.glb', + '\\\\server\\share\\asset.glb', + ] + + for (const sourceWorkspacePath of unsafeSourcePaths) { + const read = await readWorkspaceAssetLibraryEntry({ + workspaceDir, + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + sourceWorkspacePath, + }) + const opened = await openWorkspaceAssetLibraryEntry({ + workspaceDir, + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + sourceWorkspacePath, + }) + + assert.equal(read.success, false, `${sourceWorkspacePath} should be rejected as read source`) + assert.equal(!read.success && read.error.code, 'unsafe-path') + assert.equal(opened.success, false, `${sourceWorkspacePath} should be rejected as open source`) + assert.equal(!opened.success && opened.error.code, 'unsafe-path') + } +})) + +test('enriches sidecars with safe source, manifest, artifact, version, and provenance metadata', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.scene.json'), JSON.stringify({ schema: 'scene-manifest' })) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.landmarks.v1.json'), JSON.stringify({ + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + manifestWorkspacePath: 'Workflows/checkpoints/hero.scene.json', + artifactId: 'artifact-hero', + versionId: 'version-1', + provenance: { workflowId: 'wf-1', workflowNodeId: 'node-1' }, + })) + + const read = await readWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json' }) + + assert.equal(read.success, true) + if (!read.success) return + assert.equal(read.entry.source?.workspacePath, 'Workflows/checkpoints/hero.glb') + assert.equal(read.entry.manifest?.workspacePath, 'Workflows/checkpoints/hero.scene.json') + assert.equal(read.entry.manifest?.capability, 'scene-manifest') + assert.equal(read.entry.artifactId, 'artifact-hero') + assert.equal(read.entry.versionId, 'version-1') + assert.equal(read.entry.provenance?.workflowId, 'wf-1') +})) + +test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath opens', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/other.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/hero.landmarks.v1.json'), JSON.stringify({ + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + })) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/self.landmarks.v1.json'), JSON.stringify({ + sourceWorkspacePath: 'Workflows/checkpoints/self.landmarks.v1.json', + })) + await writeFile(path.join(workspaceDir, 'Workflows/checkpoints/missing.landmarks.v1.json'), JSON.stringify({ + sourceWorkspacePath: 'Workflows/checkpoints/missing.glb', + })) + + const opened = await openWorkspaceAssetLibraryEntry({ + workspaceDir, + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + }) + assert.equal(opened.success, true) + assert.equal(opened.success && opened.entry.source?.workspacePath, 'Workflows/checkpoints/hero.glb') + + const unsafe = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', sourceWorkspacePath: '../secret.glb' }) + const self = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/self.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/self.landmarks.v1.json' }) + const missing = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/missing.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/missing.glb' }) + const mismatched = await openWorkspaceAssetLibraryEntry({ workspaceDir, workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/other.glb' }) + + assert.equal(unsafe.success, false) + assert.equal(!unsafe.success && unsafe.error.code, 'unsafe-path') + assert.equal(self.success, false) + assert.equal(!self.success && self.error.code, 'not-openable') + assert.equal(missing.success, false) + assert.equal(!missing.success && missing.error.code, 'not-openable') + assert.equal(mismatched.success, false) + assert.equal(!mismatched.success && mismatched.error.code, 'not-openable') +})) + +test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => { + const handlers = new Map Promise>() + registerWorkspaceAssetLibraryIpcHandlers({ + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + getWorkspaceDir: () => '/tmp/modly-workspace', + }) + + const result = await handlers.get('workspace:library:open')?.({}, { + workspacePath: 'Workflows/hero.landmarks.v1.json', + sourceWorkspacePath: '../secret.glb', + }) + + assert.equal(typeof (result as { success?: unknown }).success, 'boolean') + assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path') +}) + +test('registers workspace library IPC handlers with structured results', async () => { + const handlers = new Map Promise>() + registerWorkspaceAssetLibraryIpcHandlers({ + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + getWorkspaceDir: () => '/tmp/modly-workspace', + }) + + assert.equal(typeof handlers.get('workspace:library:list'), 'function') + assert.equal(typeof handlers.get('workspace:library:read'), 'function') + assert.equal(typeof handlers.get('workspace:library:open'), 'function') + const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' }) + assert.equal(typeof (result as { success?: unknown }).success, 'boolean') + assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path') +}) diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts new file mode 100644 index 0000000..d47f52a --- /dev/null +++ b/electron/main/artifact-registry-service.ts @@ -0,0 +1,366 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +import type { + AssetCapability, + AssetEntryState, + AssetLibraryEntry, + AssetLibraryError, + AssetLibraryListResult, + AssetLibraryOpenResult, + AssetLibraryPreviewKind, + AssetLibraryPreviewPayload, + AssetLibraryReadResult, + AssetLibrarySourceScope, +} from '../../src/shared/types/assetLibrary.ts' +import type { ArtifactProvenance } from '../../src/shared/types/artifacts.ts' + +const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ +const ENCODED_ESCAPE_PATTERN = /%2e|%2f|%5c/i +const ALLOWED_ROOTS = ['Workflows', 'Exports'] as const +const SKIPPED_DIRS = new Set(['tmp', 'temp', 'cache']) +const INTERNAL_SUFFIXES = ['.artifact.json', '.rigmeta.json'] as const +const TEXT_EXTENSIONS = new Set(['json', 'txt', 'md']) +const INTRINSIC_MOTION_EXTENSIONS = new Set(['bvh', 'npz']) +const MESH_EXTENSIONS = new Set(['glb', 'gltf', 'obj', 'stl', 'ply', 'splat']) + +export interface AssetLibraryClassificationCandidate { + workspacePath: string + hasRigMetadata?: boolean +} + +export interface AssetLibraryClassification { + capability?: AssetCapability + state: AssetEntryState + previewKind: AssetLibraryPreviewKind + openable: boolean + nonOpenableReason?: string +} + +export interface NormalizedWorkspaceAssetPath { + workspacePath: string + absolutePath: string +} + +export interface WorkspaceAssetLibraryRequest { + workspaceDir: string +} + +export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string + sourceWorkspacePath?: string +} + +interface AssetLibraryMetadata { + sourceWorkspacePath?: string + manifestWorkspacePath?: string + artifactId?: string + versionId?: string + provenance?: ArtifactProvenance + warnings: string[] +} + +export interface IpcMainLike { + handle(channel: string, handler: (event: unknown, payload?: unknown) => Promise): void +} + +export interface WorkspaceAssetLibraryIpcDeps { + ipcMain: IpcMainLike + getWorkspaceDir: () => string +} + +function libraryError(code: AssetLibraryError['code'], message: string): AssetLibraryError { + return { code, message } +} + +function normalizeSeparators(input: string): string { + return input.replace(/\\/g, '/') +} + +function isWindowsAbsolutePath(input: string): boolean { + return WINDOWS_ABSOLUTE_PATH.test(input) || input.startsWith('\\\\') +} + +function assertSafeWorkspaceRelativePath(workspacePath: string): string { + const normalized = normalizeSeparators(workspacePath.trim()) + if (!normalized || normalized === '.') throw new Error('Workspace library path must be workspace-relative and non-empty') + if (ENCODED_ESCAPE_PATTERN.test(normalized)) throw new Error('Workspace library path must not contain encoded path escapes') + if (isAbsolute(normalized) || isWindowsAbsolutePath(workspacePath)) throw new Error('Workspace library path must not be absolute') + const segments = normalized.split('/').filter((segment) => segment && segment !== '.') + if (segments.some((segment) => segment === '..')) throw new Error('Workspace library path must not contain traversal segments') + if (!ALLOWED_ROOTS.includes(segments[0] as typeof ALLOWED_ROOTS[number])) { + throw new Error('Workspace library path must stay under allowed workspace library roots') + } + return segments.join('/') +} + +export function normalizeWorkspaceAssetPath(workspaceDir: string, workspacePath: string): NormalizedWorkspaceAssetPath { + const safePath = assertSafeWorkspaceRelativePath(workspacePath) + const root = resolve(workspaceDir) + const absolutePath = resolve(root, ...safePath.split('/')) + const back = relative(root, absolutePath) + if (!back || back.startsWith('..') || isAbsolute(back)) throw new Error('Workspace library path escapes the workspace root') + return { workspacePath: safePath, absolutePath } +} + +function extensionOf(workspacePath: string): string { + return extname(workspacePath).slice(1).toLowerCase() +} + +function isGlbOrGltf(workspacePath: string): boolean { + return /\.(glb|gltf)$/i.test(workspacePath) +} + +function sourceScopeFor(workspacePath: string): AssetLibrarySourceScope { + return workspacePath.startsWith('Exports/') ? 'exports' : 'workflows' +} + +export function classifyAssetLibraryCandidate(candidate: AssetLibraryClassificationCandidate): AssetLibraryClassification { + const extension = extensionOf(candidate.workspacePath) + + if (candidate.workspacePath.endsWith('.landmarks.v1.json')) { + return { capability: 'landmarks-sidecar', state: 'ready', previewKind: 'text', openable: false, nonOpenableReason: 'Landmark sidecars require opening their source mesh.' } + } + if (candidate.workspacePath.endsWith('.world.json')) { + return { capability: 'generated-world', state: 'ready', previewKind: 'text', openable: false, nonOpenableReason: 'Generated worlds are list-only in this release.' } + } + if (candidate.workspacePath.endsWith('.scene.json')) { + return { capability: 'scene-manifest', state: 'ready', previewKind: 'text', openable: false, nonOpenableReason: 'Scene manifests are list-only in this release.' } + } + if (INTRINSIC_MOTION_EXTENSIONS.has(extension)) { + return { capability: 'animation-motion', state: 'ready', previewKind: 'binary', openable: false, nonOpenableReason: 'Motion files are list-only in this release.' } + } + if (extension === 'glb' || extension === 'gltf') { + return { capability: candidate.hasRigMetadata ? 'rigged-mesh' : 'mesh', state: 'ready', previewKind: '3d-model', openable: true } + } + if (MESH_EXTENSIONS.has(extension)) { + return { capability: 'mesh', state: 'ready', previewKind: 'binary', openable: false, nonOpenableReason: `.${extension} workspace assets are list-only in this release.` } + } + if (TEXT_EXTENSIONS.has(extension)) { + return { state: 'unsupported', previewKind: 'text', openable: false, nonOpenableReason: 'Unsupported workspace asset.' } + } + return { state: 'unsupported', previewKind: 'binary', openable: false, nonOpenableReason: 'Unsupported workspace asset.' } +} + +function stringField(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function objectField(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : undefined +} + +function manifestCapabilityFor(workspacePath: string): 'generated-world' | 'scene-manifest' | undefined { + if (workspacePath.endsWith('.world.json')) return 'generated-world' + if (workspacePath.endsWith('.scene.json')) return 'scene-manifest' + return undefined +} + +async function safeLinkedWorkspacePath(workspaceDir: string, ownerWorkspacePath: string, candidate: unknown, expected?: { mustBeMesh?: boolean, mustBeManifest?: boolean }): Promise<{ workspacePath?: string, warning?: string }> { + const raw = stringField(candidate) + if (!raw) return {} + let normalized: NormalizedWorkspaceAssetPath + try { + normalized = normalizeWorkspaceAssetPath(workspaceDir, raw) + } catch { + return { warning: 'Ignored unsafe linked workspace path.' } + } + if (normalized.workspacePath === ownerWorkspacePath) return { warning: 'Ignored self-linked workspace path.' } + if (expected?.mustBeMesh && !isGlbOrGltf(normalized.workspacePath)) return { warning: 'Ignored non-GLB/GLTF source workspace path.' } + if (expected?.mustBeManifest && !manifestCapabilityFor(normalized.workspacePath)) return { warning: 'Ignored non-manifest workspace path.' } + try { + await stat(normalized.absolutePath) + } catch { + return { warning: 'Ignored missing linked workspace path.' } + } + return { workspacePath: normalized.workspacePath } +} + +async function readMetadata(workspaceDir: string, workspacePath: string, absolutePath: string): Promise { + const metadata: AssetLibraryMetadata = { warnings: [] } + if (extensionOf(workspacePath) !== 'json') return metadata + + let parsed: Record + try { + parsed = JSON.parse(await readFile(absolutePath, 'utf8')) as Record + } catch { + return metadata + } + + const sourceRaw = parsed.sourceWorkspacePath ?? parsed.source_workspace_path ?? parsed.sourcePath ?? objectField(parsed.source)?.workspacePath + const source = await safeLinkedWorkspacePath(workspaceDir, workspacePath, sourceRaw, { mustBeMesh: true }) + if (source.workspacePath) metadata.sourceWorkspacePath = source.workspacePath + if (source.warning) metadata.warnings.push(source.warning) + + const manifestRaw = parsed.manifestWorkspacePath ?? parsed.manifest_workspace_path ?? objectField(parsed.manifest)?.workspacePath + const manifest = await safeLinkedWorkspacePath(workspaceDir, workspacePath, manifestRaw, { mustBeManifest: true }) + if (manifest.workspacePath) metadata.manifestWorkspacePath = manifest.workspacePath + if (manifest.warning) metadata.warnings.push(manifest.warning) + + metadata.artifactId = stringField(parsed.artifactId ?? parsed.artifact_id) + metadata.versionId = stringField(parsed.versionId ?? parsed.version_id) + metadata.provenance = objectField(parsed.provenance) as ArtifactProvenance | undefined + return metadata +} + +function shouldSkipDirectory(name: string): boolean { + return name.startsWith('.') || SKIPPED_DIRS.has(name.toLowerCase()) +} + +function shouldSkipFile(name: string): boolean { + return name.startsWith('.') || INTERNAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) +} + +async function hasRigMetadata(absolutePath: string): Promise { + if (!/\.(glb|gltf)$/i.test(absolutePath)) return false + const stem = absolutePath.replace(/\.(glb|gltf)$/i, '') + try { + await stat(`${stem}.rigmeta.json`) + return true + } catch { + return false + } +} + +async function buildEntry(workspaceDir: string, workspacePath: string): Promise { + const { absolutePath } = normalizeWorkspaceAssetPath(workspaceDir, workspacePath) + const stats = await stat(absolutePath) + const classification = classifyAssetLibraryCandidate({ workspacePath, hasRigMetadata: await hasRigMetadata(absolutePath) }) + const metadata = await readMetadata(workspaceDir, workspacePath, absolutePath) + const manifestCapability = metadata.manifestWorkspacePath ? manifestCapabilityFor(metadata.manifestWorkspacePath) : undefined + return { + id: `library:${workspacePath}`, + workspacePath, + displayName: basename(workspacePath), + sourceScope: sourceScopeFor(workspacePath), + capability: classification.capability, + state: classification.state, + previewKind: classification.previewKind, + source: metadata.sourceWorkspacePath ? { workspacePath: metadata.sourceWorkspacePath, displayName: basename(metadata.sourceWorkspacePath), role: 'source-mesh' } : undefined, + manifest: metadata.manifestWorkspacePath && manifestCapability ? { workspacePath: metadata.manifestWorkspacePath, capability: manifestCapability } : undefined, + artifactId: metadata.artifactId, + versionId: metadata.versionId, + provenance: metadata.provenance, + warnings: metadata.warnings, + openable: classification.openable, + nonOpenableReason: classification.nonOpenableReason, + createdAt: (stats.birthtime.getTime() > 0 ? stats.birthtime : stats.mtime).toISOString(), + updatedAt: stats.mtime.toISOString(), + } +} + +async function collectFiles(workspaceDir: string, rootName: typeof ALLOWED_ROOTS[number]): Promise { + const root = join(workspaceDir, rootName) + const files: string[] = [] + + async function walk(dir: string): Promise { + let entries: Awaited> + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (!shouldSkipDirectory(entry.name)) await walk(join(dir, entry.name)) + continue + } + if (!entry.isFile() || shouldSkipFile(entry.name)) continue + files.push(relative(workspaceDir, join(dir, entry.name)).split(sep).join('/')) + } + } + + await walk(root) + return files +} + +export async function listWorkspaceAssetLibrary(request: WorkspaceAssetLibraryRequest): Promise { + try { + const workspacePaths = (await Promise.all(ALLOWED_ROOTS.map((root) => collectFiles(request.workspaceDir, root)))).flat().sort() + const entries = await Promise.all(workspacePaths.map((workspacePath) => buildEntry(request.workspaceDir, workspacePath))) + return { success: true, entries: entries.filter((entry) => entry.state !== 'unsupported') } + } catch (error) { + return { success: false, error: libraryError('list-failed', error instanceof Error ? error.message : String(error)) } + } +} + +async function previewEntry(absolutePath: string, entry: AssetLibraryEntry): Promise { + if (entry.previewKind === '3d-model') return { kind: '3d-model', viewerKind: extensionOf(entry.workspacePath) as 'glb' | 'gltf' } + const stats = await stat(absolutePath) + if (entry.previewKind === 'text') { + const maxBytes = 64 * 1024 + const content = await readFile(absolutePath, 'utf8') + return { kind: 'text', content: content.slice(0, maxBytes), byteLength: stats.size, truncated: content.length > maxBytes } + } + if (entry.previewKind === 'binary') { + return { kind: 'binary', binaryKind: extensionOf(entry.workspacePath), byteLength: stats.size, message: 'Binary preview is unavailable.' } + } + return { kind: 'none' } +} + +export async function readWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibraryReadRequest): Promise { + try { + const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath) + const sourceValidation = await validateRequestedSource(request.workspaceDir, normalized.workspacePath, request.sourceWorkspacePath) + if (!sourceValidation.success) return { success: false, error: sourceValidation.error } + const entry = await buildEntry(request.workspaceDir, normalized.workspacePath) + return { success: true, entry, preview: await previewEntry(normalized.absolutePath, entry) } + } catch (error) { + return { success: false, error: libraryError('unsafe-path', error instanceof Error ? error.message : String(error)) } + } +} + +async function validateRequestedSource(workspaceDir: string, workspacePath: string, sourceWorkspacePath?: string): Promise<{ success: true } | { success: false, error: AssetLibraryError }> { + if (!sourceWorkspacePath) return { success: true } + let source: NormalizedWorkspaceAssetPath + try { + source = normalizeWorkspaceAssetPath(workspaceDir, sourceWorkspacePath) + } catch (error) { + return { success: false, error: libraryError('unsafe-path', error instanceof Error ? error.message : String(error)) } + } + if (source.workspacePath === workspacePath) return { success: false, error: libraryError('not-openable', 'Linked source must not point to the same workspace asset.') } + if (!isGlbOrGltf(source.workspacePath)) return { success: false, error: libraryError('not-openable', 'Linked source must be a safe .glb/.gltf workspace asset.') } + try { + await stat(source.absolutePath) + } catch { + return { success: false, error: libraryError('not-openable', 'Linked source workspace asset was not found.') } + } + const entry = await buildEntry(workspaceDir, workspacePath) + if (entry.source?.workspacePath !== source.workspacePath) { + return { success: false, error: libraryError('not-openable', 'Requested source does not match the indexed library source link.') } + } + return { success: true } +} + +export async function openWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibraryReadRequest): Promise { + const read = await readWorkspaceAssetLibraryEntry(request) + if (!read.success) return read + if (request.sourceWorkspacePath) return { success: true, entry: read.entry } + if (!read.entry.openable) { + return { success: false, error: libraryError('not-openable', read.entry.nonOpenableReason ?? 'Workspace asset is not openable.') } + } + return { success: true, entry: read.entry } +} + +function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string } { + if (typeof payload !== 'object' || payload === null) return {} + const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown } + return { + workspacePath: typeof values.workspacePath === 'string' ? values.workspacePath : undefined, + sourceWorkspacePath: typeof values.sourceWorkspacePath === 'string' ? values.sourceWorkspacePath : undefined, + } +} + +export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLibraryIpcDeps): void { + deps.ipcMain.handle('workspace:library:list', async () => listWorkspaceAssetLibrary({ workspaceDir: deps.getWorkspaceDir() })) + deps.ipcMain.handle('workspace:library:read', async (_event, payload) => { + const { workspacePath, sourceWorkspacePath } = readPayloadRequest(payload) + if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } + return readWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) + }) + deps.ipcMain.handle('workspace:library:open', async (_event, payload) => { + const { workspacePath, sourceWorkspacePath } = readPayloadRequest(payload) + if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } + return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) + }) +} diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ba6bdff..88fe240 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -22,6 +22,7 @@ import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn, execFile } from 'child_process' import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard' import { isSetupFailureFatal, validateInstallManifest } from './extension-install-utils' +import { registerWorkspaceAssetLibraryIpcHandlers } from './artifact-registry-service' type WindowGetter = () => BrowserWindow | null const pExecFile = promisify(execFile) @@ -620,6 +621,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const workspacePath = (...parts: string[]) => join(getSettings(app.getPath('userData')).workspaceDir, ...parts) + registerWorkspaceAssetLibraryIpcHandlers({ + ipcMain, + getWorkspaceDir: () => getSettings(app.getPath('userData')).workspaceDir, + }) + ipcMain.handle('workspace:listCollections', async () => { const base = workspacePath() await mkdir(base, { recursive: true }) diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts new file mode 100644 index 0000000..eac5142 --- /dev/null +++ b/electron/preload/artifact-registry-preload.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createElectronApi } from './electron-api.ts' + +test('preload exposes scoped workspace library list/read/open methods', async () => { + const calls: Array<{ channel: string, payload?: unknown }> = [] + const api = createElectronApi({ + invoke: async (channel: string, payload?: unknown) => { + calls.push({ channel, payload }) + return { success: true, entries: [] } + }, + send: () => undefined, + on: () => undefined, + removeAllListeners: () => undefined, + }, { setZoomFactor: () => undefined }) + + await api.workspace.library.list() + await api.workspace.library.read({ workspacePath: 'Workflows/checkpoints/hero.glb' }) + await api.workspace.library.open({ workspacePath: 'Workflows/checkpoints/hero.glb' }) + + assert.deepEqual(calls, [ + { channel: 'workspace:library:list', payload: undefined }, + { channel: 'workspace:library:read', payload: { workspacePath: 'Workflows/checkpoints/hero.glb' } }, + { channel: 'workspace:library:open', payload: { workspacePath: 'Workflows/checkpoints/hero.glb' } }, + ]) +}) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts new file mode 100644 index 0000000..6930b54 --- /dev/null +++ b/electron/preload/electron-api.ts @@ -0,0 +1,37 @@ +import type { + AssetLibraryListResult, + AssetLibraryOpenRequest, + AssetLibraryOpenResult, + AssetLibraryReadRequest, + AssetLibraryReadResult, +} from '../../src/shared/types/assetLibrary.ts' + +export interface IpcRendererLike { + invoke(channel: string, ...args: unknown[]): Promise + send(channel: string, ...args: unknown[]): void + on(channel: string, listener: (...args: unknown[]) => void): void + removeAllListeners(channel: string): void +} + +export interface WebFrameLike { + setZoomFactor(factor: number): void +} + +export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike) { + return { + window: { + minimize: () => ipcRenderer.send('window:minimize'), + maximize: () => ipcRenderer.send('window:maximize'), + close: () => ipcRenderer.send('window:close'), + }, + ui: { setZoomFactor: (factor: number) => webFrame.setZoomFactor(factor) }, + shell: { openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) }, + workspace: { + library: { + list: (): Promise => ipcRenderer.invoke('workspace:library:list') as Promise, + read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, + open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, + }, + }, + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 478a9f4..6d4f8ef 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -150,6 +150,13 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('workspace:saveJobMeta', { collection, filename, meta }), deleteJob: (collection: string, filename: string): Promise => ipcRenderer.invoke('workspace:deleteJob', { collection, filename }), + library: { + list: () => ipcRenderer.invoke('workspace:library:list'), + read: (request: { workspacePath: string }) => + ipcRenderer.invoke('workspace:library:read', request), + open: (request: { workspacePath: string }) => + ipcRenderer.invoke('workspace:library:open', request), + }, }, // Extensions diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 5b8315d..3be315b 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback, useEffect } from 'react' +import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import type { ReactNode } from 'react' import { useAppStore } from '@shared/stores/appStore' import type { GenerationJob } from '@shared/stores/appStore' @@ -7,6 +7,21 @@ import { ColorPicker } from '@shared/components/ui' import GenerationHUD from './components/GenerationHUD' import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' +import { getDefaultAssetLibraryService } from './assetLibraryService.ts' +import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection.ts' +import { + ASSET_LIBRARY_SORT_OPTIONS, + buildAssetLibraryOpenRequest, + createAssetLibraryOpenJob, + describeAssetLibraryOpenability, + filterAssetLibraryScopeGroups, + getDefaultAssetLibraryCollapsedSectionKeys, + isAssetLibraryEntryOpenable, + resolveOpenPanelAfterLibrarySelection, + toggleAssetLibrarySectionKey, + type AssetLibrarySortMode, + type GenerateOpenPanel, +} from './assetLibraryUi.ts' const MIN_WIDTH = 220 const MAX_WIDTH = 520 @@ -299,6 +314,238 @@ function SmoothPopover({ ) } +// --------------------------------------------------------------------------- +// Workspace library popover +// --------------------------------------------------------------------------- + +function AssetLibraryToggleButton({ + open, + disabled, + onToggle, +}: { + open: boolean + disabled: boolean + onToggle: () => void +}) { + return ( + + ) +} + +function AssetLibraryPopover({ + entries, + selectedEntryId, + loading, + opening, + error, + searchQuery, + sortMode, + collapsedSectionKeys, + onSelectEntry, + onSearchQueryChange, + onSortModeChange, + onToggleSection, + onOpenSelected, + onRefresh, + onClose, +}: { + entries: ProjectedAssetLibraryEntry[] + selectedEntryId: string | null + loading: boolean + opening: boolean + error: string | null + searchQuery: string + sortMode: AssetLibrarySortMode + collapsedSectionKeys: string[] + onSelectEntry: (entryId: string) => void + onSearchQueryChange: (value: string) => void + onSortModeChange: (value: AssetLibrarySortMode) => void + onToggleSection: (sectionKey: string) => void + onOpenSelected: () => void + onRefresh: () => void + onClose: () => void +}) { + const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) + const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) + const selectedEntry = selectedEntryId && visibleEntryIds.has(selectedEntryId) + ? entries.find((entry) => entry.id === selectedEntryId) ?? null + : null + const normalizedSearchQuery = searchQuery.trim() + const openDisabled = !selectedEntry || !isAssetLibraryEntryOpenable(selectedEntry) || loading || opening + const selectedMessage = selectedEntry + ? describeAssetLibraryOpenability(selectedEntry) + : scopeGroups.length === 0 && normalizedSearchQuery + ? `No workspace assets match “${normalizedSearchQuery}”.` + : 'Select an asset to open it in Generate.' + + return ( +
+
+
+

Workspace library

+

Select a workspace asset and open the supported source in Generate.

+
+ +
+ + + +
+
+ + onSearchQueryChange(event.target.value)} + placeholder="Search by name, path, scope, or capability" + className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400" + /> +
+
+ + +
+
+ + {loading ? ( +

Loading workspace assets…

+ ) : scopeGroups.length === 0 && !normalizedSearchQuery ? ( +

No workspace assets are indexed yet.

+ ) : scopeGroups.length === 0 ? ( +

{`No workspace assets match “${normalizedSearchQuery}”.`}

+ ) : ( +
+ {scopeGroups.map((scopeGroup) => { + const scopeExpanded = !collapsedSectionKeys.includes(scopeGroup.sectionKey) + const scopeRegionId = `asset-library-${scopeGroup.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` + return ( +
+ + {scopeExpanded && ( +
+ {scopeGroup.entryGroups.map((group) => { + const capabilityExpanded = !collapsedSectionKeys.includes(group.sectionKey) + const capabilityRegionId = `asset-library-${group.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` + return ( +
+ + {capabilityExpanded && ( +
+ {group.entries.map((entry) => { + const selected = entry.id === selectedEntryId + return ( + + ) + })} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} + +
+

{selectedMessage}

+ {error &&

{error}

} +
+ + +
+ ) +} + // --------------------------------------------------------------------------- // GeneratePage // --------------------------------------------------------------------------- @@ -306,11 +553,20 @@ function SmoothPopover({ export default function GeneratePage(): JSX.Element { const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) - const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | 'light' | null>(null) + const [openPanel, setOpenPanel] = useState(null) const [lightSettings, setLightSettings] = useState(DEFAULT_LIGHT_SETTINGS) const [decimating, setDecimating] = useState(false) const [smoothing, setSmoothing] = useState(false) const [importing, setImporting] = useState(false) + const [libraryEntries, setLibraryEntries] = useState([]) + const [librarySelectedEntryId, setLibrarySelectedEntryId] = useState(null) + const [libraryLoaded, setLibraryLoaded] = useState(false) + const [libraryLoading, setLibraryLoading] = useState(false) + const [libraryOpening, setLibraryOpening] = useState(false) + const [libraryError, setLibraryError] = useState(null) + const [librarySearchQuery, setLibrarySearchQuery] = useState('') + const [librarySortMode, setLibrarySortMode] = useState('type') + const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) const dragging = useRef(false) @@ -330,6 +586,7 @@ export default function GeneratePage(): JSX.Element { const canUndo = useAppStore((s) => s.historyIndex > 0) const canRedo = useAppStore((s) => s.historyIndex < s.meshHistory.length - 1) const { optimizeMesh, smoothMesh, importMesh } = useApi() + const assetLibraryService = useMemo(() => getDefaultAssetLibraryService(), []) useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -349,6 +606,11 @@ export default function GeneratePage(): JSX.Element { if (!meshSelected) setGizmoMode(null) }, [meshSelected]) + useEffect(() => { + if (openPanel !== 'library' || libraryLoaded || libraryLoading) return + void loadLibraryEntries() + }, [openPanel, libraryLoaded, libraryLoading]) + async function handleUnloadAll() { await window.electron.model.unloadAll() setUnloadStatus('done') @@ -402,6 +664,70 @@ export default function GeneratePage(): JSX.Element { } } + async function loadLibraryEntries() { + setLibraryLoading(true) + setLibraryError(null) + try { + const result = await assetLibraryService.list() + if (!result.success) { + setLibraryLoaded(false) + setLibraryEntries([]) + setLibrarySelectedEntryId(null) + setLibraryError(result.error.message) + return + } + setLibraryEntries(result.entries) + setLibrarySelectedEntryId((current) => current && result.entries.some((entry) => entry.id === current) + ? current + : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) + setLibraryLoaded(true) + } catch (err) { + setLibraryLoaded(false) + setLibraryEntries([]) + setLibrarySelectedEntryId(null) + setLibraryError(err instanceof Error ? err.message : String(err)) + } finally { + setLibraryLoading(false) + } + } + + async function handleOpenSelectedLibraryEntry() { + const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null + if (!selectedEntry) { + setLibraryError('Select an asset before opening it in Generate.') + return + } + if (!isAssetLibraryEntryOpenable(selectedEntry)) { + setLibraryError(describeAssetLibraryOpenability(selectedEntry)) + return + } + + setLibraryOpening(true) + setLibraryError(null) + try { + const result = await assetLibraryService.open(buildAssetLibraryOpenRequest(selectedEntry)) + if (!result.success) { + setLibraryError(result.error.message) + return + } + const target = resolveAssetLibraryOpenTarget(result.entry) + const selection = createAssetLibraryOpenJob(result.entry, target) + if (!selection) { + setLibraryError(describeAssetLibraryOpenability(result.entry)) + return + } + setLibraryEntries((currentEntries) => currentEntries.map((entry) => entry.id === result.entry.id ? result.entry : entry)) + setLibrarySelectedEntryId(result.entry.id) + setCurrentJob(selection.job) + pushMeshUrl(selection.historyUrl) + setOpenPanel((currentPanel) => resolveOpenPanelAfterLibrarySelection(currentPanel)) + } catch (err) { + setLibraryError(err instanceof Error ? err.message : String(err)) + } finally { + setLibraryOpening(false) + } + } + async function handleSmooth(iterations: number) { if (!currentJob?.outputUrl) return setSmoothing(true) @@ -555,6 +881,39 @@ export default function GeneratePage(): JSX.Element { )} +
+ { + setLibraryError(null) + setOpenPanel((panel) => (panel === 'library' ? null : 'library')) + }} + /> + {openPanel === 'library' && ( + { + setLibraryError(null) + setLibrarySelectedEntryId(entryId) + }} + onSearchQueryChange={setLibrarySearchQuery} + onSortModeChange={setLibrarySortMode} + onToggleSection={(sectionKey) => setLibraryCollapsedSectionKeys((current) => toggleAssetLibrarySectionKey(current, sectionKey))} + onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} + onRefresh={() => { void loadLibraryEntries() }} + onClose={() => setOpenPanel(null)} + /> + )} +
+ {hasModel && ( <>
diff --git a/src/areas/generate/assetLibraryProjection.test.ts b/src/areas/generate/assetLibraryProjection.test.ts new file mode 100644 index 0000000..bc1c9df --- /dev/null +++ b/src/areas/generate/assetLibraryProjection.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + filterAssetLibraryEntries, + groupAssetLibraryEntries, + projectAssetLibraryEntry, + resolveAssetLibraryOpenTarget, +} from './assetLibraryProjection.ts' +import type { AssetLibraryEntry } from '../../shared/types/assetLibrary.ts' + +const glbEntry: AssetLibraryEntry = { + id: 'library:Workflows/checkpoints/hero.glb', + workspacePath: 'Workflows/checkpoints/hero.glb', + displayName: 'hero.glb', + sourceScope: 'workflows', + capability: 'mesh', + state: 'ready', + previewKind: '3d-model', + warnings: ['safe', 'safe'], + openable: true, +} + +test('projects entries with deduped warnings and workspace URL open target', () => { + const projected = projectAssetLibraryEntry(glbEntry) + assert.deepEqual(projected.warnings, ['safe']) + assert.deepEqual(resolveAssetLibraryOpenTarget(projected), { + kind: 'self', + url: '/workspace/Workflows/checkpoints/hero.glb', + workspacePath: 'Workflows/checkpoints/hero.glb', + }) +}) + +test('projects sidecars with safe GLB source links as linked-source open targets', () => { + const projected = projectAssetLibraryEntry({ + ...glbEntry, + id: 'library:Workflows/checkpoints/hero.landmarks.v1.json', + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + displayName: 'hero.landmarks.v1.json', + capability: 'landmarks-sidecar', + previewKind: 'text', + openable: false, + nonOpenableReason: 'Open the linked source mesh.', + source: { workspacePath: 'Workflows/checkpoints/hero.glb', displayName: 'hero.glb', role: 'source-mesh' }, + manifest: { workspacePath: 'Workflows/checkpoints/hero.scene.json', capability: 'scene-manifest' }, + }) + + assert.deepEqual(projected.source, { workspacePath: 'Workflows/checkpoints/hero.glb', displayName: 'hero.glb', role: 'source-mesh' }) + assert.deepEqual(projected.manifest, { workspacePath: 'Workflows/checkpoints/hero.scene.json', capability: 'scene-manifest' }) + assert.deepEqual(resolveAssetLibraryOpenTarget(projected), { + kind: 'linked-source', + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + url: '/workspace/Workflows/checkpoints/hero.glb', + }) +}) + +test('degrades unsafe source and manifest paths and unsupported formats to unavailable targets', () => { + const projected = projectAssetLibraryEntry({ + ...glbEntry, + id: 'unsafe-source', + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + displayName: 'hero.landmarks.v1.json', + capability: 'landmarks-sidecar', + previewKind: 'text', + openable: false, + source: { workspacePath: 'Workflows/%2e%2e/secret.glb' }, + manifest: { workspacePath: '../secret.scene.json', capability: 'scene-manifest' }, + }) + const splat = projectAssetLibraryEntry({ ...glbEntry, id: 'splat', workspacePath: 'Workflows/scan.splat', displayName: 'scan.splat', openable: true }) + + assert.equal(projected.source, undefined) + assert.equal(projected.manifest, undefined) + assert.deepEqual(projected.warnings, ['safe', 'Ignored unsafe source workspace path.', 'Ignored unsafe manifest workspace path.']) + assert.deepEqual(resolveAssetLibraryOpenTarget(projected), { + kind: 'unavailable', + reason: 'Workspace asset is not openable.', + }) + assert.deepEqual(resolveAssetLibraryOpenTarget(splat), { + kind: 'unavailable', + reason: 'Only safe .glb/.gltf workspace assets are openable in this release.', + }) +}) + +test('marks unsafe or non-openable entries unavailable without throwing', () => { + const projected = projectAssetLibraryEntry({ + ...glbEntry, + workspacePath: '../escape.glb', + state: 'unsafe', + openable: false, + nonOpenableReason: 'Unsafe workspace path.', + }) + assert.equal(projected.state, 'unsafe') + assert.deepEqual(resolveAssetLibraryOpenTarget(projected), { + kind: 'unavailable', + reason: 'Unsafe workspace path.', + }) +}) + +test('filters by name, path, capability, scope, and groups by scope then capability', () => { + const entries = [ + projectAssetLibraryEntry(glbEntry), + projectAssetLibraryEntry({ ...glbEntry, id: 'export', workspacePath: 'Exports/static.ply', displayName: 'static.ply', sourceScope: 'exports', openable: false }), + ] + assert.deepEqual(filterAssetLibraryEntries(entries, 'exports').map((entry) => entry.workspacePath), ['Exports/static.ply']) + assert.deepEqual(filterAssetLibraryEntries(entries, 'hero.glb').map((entry) => entry.displayName), ['hero.glb']) + assert.deepEqual(filterAssetLibraryEntries(entries, 'mesh').map((entry) => entry.displayName), ['hero.glb', 'static.ply']) + assert.deepEqual(groupAssetLibraryEntries(entries).map((group) => [group.scope, group.capabilityGroups.map((capability) => capability.capability)]), [ + ['exports', ['mesh']], + ['workflows', ['mesh']], + ]) +}) diff --git a/src/areas/generate/assetLibraryProjection.ts b/src/areas/generate/assetLibraryProjection.ts new file mode 100644 index 0000000..b736c8b --- /dev/null +++ b/src/areas/generate/assetLibraryProjection.ts @@ -0,0 +1,99 @@ +import type { AssetCapability, AssetLibraryEntry, AssetLibrarySourceScope } from '../../shared/types/assetLibrary.ts' + +export interface ProjectedAssetLibraryEntry extends AssetLibraryEntry { + warnings: string[] +} + +export type AssetLibraryOpenTarget = + | { kind: 'self', url: string, workspacePath: string } + | { kind: 'linked-source', url: string, workspacePath: string, sourceWorkspacePath: string } + | { kind: 'unavailable', reason: string } + +export interface AssetLibraryCapabilityGroup { + capability: AssetCapability | 'uncategorized' + entries: ProjectedAssetLibraryEntry[] +} + +export interface AssetLibraryScopeGroup { + scope: AssetLibrarySourceScope + capabilityGroups: AssetLibraryCapabilityGroup[] +} + +function isSafeWorkspacePath(workspacePath: string): boolean { + const normalized = workspacePath.replace(/\\/g, '/').trim() + return /^(Workflows|Exports)\//.test(normalized) + && !normalized.split('/').includes('..') + && !/%2e|%2f|%5c/i.test(normalized) + && !normalized.startsWith('/') + && !/^[a-zA-Z]:[\\/]/.test(workspacePath) + && !workspacePath.startsWith('\\\\') +} + +function isGlbOrGltf(workspacePath: string): boolean { + return /\.(glb|gltf)$/i.test(workspacePath) +} + +export function projectAssetLibraryEntry(entry: AssetLibraryEntry): ProjectedAssetLibraryEntry { + const warnings = [...new Set(entry.warnings)] + if (!isSafeWorkspacePath(entry.workspacePath)) { + return { ...entry, state: 'unsafe', openable: false, nonOpenableReason: entry.nonOpenableReason ?? 'Unsafe workspace path.', warnings } + } + const safeSource = entry.source && isSafeWorkspacePath(entry.source.workspacePath) ? entry.source : undefined + const safeManifest = entry.manifest && isSafeWorkspacePath(entry.manifest.workspacePath) ? entry.manifest : undefined + if (entry.source && !safeSource) warnings.push('Ignored unsafe source workspace path.') + if (entry.manifest && !safeManifest) warnings.push('Ignored unsafe manifest workspace path.') + return { ...entry, source: safeSource, manifest: safeManifest, warnings: [...new Set(warnings)] } +} + +export function resolveAssetLibraryOpenTarget(entry: ProjectedAssetLibraryEntry): AssetLibraryOpenTarget { + if (entry.state !== 'ready') { + return { kind: 'unavailable', reason: entry.nonOpenableReason ?? 'Workspace asset is not openable.' } + } + if (entry.source?.workspacePath) { + if (!isSafeWorkspacePath(entry.source.workspacePath) || entry.source.workspacePath === entry.workspacePath || !isGlbOrGltf(entry.source.workspacePath)) { + return { kind: 'unavailable', reason: 'Linked source mesh is unavailable.' } + } + return { + kind: 'linked-source', + url: `/workspace/${entry.source.workspacePath}`, + workspacePath: entry.workspacePath, + sourceWorkspacePath: entry.source.workspacePath, + } + } + if (!entry.openable) { + return { kind: 'unavailable', reason: entry.nonOpenableReason ?? 'Workspace asset is not openable.' } + } + if (!isGlbOrGltf(entry.workspacePath)) { + return { kind: 'unavailable', reason: 'Only safe .glb/.gltf workspace assets are openable in this release.' } + } + return { kind: 'self', url: `/workspace/${entry.workspacePath}`, workspacePath: entry.workspacePath } +} + +export function filterAssetLibraryEntries(entries: ProjectedAssetLibraryEntry[], query: string): ProjectedAssetLibraryEntry[] { + const needle = query.trim().toLowerCase() + if (!needle) return entries + return entries.filter((entry) => [ + entry.displayName, + entry.workspacePath, + entry.capability ?? '', + entry.sourceScope, + entry.state, + entry.source?.workspacePath ?? '', + entry.manifest?.workspacePath ?? '', + ].some((value) => value.toLowerCase().includes(needle))) +} + +export function groupAssetLibraryEntries(entries: ProjectedAssetLibraryEntry[]): AssetLibraryScopeGroup[] { + const scopes = [...new Set(entries.map((entry) => entry.sourceScope))].sort() + return scopes.map((scope) => { + const scopeEntries = entries.filter((entry) => entry.sourceScope === scope) + const capabilities = [...new Set(scopeEntries.map((entry) => entry.capability ?? 'uncategorized'))].sort() + return { + scope, + capabilityGroups: capabilities.map((capability) => ({ + capability, + entries: scopeEntries.filter((entry) => (entry.capability ?? 'uncategorized') === capability), + })), + } + }) +} diff --git a/src/areas/generate/assetLibraryService.test.ts b/src/areas/generate/assetLibraryService.test.ts new file mode 100644 index 0000000..9e7938c --- /dev/null +++ b/src/areas/generate/assetLibraryService.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createAssetLibraryService } from './assetLibraryService.ts' + +test('renderer service validates requests before IPC and projects structured errors', async () => { + let invoked = false + const service = createAssetLibraryService({ + list: async () => ({ success: true, entries: [] }), + read: async () => { invoked = true; return { success: false, error: { code: 'unexpected', message: 'should not run' } } }, + open: async () => { invoked = true; return { success: false, error: { code: 'unexpected', message: 'should not run' } } }, + }) + + const read = await service.read({ workspacePath: '../escape.glb' }) + const open = await service.open({ workspacePath: '/tmp/hero.glb' }) + assert.equal(read.success, false) + assert.equal(!read.success && read.error.code, 'unsafe-path') + assert.equal(open.success, false) + assert.equal(!open.success && open.error.code, 'unsafe-path') + assert.equal(invoked, false) +}) + +test('renderer service validates sourceWorkspacePath before read and open IPC calls', async () => { + let invoked = false + const service = createAssetLibraryService({ + list: async () => ({ success: true, entries: [] }), + read: async () => { invoked = true; return { success: false, error: { code: 'unexpected', message: 'should not run' } } }, + open: async () => { invoked = true; return { success: false, error: { code: 'unexpected', message: 'should not run' } } }, + }) + + const read = await service.read({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: '../secret.glb' }) + const open = await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'C:\\secret.glb' }) + + assert.equal(read.success, false) + assert.equal(!read.success && read.error.code, 'unsafe-path') + assert.equal(open.success, false) + assert.equal(!open.success && open.error.code, 'unsafe-path') + assert.equal(invoked, false) +}) + +test('renderer service delegates safe IPC calls and normalizes returned entries', async () => { + let openRequest: unknown = null + const service = createAssetLibraryService({ + list: async () => ({ + success: true, + entries: [{ + id: 'asset', workspacePath: 'Workflows/hero.glb', displayName: 'hero.glb', sourceScope: 'workflows', + capability: 'mesh', state: 'ready', previewKind: '3d-model', warnings: ['a', 'a'], openable: true, + }], + }), + read: async () => ({ success: false, error: { code: 'missing', message: 'Missing' } }), + open: async (request) => { openRequest = request; return { success: false, error: { code: 'missing', message: 'Missing' } } }, + }) + + const result = await service.list() + assert.equal(result.success, true) + assert.deepEqual(result.success && result.entries[0]?.warnings, ['a']) + await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) + assert.deepEqual(openRequest, { workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) +}) diff --git a/src/areas/generate/assetLibraryService.ts b/src/areas/generate/assetLibraryService.ts new file mode 100644 index 0000000..29be5b4 --- /dev/null +++ b/src/areas/generate/assetLibraryService.ts @@ -0,0 +1,68 @@ +import type { + AssetLibraryError, + AssetLibraryListResult, + AssetLibraryOpenRequest, + AssetLibraryOpenResult, + AssetLibraryReadRequest, + AssetLibraryReadResult, +} from '../../shared/types/assetLibrary.ts' +import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from './assetLibraryProjection.ts' + +export interface AssetLibraryPreloadApi { + list: () => Promise + read: (request: AssetLibraryReadRequest) => Promise + open: (request: AssetLibraryOpenRequest) => Promise +} + +export type ProjectedAssetLibraryListResult = + | { success: true, entries: ProjectedAssetLibraryEntry[] } + | { success: false, error: AssetLibraryError } + +export type ProjectedAssetLibraryReadResult = + | { success: true, entry: ProjectedAssetLibraryEntry, preview: Extract['preview'] } + | { success: false, error: AssetLibraryError } + +export type ProjectedAssetLibraryOpenResult = + | { success: true, entry: ProjectedAssetLibraryEntry } + | { success: false, error: AssetLibraryError } + +function unsafeError(message = 'Workspace path must stay under Workflows/ or Exports/.'): AssetLibraryError { + return { code: 'unsafe-path', message } +} + +function validateWorkspacePath(workspacePath: string): AssetLibraryError | null { + const trimmed = workspacePath.trim() + if (!trimmed || trimmed.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(trimmed) || trimmed.startsWith('\\\\')) return unsafeError() + if (/%2e|%2f|%5c/i.test(trimmed) || trimmed.split(/[\\/]+/).includes('..')) return unsafeError() + const normalized = trimmed.replace(/\\/g, '/') + if (!normalized.startsWith('Workflows/') && !normalized.startsWith('Exports/')) return unsafeError() + return null +} + +export function createAssetLibraryService(api: AssetLibraryPreloadApi) { + return { + async list(): Promise { + const result = await api.list() + if (!result.success) return result + return { success: true, entries: result.entries.map(projectAssetLibraryEntry) } + }, + async read(request: AssetLibraryReadRequest): Promise { + const invalid = validateWorkspacePath(request.workspacePath) ?? (request.sourceWorkspacePath ? validateWorkspacePath(request.sourceWorkspacePath) : null) + if (invalid) return { success: false, error: invalid } + const result = await api.read(request) + if (!result.success) return result + return { success: true, entry: projectAssetLibraryEntry(result.entry), preview: result.preview } + }, + async open(request: AssetLibraryOpenRequest): Promise { + const invalid = validateWorkspacePath(request.workspacePath) ?? (request.sourceWorkspacePath ? validateWorkspacePath(request.sourceWorkspacePath) : null) + if (invalid) return { success: false, error: invalid } + const result = await api.open(request) + if (!result.success) return result + return { success: true, entry: projectAssetLibraryEntry(result.entry) } + }, + } +} + +export function getDefaultAssetLibraryService() { + return createAssetLibraryService(window.electron.workspace.library) +} diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts new file mode 100644 index 0000000..4253738 --- /dev/null +++ b/src/areas/generate/assetLibraryUi.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildAssetLibraryOpenRequest, + createAssetLibraryOpenJob, + describeAssetLibraryOpenability, + filterAssetLibraryScopeGroups, + getDefaultAssetLibraryCollapsedSectionKeys, + isAssetLibraryEntryOpenable, + resolveOpenPanelAfterLibrarySelection, + toggleAssetLibrarySectionKey, + type AssetLibrarySortMode, + type GenerateOpenPanel, +} from './assetLibraryUi.ts' +import { projectAssetLibraryEntry, resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection.ts' +import type { AssetLibraryEntry } from '../../shared/types/assetLibrary.ts' + +function entry(overrides: Partial = {}): ProjectedAssetLibraryEntry { + const base: AssetLibraryEntry = { + id: 'library:Workflows/hero.glb', + workspacePath: 'Workflows/hero.glb', + displayName: 'hero.glb', + sourceScope: 'workflows', + capability: 'mesh', + state: 'ready', + previewKind: '3d-model', + warnings: [], + openable: true, + createdAt: '2026-06-16T10:00:00.000Z', + ...overrides, + } + return projectAssetLibraryEntry(base) +} + +function groupPaths(entries: ProjectedAssetLibraryEntry[], search = '', sortMode: AssetLibrarySortMode = 'type'): string[] { + return filterAssetLibraryScopeGroups(entries, search, sortMode).flatMap((scopeGroup) => ( + scopeGroup.entryGroups.flatMap((group) => group.entries.map((item) => item.workspacePath)) + )) +} + +test('organizes visible library assets by scope and capability with collapsible section keys', () => { + const entries = [ + entry({ id: 'workflow-mesh', workspacePath: 'Workflows/run/hero.glb', displayName: 'hero.glb', sourceScope: 'workflows', capability: 'mesh' }), + entry({ id: 'export-rig', workspacePath: 'Exports/rig/hero-rig.gltf', displayName: 'hero-rig.gltf', sourceScope: 'exports', capability: 'rigged-mesh' }), + entry({ id: 'hidden-cache', workspacePath: 'Workflows/run/cache/internal.glb', displayName: 'internal.glb', sourceScope: 'workflows' }), + entry({ id: 'unsupported', workspacePath: 'Exports/readme.txt', displayName: 'readme.txt', sourceScope: 'exports', state: 'unsupported', openable: false }), + ] + + const groups = filterAssetLibraryScopeGroups(entries, '', 'type') + assert.deepEqual(groups.map((group) => [group.sourceScope, group.entryGroups.map((entryGroup) => entryGroup.capability)]), [ + ['workflows', ['mesh']], + ['exports', ['rigged-mesh']], + ]) + assert.deepEqual(groupPaths(entries), ['Workflows/run/hero.glb', 'Exports/rig/hero-rig.gltf']) + assert.equal(getDefaultAssetLibraryCollapsedSectionKeys().includes('scope:workflows'), true) + assert.deepEqual(toggleAssetLibrarySectionKey(['scope:workflows'], 'scope:workflows'), []) + assert.deepEqual(toggleAssetLibrarySectionKey([], 'scope:exports'), ['scope:exports']) +}) + +test('searches workspace assets by name path capability scope source and manifest while supporting name/date sorting', () => { + const entries = [ + entry({ id: 'b', workspacePath: 'Workflows/run/zebra.glb', displayName: 'zebra.glb', sourceScope: 'workflows', capability: 'mesh', createdAt: '2026-06-15T10:00:00.000Z' }), + entry({ id: 'a', workspacePath: 'Exports/rig/alpha.gltf', displayName: 'alpha.gltf', sourceScope: 'exports', capability: 'rigged-mesh', createdAt: '2026-06-16T10:00:00.000Z' }), + entry({ + id: 'c', workspacePath: 'Exports/motion/walk.json', displayName: 'walk.json', sourceScope: 'exports', capability: 'animation-motion', openable: false, previewKind: 'text', + source: { workspacePath: 'Workflows/run/zebra.glb', displayName: 'zebra.glb' }, + manifest: { workspacePath: 'Exports/motion/walk.scene.json', capability: 'scene-manifest' }, + }), + ] + + assert.deepEqual(groupPaths(entries, 'rigged'), ['Exports/rig/alpha.gltf']) + assert.deepEqual(groupPaths(entries, 'walk.scene'), ['Exports/motion/walk.json']) + assert.deepEqual(groupPaths(entries, 'zebra.glb'), ['Workflows/run/zebra.glb', 'Exports/motion/walk.json']) + assert.deepEqual(groupPaths(entries, 'exports', 'name'), ['Exports/rig/alpha.gltf', 'Exports/motion/walk.json']) + assert.deepEqual(groupPaths(entries, '', 'date'), ['Workflows/run/zebra.glb', 'Exports/rig/alpha.gltf', 'Exports/motion/walk.json']) +}) + +test('opens only safe glb and gltf entries through existing Generate job and history state', () => { + const glb = entry({ workspacePath: 'Workflows/run/hero.glb', displayName: 'hero.glb' }) + const ply = entry({ workspacePath: 'Exports/scan.ply', displayName: 'scan.ply', openable: false, nonOpenableReason: 'Only .glb/.gltf workspace assets are openable in this release.' }) + + assert.equal(isAssetLibraryEntryOpenable(glb), true) + assert.equal(isAssetLibraryEntryOpenable(ply), false) + assert.equal(describeAssetLibraryOpenability(glb), 'Ready to open this asset directly in Generate.') + assert.equal(describeAssetLibraryOpenability(ply), 'Only .glb/.gltf workspace assets are openable in this release.') + assert.deepEqual(buildAssetLibraryOpenRequest(glb), { workspacePath: 'Workflows/run/hero.glb' }) + + const target = resolveAssetLibraryOpenTarget(glb) + assert.equal(target.kind, 'self') + if (target.kind !== 'self') throw new Error('expected self target') + + const selection = createAssetLibraryOpenJob(glb, target, 1718546400000) + assert.equal(selection.historyUrl, '/workspace/Workflows/run/hero.glb') + assert.equal(selection.job.status, 'done') + assert.equal(selection.job.outputUrl, '/workspace/Workflows/run/hero.glb') + assert.equal(selection.job.originalOutputUrl, '/workspace/Workflows/run/hero.glb') + assert.equal(resolveOpenPanelAfterLibrarySelection('library' satisfies GenerateOpenPanel), 'library') +}) + +test('builds linked-source open requests and import jobs for safe sidecars', () => { + const sidecar = entry({ + id: 'sidecar', + workspacePath: 'Workflows/run/hero.landmarks.v1.json', + displayName: 'hero.landmarks.v1.json', + capability: 'landmarks-sidecar', + previewKind: 'text', + openable: false, + source: { workspacePath: 'Workflows/run/hero.glb', displayName: 'hero.glb' }, + }) + + assert.equal(isAssetLibraryEntryOpenable(sidecar), true) + assert.equal(describeAssetLibraryOpenability(sidecar), 'Ready to open linked source hero.glb in Generate.') + assert.deepEqual(buildAssetLibraryOpenRequest(sidecar), { + workspacePath: 'Workflows/run/hero.landmarks.v1.json', + sourceWorkspacePath: 'Workflows/run/hero.glb', + }) + + const target = resolveAssetLibraryOpenTarget(sidecar) + assert.equal(target.kind, 'linked-source') + if (target.kind !== 'linked-source') throw new Error('expected linked source target') + const selection = createAssetLibraryOpenJob(sidecar, target, 1718546400001) + assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb') + assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb') + assert.equal(selection?.job.prompt, 'Workspace library: hero.landmarks.v1.json') +}) diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts new file mode 100644 index 0000000..cbb6dc1 --- /dev/null +++ b/src/areas/generate/assetLibraryUi.ts @@ -0,0 +1,253 @@ +import type { GenerationJob } from '../../shared/stores/appStore.ts' +import type { AssetLibraryOpenRequest } from '../../shared/types/assetLibrary.ts' +import { resolveAssetLibraryOpenTarget, type AssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection.ts' + +export type GenerateOpenPanel = 'export' | 'decimate' | 'smooth' | 'import' | 'library' | 'light' | null +export type AssetLibrarySortMode = 'type' | 'name' | 'date' + +export interface AssetLibraryEntryGroup { + capability: NonNullable + capabilityLabel: string + sectionKey: string + entries: ProjectedAssetLibraryEntry[] +} + +export interface AssetLibrarySourceScopeGroup { + sourceScope: ProjectedAssetLibraryEntry['sourceScope'] + sourceScopeLabel: string + sectionKey: string + entryGroups: AssetLibraryEntryGroup[] +} + +export interface AssetLibraryOpenSelection { + historyUrl: string + job: GenerationJob +} + +const WORKSPACE_URL_PREFIX = '/workspace/' + +const ASSET_LIBRARY_CAPABILITY_SECTIONS = [ + { capability: 'mesh', label: 'Mesh' }, + { capability: 'rigged-mesh', label: 'Rigged mesh' }, + { capability: 'animation-motion', label: 'Animations/motions' }, + { capability: 'landmarks-sidecar', label: 'Landmarks sidecars' }, + { capability: 'generated-world', label: 'Generated worlds' }, + { capability: 'scene-manifest', label: 'Scene manifests' }, +] as const satisfies ReadonlyArray<{ capability: NonNullable, label: string }> + +const ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS = [ + { sourceScope: 'workflows', label: 'Workflows' }, + { sourceScope: 'exports', label: 'Exports' }, +] as const satisfies ReadonlyArray<{ sourceScope: ProjectedAssetLibraryEntry['sourceScope'], label: string }> + +const ASSET_LIBRARY_CAPABILITY_ORDER = new Map( + ASSET_LIBRARY_CAPABILITY_SECTIONS.map((section, index) => [section.capability, index]), +) + +const ASSET_LIBRARY_INTERNAL_DIRECTORY_NAMES = new Set(['tmp', 'temp', 'cache']) + +export const ASSET_LIBRARY_SORT_OPTIONS = [ + { value: 'type', label: 'Type' }, + { value: 'name', label: 'Name' }, + { value: 'date', label: 'Date' }, +] as const satisfies ReadonlyArray<{ value: AssetLibrarySortMode, label: string }> + +export function getDefaultAssetLibraryCollapsedSectionKeys(): string[] { + const sectionKeys = ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS.flatMap((scopeSection) => { + const capabilityKeys = ASSET_LIBRARY_CAPABILITY_SECTIONS.map( + (capabilitySection) => `capability:${scopeSection.sourceScope}:${capabilitySection.capability}`, + ) + + return [`scope:${scopeSection.sourceScope}`, ...capabilityKeys] + }) + + return [...sectionKeys] +} + +export function toggleAssetLibrarySectionKey(currentKeys: string[], sectionKey: string): string[] { + return currentKeys.includes(sectionKey) + ? currentKeys.filter((value) => value !== sectionKey) + : [...currentKeys, sectionKey] +} + +export function buildAssetLibraryOpenRequest(entry: ProjectedAssetLibraryEntry): AssetLibraryOpenRequest { + const target = resolveAssetLibraryOpenTarget(entry) + return target.kind === 'linked-source' + ? { workspacePath: target.workspacePath, sourceWorkspacePath: target.sourceWorkspacePath } + : { workspacePath: entry.workspacePath } +} + +export function isAssetLibraryEntryOpenable(entry: ProjectedAssetLibraryEntry | null | undefined): entry is ProjectedAssetLibraryEntry { + return Boolean(entry && resolveAssetLibraryOpenTarget(entry).kind !== 'unavailable') +} + +export function describeAssetLibraryOpenability(entry: ProjectedAssetLibraryEntry): string { + if (entry.state === 'unknown-metadata') return 'Missing metadata prevents a safe open in Generate.' + if (entry.state === 'unsupported') return 'This asset is tracked in the library but is not supported in Generate.' + if (entry.state === 'unsafe') return 'This asset was rejected because its workspace path is unsafe.' + const target = resolveAssetLibraryOpenTarget(entry) + if (target.kind === 'linked-source') return `Ready to open linked source ${entry.source?.displayName ?? target.sourceWorkspacePath} in Generate.` + if (target.kind === 'self') return 'Ready to open this asset directly in Generate.' + if (entry.nonOpenableReason) return entry.nonOpenableReason + if (!/\.(glb|gltf)$/i.test(entry.workspacePath)) return 'Only .glb/.gltf workspace assets are openable in this release.' + return target.reason +} + +export function filterVisibleAssetLibraryEntries(entries: ProjectedAssetLibraryEntry[]): ProjectedAssetLibraryEntry[] { + return entries.filter((entry) => entry.state !== 'unsupported' && !hasInternalAssetLibraryDirectory(entry.workspacePath)) +} + +export function filterAssetLibraryScopeGroups( + entries: ProjectedAssetLibraryEntry[], + searchQuery: string, + sortMode: AssetLibrarySortMode, +): AssetLibrarySourceScopeGroup[] { + const normalizedSearchQuery = normalizeAssetLibrarySearchQuery(searchQuery) + const visibleEntries = filterVisibleAssetLibraryEntries(entries) + + return ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS + .map((scopeSection) => { + const scopeEntries = visibleEntries.filter((entry) => entry.sourceScope === scopeSection.sourceScope) + const scopeMatches = normalizedSearchQuery.length > 0 && matchesAssetLibrarySearch(scopeSection.label, normalizedSearchQuery) + + const entryGroups = ASSET_LIBRARY_CAPABILITY_SECTIONS + .map((capabilitySection) => { + const capabilityEntries = scopeEntries.filter((entry) => entry.capability === capabilitySection.capability) + if (capabilityEntries.length === 0) return null + + const capabilityMatches = scopeMatches || (normalizedSearchQuery.length > 0 && matchesAssetLibrarySearch(capabilitySection.label, normalizedSearchQuery)) + const visibleCapabilityEntries = !normalizedSearchQuery || capabilityMatches + ? capabilityEntries + : capabilityEntries.filter((entry) => matchesAssetLibraryEntrySearch(entry, normalizedSearchQuery)) + + if (visibleCapabilityEntries.length === 0) return null + + return { + capability: capabilitySection.capability, + capabilityLabel: capabilitySection.label, + sectionKey: `capability:${scopeSection.sourceScope}:${capabilitySection.capability}`, + entries: sortAssetLibraryEntries(visibleCapabilityEntries, sortMode), + } + }) + .filter((group): group is AssetLibraryEntryGroup => group !== null) + + const sortedEntryGroups = sortMode === 'type' + ? entryGroups + : [...entryGroups].sort((left, right) => compareAssetLibraryEntryGroups(left, right, sortMode)) + + if (sortedEntryGroups.length === 0) return null + return { + sourceScope: scopeSection.sourceScope, + sourceScopeLabel: scopeSection.label, + sectionKey: `scope:${scopeSection.sourceScope}`, + entryGroups: sortedEntryGroups, + } + }) + .filter((group): group is AssetLibrarySourceScopeGroup => group !== null) +} + +export function createAssetLibraryOpenJob( + entry: ProjectedAssetLibraryEntry, + target: AssetLibraryOpenTarget, + now = Date.now(), +): AssetLibraryOpenSelection | null { + if (target.kind === 'unavailable') return null + return { + historyUrl: target.url, + job: { + id: `library-${now}`, + imageFile: '', + status: 'done', + progress: 100, + outputUrl: target.url, + originalOutputUrl: target.url, + prompt: `Workspace library: ${entry.displayName}`, + createdAt: now, + }, + } +} + +export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpenPanel): GenerateOpenPanel { + return currentPanel === 'library' ? 'library' : currentPanel +} + +function sortAssetLibraryEntries( + entries: ProjectedAssetLibraryEntry[], + sortMode: AssetLibrarySortMode, +): ProjectedAssetLibraryEntry[] { + return [...entries].sort((left, right) => compareAssetLibraryEntries(left, right, sortMode)) +} + +function compareAssetLibraryEntryGroups( + left: AssetLibraryEntryGroup, + right: AssetLibraryEntryGroup, + sortMode: Exclude, +): number { + const entryComparison = compareAssetLibraryEntries(left.entries[0], right.entries[0], sortMode) + if (entryComparison !== 0) return entryComparison + + return (ASSET_LIBRARY_CAPABILITY_ORDER.get(left.capability) ?? Number.MAX_SAFE_INTEGER) + - (ASSET_LIBRARY_CAPABILITY_ORDER.get(right.capability) ?? Number.MAX_SAFE_INTEGER) +} + +function compareAssetLibraryEntries( + left: ProjectedAssetLibraryEntry, + right: ProjectedAssetLibraryEntry, + sortMode: AssetLibrarySortMode, +): number { + if (sortMode === 'date') { + const leftTime = resolveAssetLibrarySortTimestamp(left) + const rightTime = resolveAssetLibrarySortTimestamp(right) + if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return rightTime - leftTime + if (leftTime !== null && rightTime === null) return -1 + if (leftTime === null && rightTime !== null) return 1 + } + + return compareAssetLibraryEntryNames(left, right) +} + +function compareAssetLibraryEntryNames(left: ProjectedAssetLibraryEntry, right: ProjectedAssetLibraryEntry): number { + const displayNameComparison = left.displayName.localeCompare(right.displayName, undefined, { sensitivity: 'base' }) + if (displayNameComparison !== 0) return displayNameComparison + const workspacePathComparison = left.workspacePath.localeCompare(right.workspacePath, undefined, { sensitivity: 'base' }) + if (workspacePathComparison !== 0) return workspacePathComparison + return left.id.localeCompare(right.id, undefined, { sensitivity: 'base' }) +} + +function resolveAssetLibrarySortTimestamp(entry: ProjectedAssetLibraryEntry): number | null { + return parseAssetLibrarySortTimestamp(entry.createdAt) ?? parseAssetLibrarySortTimestamp(entry.updatedAt) +} + +function parseAssetLibrarySortTimestamp(value: string | undefined): number | null { + if (typeof value !== 'string' || value.length === 0) return null + const epochMs = Date.parse(value) + return Number.isFinite(epochMs) ? epochMs : null +} + +function normalizeAssetLibrarySearchQuery(searchQuery: string): string { + return searchQuery.trim().toLocaleLowerCase() +} + +function matchesAssetLibraryEntrySearch(entry: ProjectedAssetLibraryEntry, normalizedSearchQuery: string): boolean { + return [ + entry.displayName, + entry.workspacePath, + entry.source?.workspacePath, + entry.source?.displayName, + entry.manifest?.workspacePath, + entry.capability, + entry.sourceScope, + ...entry.warnings, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => matchesAssetLibrarySearch(value, normalizedSearchQuery)) +} + +function matchesAssetLibrarySearch(value: string, normalizedSearchQuery: string): boolean { + return value.toLocaleLowerCase().includes(normalizedSearchQuery) +} + +function hasInternalAssetLibraryDirectory(workspacePath: string): boolean { + const segments = workspacePath.replace(/\\/g, '/').trim().split('/').filter(Boolean) + return segments.slice(1, -1).some((segment) => segment.startsWith('.') || ASSET_LIBRARY_INTERNAL_DIRECTORY_NAMES.has(segment.toLocaleLowerCase())) +} diff --git a/src/shared/types/artifacts.ts b/src/shared/types/artifacts.ts new file mode 100644 index 0000000..1829318 --- /dev/null +++ b/src/shared/types/artifacts.ts @@ -0,0 +1,12 @@ +export interface ArtifactProvenance { + workflowId?: string + workflowNodeId?: string + source?: string + [key: string]: unknown +} + +export interface ArtifactRef { + artifactId?: string + versionId?: string + provenance?: ArtifactProvenance +} diff --git a/src/shared/types/assetLibrary.test.ts b/src/shared/types/assetLibrary.test.ts new file mode 100644 index 0000000..8a6e749 --- /dev/null +++ b/src/shared/types/assetLibrary.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ASSET_CAPABILITIES, + ASSET_ENTRY_STATES, + ASSET_LIBRARY_MANIFEST_CAPABILITIES, + ASSET_LIBRARY_PREVIEW_KINDS, + ASSET_LIBRARY_SOURCE_SCOPES, +} from './assetLibrary.ts' +import type { + AssetLibraryEntry, + AssetLibraryListResult, + AssetLibraryOpenRequest, + AssetLibraryReadResult, + AssetLibrarySourceLink, +} from './assetLibrary.ts' + +test('declares capability-first asset taxonomy without extension categories', () => { + assert.deepEqual(ASSET_CAPABILITIES, [ + 'mesh', + 'rigged-mesh', + 'animation-motion', + 'landmarks-sidecar', + 'generated-world', + 'scene-manifest', + ]) + assert.deepEqual(ASSET_ENTRY_STATES, ['ready', 'unknown-metadata', 'unsupported', 'unsafe']) + assert.deepEqual(ASSET_LIBRARY_PREVIEW_KINDS, ['3d-model', 'text', 'binary', 'none']) + assert.deepEqual(ASSET_LIBRARY_SOURCE_SCOPES, ['workflows', 'exports']) + assert.deepEqual(ASSET_LIBRARY_MANIFEST_CAPABILITIES, ['generated-world', 'scene-manifest']) + assert.equal((ASSET_CAPABILITIES as readonly string[]).includes('glb'), false) + assert.equal((ASSET_CAPABILITIES as readonly string[]).includes('splat'), false) +}) + +test('AssetLibraryEntry keeps capability, source scope, provenance, warnings, and openability explicit', () => { + const entry = { + id: 'library:Workflows/checkpoints/hero.glb', + workspacePath: 'Workflows/checkpoints/hero.glb', + displayName: 'hero.glb', + sourceScope: 'workflows', + capability: 'rigged-mesh', + state: 'ready', + previewKind: '3d-model', + provenance: { workflowId: 'wf-1', workflowNodeId: 'node-1' }, + warnings: ['Rig metadata was inferred from a sidecar.'], + openable: true, + } satisfies AssetLibraryEntry + + const list = { success: true, entries: [entry] } satisfies AssetLibraryListResult + assert.equal(list.entries[0]?.capability, 'rigged-mesh') + assert.equal(list.entries[0]?.sourceScope, 'workflows') + assert.equal(list.entries[0]?.provenance?.workflowId, 'wf-1') + assert.equal(list.entries[0]?.warnings[0], 'Rig metadata was inferred from a sidecar.') + assert.equal(list.entries[0]?.openable, true) +}) + +test('structured results preserve renderer-visible error codes and read previews', () => { + const failure = { + success: false, + error: { code: 'unsafe-path', message: 'Workspace path escapes the allowed roots.' }, + } satisfies AssetLibraryListResult + const read = { + success: true, + entry: { + id: 'library:Exports/model.ply', + workspacePath: 'Exports/model.ply', + displayName: 'model.ply', + sourceScope: 'exports', + capability: 'mesh', + state: 'ready', + previewKind: 'binary', + warnings: [], + openable: false, + nonOpenableReason: '.ply workspace assets are list-only in this release.', + }, + preview: { kind: 'binary', byteLength: 12, binaryKind: 'ply', message: 'Binary preview is unavailable.' }, + } satisfies AssetLibraryReadResult + + assert.equal(failure.error.code, 'unsafe-path') + assert.equal(read.preview.kind, 'binary') + assert.equal(read.entry.openable, false) +}) + +test('AssetLibraryEntry carries library-owned source, manifest, artifact, version, and provenance metadata', () => { + const source = { + workspacePath: 'Workflows/checkpoints/hero.glb', + displayName: 'hero.glb', + role: 'source-mesh', + } satisfies AssetLibrarySourceLink + const entry = { + id: 'library:Workflows/checkpoints/hero.landmarks.v1.json', + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + displayName: 'hero.landmarks.v1.json', + sourceScope: 'workflows', + capability: 'landmarks-sidecar', + state: 'ready', + previewKind: 'text', + source, + manifest: { + workspacePath: 'Workflows/checkpoints/hero.scene.json', + capability: 'scene-manifest', + }, + artifactId: 'artifact-hero', + versionId: 'version-1', + provenance: { workflowId: 'wf-1', workflowNodeId: 'node-1', source: 'library-sidecar' }, + warnings: [], + openable: false, + } satisfies AssetLibraryEntry + + assert.equal(entry.source?.workspacePath, 'Workflows/checkpoints/hero.glb') + assert.equal(entry.manifest?.capability, 'scene-manifest') + assert.equal(entry.artifactId, 'artifact-hero') + assert.equal(entry.versionId, 'version-1') + assert.equal(entry.provenance?.source, 'library-sidecar') +}) + +test('read and open requests can carry a sourceWorkspacePath for linked-source opens', () => { + const request = { + workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', + sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', + } satisfies AssetLibraryOpenRequest + + assert.equal(request.workspacePath, 'Workflows/checkpoints/hero.landmarks.v1.json') + assert.equal(request.sourceWorkspacePath, 'Workflows/checkpoints/hero.glb') +}) diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts new file mode 100644 index 0000000..ef232f3 --- /dev/null +++ b/src/shared/types/assetLibrary.ts @@ -0,0 +1,83 @@ +import type { ArtifactProvenance } from './artifacts.ts' + +export const ASSET_CAPABILITIES = [ + 'mesh', + 'rigged-mesh', + 'animation-motion', + 'landmarks-sidecar', + 'generated-world', + 'scene-manifest', +] as const + +export const ASSET_ENTRY_STATES = ['ready', 'unknown-metadata', 'unsupported', 'unsafe'] as const +export const ASSET_LIBRARY_PREVIEW_KINDS = ['3d-model', 'text', 'binary', 'none'] as const +export const ASSET_LIBRARY_SOURCE_SCOPES = ['workflows', 'exports'] as const +export const ASSET_LIBRARY_MANIFEST_CAPABILITIES = ['generated-world', 'scene-manifest'] as const + +export type AssetCapability = typeof ASSET_CAPABILITIES[number] +export type AssetEntryState = typeof ASSET_ENTRY_STATES[number] +export type AssetLibraryPreviewKind = typeof ASSET_LIBRARY_PREVIEW_KINDS[number] +export type AssetLibrarySourceScope = typeof ASSET_LIBRARY_SOURCE_SCOPES[number] +export type AssetLibraryManifestCapability = typeof ASSET_LIBRARY_MANIFEST_CAPABILITIES[number] + +export interface AssetLibraryError { + code: 'unsafe-path' | 'not-found' | 'not-openable' | 'read-failed' | 'list-failed' | 'invalid-request' + message: string +} + +export interface AssetLibraryEntry { + id: string + workspacePath: string + displayName: string + sourceScope: AssetLibrarySourceScope + capability?: AssetCapability + state: AssetEntryState + previewKind: AssetLibraryPreviewKind + source?: AssetLibrarySourceLink + manifest?: AssetLibraryManifestRef + artifactId?: string + versionId?: string + provenance?: ArtifactProvenance + warnings: string[] + openable: boolean + nonOpenableReason?: string + createdAt?: string + updatedAt?: string +} + +export interface AssetLibrarySourceLink { + workspacePath: string + displayName?: string + role?: 'source-mesh' | 'source-artifact' | 'related-source' +} + +export interface AssetLibraryManifestRef { + workspacePath: string + capability: AssetLibraryManifestCapability +} + +export type AssetLibraryPreviewPayload = + | { kind: '3d-model', viewerKind: 'glb' | 'gltf' } + | { kind: 'text', content: string, byteLength: number, truncated: boolean } + | { kind: 'binary', binaryKind: string, byteLength: number, message: string } + | { kind: 'none' } + +export interface AssetLibraryReadRequest { + workspacePath: string + sourceWorkspacePath?: string +} + +export type AssetLibraryOpenRequest = AssetLibraryReadRequest +export type AssetLibraryListRequest = Record + +export type AssetLibraryListResult = + | { success: true, entries: AssetLibraryEntry[] } + | { success: false, error: AssetLibraryError } + +export type AssetLibraryReadResult = + | { success: true, entry: AssetLibraryEntry, preview: AssetLibraryPreviewPayload } + | { success: false, error: AssetLibraryError } + +export type AssetLibraryOpenResult = + | { success: true, entry: AssetLibraryEntry } + | { success: false, error: AssetLibraryError } diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 94c42fd..e287df9 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -1,6 +1,14 @@ // Type declarations for the Electron API exposed via preload export {} +import type { + AssetLibraryListResult, + AssetLibraryOpenRequest, + AssetLibraryOpenResult, + AssetLibraryReadRequest, + AssetLibraryReadResult, +} from './assetLibrary.ts' + // ─── Extension types ────────────────────────────────────────────────────────── export interface ExtensionNode { @@ -205,6 +213,11 @@ declare global { listJobs: (collection: string) => Promise saveJobMeta: (collection: string, filename: string, meta: unknown) => Promise deleteJob: (collection: string, filename: string) => Promise + library: { + list: () => Promise + read: (request: AssetLibraryReadRequest) => Promise + open: (request: AssetLibraryOpenRequest) => Promise + } } setup: { check: () => Promise<{ needed: boolean; defaultDataDir: string; platform: string; arch: string }>