54d0fb75ea
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
286 lines
8.8 KiB
JavaScript
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();
|
|
}
|