4a46f89c9b
客户端新增随包提供的插件宿主和 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>
229 lines
7.5 KiB
JavaScript
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 };
|