Skip to content

可运行示例与消费者验证

七个 Node 示例和一个浏览器存储示例只使用安装后的公开包,不依赖仓库源码别名或测试工具包。把仓库的 examples/ 目录复制到仓库外,在复制后的目录执行:

bash
npm install
node node-inspect-validate/index.mjs
node revision-checked-edit-save/index.mjs
node publish-restore/index.mjs
node mcp-stdio-client/index.mjs
node reward-panel-states/index.mjs
node reward-panel-layout/index.mjs
node reward-card-generation/index.mjs

不传参数时会创建独立的临时工程,并在 JSON 输出中给出 projectPath;文件保留供检查。前两个示例也可传入 .fairy 路径。第二个示例会修改传入的工程,且要求 Main/MainView/title 结构,请只对工程副本执行。第三、第五至第七个命令只创建自己的示例,不接受用户目录覆盖;验证当前分支未发布的代码请用下方 pack:check,不能把 registry 版本当作当前源码。

三状态奖励面板

这是首个编辑任务的完整 SDK 实现,稳定版 0.6.1 即可运行。创建两包临时工程后,从 outline 获取 Main/RewardPanelclaimButtonclaimedMark 的唯一 ID,用一个事务新增 rewardState 控制器和 text / look / display 三个 gear,控制未达成、可领取、已领取三种状态。现有布局、其他组件和 Shared 包图片均保留。

让 Agent 自己完成任务时,只创建待编辑工程:

bash
node reward-panel-states/index.mjs --create

运行 node reward-panel-states/index.mjs(或 npm run reward)则会新建另一份独立工程并完成 SDK 编辑、验证、保存和 UAM 回读。两种命令均输出实际 projectPath;无参数命令不会继续编辑上一次 --create 的工程。

js
export async function editRewardPanel(projectPath, runtime = createNodeBackendRuntime({
	allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
	const { sessionId } = data(await runtime.openSession({ projectPath }));
	let keepOpen = false;
	try {
		const outline = data(await runtime.getProjectOutline({ sessionId }));
		const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
		assert.equal(packages.length, 1, 'Expected one Main package');
		const components = packages[0].resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardPanel');
		assert.equal(components.length, 1, 'Expected one Main/RewardPanel component');
		const selector = { packageId: packages[0].id, componentResourceId: components[0].id };
		const targets = {};
		for (const name of ['claimButton', 'claimedMark']) {
			const nodes = components[0].component.displayList.filter((node) => node.name === name);
			assert.equal(nodes.length, 1, `Expected one ${name} node`);
			targets[name] = data(await runtime.queryEntity({ sessionId,
				target: { kind: 'displayNode', selector: { ...selector, displayNodeId: nodes[0].id } },
			}));
		}
		assert.equal(targets.claimButton.revision, targets.claimedMark.revision, 'Refresh targets after a concurrent edit');
		const controllerName = 'rewardState';
		const pages = [
			{ id: 'locked', name: 'Locked', remark: '' },
			{ id: 'claimable', name: 'Claimable', remark: '' },
			{ id: 'claimed', name: 'Claimed', remark: '' },
		];
		const common = { name: '', controllerName, condition: '', positionsInPercent: false,
			tween: false, tweenDuration: 0.3, tweenDelay: 0, easeType: 5, customEasePath: '' };
		const transaction = { sessionId, expectedRevision: targets.claimButton.revision, operations: [
			{ kind: 'addController', selector: { ...selector, controllerName }, controller: {
				name: controllerName, selectedIndex: 0, autoRadioGroupDepth: false, alias: '', exported: true,
				homePageType: 'default', homePage: '', pages, actions: [],
			} },
			{ kind: 'addGear', selector: { ...targets.claimButton.target.selector, kind: 'text', controllerName }, gear: {
				...common, kind: 'text', defaultValue: { text: '未达成' },
				states: pages.map((page, index) => ({ pageId: page.id, value: { text: ['未达成', '领取奖励', '已领取'][index] } })),
			} },
			{ kind: 'addGear', selector: { ...targets.claimButton.target.selector, kind: 'look', controllerName }, gear: {
				...common, kind: 'look', defaultValue: { alpha: 1, rotation: 0, grayed: true, touchable: false },
				states: pages.map((page, index) => ({ pageId: page.id, value: { alpha: 1, rotation: 0, grayed: index !== 1, touchable: index === 1 } })),
			} },
			{ kind: 'addGear', selector: { ...targets.claimedMark.target.selector, kind: 'display', controllerName },
				gear: { kind: 'display', name: '', controllerName, visibleOnPageIds: ['claimed'] } },
		] };
		const preview = data(await runtime.preflightTransaction(transaction));
		const changed = data(await runtime.applyTransaction(transaction));
		keepOpen = true;
		const validation = data(await runtime.validateSession({ sessionId }));
		if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
		const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
		const project = await readProjectAsUam(new NodeIO(), projectPath);
		keepOpen = false;
		return { projectPath, selector, revision: saved.revision, dirty: saved.dirty, preview, validation, project };
	} catch (cause) {
		if (!keepOpen) throw cause;
		throw Object.assign(new Error('Reward panel edit needs host recovery; the session remains open.', { cause }), {
			recovery: { runtime, sessionId, projectPath },
		});
	} finally {
		if (!keepOpen) data(await runtime.closeSession({ sessionId }));
	}
}

导入 editRewardPanel(projectPath, runtime?) 可由宿主驱动同一流程。提交后的验证、保存或回读失败保留会话并抛出 recovery: { runtime, sessionId, projectPath }cause 保留失败报告;按下方单字段编辑示例的恢复规则处理。再次执行同一任务会因已有控制器而拒绝,宿主应先查询并重新规划。

消费者检查在 SDK 与真实 MCP stdio 上分别执行这段编辑函数:预演不写盘,保存后独立对比完整 UAM,仅允许新增指定控制器和三组 gear;完整文件清单不变,仅 assets/Main/RewardPanel.xml 字节改变,其他文件(包括 PNG)不变;重新打开可查询控制器,重复任务被拒绝且不改文件。这些检查不包含 FairyGUI 渲染或实际点击,视觉验收按三状态表与流程在编辑器或运行时执行。

奖励面板布局与入场动画

进阶任务 B 复用 A 已保存的三状态面板,调整留白、尺寸与位置,并新增一次性入场动画。稳定版 0.6.1 即可运行;控制器、gear、文案及图片字节保持不变。

让 Agent 完成 B 时,先创建一份已经完成 A、尚未改版的工程:

bash
node reward-panel-layout/index.mjs --create

接入指南将输出 projectPath 的父目录加入 MCP 授权范围并重启连接,再交给 Agent:

<projectPath>Main/RewardPanel 改为 420 × 320,按下表调整五个子节点的布局。新增 intro 动画:30 fps,第 0 帧同时开始两个 12 帧的 QuadOut tween,让面板自身透明度从 0 到 1、位置偏移从 (0, 24) 到 (0, 0),入场自动播放一次,无延迟。保留三页 rewardState、全部 gear、文案、其他组件和资源字节。查询精确 ID 与当前 revision 后,以同一批七个操作预演、提交、验证、保存并重新打开核对。目标不唯一、缺少 A 的控制器、已有 intro、revision 冲突或验证不完整时停止并报告,保留已提交但未保存的工作。

节点原位置 → 新位置原尺寸 → 新尺寸
background(0, 0) → (0, 0)360 × 280 → 420 × 320
title(24, 24) → (32, 28)312 × 32 → 356 × 36
rewardIcon(152, 80) → (178, 104)56 × 56 → 64 × 64
claimButton(80, 160) → (110, 204)200 × 48,保持不变
claimedMark(24, 228) → (32, 272)312 × 28 → 356 × 28

七个操作为一个 setComponentProps、五个 setDisplayNodeProps 和一个 addTransition。UAM 动画时间与 duration 使用帧;12 / 30 = 0.4 秒。两个 item 的 targetNodeId 为空,作用于面板自身;位移相对于宿主放置面板的位置。动画不负责奖励发放或控制器切页。

js
export async function redesignRewardPanel(projectPath, runtime = createNodeBackendRuntime({
	allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
	const { sessionId } = data(await runtime.openSession({ projectPath }));
	let keepOpen = false;
	try {
		const outline = data(await runtime.getProjectOutline({ sessionId }));
		const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
		assert.equal(packages.length, 1, 'Expected one Main package');
		const components = packages[0].resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardPanel');
		assert.equal(components.length, 1, 'Expected one Main/RewardPanel component');
		const selector = { packageId: packages[0].id, componentResourceId: components[0].id };
		const current = data(await runtime.queryEntity({ sessionId, target: { kind: 'component', selector } }));
		const controller = data(await runtime.queryEntity({ sessionId, target: { kind: 'controller', selector: { ...selector, controllerName: 'rewardState' } } }));
		assert.equal(controller.revision, current.revision, 'Refresh after a concurrent edit');
		assert.deepEqual(controller.entity.properties.pages.map((page) => page.name), ['Locked', 'Claimable', 'Claimed'], 'Complete task A before this example');
		assert(!components[0].component.transitions.some((transition) => transition.name === 'intro'), 'An intro transition already exists; query and replan');
		const layout = {
			background: { size: { width: 420, height: 320 } },
			title: { position: { x: 32, y: 28 }, size: { width: 356, height: 36 } },
			rewardIcon: { position: { x: 178, y: 104 }, size: { width: 64, height: 64 } },
			claimButton: { position: { x: 110, y: 204 } },
			claimedMark: { position: { x: 32, y: 272 }, size: { width: 356, height: 28 } },
		};
		const operations = [{ kind: 'setComponentProps', selector, props: { size: { width: 420, height: 320 } } }];
		for (const [name, props] of Object.entries(layout)) {
			const nodes = components[0].component.displayList.filter((node) => node.name === name);
			assert.equal(nodes.length, 1, `Expected one ${name} node`);
			const target = data(await runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector: { ...selector, displayNodeId: nodes[0].id } } }));
			assert.equal(target.revision, current.revision, 'Refresh targets after a concurrent edit');
			operations.push({ kind: 'setDisplayNodeProps', selector: target.target.selector, props });
		}
		// UAM timing is in frames: 12 frames / 30 fps = 0.4 seconds. Empty target means this component.
		const item = { name: '', time: 0, targetNodeId: '', tween: true, duration: 12,
			easeType: EaseType.QuadOut, repeat: 0, yoyo: false, endLabel: '', path: '', customEasePath: '' };
		operations.push({ kind: 'addTransition', selector: { ...selector, transitionName: 'intro' }, transition: {
			name: 'intro', autoPlay: true, autoPlayTimes: 1, autoPlayDelay: 0, options: 0, fps: 30,
			items: [
				{ ...item, actionType: TransitionActionType.Alpha, startValue: [0], endValue: [1], label: 'fade-in' },
				{ ...item, actionType: TransitionActionType.XY, startValue: [0, 24], endValue: [0, 0], label: 'slide-up' },
			],
		} });
		const transaction = { sessionId, expectedRevision: current.revision, operations };
		const preview = data(await runtime.preflightTransaction(transaction));
		const changed = data(await runtime.applyTransaction(transaction));
		keepOpen = true;
		const validation = data(await runtime.validateSession({ sessionId }));
		if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
		const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
		const project = await readProjectAsUam(new NodeIO(), projectPath);
		keepOpen = false;
		return { projectPath, selector, revision: saved.revision, dirty: saved.dirty, preview, validation, project };
	} catch (cause) {
		if (!keepOpen) throw cause;
		throw Object.assign(new Error('Reward panel redesign needs host recovery; the session remains open.', { cause }), {
			recovery: { runtime, sessionId, projectPath },
		});
	} finally {
		if (!keepOpen) data(await runtime.closeSession({ sessionId }));
	}
}

redesignRewardPanel(projectPath, runtime?) 可由宿主导入;提交后失败的恢复句柄和处理方式与 A 相同。直接运行 node reward-panel-layout/index.mjs(或 npm run reward-layout)则会创建另一份独立工程,完成 B,并分别发布修改前后的 .fui 和图集。JSON 的 before.files / after.files 给出真实文件路径;两个发布目录在工程目录之外,不覆盖原工程,也不会继续编辑先前 --create 的结果。

渲染与动画验收

在已有 FairyGUI/LayaAir 宿主中,分别加载两个发布目录的包,创建 Main/RewardPanel;先设置宿主位置,再加入舞台。修改后自动播放一次 intro,也可用 panel.getTransition('intro').play() 重播。编辑器可直接打开 projectPath 检查工程和时间轴。

下图为同一 520 × 420 视口、Claimable 页的真实发布产物截图,使用 OpenFairyGUI 0.4.0、LayaAir 3.3.10 / FairyGUI 和 Chromium 151.0.7922.34

修改前修改后动画中点(0.2 秒)
360 × 280 原面板420 × 320 改版面板透明度 0.75,向下偏移 6 的动画中点
动画时间面板透明度相对宿主位置的偏移
0 秒0(0, 24)
0.2 秒0.75(0, 6)
0.4 秒1(0, 0)

该次原生运行时验收已检查自动播放结束、上述时间点、A 的全部三页,以及只有 Claimable 页响应真实鼠标点击;控制台无错误。更换工程、样式或运行时后,应重新执行这些视觉检查。

pack:check 中的 B 检查覆盖 SDK 与真实 MCP 的查询、预演、保存回读、完整 UAM/文件比较、重复任务拒绝,以及发布二进制中的尺寸和 0.4 秒动画。B 仅改变 assets/Main/RewardPanel.xml,其他文件字节不变。上述 FairyGUI 截图验收是单独执行的;消费者门禁中的 Chromium 测试仍是下方 OPFS 存储页面,不能混为一项渲染门禁。

从模板生成奖励卡片

任务 C 从已有 Main/RewardCardTemplate 生成三个导出的组件,稳定版 0.6.1 即可运行。每个新组件只有一个引用模板的 Label 实例,以正式实例属性设置标题与图标;模板的子节点保持在原组件中。图标引用现有 Shared 包的两张 2 × 2 红蓝 PNG,它们是用于验证资源复用的色块。

先创建包含模板与图片、尚未生成卡片的独立工程(不要求先运行 A/B):

bash
node reward-card-generation/index.mjs --create

接入指南授权实际 projectPath 后,将下列任务和配置表交给 Agent:

<projectPath> 的 Main 包中新增表中的三个导出组件。查询 RewardCardTemplate 和 Shared 图片的唯一 ID,核实模板为含 title 文本及 icon Loader 的 Label,图片已经导出。每个新组件与模板同尺寸(示例为 240 × 180),仅包含位于 (0, 0)、同尺寸的 card 组件实例,引用原模板并配置 Label 的 title/icon;图标 URL 使用实际包与资源 ID。保留已有资源,不复制模板子节点或图片。查询结果需属于同一 revision,以三个 addComponent 一次预演、提交,完整验证后保存并重新打开核对。目标不唯一、ID/名称已占用、revision 冲突或验证不完整时停止并报告,保留已提交但未保存的工作。

新组件稳定资源 ID标题现有图片
DailyRewardCardcardday1每日奖励 ×100Shared/red
WeeklyRewardCardcardweek连签奖励 ×500Shared/blue
BonusRewardCardcardbon1额外奖励 ×20Shared/red
js
const cards = [
	{ id: 'cardday1', name: 'DailyRewardCard', title: '每日奖励 ×100', image: 'red' },
	{ id: 'cardweek', name: 'WeeklyRewardCard', title: '连签奖励 ×500', image: 'blue' },
	{ id: 'cardbon1', name: 'BonusRewardCard', title: '额外奖励 ×20', image: 'red' },
];

export async function generateRewardCards(projectPath, runtime = createNodeBackendRuntime({
	allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
	const { sessionId } = data(await runtime.openSession({ projectPath }));
	let keepOpen = false;
	try {
		const outline = data(await runtime.getProjectOutline({ sessionId }));
		const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
		const sharedPackages = outline.packages.filter((pkg) => pkg.name === 'Shared');
		assert.equal(packages.length, 1, 'Expected one Main package');
		assert.equal(sharedPackages.length, 1, 'Expected one Shared package');
		const main = packages[0]; const shared = sharedPackages[0];
		const templates = main.resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardCardTemplate');
		assert.equal(templates.length, 1, 'Expected one RewardCardTemplate');
		const selector = { packageId: main.id, componentResourceId: templates[0].id };
		const template = data(await runtime.queryEntity({ sessionId, target: { kind: 'component', selector } }));
		assert.equal(template.revision, outline.revision, 'Refresh after a concurrent edit');
		assert.equal(template.entity.properties.properties.extensionType, 'Label', 'Expected a Label template');
		for (const [name, kind] of [['title', 'text'], ['icon', 'loader']]) {
			const nodes = templates[0].component.displayList.filter((node) => node.name === name);
			assert.equal(nodes.length, 1, `Expected one child named ${name}`);
			assert.equal(nodes[0].kind, kind, `Expected ${name} to be a ${kind}`);
		}
		// Build only new wrappers through public Core defaults; do not copy or reconstruct the template.
		const document = new Document();
		const generated = document.createPackage(main.name).setId(main.id);
		const { width, height } = template.entity.properties.size;
		for (const card of cards) {
			assert(!main.resources.some((resource) => resource.id === card.id || resource.name === card.name), `Generated card already exists: ${card.name}; query and replan`);
			const images = shared.resources.filter((resource) => resource.kind === 'image' && resource.name === card.image);
			assert.equal(images.length, 1, `Expected one Shared/${card.image} image`);
			const icon = data(await runtime.queryEntity({ sessionId, target: { kind: 'resource', selector: { packageId: shared.id, resourceId: images[0].id } } }));
			assert.equal(icon.revision, outline.revision, 'Refresh resource references after a concurrent edit');
			assert(icon.entity.properties.exported, 'The icon must already be exported for its ui:// reference');
			const wrapper = document.createComponent(card.name).setId(card.id).setPath('/').setSize(width, height).setExported(true);
			wrapper.addChild(document.createGComponent('card').setId('card').setSrc(templates[0].id).setPackageId(main.id).setSize(width, height)
				.setInstanceExtType('Label').setInstanceTitle(card.title).setInstanceIcon(`ui://${shared.id}${images[0].id}`));
			generated.addResource(wrapper);
		}
		const resources = liftDocumentToUamProject(document).packages[0].resources;
		const transaction = { sessionId, expectedRevision: outline.revision, operations: resources.map((component, index) => ({
			kind: 'addComponent', selector: { packageId: main.id }, component, atIndex: main.resources.length + index,
		})) };
		const preview = data(await runtime.preflightTransaction(transaction));
		const changed = data(await runtime.applyTransaction(transaction));
		keepOpen = true;
		const validation = data(await runtime.validateSession({ sessionId }));
		if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
		const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
		const project = await readProjectAsUam(new NodeIO(), projectPath);
		keepOpen = false;
		return { projectPath, template: selector, generated: resources.map(({ id, name }) => ({ id, name })), revision: saved.revision, dirty: saved.dirty, preview, validation, project };
	} catch (cause) {
		if (!keepOpen) throw cause;
		throw Object.assign(new Error('Reward card generation needs host recovery; the session remains open.', { cause }), {
			recovery: { runtime, sessionId, projectPath },
		});
	} finally {
		if (!keepOpen) data(await runtime.closeSession({ sessionId }));
	}
}

宿主可导入 generateRewardCards(projectPath, runtime?);提交后失败的恢复方式与 A 相同。无参数运行 node reward-card-generation/index.mjs(或 npm run reward-cards)会新建另一份工程,完成生成、验证、保存、回读并发布;JSON 的 generated 给出新组件 ID/名称,published.files 给出 .fui 与图集路径。发布目录在工程目录外,不会继续编辑先前 --create 的结果。

生成结构与渲染验收

消费者检查在 SDK 与真实 MCP stdio 中分别运行生成函数:预演不写盘;仅新增三个组件 XML,并更新 assets/Main/package.xml。独立回读后移除三个新增组件,完整 UAM 必须等于生成前;所有已有文件(除 Main 的资源清单)逐字节不变,包括模板 XML、其他组件和 PNG。另行检查生成结构、标题、图标与模板引用、重新打开后的查询、重复任务拒绝,以及发布二进制中的引用与实例属性。

在实际 FairyGUI/LayaAir 宿主加载发布的 Shared/Main 包,分别创建模板与三个新组件。下图使用 OpenFairyGUI 0.4.0、LayaAir 3.3.10 / FairyGUI、Chromium 151.0.7922.34,按左上模板、右上每日奖励、左下连签奖励、右下额外奖励排列:

原模板与三个引用模板的奖励卡片

该次运行时验收核对了四个对象的文字、尺寸、模板 URL 和图标中心的红蓝 RGBA 像素;修改每日奖励的实例文字后,其他卡片与模板文字不变,控制台无错误。实例保留模板引用,模板样式的后续修改会影响全部实例;外层组件尺寸是生成时的模板尺寸,不自动跟随后续改尺寸。示例不包含奖励发放逻辑。

截图验收单独执行,未纳入 pack:check 的浏览器渲染门禁;更换模板、资源或运行时后需重新检查。UAM、XML、二进制或 dirty: false 检查不能代替实际渲染。

读取与校验

示例返回现有 InspectReport 和工程验证报告。退出码为:valid 为 0、invalid 为 1、incomplete 为 3。以下代码直接引用受消费者检查执行的源文件:

js
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { NodeIO } from '@openfairygui/core/node';
import { inspect } from '@openfairygui/functions';
import { validateProjectNode } from '@openfairygui/functions/node';
import { createDemoProject } from '../create-demo-project.mjs';

// #region example
export async function inspectAndValidate(projectPath) {
	const document = await new NodeIO().readProject(projectPath);
	return {
		inspection: inspect(document),
		validation: await validateProjectNode(projectPath),
	};
}
// #endregion example

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
	const projectPath = process.argv[2] ?? await createDemoProject();
	const result = await inspectAndValidate(projectPath);
	console.log(JSON.stringify({ projectPath, ...result }, null, 2));
	process.exitCode = result.validation.status === 'valid' ? 0 : result.validation.status === 'invalid' ? 1 : 3;
}

CLI 的对应机器入口是 ofgui inspect <工程路径> --jsonofgui validate <工程路径> --json。它们在统一 envelope 的 result 保留原报告,JSON 模式的读取失败也返回同一 envelope;stdout 不混入人类日志。完整结构、退出码及离线 schema 见 CLI 机器输出。无 --json 时保留终端报告格式。

带 revision 的修改、保存与回读

示例从 outline 获取资源 ID,再用 queryEntity 读取当前属性与 revision;将同一批文本修改先预演、再正式 apply,要求验证结果为 validcomplete: true,使用事务返回的 revision 保存,通过公开 Node I/O 重新读取工程,最后释放会话锁。预演不预留 revision。失败或验证不完整直接终止,不盲目重试 stale write。

提交后的验证、保存或回读失败会保留打开的会话,并抛出带有 recovery: { runtime, sessionId, projectPath } 的错误;cause 链保留原始错误及 Backend/验证报告。导入 editAndSave 的宿主应捕获错误,处理故障后用同一会话重新验证、明确保存,再关闭;可通过第三个参数传入宿主的 runtime。提交前失败会关闭干净会话。恢复句柄仅在当前进程内有效,独立命令失败退出后不会持久保存内存编辑。

js
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { readProjectAsUam } from '@openfairygui/core';
import { NodeIO } from '@openfairygui/core/node';
import { createNodeBackendRuntime } from '@openfairygui/backend/node';
import { createDemoProject } from '../create-demo-project.mjs';

function data(result) {
	if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`, { cause: result });
	return result.data;
}

// #region example
export async function editAndSave(projectPath, text = 'Saved by a consumer', runtime = createNodeBackendRuntime({
	allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
	const opened = data(await runtime.openSession({ projectPath }));
	const sessionId = opened.sessionId;
	let keepOpen = false;
	try {
		const outline = data(runtime.getProjectOutline({ sessionId }));
		const pkg = outline.packages.find((entry) => entry.name === 'Main');
		const component = pkg?.resources.find((entry) => entry.name === 'MainView' && entry.kind === 'component');
		const title = component?.component?.displayList.find((entry) => entry.name === 'title' && entry.kind === 'text');
		if (!title) throw new Error('This example expects Main/MainView with a text node named title.');
		const selector = { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id };
		const current = data(runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector } }));
		const transaction = {
			sessionId, expectedRevision: current.revision,
			operations: [{ kind: 'setDisplayNodeProps', selector, props: { text } }],
		};
		// Preview executes on an isolated snapshot; apply still rechecks this revision.
		data(await runtime.preflightTransaction(transaction));
		const changed = data(await runtime.applyTransaction(transaction));
		keepOpen = true;
		const validation = data(runtime.validateSession({ sessionId }));
		if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
		const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
		const project = await readProjectAsUam(new NodeIO(), projectPath);
		keepOpen = false;
		return { selector, revision: saved.revision, dirty: saved.dirty, project };
	} catch (cause) {
		if (!keepOpen) throw cause;
		// The host must handle this live session before closing it; do not retry or discard edits here.
		throw Object.assign(new Error('Edit/save failed; the session remains open for recovery.', { cause }), {
			recovery: { runtime, sessionId, projectPath },
		});
	} finally {
		if (!keepOpen) data(await runtime.closeSession({ sessionId }));
	}
}
// #endregion example

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
	const projectPath = process.argv[2] ?? await createDemoProject();
	console.log(JSON.stringify({ projectPath, ...await editAndSave(projectPath, process.argv[3]) }, null, 2));
}

发布、读取产物与受限恢复

第三个示例创建两包工程,包含文字、组件、两张图片及跨包引用,通过 publishNode 发布,从返回的真实文件清单读取二进制,再将自己生成的可信产物恢复到独立目录,回读并要求完整验证通过。对比包/资源 ID、组件几何与文字、跨包引用;不比较原工程标识、编辑器本地状态或 XML 文本。

js
import assert from 'node:assert/strict';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { liftDocumentToUamProject, writeProjectFromUam } from '@openfairygui/core';
import { NodeIO } from '@openfairygui/core/node';
import { publishNode, restoreNode, validateProjectNode } from '@openfairygui/functions/node';
import { createDemoProject } from '../create-demo-project.mjs';

// Two opaque 2x2 PNGs make atlas references and recovered pixels independently checkable.
export const IMAGE_BYTES = {
	red: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=',
	blue: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEElEQVR4nGNgYPj/H4KhDAA/0gf5tBJPzQAAAABJRU5ErkJggg==',
};

export async function createPublishProject(parent) {
	const projectPath = await createDemoProject(parent);
	const document = await new NodeIO().readProject(projectPath);
	document.getRoot().setProjectType(4);
	const main = document.getRoot().listPackages()[0].listComponents()[0];
	const shared = document.createPackage('Shared').setId('pkgshare');
	const badge = document.createComponent('Badge').setId('badge').setPath('/').setSize(24, 24).setExported(true);
	shared.addResource(badge);
	for (const color of Object.keys(IMAGE_BYTES)) {
		shared.addResource(document.createImageResource(color).setId(color).setPath('/').setFileName(`${color}.png`).setWidth(2).setHeight(2).setExported(true));
		badge.addChild(document.createGImage(color).setId(color).setSrc(color).setXY(color === 'red' ? 0 : 4, 0).setSize(2, 2));
	}
	main.addChild(document.createGImage('icon').setId('icon').setSrc('red').setPackageId('pkgshare').setXY(8, 8).setSize(2, 2));
	main.addChild(document.createGComponent('badge').setId('badge-instance').setSrc('badge').setPackageId('pkgshare').setXY(48, 72).setSize(24, 24));
	const project = liftDocumentToUamProject(document);
	for (const resource of project.packages.find((pkg) => pkg.id === 'pkgshare').resources) {
		if (resource.kind === 'image') resource.sourceBytes = Uint8Array.from(Buffer.from(IMAGE_BYTES[resource.id], 'base64'));
	}
	await writeProjectFromUam(new NodeIO(), project, projectPath);
	return projectPath;
}

// Deliberately compares supported runtime semantics, not editor-local state or original XML spelling.
export function supportedSemantics(document) {
	return document.getRoot().listPackages().map((pkg) => ({
		id: pkg.getId(), resources: pkg.listResources().map((resource) => ({
			id: resource.getId(), kind: resource.propertyType,
			size: [resource.getWidth(), resource.getHeight()],
			...(resource.propertyType === 'Component' ? { children: resource.listChildren().map((node) => ({
				id: node.getId(), kind: node.propertyType, name: node.getName(),
				position: [node.getX(), node.getY()], size: [node.getWidth(), node.getHeight()],
				...(node.getText ? { text: node.getText() } : {}),
				...(node.getSrc?.() ? { reference: { packageId: node.getPackageId() || pkg.getId(), resourceId: node.getSrc() } } : {}),
			})) } : {}),
		})).sort((a, b) => a.id.localeCompare(b.id)),
	})).sort((a, b) => a.id.localeCompare(b.id));
}

export function mergePublishedPackages(packages) {
	// NodeIO reads one binary at a time and includes empty dependency placeholders.
	const byId = new Map();
	for (const pkg of packages) {
		const previous = byId.get(pkg.id);
		assert(!previous?.resources.length || !pkg.resources.length, `Duplicate populated package: ${pkg.id}`);
		if (!previous || pkg.resources.length) byId.set(pkg.id, pkg);
	}
	return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
}

// #region example
export async function publishAndRestore(projectPath) {
	const io = new NodeIO();
	const document = await io.readProject(projectPath);
	const expected = supportedSemantics(document);
	document.setLogger({ debug() {}, info() {}, warn: console.error, error: console.error });
	const output = path.join(path.dirname(projectPath), 'release');
	const published = await publishNode({ document, output, plugins: [], codeGeneration: false });
	const packages = [];
	for (const file of published.files) {
		assert.equal((await stat(file.path)).size, file.size);
		if (file.path.endsWith('.fui')) packages.push(...supportedSemantics(await io.readBinary(file.path)));
	}
	assert.deepEqual(mergePublishedPackages(packages), expected);
	// Trusted artifacts produced above; never point this at unknown third-party downloads.
	const restored = await restoreNode({ inputDir: output, output: path.join(path.dirname(projectPath), 'restored'), projectType: 4 });
	assert.deepEqual(supportedSemantics(await io.readProject(restored.projectPath)), expected);
	const validation = await validateProjectNode(restored.projectPath);
	assert.equal(validation.status, 'valid');
	assert.equal(validation.complete, true);
	return { projectPath, published, restored: { projectPath: restored.projectPath, warnings: restored.warnings }, validation };
}
// #endregion example

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
	console.log(JSON.stringify(await publishAndRestore(await createPublishProject()), null, 2));
}

ofgui publish <工程> -o <发布目录> --project-type layabox --json 返回 {schemaVersion:1,command:"publish",success:true,result:{files:[{path,size}]}};文件清单由 Node 工作流在实际写入后产生,路径是最终绝对路径、size 为字节数,不含未改动旧文件或任意插件私有 I/O。显式运行时输出目录原子提交,不扩大到独立 codegen 路径或插件副作用。

ofgui restore <可信发布目录> -o <独立工程目录> --json 返回 {schemaVersion:1,command:"restore",success:true,result:{projectPath,packages:[{id,name}],warnings}}。两命令退出 0 表示工作流成功,1 为工作流失败,2 为命令语法错误;错误 JSON 使用 success:falseerror:{code,message},code 为 publish_failedrestore_failedinvalid_arguments。JSON 模式的人类日志进入 stderr,stdout 只有一个结果;帮助仍为文本。恢复成功及 warnings 不替代回读验证。

恢复只接受可信本地产物;输出必须为独立目录,默认拒绝覆盖,--force 也须等暂存恢复完成才替换旧目录。完整限制见恢复边界,安装后可离线读 ofgui docs cat restore-limits --json。不承诺还原发布物未携带的源码信息。

MCP stdio 客户端

第四个命令复用正式 MCP SDK 启动安装后的 @openfairygui/mcp/stdio,不依赖全局可执行文件、shell 命令拼接或 HTTP 端口。它发现工具和版本绑定文档,显式限制 OPENFAIRYGUI_ALLOWED_PROJECT_ROOTS,从 outline 取得 Main/MainView/title 的精确 ID,读取当前 revision 并预演文本修改。它不执行 apply/save,断言查询结果和干净会话保持不变,并在 finally 中关闭会话和 stdio 连接。

可传入有上述结构的 .fairy 文件;没有参数时新建独立样例。打开文件会话仍需短暂持有锁,不会绕过已有锁。pack:check 直接执行此文件,核对完整工程文件未变,并由后续会话打开证明锁已释放。SDK 是示例显式声明的消费者依赖,不加入产品的新抽象层。

js
export async function inspectThroughMcp(projectPath) {
	const root = path.dirname(await realpath(projectPath));
	// The installed public stdio export avoids global executables, shell quoting and assumed HTTP ports.
	const transport = new StdioClientTransport({
		command: process.execPath,
		args: ['--input-type=module', '--eval', 'const m = await import(process.argv[1]); await m.connectOpenFairyGuiMcpStdio();', import.meta.resolve('@openfairygui/mcp/stdio')],
		env: { OPENFAIRYGUI_ALLOWED_PROJECT_ROOTS: root }, stderr: 'inherit',
	});
	const client = new Client({ name: 'openfairygui-example', version: '1.0.0' });
	let sessionId;
	async function call(method, input = {}) {
		const result = await client.callTool({ name: `openfairygui_backend_${method}`, arguments: input });
		const backend = result.structuredContent?.backendResult;
		if (result.isError || !backend?.ok) throw new Error(JSON.stringify(backend?.error ?? result));
		return backend.data;
	}
	try {
		await client.connect(transport);
		const { tools } = await client.listTools(); // The SDK also uses advertised output schemas to validate calls.
		const docs = await client.readResource({ uri: 'openfairygui://docs/index' });
		const documentation = JSON.parse(docs.contents[0].text);
		const capabilities = await call('get_capabilities');
		assert.equal(documentation.BACKEND_CAPABILITY_SCHEMA_VERSION, capabilities.capabilitySchemaVersion);
		assert.equal(documentation.BACKEND_CONTRACT_VERSION, capabilities.contractVersion);
		const opened = await call('open_session', { projectPath }); sessionId = opened.sessionId;
		const outline = await call('get_project_outline', { sessionId });
		const pkg = outline.packages.find((entry) => entry.name === 'Main');
		const component = pkg?.resources.find((entry) => entry.name === 'MainView' && entry.kind === 'component');
		const title = component?.component?.displayList.find((entry) => entry.name === 'title' && entry.kind === 'text');
		assert(title, 'This example expects Main/MainView/title; it will not guess another target.');
		const target = { kind: 'displayNode', selector: { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id } };
		const current = await call('query_entity', { sessionId, target });
		const preview = await call('preflight_transaction', {
			sessionId, expectedRevision: current.revision,
			operations: [{ kind: 'setDisplayNodeProps', selector: target.selector, props: { text: `${current.entity.properties.text} (preview only)` } }],
		});
		assert.deepEqual(await call('query_entity', { sessionId, target }), current);
		const session = await call('get_session', { sessionId });
		assert.equal(session.revision, current.revision); assert.equal(session.dirty, false);
		// No apply/save: a successful preview is not authorization, a reserved revision or a persisted edit.
		return { projectPath, toolNames: tools.map((tool) => tool.name), documentation, current, preview, session };
	} finally {
		try { if (sessionId) await call('close_session', { sessionId }); }
		finally { await client.close(); }
	}
}

真实浏览器存储

在上述仓库外的 examples/ 副本执行 npm run browser,用 Chromium 打开终端显示的 localhost 地址。示例只在当前 origin 的 OPFS 中首次创建 openfairygui-example/,不请求本地目录权限、不覆盖已有示例;清除站点数据会删除该存储。点击 Open → Preview & apply → Save → 刷新 → Open,可看到保存后的 title、revision 和 dirty;未保存时 Close 拒绝丢弃编辑,但刷新仍可能丢失内存修改。Validate saved files 显式水合源字节并调用 validateProjectWeb,不能用没有图片字节的检查冒充完整验证。

js
export async function createBrowserExample() {
	if (!navigator.storage?.getDirectory || !navigator.locks) throw new Error('This example requires OPFS and Web Locks on localhost or HTTPS.');
	const root = await navigator.storage.getDirectory();
	const fileSystem = createBackendStorageFileSystem(createFileSystemAccessFileSystem(root));
	const io = new WebIO(fileSystem);
	const projectPath = 'openfairygui-example/Example.fairy';
	// Only seed our own missing demo. Serialize first-run initialization across tabs.
	await navigator.locks.request('openfairygui-example:initialize', async () => {
		if (await fileSystem.exists(projectPath)) return;
		const document = new Document();
		document.getRoot().setProjectId('browser-example').setProjectType(0).setVersion('3.0')
			.setSettings({ publish: {}, common: {}, adaptation: {} });
		const pkg = document.createPackage('Main').setId('pkgdemo1');
		const component = document.createComponent('MainView').setId('cmpdemo1').setPath('/').setExported(true).setSize(320, 180);
		component.addChild(document.createGTextField('title').setId('title').setText('Hello browser').setXY(16, 18).setSize(240, 32));
		pkg.addResource(component);
		pkg.addResource(document.createImageResource('pixel').setId('pixel').setPath('/').setFileName('pixel.png').setWidth(2).setHeight(1));
		const project = liftDocumentToUamProject(document);
		const canvas = new OffscreenCanvas(2, 1);
		const context = canvas.getContext('2d');
		context.fillStyle = '#ff0000'; context.fillRect(0, 0, 1, 1);
		context.fillStyle = '#0000ff'; context.fillRect(1, 0, 1, 1);
		project.packages[0].resources.find((resource) => resource.kind === 'image').sourceBytes = new Uint8Array(await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer());
		await writeProjectFromUam(io, project, projectPath);
	});
	const runtime = new BackendRuntime({ fileSystem, allowedProjectRoots: ['openfairygui-example'] });
	let sessionId;
	function read() {
		const outline = data(runtime.getProjectOutline({ sessionId }));
		const pkg = outline.packages.find((entry) => entry.name === 'Main');
		const component = pkg?.resources.find((entry) => entry.kind === 'component' && entry.name === 'MainView');
		const title = component?.component?.displayList.find((entry) => entry.kind === 'text' && entry.name === 'title');
		if (!title) throw new Error('Expected Main/MainView/title in this demo; no guessed identifiers.');
		const selector = { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id };
		return { selector, ...data(runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector } })) };
	}
	return {
		runtime, fileSystem, projectPath,
		get sessionId() { return sessionId; },
		async open() {
			if (sessionId) return data(runtime.getSession({ sessionId }));
			const opened = data(await runtime.openSession({ projectPath }));
			sessionId = opened.sessionId;
			return opened;
		},
		async close() {
			if (!sessionId) return;
			const session = data(runtime.getSession({ sessionId }));
			if (session.dirty) throw new Error('Save this demo before closing; unsaved edits will not be discarded.');
			data(await runtime.closeSession({ sessionId })); sessionId = undefined;
		},
		read,
		async edit(text) {
			const current = read();
			const transaction = { sessionId, expectedRevision: current.revision, operations: [{ kind: 'setDisplayNodeProps', selector: current.selector, props: { text } }] };
			data(await runtime.preflightTransaction(transaction));
			return data(await runtime.applyTransaction(transaction));
		},
		async save() {
			return data(await runtime.saveSession({ sessionId, expectedRevision: read().revision }));
		},
		async validate() { return validateProjectWeb(await readProjectAsUam(io, projectPath, { hydrateResourceBytes: true })); },
	};
}

示例复用 Core File System Access 适配器、WebIO、Backend storage bridge 和浏览器 Web Locks,不实现另一份文件系统/锁。pack:check 在真实 Chromium 中执行此页面,断言预演和失败不写盘、stale revision 被拒绝、路径拒绝保留 dirty、保存只改变目标 XML、PNG 字节和红蓝 RGBA 不变、刷新回读、双标签页锁冲突以及正常/异常关闭后释放锁。成功现场包括 browser-evidence.jsonbrowser-consumer.png;失败保留消费者目录。

OPFS 是 origin 私有存储,不等同于 showDirectoryPicker 选择的用户目录。这里不承诺本地目录授权、IndexedDB/ZIP 适配器、跨浏览器矩阵、图片替换 Worker 或 FairyGUI 渲染器的验证。平台说明见 MDN OPFS

当前源码与发布物验证

从仓库根目录执行:

bash
pnpm pack:check
pnpm pack:check --artifacts .release

第一条命令先构建并打包当前五个发布包;第二条只读取指定目录内、与当前包名和版本匹配的五个 tarball,不重新打包。发布流程在两个 registry 的 publish 之前运行第二条命令,检查的就是将要发布的文件。

检查在仓库外的全新目录执行:

  • 用五个本地 tarball 安装生产依赖,并将内部包依赖固定到这些 tarball;禁止 workspace link,清除环境中的 Node loader/源码解析配置。
  • 按真实 exports 检查打包文件、ESM import、CJS require、Node/Web 入口;Worker 单独作为浏览器入口,不在 Node 主线程导入。
  • 验证安装后的 CLI/bin、版本、inspect/validate JSON,以及 MCP stdio initialize 和工具发现。
  • 运行本页七个 Node 示例(包括真实 stdio 客户端),检查读/预演不写盘、保存后只改变预期字段与对应 XML、无新增无关文件、会话锁释放、stale revision 被拒绝;A/B/C 额外执行上述 SDK/MCP 完整语义、文件、发布动画及生成组件引用比较。单字段编辑示例的验证失败和暂存写入失败须保留 revision、dirty、诊断及锁,原文件不变;故障解除后用同一会话明确保存并回读。
  • 发布示例额外核对实际 manifest/文件字节长度、二进制组件与跨包引用、图集红蓝 RGBA 像素、恢复后素材与工程验证;损坏图集下强制恢复失败须保留完整旧目录。发布/恢复真实 Agent 任务使用独立受限宿主,见评测指南
  • 生产运行通过后,再声明并安装锁定版本的 TypeScript、Node 类型、esbuild 和 Playwright,严格编译 .mts/.cts 消费者,不启用 skipLibCheck 或源码 alias;浏览器/Worker 无 Node external 打包后,执行上述真实 Chromium 页面验证。

成功后自动删除检查器创建的临时目录;失败保留现场并打印路径。pnpm pack:check --keep 可以保留成功现场。安装需要 registry 和匹配 Chromium 的网络或缓存;浏览器下载失败/启动失败不算通过。Playwright 与 Chromium 安装版本绑定,见 浏览器安装说明。默认不安装系统依赖;Linux CI 显式使用 --browser-deps 安装 Chromium 所需系统包(可能需要 sudo),Windows 忽略该系统依赖选项。浏览器缓存位于仓库外,不随消费者临时目录删除。

这些检查证明包入口、类型、Node 工作流及真实 Chromium 存储页面行为,不证明本地目录权限、完整编辑器 UI、所有图片格式或全部发布/恢复格式。项目测试与用户示例分别维护;本页八个示例均纳入消费者验证。

验证入口和 CI 范围见开发指南,产品入口见包与工具

MIT Licensed