可运行示例与消费者验证
七个 Node 示例和一个浏览器存储示例只使用安装后的公开包,不依赖仓库源码别名或测试工具包。把仓库的 examples/ 目录复制到仓库外,在复制后的目录执行:
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/RewardPanel、claimButton 和 claimedMark 的唯一 ID,用一个事务新增 rewardState 控制器和 text / look / display 三个 gear,控制未达成、可领取、已领取三种状态。现有布局、其他组件和 Shared 包图片均保留。
让 Agent 自己完成任务时,只创建待编辑工程:
node reward-panel-states/index.mjs --create运行 node reward-panel-states/index.mjs(或 npm run reward)则会新建另一份独立工程并完成 SDK 编辑、验证、保存和 UAM 回读。两种命令均输出实际 projectPath;无参数命令不会继续编辑上一次 --create 的工程。
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、尚未改版的工程:
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 为空,作用于面板自身;位移相对于宿主放置面板的位置。动画不负责奖励发放或控制器切页。
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 秒) |
|---|---|---|
![]() | ![]() | ![]() |
| 动画时间 | 面板透明度 | 相对宿主位置的偏移 |
|---|---|---|
| 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):
node reward-card-generation/index.mjs --create按接入指南授权实际 projectPath 后,将下列任务和配置表交给 Agent:
在
<projectPath>的 Main 包中新增表中的三个导出组件。查询RewardCardTemplate和 Shared 图片的唯一 ID,核实模板为含title文本及iconLoader 的 Label,图片已经导出。每个新组件与模板同尺寸(示例为 240 × 180),仅包含位于 (0, 0)、同尺寸的card组件实例,引用原模板并配置 Label 的 title/icon;图标 URL 使用实际包与资源 ID。保留已有资源,不复制模板子节点或图片。查询结果需属于同一 revision,以三个addComponent一次预演、提交,完整验证后保存并重新打开核对。目标不唯一、ID/名称已占用、revision 冲突或验证不完整时停止并报告,保留已提交但未保存的工作。
| 新组件 | 稳定资源 ID | 标题 | 现有图片 |
|---|---|---|---|
DailyRewardCard | cardday1 | 每日奖励 ×100 | Shared/red |
WeeklyRewardCard | cardweek | 连签奖励 ×500 | Shared/blue |
BonusRewardCard | cardbon1 | 额外奖励 ×20 | Shared/red |
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。以下代码直接引用受消费者检查执行的源文件:
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 <工程路径> --json 与 ofgui validate <工程路径> --json。它们在统一 envelope 的 result 保留原报告,JSON 模式的读取失败也返回同一 envelope;stdout 不混入人类日志。完整结构、退出码及离线 schema 见 CLI 机器输出。无 --json 时保留终端报告格式。
带 revision 的修改、保存与回读
示例从 outline 获取资源 ID,再用 queryEntity 读取当前属性与 revision;将同一批文本修改先预演、再正式 apply,要求验证结果为 valid 且 complete: true,使用事务返回的 revision 保存,通过公开 Node I/O 重新读取工程,最后释放会话锁。预演不预留 revision。失败或验证不完整直接终止,不盲目重试 stale write。
提交后的验证、保存或回读失败会保留打开的会话,并抛出带有 recovery: { runtime, sessionId, projectPath } 的错误;cause 链保留原始错误及 Backend/验证报告。导入 editAndSave 的宿主应捕获错误,处理故障后用同一会话重新验证、明确保存,再关闭;可通过第三个参数传入宿主的 runtime。提交前失败会关闭干净会话。恢复句柄仅在当前进程内有效,独立命令失败退出后不会持久保存内存编辑。
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 文本。
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:false 和 error:{code,message},code 为 publish_failed、restore_failed 或 invalid_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 是示例显式声明的消费者依赖,不加入产品的新抽象层。
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,不能用没有图片字节的检查冒充完整验证。
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.json 与 browser-consumer.png;失败保留消费者目录。
OPFS 是 origin 私有存储,不等同于 showDirectoryPicker 选择的用户目录。这里不承诺本地目录授权、IndexedDB/ZIP 适配器、跨浏览器矩阵、图片替换 Worker 或 FairyGUI 渲染器的验证。平台说明见 MDN OPFS。
当前源码与发布物验证
从仓库根目录执行:
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、所有图片格式或全部发布/恢复格式。项目测试与用户示例分别维护;本页八个示例均纳入消费者验证。


