/** * 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(); }