Files
Genarrative/plugins/agc-cocos-editor/native/cocos-editor-bridge/payload/bootstrap.cjs
T
kdletters 4a46f89c9b
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s
接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-13 14:48:55 +08:00

229 lines
7.5 KiB
JavaScript

'use strict';
// This module is evaluated in the Creator main-process Node context by the
// native bootstrap. It is not a Creator extension and writes no project files.
const net = require('node:net');
const fs = require('node:fs');
const path = require('node:path');
const SCHEMA_VERSION = 'game-creator-cocos-editor-bridge.v1';
const MAX_CODE_BYTES = 128 * 1024;
const MAX_FRAME_BYTES = MAX_CODE_BYTES * 6 + 16 * 1024;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const INSTANCE_KEY = Symbol.for('genarrative.cocos-editor-bridge.v1');
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
function normalizeProjectPath(value) {
if (typeof value !== 'string' || !path.isAbsolute(value)) {
throw new Error('projectPath must be absolute');
}
// Rust canonicalize 的扩展路径必须先转回 Node 可解析的盘符/UNC 形式。
const ordinary = value
.replace(/^\\\\\?\\UNC\\/i, '\\\\')
.replace(/^\\\\\?\\/, '');
const canonical = fs.realpathSync(ordinary);
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
}
function validateEnvelope(request, projectPath) {
if (!request || typeof request !== 'object' || Array.isArray(request)) {
throw new Error('request must be an object');
}
const keys = Object.keys(request).sort().join(',');
if (keys !== 'command,processId,projectPath,requestId,schemaVersion') {
throw new Error('unknown or missing request fields');
}
if (
request.schemaVersion !== SCHEMA_VERSION ||
request.processId !== process.pid
) {
throw new Error('bridge identity mismatch');
}
if (
typeof request.requestId !== 'string' ||
!request.requestId ||
request.requestId.length > 128
) {
throw new Error('invalid requestId');
}
if (normalizeProjectPath(request.projectPath) !== projectPath) {
throw new Error('project identity mismatch');
}
const command = request.command;
if (!command || typeof command !== 'object' || Array.isArray(command)) {
throw new Error('command must be an object');
}
if (command.op === 'ping' || command.op === 'status') {
if (Object.keys(command).length !== 1)
throw new Error('unknown command fields');
} else if (command.op === 'execute') {
if (Object.keys(command).sort().join(',') !== 'code,op')
throw new Error('unknown execute fields');
if (
typeof command.code !== 'string' ||
!command.code.trim() ||
command.code.includes('\0')
) {
throw new Error('execute.code must be non-empty text without NUL');
}
if (Buffer.byteLength(command.code, 'utf8') > MAX_CODE_BYTES)
throw new Error('execute.code exceeds limit');
} else {
throw new Error('unsupported command');
}
}
function install(Editor, options = {}) {
if (
!Editor?.Project?.path ||
typeof Editor?.Message?.request !== 'function'
) {
throw new Error('bootstrap requires the Creator main-process Editor API');
}
const projectPath = normalizeProjectPath(Editor.Project.path);
const existing = globalThis[INSTANCE_KEY];
if (existing) {
if (existing.projectPath !== projectPath)
throw new Error('bridge is bound to another project');
return existing;
}
const endpoint =
options.endpoint ?? `\\\\.\\pipe\\genarrative-cocos-editor-${process.pid}`;
let busy = false;
let pendingExecutions = 0;
let queue = Promise.resolve();
const sockets = new Set();
const response = (request, ok, result, error) => ({
schemaVersion: SCHEMA_VERSION,
requestId: typeof request?.requestId === 'string' ? request.requestId : '',
processId: process.pid,
ok,
...(ok
? { result: result === undefined ? null : result }
: { error: String(error) }),
});
function send(socket, value) {
if (socket.destroyed) return;
let payload;
try {
payload = JSON.stringify(value);
if (Buffer.byteLength(payload, 'utf8') + 1 > MAX_RESPONSE_BYTES)
throw new Error('result exceeds limit');
} catch (error) {
payload = JSON.stringify(
response(
value,
false,
null,
`result is not serializable: ${error.message}`,
),
);
}
socket.end(`${payload}\n`);
}
async function dispatch(socket, request) {
try {
validateEnvelope(request, projectPath);
if (normalizeProjectPath(Editor.Project.path) !== projectPath)
throw new Error('Creator project changed');
if (request.command.op === 'ping') {
send(socket, response(request, true, { ready: true }));
} else if (request.command.op === 'status') {
send(
socket,
response(request, true, {
ready: true,
busy,
creatorVersion:
typeof Editor.App?.version === 'string'
? Editor.App.version
: null,
}),
);
} else {
if (pendingExecutions >= 8) throw new Error('execute queue is full');
pendingExecutions += 1;
// Serialize mutations. A client timeout does not cancel JavaScript or
// permit replay: the next execution waits until this one has settled.
queue = queue.then(async () => {
busy = true;
try {
if (normalizeProjectPath(Editor.Project.path) !== projectPath)
throw new Error('Creator project changed');
const execute = new AsyncFunction(
'Editor',
'require',
`"use strict";\n${request.command.code}`,
);
const result = await execute(Editor, require);
send(socket, response(request, true, result));
} catch (error) {
send(socket, response(request, false, null, error?.stack ?? error));
} finally {
busy = false;
pendingExecutions -= 1;
}
});
await queue;
}
} catch (error) {
send(socket, response(request, false, null, error?.message ?? error));
}
}
const server = net.createServer((socket) => {
sockets.add(socket);
let buffer = Buffer.alloc(0);
let received = false;
socket.on('error', () => {});
socket.on('close', () => sockets.delete(socket));
socket.on('data', (chunk) => {
if (received) return;
if (buffer.length + chunk.length > MAX_FRAME_BYTES) {
received = true;
socket.destroy();
return;
}
buffer = Buffer.concat([buffer, chunk]);
const newline = buffer.indexOf(10);
if (newline === -1) return;
received = true;
let request;
try {
request = JSON.parse(buffer.subarray(0, newline).toString('utf8'));
} catch {
socket.destroy();
return;
}
void dispatch(socket, request);
});
});
server.maxConnections = 8;
const ready = new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(endpoint, () => {
server.removeListener('error', reject);
resolve();
});
});
// A duplicate bootstrap uses the same instance rather than rebinding or
// creating a second queue. Disposal is a native-loader lifecycle API.
const instance = {
projectPath,
ready,
async dispose() {
await queue;
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
if (globalThis[INSTANCE_KEY] === instance)
delete globalThis[INSTANCE_KEY];
},
};
globalThis[INSTANCE_KEY] = instance;
ready.catch(() => {
if (globalThis[INSTANCE_KEY] === instance) delete globalThis[INSTANCE_KEY];
});
return instance;
}
module.exports = { install, SCHEMA_VERSION };