diff --git a/docs/zh/mcp-scene-authoritative-process-design.md b/docs/zh/mcp-scene-authoritative-process-design.md new file mode 100644 index 000000000..a52e86f3f --- /dev/null +++ b/docs/zh/mcp-scene-authoritative-process-design.md @@ -0,0 +1,93 @@ +# MCP 场景权威进程设计 + +## 背景 + +`cocos-cli` 不作为 PinK 扩展由 `src/index.ts` 加载。IDE 的 cocos-code utility process 按顺序调用 `src/lib` 门面接口,例如 `Project.init()`、`Scene.init()`、`Scene.startupWorker()`、`Server.start()` 和 `Mcp.register()`。 + +Hierarchy 使用的场景由 IDE Scene WebView 持有;CLI 启动的 Node scene worker 会加载另一份 `cc.Scene`。即使二者打开同一个 `.scene` 文件,也不能共享未保存的内存状态、Undo 栈或编辑器选择状态。因此,reimport、磁盘重载和缓存刷新都不能保证 MCP 与 Hierarchy 一致。 + +设计原则:**Hierarchy 所属 Scene WebView 的 `SceneInstance` 是 MCP 场景读写的唯一权威。** CLI worker 不能再作为 PinK IDE 中 MCP 场景操作的回退目标。 + +## 当前实现 + +```text +MCP scene tool + -> cocos-cli proxy(Editor / Node / Component / Prefab) + -> requestSceneService() + -> PinK authority RPC client + -> 临时:项目级 named pipe + -> @pink-hierarchy extension host + -> 当前 Hierarchy SceneInstance + -> Scene WebView + +Scene WebView i18n / assets / engine bootstrap + -> cocos-cli Node scene worker RPC +``` + +`Scene.init()` 仅标记当前生命周期要求 IDE authority;它不会在 utility process 中动态导入 `pink`。 + +`Scene.startupWorker(projectPath)` 的当前行为如下: + +1. 如果 PinK 尚未注入 authority RPC,则创建项目级的临时 named-pipe client; +2. 启动 Node scene worker; +3. MCP proxy 已经绑定 authority,因此场景操作会转发到 Hierarchy,而不会使用 worker 内的 `cc.Scene`。 + +worker **必须继续启动**。Scene WebView 的 i18n、本地资源和引擎 RPC 初始化依赖 worker 的 `Rpc` 实例;停止 worker 会导致 `Rpc instance is not started`,并使场景编辑器启动失败。 + +临时 named pipe 的 endpoint 由项目绝对路径计算,避免不同项目串线。每个请求都新建连接,避免 Hierarchy 重启、切换场景或 WebView 重建时保留旧的 `SceneInstance` 引用。 + +## Authority 路由规则 + +- `Editor`、`Node`、`Component`、`Prefab` 的 MCP 请求统一使用 `requestSceneService()`。 +- 在 PinK 已配置 authority 时,所有请求都发给 authority;不得回退到 Node worker。 +- 在 IDE 生命周期已经开始、但 authority 尚未配置时,直接报错,避免写入独立 worker 场景。 +- 只有独立 CLI 运行模式才允许直接调用 Node worker。 +- authority 优先使用 PinK active scene;当焦点不在场景编辑器但 Hierarchy 仅打开一个场景时,使用该唯一打开场景。 +- 没有打开场景时,查询返回空;写操作报错。 +- `Editor.save` 是命令型操作。当前 bridge 保存成功后返回 `undefined`,不能伪造 `{ uuid, url, file }` 等不完整 `IAssetInfo`,否则 MCP 的结果 schema 校验会失败。 + +## 临时实现与正式 PinK 接入 + +当前 named pipe 是为了验证跨进程 authority 路由而添加的**临时适配层**,位于 CLI 的 `pink-scene-authority-bridge.ts` 和 PinK Hierarchy extension 的临时补丁中。 + +正式 PinK 接入应以内部 IPC 替换它: + +```text +cocos-code utility process + -> PinK main-process scene authority channel + -> Hierarchy extension host + -> SceneInstance + -> Scene WebView +``` + +PinK 应提供 project-scoped 的 `request(module, method, args)` authority RPC,并在 utility process 初始化 CLI scene 模块时注入: + +```ts +CliScene.bindIdeSceneAuthorityRpc({ + request: (module, method, args) => + sceneAuthorityChannel.request(projectId, module, method, args), +}); +``` + +正式接入后: + +- 删除临时 named-pipe client/server; +- 继续调用 `Scene.startupWorker(projectPath)`,不能将其改为 no-op; +- authority 仍然只负责 MCP 场景命令,worker 仍只负责 Scene WebView 基础设施; +- main process 必须按项目/窗口路由请求,并校验 SceneInstance 仍处于 open 状态,避免跨项目或陈旧场景引用。 + +## 不采用的方案 + +- 不在两个 scene process 之间同步 `cc.Scene`、磁盘文件或操作日志;它们无法共享未保存状态与 Undo。 +- 不因为当前没有打开场景而静默回退到 CLI worker。 +- 不让 MCP 从 utility process 获得或缓存 `SceneInstance` 对象;跨进程只传递请求和可序列化结果。 +- 正式 PinK 方案不长期使用 named pipe;应使用 PinK 的内部 IPC/authority channel。 + +## 验收 + +1. Hierarchy 打开 `scene.scene` 后,`mcp_cocos-cli_scene-query-current` 立即返回同一份未保存状态。 +2. Agent 新建、删除或修改节点后,Hierarchy 立即显示变化,且 IDE Undo 可撤销。 +3. `scene-save` 成功且 MCP 不再报 `Tool result validation failed`。 +4. 关闭全部场景后,查询返回未打开;写操作报错而不写入 CLI worker 副本。 +5. 完全重启 PinK 后,Scene WebView 能正常加载 i18n;日志显示 scene worker 已启动,同时 MCP 仍走 PinK authority。 +6. 多项目或多个 PinK 窗口并存时,请求只能路由到同一项目中的 Hierarchy SceneInstance。 diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 752ee1450..deb7cbe7a 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -7903,6 +7903,12 @@ export declare interface BaseItem { required?: boolean; dependencies?: string[]; } +export declare function bindIdeSceneAuthority(sceneApi: IPinkSceneApi): { + dispose(): void; +}; +export declare function bindIdeSceneAuthorityRpc(authorityRpc: ISceneAuthorityRpc): { + dispose(): void; +}; export declare interface BitmapFontAssetUserData { _fntConfig: any; fontSize: number; @@ -8570,6 +8576,13 @@ export declare interface IPhysicsMaterial { spinningFriction: number; restitution: number; } +export declare interface IPinkSceneApi { + getActiveScene(): Promise; + queryOpenedScenes(): Promise; + open(urlOrUUID: string, options?: { + openEditor?: boolean; + }): Promise; +} export declare interface IPluginScriptInfo extends PluginScriptInfo { url: string; } @@ -8645,6 +8658,9 @@ export declare interface IResolvedCustomJointTextureLayout { textureLength: number; contents: IResolvedChunkContent[]; } +export declare interface ISceneAuthorityRpc { + request(module: string, method: string, args: unknown[]): Promise; +} export declare interface ISocketConfig { connection: (socket: any) => void; disconnect: (socket: any) => void; @@ -9085,6 +9101,8 @@ export declare function saveMaterial(uuidOrUrlOrPath: string, dump: MaterialDump export declare function saveSerializedData(uuidOrUrlOrPath: string, patch: SerializedAssetPatch): Promise; export declare namespace Scene { export { + bindIdeSceneAuthority, + bindIdeSceneAuthorityRpc, init_6 as init, startupWorker } @@ -9210,7 +9228,7 @@ export declare function start(): Promise; export declare function start_2(port?: number, host?: string): Promise; export declare function startCompileScript(assetChanges?: AssetChangeInfo[]): Promise; export declare function startEngineCompilation(force?: boolean): Promise; -export declare function startupWorker(projectPath: string): Promise; +export declare function startupWorker(_projectPath: string): Promise; export declare function stop_2(): Promise; export declare const SUPPORT_CREATE_TYPES: readonly ["animation-clip", "typescript", "auto-atlas", "effect", "scene", "prefab", "material", "texture-cube", "terrain", "physics-material", "label-atlas", "render-texture", "directory", "effect-header"]; export declare enum TangentImportSetting { diff --git a/src/core/scene/main-process/pink-scene-authority-bridge.ts b/src/core/scene/main-process/pink-scene-authority-bridge.ts new file mode 100644 index 000000000..cfcac8d3d --- /dev/null +++ b/src/core/scene/main-process/pink-scene-authority-bridge.ts @@ -0,0 +1,89 @@ +import { createHash, randomUUID } from 'crypto'; +import { createConnection } from 'net'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import type { ISceneAuthorityRpc } from './pink-scene-authority'; + +interface IAuthorityRequest { + id: string; + module: string; + method: string; + args: unknown[]; +} + +interface IAuthorityResponse { + id: string; + result?: unknown; + error?: string; +} + +/** A local, project-scoped endpoint owned by PinK's Hierarchy extension. */ +export function getPinkSceneAuthorityEndpoint(projectPath: string): string { + const key = createHash('sha256').update(projectPath.toLowerCase()).digest('hex').slice(0, 24); + return process.platform === 'win32' + ? `\\\\.\\pipe\\cocos-cli-pink-scene-${key}` + : join(tmpdir(), `cocos-cli-pink-scene-${key}.sock`); +} + +/** + * Creates the utility-process side of the temporary PinK authority bridge. + * A new connection is deliberately used per request so restarting or changing + * the active scene in the Hierarchy extension cannot leave stale state here. + */ +export function createPinkSceneAuthorityRpc(projectPath: string): ISceneAuthorityRpc { + return { + request: (module, method, args) => requestPinkSceneAuthority(projectPath, module, method, args), + }; +} + +function requestPinkSceneAuthority( + projectPath: string, + module: string, + method: string, + args: unknown[], + timeoutMs = 5_000, +): Promise { + const endpoint = getPinkSceneAuthorityEndpoint(projectPath); + const id = randomUUID(); + + return new Promise((resolve, reject) => { + const socket = createConnection(endpoint); + let buffer = ''; + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + callback(); + }; + const timeout = setTimeout(() => finish(() => reject(new Error( + `[Cocos CLI] Timed out waiting for the PinK scene authority bridge (${endpoint}).`, + ))), timeoutMs); + + socket.once('error', (error) => finish(() => reject(new Error( + `[Cocos CLI] PinK scene authority bridge is unavailable: ${error.message}`, + )))); + socket.once('connect', () => { + const request: IAuthorityRequest = { id, module, method, args }; + socket.write(`${JSON.stringify(request)}\n`); + }); + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const lineEnd = buffer.indexOf('\n'); + if (lineEnd < 0) return; + try { + const response = JSON.parse(buffer.slice(0, lineEnd)) as IAuthorityResponse; + if (response.id !== id) return; + if (response.error) { + finish(() => reject(new Error(response.error))); + } else { + finish(() => resolve(response.result)); + } + } catch (error) { + finish(() => reject(error)); + } + }); + }); +} diff --git a/src/core/scene/main-process/pink-scene-authority.ts b/src/core/scene/main-process/pink-scene-authority.ts new file mode 100644 index 000000000..1511335c6 --- /dev/null +++ b/src/core/scene/main-process/pink-scene-authority.ts @@ -0,0 +1,273 @@ +/** + * Adapter for PinK's scene API. + * + * PinK already routes SceneInstance calls to the scene WebView currently shown + * by the hierarchy. Keeping this boundary in the extension host is important: + * the CLI scene worker is a separate process and must not become a second + * source of truth when an IDE scene editor is available. + */ +export interface IPinkSceneApi { + getActiveScene(): Promise; + queryOpenedScenes(): Promise; + open(urlOrUUID: string, options?: { openEditor?: boolean }): Promise; +} + +/** + * Cross-process authority contract used by the cocos-code utility process. + * The implementation belongs to PinK and must route the request to the Scene + * WebView owned by Hierarchy. + */ +export interface ISceneAuthorityRpc { + request(module: string, method: string, args: unknown[]): Promise; +} + +function noActiveSceneError(): Error { + return new Error('No active PinK scene editor. Open a scene in the hierarchy before using scene operations.'); +} + +export class PinkSceneAuthority { + private sceneApi: IPinkSceneApi | undefined; + private authorityRpc: ISceneAuthorityRpc | undefined; + private ideAuthorityRequired = false; + + /** Marks the current process as the IDE lib facade, not a standalone CLI. */ + expectIdeAuthority(): void { + this.ideAuthorityRequired = true; + } + + /** Binds an in-process PinK scene API (primarily useful in unit tests). */ + attach(sceneApi: IPinkSceneApi): { dispose(): void } { + this.sceneApi = sceneApi; + this.authorityRpc = undefined; + return { + dispose: () => { + if (this.sceneApi === sceneApi) { + this.sceneApi = undefined; + } + }, + }; + } + + /** Binds the RPC adapter supplied by the cocos-code utility host. */ + attachRpc(authorityRpc: ISceneAuthorityRpc): { dispose(): void } { + this.sceneApi = undefined; + this.authorityRpc = authorityRpc; + return { + dispose: () => { + if (this.authorityRpc === authorityRpc) { + this.authorityRpc = undefined; + } + }, + }; + } + + /** True means this run is hosted by PinK, even when no scene is active. */ + isHostedByPink(): boolean { + return this.sceneApi !== undefined || this.authorityRpc !== undefined; + } + + /** True after the IDE has entered the lib facade lifecycle. */ + requiresIdeAuthority(): boolean { + return this.ideAuthorityRequired; + } + + async request(module: string, method: string, args: unknown[] = []): Promise { + const authorityRpc = this.authorityRpc; + if (authorityRpc) { + return authorityRpc.request(module, method, args) as Promise; + } + + const api = this.sceneApi; + if (!api) { + throw new Error('PinK scene authority is unavailable.'); + } + + if (module === 'Editor') { + return this.requestEditor(api, method, args); + } + + const scene = await this.getCurrentScene(api); + switch (module) { + case 'Node': + return this.requestNode(scene, method, args); + case 'Component': + return this.requestComponent(scene, method, args); + case 'Prefab': + return this.requestPrefab(scene, method, args); + default: + throw new Error(`PinK scene authority does not support ${module}.${method}.`); + } + } + + private async requestEditor(api: IPinkSceneApi, method: string, args: unknown[]): Promise { + switch (method) { + case 'open': { + const params = args[0] as { urlOrUUID: string; includeChildren?: boolean; includeComponents?: boolean }; + const scene = await api.open(params.urlOrUUID, { openEditor: true }); + return this.queryRoot(scene, params); + } + case 'queryCurrent': { + const scene = await this.findCurrentScene(api); + return scene ? this.queryRoot(scene, {}) : null as T; + } + case 'hasOpen': + return Boolean(await this.findCurrentScene(api)) as T; + case 'close': { + const scene = await this.findScene(api, (args[0] as { urlOrUUID?: string } | undefined)?.urlOrUUID); + if (!scene) return true as T; + await scene.close(); + return true as T; + } + case 'save': { + const scene = await this.findScene(api, (args[0] as { urlOrUUID?: string } | undefined)?.urlOrUUID); + if (!scene) throw noActiveSceneError(); + await scene.save(); + // PinK's SceneInstance save API is a command. Do not synthesize + // a partial IAssetInfo from the SceneInstance identity: MCP's + // scene-save schema accepts no data, but rejects incomplete + // asset metadata. A native PinK bridge may return a complete + // asset descriptor in the future. + return undefined as T; + } + case 'reload': + throw new Error('Reloading an active PinK scene is not exposed by the PinK scene API.'); + case 'create': + throw new Error('Creating a scene asset is not exposed by the PinK scene API.'); + default: + throw new Error(`PinK scene authority does not support Editor.${method}.`); + } + } + + private async requestNode(scene: any, method: string, args: unknown[]): Promise { + const params = args[0] as any; + switch (method) { + case 'createByType': return scene.createNodeByType(params) as Promise; + case 'createByAsset': return scene.createNodeByAsset(params) as Promise; + case 'delete': + await scene.deleteNode(params); + return { path: params.path } as T; + case 'query': return scene.query(params) as Promise; + case 'queryNodeTree': return scene.queryNodeTree(params) as Promise; + case 'setProperty': + await scene.setProperty(params); + return true as T; + case 'getPathByUuid': return this.findPathByUuid(scene, String(params)) as Promise; + case 'setParent': return this.invoke(scene, 'node', 'set-parent', params) as Promise; + case 'reorder': return this.invoke(scene, 'node', 'reorder', params) as Promise; + case 'copy': return this.invoke(scene, 'node', 'copy', params) as Promise; + case 'paste': return this.invoke(scene, 'node', 'paste', params) as Promise; + case 'duplicate': return this.invoke(scene, 'node', 'duplicate', params) as Promise; + case 'cut': return this.invoke(scene, 'node', 'cut', params) as Promise; + case 'queryClipboardState': return this.invoke(scene, 'node', 'query-clipboard-state') as Promise; + case 'moveArrayElement': return this.invoke(scene, 'node', 'move-array-element', params) as Promise; + case 'removeArrayElement': return this.invoke(scene, 'node', 'remove-array-element', params) as Promise; + case 'changeNodeLock': return this.invoke(scene, 'node', 'change-node-lock', params) as Promise; + case 'queryNodesByAssetUuid': return this.invoke(scene, 'node', 'query-nodes-by-asset-uuid', params) as Promise; + case 'queryNodesMissAsset': return this.invoke(scene, 'node', 'query-nodes-miss-asset') as Promise; + default: throw new Error(`PinK scene authority does not support Node.${method}.`); + } + } + + private async requestComponent(scene: any, method: string, args: unknown[]): Promise { + const params = args[0] as any; + switch (method) { + case 'add': return scene.addComponent(params) as Promise; + case 'remove': return Boolean(await scene.removeComponent(params)) as T; + case 'query': return scene.query(typeof params === 'string' ? { path: params } : params) as Promise; + case 'setProperty': + await scene.setProperty(params); + return true as T; + case 'queryAll': { + const components = await this.invoke(scene, 'component', 'query-all'); + return components.map((component: { name: string }) => component.name) as T; + } + case 'recalculateLODGroupBounds': + return this.invoke(scene, 'component', 'recalculate-lod-group-bounds', params) as Promise; + case 'insertLOD': + return this.invoke(scene, 'component', 'insert-lod', params) as Promise; + case 'eraseLOD': + return this.invoke(scene, 'component', 'erase-lod', params) as Promise; + case 'queryLODGroupRelativeHeight': + return this.invoke(scene, 'component', 'query-lod-group-relative-height', params) as Promise; + default: throw new Error(`PinK scene authority does not support Component.${method}.`); + } + } + + private async requestPrefab(scene: any, method: string, args: unknown[]): Promise { + const params = args[0] as any; + switch (method) { + case 'applyPrefabChanges': return this.invoke(scene, 'prefab', 'apply-changes', params) as Promise; + case 'createPrefabFromNode': return this.invoke(scene, 'prefab', 'create-prefab', params) as Promise; + case 'revertToPrefab': return this.invoke(scene, 'prefab', 'revert', params) as Promise; + case 'unpackPrefabInstance': + await this.invoke(scene, 'prefab', 'unlink', params); + return scene.query({ path: params.path }) as Promise; + case 'isPrefabInstance': { + const node = await scene.query({ path: params.path }); + return Boolean(node?.__prefab__) as T; + } + case 'getPrefabInfo': { + const node = await scene.query({ path: params.path }); + return (node?.__prefab__ ?? null) as T; + } + case 'unlinkPrefab': return this.invoke(scene, 'prefab', 'unlink', params) as Promise; + default: throw new Error(`PinK scene authority does not support Prefab.${method}.`); + } + } + + /** + * A hierarchy can still display a scene while a non-scene editor has focus, + * in which case PinK's active-scene API intentionally returns undefined. + * The hierarchy process is still authoritative, so use its single opened + * editor scene as the deterministic fallback. + */ + private async findCurrentScene(api: IPinkSceneApi): Promise { + const active = await api.getActiveScene(); + if (active) return active; + + const opened = await api.queryOpenedScenes(); + const editorScenes = opened.filter((scene) => scene?.openEditor !== false); + if (editorScenes.length === 1) return editorScenes[0]; + if (opened.length === 1) return opened[0]; + return undefined; + } + + private async getCurrentScene(api: IPinkSceneApi): Promise { + const scene = await this.findCurrentScene(api); + if (!scene) throw noActiveSceneError(); + return scene; + } + + private async findScene(api: IPinkSceneApi, urlOrUUID?: string): Promise { + if (!urlOrUUID) return this.findCurrentScene(api); + const scenes = await api.queryOpenedScenes(); + return scenes.find((scene) => scene.uuid === urlOrUUID || scene.url === urlOrUUID || scene.file === urlOrUUID); + } + + private queryRoot(scene: any, options: { includeChildren?: boolean; includeComponents?: boolean }): Promise { + return scene.query({ + path: '', + includeChildren: options.includeChildren, + includeComponents: options.includeComponents, + }) as Promise; + } + + private invoke(scene: any, target: string, method: string, params?: unknown): Promise { + return params === undefined ? scene.invoke(target, method) : scene.invoke(target, method, params); + } + + private async findPathByUuid(scene: any, uuid: string): Promise { + const root = await scene.queryNodeTree({}); + const visit = (node: any): string | undefined => { + if (node?.uuid === uuid) return node.path ?? ''; + for (const child of node?.children ?? []) { + const found = visit(child); + if (found !== undefined) return found; + } + return undefined; + }; + return visit(root) ?? ''; + } +} + +export const pinkSceneAuthority = new PinkSceneAuthority(); diff --git a/src/core/scene/main-process/proxy/component-proxy.ts b/src/core/scene/main-process/proxy/component-proxy.ts index 780d8075c..27b94bc1a 100644 --- a/src/core/scene/main-process/proxy/component-proxy.ts +++ b/src/core/scene/main-process/proxy/component-proxy.ts @@ -15,9 +15,9 @@ import { ISetPropertyOptionsInfo } from '../../common/cli/component'; import type { IAssetInfo } from '../../../assets/@types/public'; import { assetManager } from '../../../assets'; -import { Rpc } from '../rpc'; import { DumpConverter } from './dump-converter'; import { getExpectedAssetType, resolveAssetReference } from './asset-reference-resolver'; +import { requestSceneService } from './scene-authority-request'; export interface IComponentProxy extends Omit { add(params: IAddComponentOptions): Promise; @@ -27,16 +27,16 @@ export interface IComponentProxy extends Omit { - const result: any = await Rpc.getInstance().request('Component', 'add', [params]); + const result: any = await requestSceneService('Component', 'add', [params]); return DumpConverter.toComponent(result); }, remove(params: IRemoveComponentOptions): Promise { - return Rpc.getInstance().request('Component', 'remove', [params]); + return requestSceneService('Component', 'remove', [params]); }, async query(params: IQueryComponentOptions): Promise { - const result: any = await Rpc.getInstance().request('Component', 'query', [params]); + const result: any = await requestSceneService('Component', 'query', [params]); if (!result) return null; if (typeof params !== 'string') { return DumpConverter.toComponent(result); @@ -49,12 +49,12 @@ export const ComponentProxy: IComponentProxy = { segments.pop(); const nodePath = segments.join('/'); - const compDump: any = await Rpc.getInstance().request('Component', 'query', [params.componentPath]); + const compDump: any = await requestSceneService('Component', 'query', [params.componentPath]); if (!compDump) { throw new Error(`Component not found: ${params.componentPath}`); } - const nodeTree: any = await Rpc.getInstance().request('Node', 'queryNodeTree', [{ path: nodePath }]); + const nodeTree: any = await requestSceneService('Node', 'queryNodeTree', [{ path: nodePath }]); if (!nodeTree) { throw new Error(`Node not found: ${nodePath}`); } @@ -101,7 +101,7 @@ export const ComponentProxy: IComponentProxy = { } for (const { key, propDef, dumpValue } of pendingUpdates) { - await Rpc.getInstance().request('Component', 'setProperty', [{ + await requestSceneService('Component', 'setProperty', [{ nodePath, path: `__comps__.${compIndex}.${key}`, dump: { ...propDef, value: dumpValue }, @@ -112,22 +112,22 @@ export const ComponentProxy: IComponentProxy = { }, queryAll(): Promise { - return Rpc.getInstance().request('Component', 'queryAll'); + return requestSceneService('Component', 'queryAll'); }, recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise { - return Rpc.getInstance().request('Component', 'recalculateLODGroupBounds', [options]); + return requestSceneService('Component', 'recalculateLODGroupBounds', [options]); }, insertLOD(options: IInsertLODOptions): Promise { - return Rpc.getInstance().request('Component', 'insertLOD', [options]); + return requestSceneService('Component', 'insertLOD', [options]); }, eraseLOD(options: IEraseLODOptions): Promise { - return Rpc.getInstance().request('Component', 'eraseLOD', [options]); + return requestSceneService('Component', 'eraseLOD', [options]); }, queryLODGroupRelativeHeight(options: IQueryLODGroupRelativeHeightOptions): Promise { - return Rpc.getInstance().request('Component', 'queryLODGroupRelativeHeight', [options]); + return requestSceneService('Component', 'queryLODGroupRelativeHeight', [options]); }, }; diff --git a/src/core/scene/main-process/proxy/editor-proxy.ts b/src/core/scene/main-process/proxy/editor-proxy.ts index 35a736b9d..eb05dd4ed 100644 --- a/src/core/scene/main-process/proxy/editor-proxy.ts +++ b/src/core/scene/main-process/proxy/editor-proxy.ts @@ -8,8 +8,8 @@ import { ISceneInfo, INodeInfo, } from '../../common'; -import { Rpc } from '../rpc'; import { DumpConverter, IDumpConvertOptions } from './dump-converter'; +import { requestSceneService } from './scene-authority-request'; export interface IEditorProxy extends Omit { open(params: IOpenOptions): Promise; @@ -25,27 +25,27 @@ function convertEditorResult(dump: any, options?: IDumpConvertOptions): ISceneIn export const EditorProxy: IEditorProxy = { async open(params: IOpenOptions) { - const result: any = await Rpc.getInstance().request('Editor', 'open', [params]); + const result: any = await requestSceneService('Editor', 'open', [params]); return convertEditorResult(result); }, close(params: ICloseOptions) { - return Rpc.getInstance().request('Editor', 'close', [params]); + return requestSceneService('Editor', 'close', [params]); }, save(params: ISaveOptions) { - return Rpc.getInstance().request('Editor', 'save', [params]); + return requestSceneService('Editor', 'save', [params]); }, reload(params: IReloadOptions) { - return Rpc.getInstance().request('Editor', 'reload', [params]); + return requestSceneService('Editor', 'reload', [params]); }, create(params: ICreateOptions) { - return Rpc.getInstance().request('Editor', 'create', [params]); + return requestSceneService('Editor', 'create', [params]); }, async queryCurrent() { - const result: any = await Rpc.getInstance().request('Editor', 'queryCurrent'); + const result: any = await requestSceneService('Editor', 'queryCurrent'); if (!result) return null; return convertEditorResult(result); }, hasOpen() { - return Rpc.getInstance().request('Editor', 'hasOpen'); + return requestSceneService('Editor', 'hasOpen'); } }; diff --git a/src/core/scene/main-process/proxy/node-proxy.ts b/src/core/scene/main-process/proxy/node-proxy.ts index dfc73d268..921d2a47e 100644 --- a/src/core/scene/main-process/proxy/node-proxy.ts +++ b/src/core/scene/main-process/proxy/node-proxy.ts @@ -11,8 +11,8 @@ import { IPublicNodeService, } from '../../common'; import { INodeInfo } from '../../common/cli/node'; -import { Rpc } from '../rpc'; import { DumpConverter } from './dump-converter'; +import { requestSceneService } from './scene-authority-request'; export interface INodeProxy extends Omit { createByType(params: ICreateByNodeTypeParams): Promise; @@ -23,18 +23,18 @@ export interface INodeProxy extends Omit { - const result: any = await Rpc.getInstance().request('Node', 'createByType', [params]); + const result: any = await requestSceneService('Node', 'createByType', [params]); return result ? DumpConverter.toNode(result) : null; }, async createByAsset(params: ICreateByAssetParams): Promise { - const result: any = await Rpc.getInstance().request('Node', 'createByAsset', [params]); + const result: any = await requestSceneService('Node', 'createByAsset', [params]); return result ? DumpConverter.toNode(result) : null; }, delete(params: IDeleteNodeParams): Promise { - return Rpc.getInstance().request('Node', 'delete', [params]); + return requestSceneService('Node', 'delete', [params]); }, async update(params: IUpdateNodeParams): Promise { - const nodeDump: any = await Rpc.getInstance().request('Node', 'query', [{ path: params.path }]); + const nodeDump: any = await requestSceneService('Node', 'query', [{ path: params.path }]); if (!nodeDump) { throw new Error(`Node not found: ${params.path}`); } @@ -55,7 +55,7 @@ export const NodeProxy: INodeProxy = { if (!propDef) { throw new Error(`Property '${key}' not found on node`); } - await (Rpc.getInstance() as any).request('Node', 'setProperty', [{ + await requestSceneService('Node', 'setProperty', [{ nodePath: params.path, path: key, dump: { ...propDef, value }, @@ -68,7 +68,7 @@ export const NodeProxy: INodeProxy = { if (!nameDef) { throw new Error('Property \'name\' not found on node'); } - await (Rpc.getInstance() as any).request('Node', 'setProperty', [{ + await requestSceneService('Node', 'setProperty', [{ nodePath: params.path, path: 'name', dump: { ...nameDef, value: params.name }, @@ -79,7 +79,7 @@ export const NodeProxy: INodeProxy = { } // After name/path decoupling, rename no longer simply replaces the last path segment // (same-name siblings produce suffixes); must reverse-lookup via UUID from NodePathManager - const realPath = await Rpc.getInstance().request('Node', 'getPathByUuid', [nodeUuid]); + const realPath = await requestSceneService('Node', 'getPathByUuid', [nodeUuid]); if (!realPath) { throw new Error(`Cannot resolve path for node '${nodeUuid}' after rename`); } @@ -89,7 +89,7 @@ export const NodeProxy: INodeProxy = { return { path: currentPath }; }, async query(params?: IQueryNodeParams): Promise { - const result: any = await Rpc.getInstance().request('Node', 'query', [{ + const result: any = await requestSceneService('Node', 'query', [{ path: params?.path ?? '', includeChildren: params?.includeChildren ?? false, includeComponents: params?.includeComponents ?? false, @@ -98,6 +98,6 @@ export const NodeProxy: INodeProxy = { return DumpConverter.toNode(result, { path: params?.path }); }, queryNodeTree(params: IQueryNodeTreeParams): Promise { - return Rpc.getInstance().request('Node', 'queryNodeTree', [params]); + return requestSceneService('Node', 'queryNodeTree', [params]); }, }; diff --git a/src/core/scene/main-process/proxy/prefab-proxy.ts b/src/core/scene/main-process/proxy/prefab-proxy.ts index 667f0141d..5cd96ff1a 100644 --- a/src/core/scene/main-process/proxy/prefab-proxy.ts +++ b/src/core/scene/main-process/proxy/prefab-proxy.ts @@ -6,8 +6,8 @@ import type { IPrefabInfo, } from '../../common'; import { INodeInfo } from '../../common/cli/node'; -import { Rpc } from '../rpc'; import { DumpConverter } from './dump-converter'; +import { requestSceneService } from './scene-authority-request'; export interface IPrefabProxy extends Omit { createPrefabFromNode(params: ICreatePrefabFromNodeParams): Promise; @@ -17,25 +17,25 @@ export interface IPrefabProxy extends Omit { - return Rpc.getInstance().request('Prefab', 'applyPrefabChanges', [params]); + return requestSceneService('Prefab', 'applyPrefabChanges', [params]); }, async createPrefabFromNode(params: ICreatePrefabFromNodeParams): Promise { - const result: any = await Rpc.getInstance().request('Prefab', 'createPrefabFromNode', [params]); + const result: any = await requestSceneService('Prefab', 'createPrefabFromNode', [params]); return DumpConverter.toNode(result); }, async getPrefabInfo(params: IGetPrefabInfoParams): Promise { - const result: any = await Rpc.getInstance().request('Prefab', 'getPrefabInfo', [params]); + const result: any = await requestSceneService('Prefab', 'getPrefabInfo', [params]); if (!result) return null; return DumpConverter.convertPrefab(result); }, isPrefabInstance(params: IIsPrefabInstanceParams): Promise { - return Rpc.getInstance().request('Prefab', 'isPrefabInstance', [params]); + return requestSceneService('Prefab', 'isPrefabInstance', [params]); }, revertToPrefab(params: IRevertToPrefabParams): Promise { - return Rpc.getInstance().request('Prefab', 'revertToPrefab', [params]); + return requestSceneService('Prefab', 'revertToPrefab', [params]); }, async unpackPrefabInstance(params: IUnpackPrefabInstanceParams): Promise { - const result: any = await Rpc.getInstance().request('Prefab', 'unpackPrefabInstance', [params]); + const result: any = await requestSceneService('Prefab', 'unpackPrefabInstance', [params]); return DumpConverter.toNode(result); } -}; \ No newline at end of file +}; diff --git a/src/core/scene/main-process/proxy/scene-authority-request.ts b/src/core/scene/main-process/proxy/scene-authority-request.ts new file mode 100644 index 000000000..bcc9fb580 --- /dev/null +++ b/src/core/scene/main-process/proxy/scene-authority-request.ts @@ -0,0 +1,21 @@ +import { Rpc } from '../rpc'; +import { pinkSceneAuthority } from '../pink-scene-authority'; + +/** + * In PinK, always use its active SceneInstance, which is bound to the WebView + * displayed by the hierarchy. Only standalone CLI runs use the Node worker. + */ +export async function requestSceneService(module: string, method: string, args: unknown[] = []): Promise { + if (pinkSceneAuthority.isHostedByPink()) { + return pinkSceneAuthority.request(module, method, args); + } + + if (pinkSceneAuthority.requiresIdeAuthority()) { + throw new Error( + '[Cocos CLI] IDE scene authority is unavailable. ' + + 'The cocos-code utility process must bind a Scene authority RPC before MCP scene operations.', + ); + } + + return (Rpc.getInstance() as any).request(module, method, args) as Promise; +} diff --git a/src/core/scene/test/lib-scene-authority.test.ts b/src/core/scene/test/lib-scene-authority.test.ts new file mode 100644 index 000000000..2457b9863 --- /dev/null +++ b/src/core/scene/test/lib-scene-authority.test.ts @@ -0,0 +1,29 @@ +jest.mock('../index', () => ({ init: jest.fn().mockResolvedValue(undefined) })); + +import { bindIdeSceneAuthority, bindIdeSceneAuthorityRpc } from '../../../lib/scene/scene'; +import { pinkSceneAuthority } from '../main-process/pink-scene-authority'; + +describe('lib Scene authority integration', () => { + it('binds an in-process IDE authority', async () => { + const binding = bindIdeSceneAuthority({ + getActiveScene: jest.fn(), + queryOpenedScenes: jest.fn(), + open: jest.fn(), + }); + + expect(pinkSceneAuthority.isHostedByPink()).toBe(true); + + binding.dispose(); + expect(pinkSceneAuthority.isHostedByPink()).toBe(false); + }); + + it('accepts the cross-process authority RPC adapter', async () => { + const request = jest.fn(); + const binding = bindIdeSceneAuthorityRpc({ request }); + + expect(pinkSceneAuthority.isHostedByPink()).toBe(true); + + binding.dispose(); + expect(pinkSceneAuthority.isHostedByPink()).toBe(false); + }); +}); diff --git a/src/core/scene/test/scene-authority-request.test.ts b/src/core/scene/test/scene-authority-request.test.ts new file mode 100644 index 000000000..31cdb4e89 --- /dev/null +++ b/src/core/scene/test/scene-authority-request.test.ts @@ -0,0 +1,96 @@ +const rpcRequest = jest.fn(); + +jest.mock('../main-process/rpc', () => ({ + Rpc: { + getInstance: () => ({ request: rpcRequest }), + }, +})); + +import { requestSceneService } from '../main-process/proxy/scene-authority-request'; +import { pinkSceneAuthority } from '../main-process/pink-scene-authority'; + +describe('scene authority request routing', () => { + let dispose: { dispose(): void } | undefined; + + beforeEach(() => { + rpcRequest.mockReset(); + }); + + afterEach(() => { + dispose?.dispose(); + dispose = undefined; + }); + + it('uses the Node worker only outside the PinK extension host', async () => { + rpcRequest.mockResolvedValue({ source: 'worker' }); + + await expect(requestSceneService('Editor', 'queryCurrent')).resolves.toEqual({ source: 'worker' }); + expect(rpcRequest).toHaveBeenCalledWith('Editor', 'queryCurrent', []); + }); + + it('uses the active PinK SceneInstance instead of the Node worker', async () => { + const scene = { + query: jest.fn().mockResolvedValue({ source: 'pink-webview' }), + }; + dispose = pinkSceneAuthority.attach({ + getActiveScene: jest.fn().mockResolvedValue(scene), + queryOpenedScenes: jest.fn().mockResolvedValue([scene]), + open: jest.fn(), + }); + + await expect(requestSceneService('Editor', 'queryCurrent')).resolves.toEqual({ source: 'pink-webview' }); + expect(scene.query).toHaveBeenCalledWith({ path: '', includeChildren: undefined, includeComponents: undefined }); + expect(rpcRequest).not.toHaveBeenCalled(); + }); + + it('returns no partial asset metadata after saving through PinK', async () => { + const scene = { + save: jest.fn().mockResolvedValue(undefined), + }; + dispose = pinkSceneAuthority.attach({ + getActiveScene: jest.fn().mockResolvedValue(scene), + queryOpenedScenes: jest.fn().mockResolvedValue([scene]), + open: jest.fn(), + }); + + await expect(requestSceneService('Editor', 'save', [{}])).resolves.toBeUndefined(); + expect(scene.save).toHaveBeenCalledTimes(1); + expect(rpcRequest).not.toHaveBeenCalled(); + }); + + it('uses the authority RPC adapter supplied by the IDE utility host', async () => { + const request = jest.fn().mockResolvedValue({ source: 'hierarchy-webview' }); + dispose = pinkSceneAuthority.attachRpc({ request }); + + await expect(requestSceneService('Node', 'query', [{ path: '' }])).resolves.toEqual({ source: 'hierarchy-webview' }); + expect(request).toHaveBeenCalledWith('Node', 'query', [{ path: '' }]); + expect(rpcRequest).not.toHaveBeenCalled(); + }); + + it('does not fall back to the worker when PinK has no active scene', async () => { + dispose = pinkSceneAuthority.attach({ + getActiveScene: jest.fn().mockResolvedValue(undefined), + queryOpenedScenes: jest.fn().mockResolvedValue([]), + open: jest.fn(), + }); + + await expect(requestSceneService('Editor', 'queryCurrent')).resolves.toBeNull(); + await expect(requestSceneService('Node', 'query', [{ path: '' }])).rejects.toThrow('No active PinK scene editor'); + expect(rpcRequest).not.toHaveBeenCalled(); + }); + + it('uses the only hierarchy scene when a non-scene editor has focus', async () => { + const scene = { + openEditor: true, + query: jest.fn().mockResolvedValue({ source: 'opened-hierarchy-scene' }), + }; + dispose = pinkSceneAuthority.attach({ + getActiveScene: jest.fn().mockResolvedValue(undefined), + queryOpenedScenes: jest.fn().mockResolvedValue([scene]), + open: jest.fn(), + }); + + await expect(requestSceneService('Editor', 'queryCurrent')).resolves.toEqual({ source: 'opened-hierarchy-scene' }); + expect(rpcRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index 338843cd3..324bbe6e3 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -1,11 +1,54 @@ import { init as sceneInit } from '../../core/scene'; import { GlobalPaths } from '../../global'; +import { pinkSceneAuthority, type IPinkSceneApi, type ISceneAuthorityRpc } from '../../core/scene/main-process/pink-scene-authority'; +import { createPinkSceneAuthorityRpc } from '../../core/scene/main-process/pink-scene-authority-bridge'; + +let ideSceneAuthorityDispose: { dispose(): void } | undefined; + +/** + * Bind the scene API exposed by the IDE runtime. + * + * `src/lib` is loaded by the cocos-code utility process. In that process the + * PinK scene API already routes to the WebView used by Hierarchy, so it is the + * only valid authority for MCP scene operations. This is intentionally a + * facade API, not an extension activation hook. + */ +export function bindIdeSceneAuthority(sceneApi: IPinkSceneApi): { dispose(): void } { + return replaceIdeSceneAuthority(pinkSceneAuthority.attach(sceneApi)); +} + +/** + * Bind the authority RPC supplied by PinK's cocos-code utility host. + * + * The utility process cannot receive a SceneInstance object directly. The + * adapter must forward each request to the IDE process that owns Hierarchy's + * Scene WebView. + */ +export function bindIdeSceneAuthorityRpc(authorityRpc: ISceneAuthorityRpc): { dispose(): void } { + return replaceIdeSceneAuthority(pinkSceneAuthority.attachRpc(authorityRpc)); +} + +function replaceIdeSceneAuthority(registration: { dispose(): void }): { dispose(): void } { + ideSceneAuthorityDispose?.dispose(); + const handle = { + dispose: () => { + if (ideSceneAuthorityDispose === handle) { + ideSceneAuthorityDispose = undefined; + } + registration.dispose(); + }, + }; + ideSceneAuthorityDispose = handle; + console.info('[Cocos CLI] IDE scene authority attached through lib facade.'); + return handle; +} /** * Initialize the scene module. * Registers the scene middleware and initializes scene config. */ export async function init(): Promise { + pinkSceneAuthority.expectIdeAuthority(); await sceneInit(); } @@ -14,7 +57,17 @@ export async function init(): Promise { * * @param projectPath Path to the project directory */ -export async function startupWorker(projectPath: string): Promise { +export async function startupWorker(_projectPath: string): Promise { + if (!pinkSceneAuthority.isHostedByPink()) { + bindIdeSceneAuthorityRpc(createPinkSceneAuthorityRpc(_projectPath)); + console.info('[Cocos CLI] PinK scene authority RPC bridge configured.'); + } + + // The worker remains necessary for the scene WebView's bootstrap services + // (i18n, asset and engine RPC). It must not be used as the MCP authority: + // requestSceneService is already bound above and routes scene operations + // to Hierarchy's SceneInstance instead. const { sceneWorker } = await import('../../core/scene/main-process/scene-worker'); - await sceneWorker.start(GlobalPaths.enginePath, projectPath); + await sceneWorker.start(GlobalPaths.enginePath, _projectPath); + console.info('[Cocos CLI] Scene worker started for Scene WebView infrastructure; MCP uses the PinK authority bridge.'); }