Files
Genarrative/plugins/agc-unity-editor/native/unity-editor-bridge/src/lib.rs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

508 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Unity 插件与 Agent Runtime 共用的执行服务。helper 生命周期与执行不确定门闩分离。
mod transport;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, TryLockError};
use std::time::{Duration, Instant};
use editor_adapter_api::{EditorAdapter, EditorConnectionInfo};
use serde::Deserialize;
use serde_json::{json, Value};
pub const UNITY_EDITOR_ADAPTER_ID: &str = "unity-editor";
pub const PROTOCOL_VERSION: &str = "agc.unity.attach.v1";
pub const MAX_EXECUTE_CODE_BYTES: usize = 128 * 1024;
pub const MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
pub const DEFAULT_COMMAND_TIMEOUT_MS: u32 = 60_000;
const HELPER_SUFFIX: &[&str] = &["dotnet", "publish", "win-x64", "Agc.Unity.Attach.exe"];
pub fn is_supported_platform() -> bool {
cfg!(all(target_os = "windows", target_arch = "x86_64"))
}
fn service() -> &'static UnityEditorService {
static SERVICE: OnceLock<UnityEditorService> = OnceLock::new();
SERVICE.get_or_init(UnityEditorService::default)
}
/// 候选路径由宿主内置插件注册流程提供,模型和插件 RPC 不能改写。
pub fn configure_helper_candidates(candidates: Vec<PathBuf>) -> Result<(), String> {
service().configure(candidates)
}
/// 项目切换只使连接失效;不等待正在执行的 C#,也不清除执行结果不确定状态。
pub fn disconnect_unity_editor() {
service().disconnect();
}
pub fn execute_unity_editor_code_for_project(
project_path: &str,
code: &str,
timeout_ms: u32,
) -> Result<Value, String> {
execution_result(service().call(
"execute",
RpcParams {
project_path: Some(project_path.into()),
code: Some(code.into()),
timeout_ms: Some(timeout_ms),
..Default::default()
},
))
}
#[derive(Default)]
struct UnityEditorService {
candidates: Mutex<Vec<PathBuf>>,
state: Mutex<ServiceState>,
epoch: AtomicU64,
uncertain: AtomicBool,
}
#[derive(Default)]
struct ServiceState {
helper: Option<transport::HelperProcess>,
epoch: u64,
next_id: u64,
}
impl UnityEditorService {
fn configure(&self, candidates: Vec<PathBuf>) -> Result<(), String> {
for candidate in &candidates {
validate_candidate(candidate)?;
}
let mut current = self
.candidates
.lock()
.map_err(|_| "Unity helper 配置锁损坏")?;
if *current != candidates {
*current = candidates;
self.disconnect();
}
Ok(())
}
fn disconnect(&self) {
self.epoch.fetch_add(1, Ordering::SeqCst);
if let Ok(mut state) = self.state.try_lock() {
state.helper.take();
}
}
fn helper_path(&self) -> Result<PathBuf, String> {
let candidates = self
.candidates
.lock()
.map_err(|_| "Unity helper 配置锁损坏")?;
for candidate in candidates.iter() {
if !candidate.is_file() {
continue;
}
validate_candidate(candidate)?;
let metadata = fs::symlink_metadata(candidate).map_err(|_| "Unity helper 不可读")?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("Unity helper 必须是插件包中的普通文件".into());
}
let root = candidate
.ancestors()
.nth(4)
.ok_or("Unity helper 包目录无效")?;
let root = fs::canonicalize(root).map_err(|_| "Unity helper 包目录不可读")?;
let path = fs::canonicalize(candidate).map_err(|_| "Unity helper 不可读")?;
if !path.starts_with(&root) {
return Err("Unity helper 路径越出插件包目录".into());
}
return Ok(path);
}
Err("Unity 插件未提供当前平台的 Attach helper,请先构建或安装完整插件".into())
}
fn call(&self, method: &str, params: RpcParams) -> Result<Value, String> {
if method == "connect" {
// 即使新目标参数错误,也不能继续发布上一条连接。
self.disconnect();
}
let request_epoch = self.epoch.load(Ordering::SeqCst);
let project = normalize_project(params.project_path.as_deref().ok_or("缺少 projectPath")?)?;
let timeout = params.timeout_ms.unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS);
if timeout == 0 || timeout > 60_000 {
return Err("timeoutMs 必须在 1..=60000".into());
}
if params.process_id == Some(0) {
return Err("processId 必须大于 0".into());
}
if method == "execute" {
validate_code(params.code.as_deref().ok_or("缺少 code")?)?;
if self.uncertain.load(Ordering::SeqCst) {
return Ok(reconciliation(
"先前 Unity 执行结果待核对,当前宿主不再发送执行命令",
));
}
} else if params.code.is_some() {
return Err("只有 execute 可以提供 code".into());
}
if method == "disconnect" {
self.disconnect();
return Ok(
json!({"adapter": UNITY_EDITOR_ADAPTER_ID, "connected": false,
"pid": null, "projectPath": project, "version": null,
"startedUtc": null, "generation": null}),
);
}
if !is_supported_platform() {
return Err("Unity Attach 仅支持 Windows x64 Mono Editor".into());
}
// 外层期限包含 helper 启动、stdin 写入和 stdout 读取;不在互斥锁后排队。
let deadline = Instant::now() + Duration::from_millis(u64::from(timeout) + 10_000);
let mut state = match self.state.try_lock() {
Ok(state) => state,
Err(TryLockError::WouldBlock) => {
return Err("Unity 编辑器已有请求正在执行,请等待回执".into())
}
Err(TryLockError::Poisoned(_)) => {
self.uncertain.store(true, Ordering::SeqCst);
return Ok(reconciliation("Unity 执行服务状态异常,需要核对"));
}
};
if method == "execute" && self.uncertain.load(Ordering::SeqCst) {
return Ok(reconciliation("先前 Unity 执行结果待核对"));
}
let epoch = self.epoch.load(Ordering::SeqCst);
if epoch != request_epoch {
return Err("Unity 项目连接在派发前已切换,未发送请求".into());
}
if state.epoch != epoch {
state.helper.take();
state.epoch = epoch;
}
if state.helper.is_none() {
state.helper = Some(transport::HelperProcess::spawn(&self.helper_path()?)?);
}
state.next_id = state.next_id.checked_add(1).ok_or("Unity 请求 id 已耗尽")?;
let id = state.next_id;
let mut input = json!({"projectPath": project, "timeoutMs": timeout});
if let Some(pid) = params.process_id {
input["processId"] = json!(pid);
}
if let Some(code) = params.code {
input["code"] = json!(code);
}
let request = json!({"jsonrpc":"2.0", "protocol": PROTOCOL_VERSION,
"id": id, "method": method, "params": input});
let response = state
.helper
.as_mut()
.expect("helper initialized")
.exchange(&request, deadline);
let result = match response {
Ok(bytes) => parse_response(&bytes, id, method, &project).and_then(|result| {
if method != "execute"
&& params.process_id.is_some_and(|pid| {
result.get("pid").and_then(Value::as_u64) != Some(u64::from(pid))
})
{
return Err("Unity helper 回执 PID 与指定目标不一致".into());
}
Ok(result)
}),
Err(error) => Err(error),
};
let result = self.finish_response(method, result);
// 旧请求的回执仍返回原调用方,但不能留下一条可供新项目复用的连接。
if self.epoch.load(Ordering::SeqCst) != epoch
|| result.is_err()
|| result
.as_ref()
.ok()
.and_then(|v| v.get("status"))
.and_then(Value::as_str)
== Some("needs-reconciliation")
{
state.helper.take();
}
result
}
fn finish_response(
&self,
method: &str,
response: Result<Value, String>,
) -> Result<Value, String> {
if method != "execute" {
return response;
}
match response {
Ok(value)
if value.get("status").and_then(Value::as_str) != Some("needs-reconciliation") =>
{
Ok(value)
}
Ok(value) => {
self.uncertain.store(true, Ordering::SeqCst);
Ok(value)
}
Err(error) => {
self.uncertain.store(true, Ordering::SeqCst);
Ok(reconciliation(&error))
}
}
}
}
fn validate_candidate(candidate: &Path) -> Result<(), String> {
if !candidate.is_absolute()
|| candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err("Unity helper 候选必须是固定插件包内绝对路径".into());
}
let suffix: PathBuf = HELPER_SUFFIX.iter().collect();
if !candidate.ends_with(suffix) {
return Err("Unity helper 只允许固定 dotnet/publish/win-x64/Agc.Unity.Attach.exe".into());
}
Ok(())
}
fn normalize_project(project: &str) -> Result<String, String> {
let path = Path::new(project);
if !path.is_absolute() || !path.is_dir() {
return Err("Unity projectPath 必须是现存绝对目录".into());
}
let path = fs::canonicalize(path).map_err(|_| "Unity 项目目录不可读")?;
for (part, directory) in [
("Assets", true),
("Packages", true),
("ProjectSettings", true),
("ProjectSettings/ProjectVersion.txt", false),
] {
let metadata =
fs::symlink_metadata(path.join(part)).map_err(|_| format!("Unity 项目缺少 {part}"))?;
if metadata.file_type().is_symlink()
|| (directory && !metadata.is_dir())
|| (!directory && !metadata.is_file())
{
return Err(format!(
"Unity 项目标记不是普通{}{part}",
if directory { "目录" } else { "文件" }
));
}
}
Ok(path.to_string_lossy().into_owned())
}
pub fn validate_code(code: &str) -> Result<(), String> {
if code.trim().is_empty() {
return Err("execute.code 不能为空".into());
}
if code.contains('\0') {
return Err("execute.code 不能包含 NUL".into());
}
if code.len() > MAX_EXECUTE_CODE_BYTES {
return Err("execute.code 超过 128 KiB 上限".into());
}
Ok(())
}
fn reconciliation(message: &str) -> Value {
json!({"ok": false, "status": "needs-reconciliation", "retryAllowed": false,
"dispatched": true, "error": {"code":"execution-uncertain", "message": message}})
}
/// call 在可能写入后只返回已核验终态或 needs-reconciliation;剩余 Err 都确认未派发。
fn execution_result(result: Result<Value, String>) -> Result<Value, String> {
Ok(result.unwrap_or_else(|message| {
json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,
"error":{"code":"not-dispatched","message":message}})
}))
}
fn parse_response(bytes: &[u8], id: u64, method: &str, project: &str) -> Result<Value, String> {
if bytes.len() > MAX_MESSAGE_BYTES {
return Err("Unity helper 响应超过 2 MiB".into());
}
let envelope: Value =
serde_json::from_slice(bytes).map_err(|_| "Unity helper 返回无效 JSON")?;
if envelope.get("jsonrpc").and_then(Value::as_str) != Some("2.0")
|| envelope.get("protocol").and_then(Value::as_str) != Some(PROTOCOL_VERSION)
|| envelope.get("id").and_then(Value::as_u64) != Some(id)
{
return Err("Unity helper 响应协议或请求 id 不匹配".into());
}
if envelope.get("error").is_some() {
return Err("Unity helper 返回协议错误,缺少可信操作回执".into());
}
let result = envelope
.get("result")
.filter(|r| r.is_object())
.ok_or("Unity helper 缺少结果对象")?
.clone();
if method == "execute" {
let status = result
.get("status")
.and_then(Value::as_str)
.ok_or("Unity helper 缺少执行状态")?;
let ok = result
.get("ok")
.and_then(Value::as_bool)
.ok_or("Unity helper 缺少执行回执")?;
let dispatched = result
.get("dispatched")
.and_then(Value::as_bool)
.ok_or("Unity helper 缺少发送状态")?;
if result.get("retryAllowed").and_then(Value::as_bool) != Some(false) {
return Err("Unity helper 缺少禁止自动重放的回执".into());
}
match status {
"completed" if ok && dispatched && result.get("result").is_some() => {}
"failed" if !ok && valid_error(result.get("error")) => {}
"needs-reconciliation" if !ok && dispatched && valid_error(result.get("error")) => {}
_ => return Err("Unity helper 返回未知或矛盾的执行状态".into()),
}
} else {
if result.get("adapter").and_then(Value::as_str) != Some(UNITY_EDITOR_ADAPTER_ID) {
return Err("Unity helper 适配器身份不匹配".into());
}
let response_project = result
.get("projectPath")
.and_then(Value::as_str)
.ok_or("Unity helper 缺少项目身份")?;
let same = fs::canonicalize(response_project)
.ok()
.zip(fs::canonicalize(project).ok())
.is_some_and(|(a, b)| a == b);
if !same {
return Err("Unity helper 返回了其它项目的状态".into());
}
let connected = result
.get("connected")
.and_then(Value::as_bool)
.ok_or("Unity helper 缺少连接状态")?;
let pid = result.get("pid").ok_or("Unity helper 缺少进程身份")?;
if !(pid.is_null() || pid.as_u64().is_some_and(|n| n > 0 && n <= u32::MAX as u64)) {
return Err("Unity helper 进程身份无效".into());
}
if !result
.get("version")
.is_some_and(|v| v.is_null() || v.is_string())
{
return Err("Unity helper 版本回执无效".into());
}
if connected
&& (pid.is_null()
|| !nonempty_string(result.get("startedUtc"))
|| !nonempty_string(result.get("generation")))
{
return Err("Unity helper 缺少已连接进程的启动身份或 generation".into());
}
if method == "connect" && !connected {
return Err("Unity helper 未完成连接握手".into());
}
}
Ok(result)
}
fn nonempty_string(value: Option<&Value>) -> bool {
value
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
}
fn valid_error(value: Option<&Value>) -> bool {
value.is_some_and(|v| nonempty_string(v.get("code")) && nonempty_string(v.get("message")))
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RpcParams {
project_path: Option<String>,
process_id: Option<u32>,
code: Option<String>,
timeout_ms: Option<u32>,
}
/// 所有实例只持有配置诊断,实际执行统一进入进程级共享服务。
pub struct UnityEditorAdapter {
configuration_error: Option<String>,
}
impl UnityEditorAdapter {
pub fn new(helper_candidates: Vec<PathBuf>) -> Self {
Self {
configuration_error: if helper_candidates.is_empty() {
None
} else {
configure_helper_candidates(helper_candidates).err()
},
}
}
}
impl Default for UnityEditorAdapter {
fn default() -> Self {
Self::new(Vec::new())
}
}
fn canonical_method(method: &str) -> Result<&str, String> {
let method = method.strip_prefix("editor.").unwrap_or(method);
match method {
"detect" | "connect" | "status" | "execute" | "disconnect" => Ok(method),
_ => Err("Unity 适配器不支持该 RPC 方法".into()),
}
}
impl EditorAdapter for UnityEditorAdapter {
fn id(&self) -> &'static str {
UNITY_EDITOR_ADAPTER_ID
}
fn detect(&self, project_path: &Path) -> Result<EditorConnectionInfo, String> {
serde_json::from_value(self.rpc("detect", json!({"projectPath": project_path}))?)
.map_err(|_| "Unity 探测回执无效".into())
}
fn connect(
&mut self,
pid: u32,
project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
serde_json::from_value(self.rpc(
"connect",
json!({"projectPath":project_path,"processId":pid}),
)?)
.map_err(|_| "Unity 连接回执无效".into())
}
fn disconnect(&mut self) {
disconnect_unity_editor();
}
fn translate_rpc(&self, method: &str, params: Value) -> Result<Value, String> {
Ok(json!({"method":canonical_method(method)?,"params":params}))
}
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
let method = canonical_method(method)?;
if method == "connect" {
disconnect_unity_editor();
}
if let Some(error) = &self.configuration_error {
return if method == "execute" {
execution_result(Err(error.clone()))
} else {
Err(error.clone())
};
}
let result = serde_json::from_value(params)
.map_err(|_| "Unity RPC 参数无效或含未允许字段".to_string())
.and_then(|params| service().call(method, params));
if method == "execute" {
execution_result(result)
} else {
result
}
}
}
#[cfg(test)]
mod tests;