Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ dist/
.vite/
test-results/
playwright-report/
e2e/.tmp/
.DS_Store
*.log
139 changes: 139 additions & 0 deletions e2e/persistence-opfs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';

const HERE = fileURLToPath(new URL('.', import.meta.url));

/**
* TML-53 范围项(OPFS 适配器):浏览器级验证 OPFS 后端与 IndexedDB 行为一致。
*
* 存储后端通过 ?storage=opfs 切换(嵌入式宿主透传),数据落 OPFS(真实 Chromium
* 实现):AC1 式「导出 → 清空 OPFS → 导入 → 刷新后最近项目恢复」全链路 + 直接
* 断言数据位于 OPFS 目录(而非 IndexedDB),以及 AC2 式跨标签页冲突在
* Web Locks 临界区下同样按 CAS 拒绝并显式解决。
*/

const OPFS_ROOT = 'lumora-studio';

async function clearOpfs(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(async (rootName) => {
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry(rootName, { recursive: true });
} catch {
// 目录不存在(首次运行):视为已清空
}
}, OPFS_ROOT);
}

/** 直接读取 OPFS 中项目文件列表:证明数据真的落 OPFS 而非 IndexedDB */
async function opfsProjectFiles(page: import('@playwright/test').Page): Promise<string[]> {
return page.evaluate(async (rootName) => {
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(rootName);
const projects = await dir.getDirectoryHandle('projects');
const names: string[] = [];
for await (const [name] of (projects as unknown as { entries(): AsyncIterableIterator<[string, unknown]> }).entries()) {
if (!name.startsWith('.')) names.push(name);
}
return names;
}, OPFS_ROOT);
}

test('OPFS 后端 AC1:导出→清空→导入完整恢复,数据落 OPFS 且刷新后可重开', async ({ page }) => {
await page.goto('/?storage=opfs');
await expect(page.getByTestId('studio-empty-hint')).toBeVisible();

// 1. 打开示例项目并追加两台摄像机(共 3 台镜头)
await page.getByTestId('open-sample-project').click();
await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible();
for (let i = 0; i < 2; i++) {
await page.getByTestId('add-object').click();
await page.getByTestId('add-摄像机').click();
}
await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3);
await expect(page.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });

// 数据已写入 OPFS 目录(真实文件系统校验,而非 IndexedDB)
expect(await opfsProjectFiles(page)).toHaveLength(1);

// 2. 导出工程包并校验内容(含三镜头)
const downloadPromise = page.waitForEvent('download');
await page.getByTestId('project-menu').click();
await page.getByTestId('project-export').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('示例项目.lumora');
const tmpDir = join(HERE, '.tmp');
mkdirSync(tmpDir, { recursive: true });
const exportPath = join(tmpDir, 'tml90-opfs-export.lumora');
await download.saveAs(exportPath);
const pkg = JSON.parse(readFileSync(exportPath, 'utf8')) as {
project: { objects: Array<{ type: string }> };
};
expect(pkg.project.objects.filter((o) => o.type === 'camera')).toHaveLength(3);

// 3. 清空本地数据:卸载 Studio → 删除 OPFS 根目录 → 重新挂载
await page.getByTestId('project-menu').click(); // 收起菜单
await page.getByTestId('studio-mount-toggle').click();
await expect(page.getByTestId('studio-placeholder')).toBeVisible();
await clearOpfs(page);
await page.getByTestId('studio-mount-toggle').click();
await expect(page.getByTestId('open-sample-project')).toBeVisible();
await page.getByTestId('project-menu').click();
await expect(page.getByText('暂无本地项目')).toBeVisible();

// 4. 导入工程包:数据与引用完整恢复
await page.setInputFiles('[data-testid="project-import-input"]', exportPath);
await expect(page.getByTestId('studio-empty-hint')).not.toBeVisible();
await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3);
await expect(page.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });
expect(await opfsProjectFiles(page)).toHaveLength(1);

// 5. 已持久化(OPFS):刷新后可从最近项目重新打开
await page.reload();
await page.getByTestId('project-menu').click();
await expect(page.getByTestId('recent-project')).toContainText('示例项目');
await page.locator('[data-testid="recent-project"] .lumora-project-menu__recent-open').click();
await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible();
await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3);
});

test('OPFS 后端 AC2:跨标签页冲突按 CAS 拒绝,显式「加载较新版本」解决', async ({ context }) => {
// 同一 context 的两个页面共享 OPFS 与 Web Locks:模拟两个标签页编辑同一项目
const pageA = await context.newPage();
await pageA.goto('/?storage=opfs');
await pageA.getByTestId('open-sample-project').click();
await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });

// A 新增一台摄像机并等待落盘(rev1)
await pageA.getByTestId('add-object').click();
await pageA.getByTestId('add-摄像机').click();
await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2);
await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });

// B 打开同 uri 示例项目:对账发现本地已存 rev1 ≠ 打开 rev0 → 立即冲突
const pageB = await context.newPage();
await pageB.goto('/?storage=opfs');
await pageB.getByTestId('open-sample-project').click();
await expect(pageB.getByTestId('save-state-badge')).toHaveText(/保存失败/, { timeout: 6000 });

// B 继续编辑使本地计数追平(rev1):仍冲突,绝不覆盖 A 的较新内容
await pageB.getByTestId('add-object').click();
await pageB.getByTestId('add-摄像机').click();
await expect(pageB.getByTestId('save-state-badge')).toHaveText(/保存失败/, { timeout: 6000 });
await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存');
await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2);

// B 显式解决「加载较新版本」:内容切换为 A 的已存内容,冲突解除
await pageB.getByTestId('save-reload').click();
await expect(pageB.locator('.lumora-tree-row__type--camera')).toHaveCount(2);
await expect(pageB.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });

// 解决后 B 可正常保存(基于已存内容追加,rev2)
await pageB.getByTestId('add-object').click();
await pageB.getByTestId('add-摄像机').click();
await expect(pageB.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 });
await expect(pageB.locator('.lumora-tree-row__type--camera')).toHaveCount(3);
await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2);
});
5 changes: 4 additions & 1 deletion examples/embedded-host/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const PLUGINS: PluginDescriptor[] = [mockPlugin, brokenManifestPlugin, brokenEng
/** 默认只记录事件摘要;?debug=full 时输出完整 payload(大数据量下会产生 GB 级字符串,仅限调试) */
const DEBUG_FULL = new URLSearchParams(window.location.search).get('debug') === 'full';

/** 本地存储后端选择:?storage=opfs 使用 OPFS,缺省 IndexedDB(持久化门面可切换,TML-53 范围项) */
const STORAGE = new URLSearchParams(window.location.search).get('storage') === 'opfs' ? 'opfs' : 'indexeddb';

export default function App() {
const [mounted, setMounted] = useState(true);
const [log, setLog] = useState<string[]>([]);
Expand Down Expand Up @@ -133,7 +136,7 @@ export default function App() {
</header>
<div className="host__layout">
{mounted ? (
<LumoraStudio ref={handleRef} plugins={PLUGINS} hostVersion="0.1.0" className="host__studio" />
<LumoraStudio ref={handleRef} plugins={PLUGINS} hostVersion="0.1.0" storage={STORAGE} className="host__studio" />
) : (
<div className="host__placeholder" data-testid="studio-placeholder">
Studio 已卸载 —— WebGL 场景、插件贡献项与事件订阅均已释放
Expand Down
7 changes: 5 additions & 2 deletions packages/studio/src/components/LumoraStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PluginDescriptor, Project } from '@lumora/core';
import { createStudioRuntime } from '../runtime/studio-runtime';
import type { StudioRuntime } from '../runtime/studio-runtime';
import { useSceneEditor } from '../hooks/use-scene-editor';
import type { StorageBackend } from '../persistence/project-storage';
import { PanelHost } from './panels/PanelHost';
import { Toolbar } from './Toolbar';
import { CommandPalette } from './CommandPalette';
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface LumoraStudioProps {
onError?: (error: unknown) => void;
/** 场景槽位,缺省为内置 3D 场景编辑器视口 */
scene?: (project: Project | null) => ReactNode;
/** 本地存储后端(缺省 indexeddb;opfs = Origin Private File System) */
storage?: StorageBackend;
className?: string;
}

Expand All @@ -45,7 +48,7 @@ export interface LumoraStudioHandle {
* - 卸载时释放全部资源:停用插件、移除订阅、销毁事件总线、资源缓存与 WebGL 场景
*/
export const LumoraStudio = forwardRef<LumoraStudioHandle, LumoraStudioProps>(function LumoraStudio(
{ plugins = [], hostVersion, initialProject, onError, scene, className },
{ plugins = [], hostVersion, initialProject, onError, scene, storage, className },
ref,
) {
const runtimeRef = useRef<StudioRuntime | null>(null);
Expand Down Expand Up @@ -81,7 +84,7 @@ export const LumoraStudio = forwardRef<LumoraStudioHandle, LumoraStudioProps>(fu
const onErrorRef = onError;
const boot = async () => {
// 先接入本地持久化与自动保存,再打开初始项目(自动保存脏基线以打开为准)
await runtime.init();
await runtime.init({ storage });
for (const descriptor of pluginsRef) {
if (cancelBootRef.current) return;
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/ProjectMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MAX_PACKAGE_TEXT_BYTES, genId } from '@lumora/core';
import type { Project } from '@lumora/core';
import type { StudioRuntime } from '../runtime/studio-runtime';
import type { AutosaveState } from '../persistence/autosave';
import type { ProjectSummary } from '../persistence/project-store';
import type { ProjectSummary } from '../persistence/project-storage';
import { showToast } from './editor/toasts';

interface ProjectMenuProps {
Expand Down
9 changes: 7 additions & 2 deletions packages/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ export { buildScene, syncScene } from './components/editor/scene-builder';
export { PanelErrorBoundary } from './components/panels/PanelErrorBoundary';
export { createStudioRuntime } from './runtime/studio-runtime';
export type { StudioRuntime, StudioRuntimeOptions } from './runtime/studio-runtime';
export { ProjectStore, estimateStorage } from './persistence/project-store';
export { ProjectStore } from './persistence/project-store';
export { OpfsProjectStore } from './persistence/project-store-opfs';
export { estimateStorage } from './persistence/project-storage';
export type {
DuplicateOutcome,
ProjectStorage,
ProjectSummary,
RenameOutcome,
SaveOutcome,
StorageBackend,
StoredProject,
} from './persistence/project-store';
} from './persistence/project-storage';
export { ProjectAutosaver, AUTOSAVE_DEBOUNCE_MS } from './persistence/autosave';
export type { AutosaverOptions, AutosaveState } from './persistence/autosave';
export { ProjectPersistence } from './persistence/project-persistence';
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/persistence/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*/

import type { Project, SceneEditor } from '@lumora/core';
import type { ProjectStore, SaveFailureCode, SaveOutcome } from './project-store';
import type { ProjectStorage, SaveFailureCode, SaveOutcome } from './project-storage';

export const AUTOSAVE_DEBOUNCE_MS = 2000;

Expand All @@ -49,7 +49,7 @@ interface LatchedError {
}

export class ProjectAutosaver {
private store: ProjectStore | null;
private store: ProjectStorage | null;
private currentUri: string | null = null;
private lastSavedRevision = 0;
/** 各 uri 已确认落盘的 revision(含已切换走的项目:旧项目保存成功也推进基线)。
Expand All @@ -74,7 +74,7 @@ export class ProjectAutosaver {

constructor(
private readonly editor: SceneEditor,
store: ProjectStore | null,
store: ProjectStorage | null,
options: AutosaverOptions = {},
) {
this.store = store;
Expand All @@ -96,7 +96,7 @@ export class ProjectAutosaver {
}

/** 持久化就绪后接入(init 完成):对当前打开的项目重新对账(冷启动不丢事件)。 */
setStore(store: ProjectStore | null): void {
setStore(store: ProjectStorage | null): void {
this.store = store;
const project = this.editor.getProject();
if (this.disposed) return;
Expand Down
28 changes: 19 additions & 9 deletions packages/studio/src/persistence/project-persistence.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/**
* 项目持久化门面(FR-001 / FR-011):StudioRuntime 与 UI 之间的统一入口。
*
* - 本地存储:IndexedDB ProjectStore(新建/重命名/复制/删除/最近项目);
* - 本地存储:IndexedDB ProjectStore 或 OPFS OpfsProjectStore(init 可配置后端,
* 缺省 IndexedDB;新建/重命名/复制/删除/最近项目);
* - 自动保存:ProjectAutosaver 随编辑器事件防抖落盘(2 秒),失败保持脏状态;
* - 工程包:导出(buildProjectPackage 剥离私有数据 + 配额预检)与导入
* (parseProjectPackage 纯函数解析,校验失败不产生任何副作用 —— 当前项目
* 保持原样,失败回滚由「解析通过后才打开」保证);
* - 冲突解决:reloadOpenProject(以存储内容为基线重开,显式丢弃未保存变更)
* 与 duplicateProject(打开中的项目以编辑器快照为准复制,磁盘记录可能落后)。
*
* 编辑器监听在构造期同步接入:IndexedDB 打开前的冷启动变更(project:changed)
* 编辑器监听在构造期同步接入:存储打开前的冷启动变更(project:changed)
* 不会丢失(自动保存先以「仅内存」状态受理,init 完成后重新对账)。
*/

Expand All @@ -24,8 +25,10 @@ import {
import type { MissingAssetWarning, PackageImportError } from '@lumora/core';
import { ProjectAutosaver } from './autosave';
import type { AutosaveState } from './autosave';
import { ProjectStore, estimateStorage } from './project-store';
import type { DuplicateOutcome, ProjectSummary, SaveOutcome } from './project-store';
import { ProjectStore } from './project-store';
import { OpfsProjectStore } from './project-store-opfs';
import { estimateStorage } from './project-storage';
import type { DuplicateOutcome, ProjectStorage, ProjectSummary, SaveOutcome, StorageBackend } from './project-storage';

export interface PersistenceEventMap extends Record<string, unknown> {
'save-state': { state: AutosaveState };
Expand All @@ -50,7 +53,7 @@ function safeFilename(name: string): string {
}

export class ProjectPersistence {
private store: ProjectStore | null = null;
private store: ProjectStorage | null = null;
private readonly autosaver: ProjectAutosaver;
private unsubscribeEditor: { dispose(): void } | null = null;
private currentUri: string | null = null;
Expand All @@ -73,20 +76,27 @@ export class ProjectPersistence {
});
}

/** 本地持久化是否可用(IndexedDB 打开失败时静默降级为仅内存编辑) */
/** 本地持久化是否可用(存储打开失败时静默降级为仅内存编辑) */
get available(): boolean {
return this.store !== null;
}

/** 实际生效的存储后端(init 前为 null) */
get backend(): StorageBackend | null {
return this.store?.kind ?? null;
}

/** 打开项目后的当前 uri(重命名/复制等操作据此分流) */
get openUri(): string | null {
return this.currentUri;
}

/** 初始化:打开存储并接入自动保存。幂等。 */
async init(options: { debounceMs?: number; dbName?: string } = {}): Promise<void> {
/** 初始化:打开存储并接入自动保存。幂等;storage 缺省为 indexeddb。 */
async init(options: { debounceMs?: number; dbName?: string; storage?: StorageBackend } = {}): Promise<void> {
if (this.disposed || this.store) return;
this.store = await ProjectStore.create(options.dbName);
const backend = options.storage ?? 'indexeddb';
this.store =
backend === 'opfs' ? await OpfsProjectStore.create(options.dbName) : await ProjectStore.create(options.dbName);
this.autosaver.setStore(this.store);
if (options.debounceMs !== undefined) this.autosaver.setDebounceMs(options.debounceMs);
}
Expand Down
Loading