Files
Genarrative/plugins/agc-unity-editor/src/entry.mjs
T
kdletters 54d0fb75ea 接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
2026-09-20 16:35:04 +08:00

286 lines
8.8 KiB
JavaScript

/**
* AGC Unity 插件的 agc.plugin.v1 入口。
* 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用
* 经 host.rpc 交给统一原生服务,入口不查找进程、不启动 helper、不持有凭据。
*/
import { pathToFileURL } from 'node:url';
export const UNITY_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
export const UNITY_EXECUTE_COMMAND_ID = 'unity.editor.execute';
export const UNITY_CONNECTION_CAPABILITY_ID = 'unity.editor.connection';
const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
const MAX_CODE_BYTES = 128 * 1024;
export function createUnityEditorPlugin({ send, timeoutMs = 85_000 }) {
let nextId = 1;
let activeProjectPath = null;
let projectEpoch = 0;
let disposed = false;
let executing = false;
let uncertain = false;
const pending = new Map();
function request(method, params) {
if (disposed) return Promise.reject(new Error('插件已停止'));
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('Unity 插件宿主 RPC 超时'));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
try {
send({ jsonrpc: '2.0', id, method, params });
} catch (error) {
clearTimeout(timer);
pending.delete(id);
reject(error);
}
});
}
function editorParams(input, allowed) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Unity 参数必须是对象');
}
for (const key of Object.keys(input)) {
if (!allowed.includes(key)) throw new Error(`Unity 不接受参数:${key}`);
}
if (!activeProjectPath) throw new Error('当前没有受控 Unity 项目');
if (
input.timeoutMs !== undefined &&
(!Number.isInteger(input.timeoutMs) ||
input.timeoutMs < 1 ||
input.timeoutMs > 60_000)
) {
throw new Error('timeoutMs 必须在 1..60000 之间');
}
return { ...input, projectPath: activeProjectPath };
}
function reconcile(error) {
uncertain = true;
return {
ok: false,
status: 'needs-reconciliation',
retryAllowed: false,
dispatched: true,
error: {
code: 'execution-uncertain',
message: typeof error === 'string' ? error : 'Unity 执行结果待核对',
},
};
}
async function execute(input) {
const params = editorParams(input, ['code', 'timeoutMs']);
if (
typeof params.code !== 'string' ||
!params.code.trim() ||
params.code.includes('\0') ||
Buffer.byteLength(params.code) > MAX_CODE_BYTES
) {
throw new Error('Unity C# 代码必须非空、不含 NUL 且不超过 128 KiB');
}
if (uncertain)
return reconcile('先前执行结果待核对,请核对 Unity 后重启客户端');
if (executing) {
return {
ok: false,
status: 'failed',
dispatched: false,
retryAllowed: false,
error: { code: 'editor-busy', message: 'Unity 已有执行正在处理' },
};
}
executing = true;
try {
const result = await request('host.rpc', {
method: 'editor.execute',
params,
});
if (result?.status === 'needs-reconciliation')
return reconcile(result.error?.message);
const validError =
typeof result?.error?.code === 'string' &&
result.error.code.trim() &&
typeof result?.error?.message === 'string' &&
result.error.message.trim();
if (
!result ||
typeof result.ok !== 'boolean' ||
typeof result.dispatched !== 'boolean' ||
result.retryAllowed !== false ||
!(
(result.status === 'completed' &&
result.ok &&
result.dispatched &&
!Object.hasOwn(result, 'error') &&
Object.hasOwn(result, 'result')) ||
(result.status === 'failed' &&
!result.ok &&
validError &&
!Object.hasOwn(result, 'result'))
)
) {
return reconcile('Unity 执行回执无效');
}
return result;
} catch {
return reconcile('Unity 执行连接中断或超时,结果待核对');
} finally {
executing = false;
}
}
async function connection(input = {}) {
const params = editorParams(input, ['operation', 'processId', 'timeoutMs']);
const operation = params.operation ?? 'detect';
if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) {
throw new Error('不支持的 Unity 连接操作');
}
if (
params.processId !== undefined &&
(!Number.isInteger(params.processId) || params.processId <= 0)
) {
throw new Error('processId 必须是正整数');
}
delete params.operation;
return request('host.rpc', { method: `editor.${operation}`, params });
}
async function handleMessage(message) {
if (disposed) return;
const value = typeof message === 'string' ? JSON.parse(message) : message;
if (!value || value.jsonrpc !== '2.0') return;
if (value.method === 'host.event') {
if (value.params?.type === 'project.changed') {
const project = value.params.payload?.projectPath;
activeProjectPath =
typeof project === 'string' && project ? project : null;
projectEpoch += 1;
}
return;
}
if (value.method !== undefined) {
if (value.id === undefined) return;
try {
const handler =
value.method === UNITY_EXECUTE_COMMAND_ID
? execute
: value.method === UNITY_CONNECTION_CAPABILITY_ID
? connection
: null;
if (!handler) throw new Error('插件未注册该方法');
const result = await handler(value.params ?? {});
if (!disposed) send({ jsonrpc: '2.0', id: value.id, result });
} catch (error) {
if (!disposed) {
if (value.method === UNITY_EXECUTE_COMMAND_ID) {
send({
jsonrpc: '2.0',
id: value.id,
result: {
ok: false,
status: 'failed',
retryAllowed: false,
dispatched: false,
error: { code: 'invalid-input', message: error.message },
},
});
} else {
send({
jsonrpc: '2.0',
id: value.id,
error: { code: -32602, message: error.message },
});
}
}
}
return;
}
const item = pending.get(value.id);
if (!item) return;
pending.delete(value.id);
clearTimeout(item.timer);
if (value.error) item.reject(new Error('插件宿主拒绝请求'));
else item.resolve(value.result);
}
async function start() {
await request('host.registerCommand', {
id: UNITY_EXECUTE_COMMAND_ID,
title: '执行 Unity C#',
description: '在当前 Unity 项目的编辑器主线程执行 C#',
});
await request('host.registerCapability', {
id: UNITY_CONNECTION_CAPABILITY_ID,
description: '当前 Unity 项目的编辑器连接与状态',
});
const epoch = projectEpoch;
const result = await request('host.events.subscribe', {
type: 'project.changed',
});
if (epoch === projectEpoch) activeProjectPath = result?.projectPath ?? null;
}
function dispose() {
disposed = true;
for (const item of pending.values()) {
clearTimeout(item.timer);
item.reject(new Error('插件已停止'));
}
pending.clear();
}
return { start, handleMessage, dispose };
}
export function startUnityEditorStdioPlugin({
stdin = process.stdin,
stdout = process.stdout,
} = {}) {
const plugin = createUnityEditorPlugin({
send(message) {
const line = `${JSON.stringify(message)}\n`;
if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES)
throw new Error('插件消息过大');
stdout.write(line);
},
});
let buffer = '';
let stopped = false;
const stop = () => {
stopped = true;
buffer = '';
plugin.dispose();
stdin.pause();
};
stdin.setEncoding('utf8');
stdin.on('data', (chunk) => {
if (stopped) return;
buffer += chunk;
let boundary;
while ((boundary = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 1);
if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES) {
stop();
return;
}
if (line.trim()) void plugin.handleMessage(line).catch(stop);
}
if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) stop();
});
stdin.on('end', stop);
stdin.on('error', stop);
void plugin.start().catch(stop);
return plugin;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
startUnityEditorStdioPlugin();
}