接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
|
||||
```text
|
||||
plugins/
|
||||
├─ agc-godot-editor/ Godot GDExtension 编辑器桥接(Windows x64)
|
||||
│ ├─ src/ AGC 插件协议入口
|
||||
│ └─ native/ GDExtension 载荷、实例缓存与 EditorAdapter
|
||||
├─ agc-unity-editor/ Unity Mono 编辑器桥接(Windows x64)
|
||||
│ ├─ src/ AGC 插件协议入口
|
||||
│ ├─ native/ 通用 EditorAdapter 与 helper 生命周期
|
||||
@@ -78,3 +81,9 @@ feature 会构建自包含 Attach helper,并只将运行文件与许可放入
|
||||
.NET 10 SDK 与 Visual Studio C++ x64 工具链,最终用户不需另装这两项。
|
||||
源码来源、执行归属和验收边界见
|
||||
[Unity 插件接入](../docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)。
|
||||
|
||||
Godot 插件通过 `godot-editor-execute` feature 构建并校验 GDExtension 载荷,只分发
|
||||
运行入口、DLL、元数据及许可。构建机需要 Windows x64 C 工具链;DLL 原件留在安装资源,
|
||||
每个编辑器的临时加载副本放在 AGC 私有缓存。连接时维护工程内受管 `.gdextension`
|
||||
引用及其 UID,通过 Godot 聚焦扫描首次加载。文件归属、真实执行和卸载规则见
|
||||
[Godot 插件接入](<../docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/bin/
|
||||
/.build/
|
||||
@@ -0,0 +1,84 @@
|
||||
param([string]$Compiler = $env:AGC_GODOT_C_COMPILER)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = $PSScriptRoot
|
||||
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Godot editor native payload requires Windows x64.' }
|
||||
if (-not [Environment]::Is64BitProcess) { throw 'A 64-bit build host is required.' }
|
||||
if (-not $Compiler) {
|
||||
foreach ($candidate in @('gcc.exe', 'clang.exe', 'cl.exe')) {
|
||||
$found = Get-Command $candidate -ErrorAction SilentlyContinue
|
||||
if ($found) { $Compiler = $found.Source; break }
|
||||
}
|
||||
}
|
||||
if (-not $Compiler) { throw 'No C compiler found. Install a Windows x64 C toolchain or pass -Compiler.' }
|
||||
$Compiler = (Get-Command $Compiler -ErrorAction Stop).Source
|
||||
$build = Join-Path $root '.build'
|
||||
$output = Join-Path $root 'bin/win-x64'
|
||||
New-Item -ItemType Directory -Path $build,$output -Force | Out-Null
|
||||
$utf8 = [Text.UTF8Encoding]::new($false)
|
||||
$inputs = @('src/native.c','src/bridge.gd','vendor/gdextension_interface.h','vendor/provenance.json','build.ps1')
|
||||
$fingerprint = 'agc.godot.editor.v1/windows/x86_64/c11/O2' + "`n"
|
||||
$fingerprint += 'compiler:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Compiler).Hash.ToLowerInvariant() + "`n"
|
||||
foreach ($inputPath in $inputs) { $fingerprint += $inputPath + ':' + (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root $inputPath)).Hash.ToLowerInvariant() + "`n" }
|
||||
$hasher = [Security.Cryptography.SHA256]::Create()
|
||||
try { $buildId = 'sha256:' + ([BitConverter]::ToString($hasher.ComputeHash($utf8.GetBytes($fingerprint))).Replace('-','').ToLowerInvariant()) } finally { $hasher.Dispose() }
|
||||
$existingDll = Join-Path $output 'agc_godot_editor.dll'
|
||||
$existingMetadata = Join-Path $output 'metadata.json'
|
||||
if ((Test-Path -LiteralPath $existingDll) -and (Test-Path -LiteralPath $existingMetadata)) {
|
||||
try {
|
||||
$existing = Get-Content -LiteralPath $existingMetadata -Raw | ConvertFrom-Json
|
||||
$existingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $existingDll).Hash.ToLowerInvariant()
|
||||
if ($existing.protocol -eq 'agc.godot.editor.v1' -and $existing.buildId -eq $buildId -and $existing.sha256 -eq $existingHash -and $existing.entrySymbol -eq 'agc_godot_editor_init' -and $existing.platform -eq 'windows' -and $existing.arch -eq 'x86_64' -and $existing.minimumGodotVersion -eq '4.7') {
|
||||
Write-Output "Native payload is current: $buildId"
|
||||
return
|
||||
}
|
||||
} catch { Write-Verbose 'Existing metadata could not be verified; rebuilding.' }
|
||||
}
|
||||
$script = [IO.File]::ReadAllBytes((Join-Path $root 'src/bridge.gd'))
|
||||
$embedded = [Text.StringBuilder]::new()
|
||||
[void]$embedded.AppendLine('/* Generated from src/bridge.gd; never reads a project-side script. */')
|
||||
[void]$embedded.AppendLine('#define AGC_BUILD_ID "' + $buildId + '"')
|
||||
[void]$embedded.AppendLine('static const unsigned char AGC_EMBEDDED_BRIDGE[] = {')
|
||||
for ($index = 0; $index -lt $script.Length; $index += 32) {
|
||||
$last = [Math]::Min($index + 31, $script.Length - 1)
|
||||
[void]$embedded.AppendLine(($script[$index..$last] -join ',') + ',')
|
||||
}
|
||||
[void]$embedded.AppendLine('0};')
|
||||
[IO.File]::WriteAllText((Join-Path $build 'embedded_bridge.h'), $embedded.ToString(), $utf8)
|
||||
$previousTemp = $env:TEMP
|
||||
$previousTmp = $env:TMP
|
||||
$previousLocation = Get-Location
|
||||
try {
|
||||
$env:TEMP = $build
|
||||
$env:TMP = $build
|
||||
Set-Location -LiteralPath $build
|
||||
$source = Join-Path $root 'src/native.c'
|
||||
$vendor = Join-Path $root 'vendor'
|
||||
$temporaryDll = Join-Path $build 'agc_godot_editor.dll'
|
||||
$compilerName = [IO.Path]::GetFileName($Compiler).ToLowerInvariant()
|
||||
if ($compilerName -eq 'cl.exe') {
|
||||
& $Compiler /nologo /std:c11 /O2 /W4 /WX /LD /D_CRT_SECURE_NO_WARNINGS "/I$vendor" "/I$build" $source "/Fe:$temporaryDll" /link /Brepro
|
||||
} else {
|
||||
$flags = @('-std=c11','-O2','-Wall','-Wextra','-Werror','-shared')
|
||||
if ($compilerName -eq 'gcc.exe') { $flags += @('-static-libgcc','-Wl,--no-insert-timestamp') }
|
||||
& $Compiler @flags -I $vendor -I $build $source -o $temporaryDll
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Native compiler exited with $LASTEXITCODE" }
|
||||
$dll = Join-Path $output 'agc_godot_editor.dll'
|
||||
Copy-Item -LiteralPath $temporaryDll -Destination $dll -Force
|
||||
$metadata = [ordered]@{
|
||||
protocol = 'agc.godot.editor.v1'
|
||||
buildId = $buildId
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dll).Hash.ToLowerInvariant()
|
||||
platform = 'windows'
|
||||
arch = 'x86_64'
|
||||
entrySymbol = 'agc_godot_editor_init'
|
||||
minimumGodotVersion = '4.7'
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $output 'metadata.json'), ($metadata | ConvertTo-Json) + "`n", $utf8)
|
||||
Write-Output "Built $dll"
|
||||
Write-Output "Build identity: $buildId"
|
||||
} finally {
|
||||
Set-Location -LiteralPath $previousLocation
|
||||
$env:TEMP = $previousTemp
|
||||
$env:TMP = $previousTmp
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
const PROTOCOL := "agc.godot.editor.v1"
|
||||
const DESCRIPTOR := "res://agc-editor-bridge.gdextension"
|
||||
const MAX_MESSAGE := 2 * 1024 * 1024
|
||||
const MAX_CODE := 128 * 1024
|
||||
const MAX_PEERS := 8
|
||||
const NODE_NAME := "_AGC_GODOT_EDITOR_BRIDGE"
|
||||
const OWNER_META := "_agc_godot_editor_protocol"
|
||||
|
||||
class Capture extends Logger:
|
||||
var mutex := Mutex.new()
|
||||
var entries: Array = []
|
||||
var used := 0
|
||||
var had_error := false
|
||||
var truncated := false
|
||||
var secret := ""
|
||||
|
||||
func add(level: String, text: String, failure: bool) -> void:
|
||||
mutex.lock()
|
||||
had_error = had_error or failure
|
||||
var cleaned := text.replace(secret, "[redacted]") if not secret.is_empty() else text
|
||||
cleaned = cleaned.left(4096)
|
||||
var size := cleaned.to_utf8_buffer().size()
|
||||
if entries.size() < 128 and used + size <= 65536:
|
||||
entries.append({"level": level, "message": cleaned})
|
||||
used += size
|
||||
else:
|
||||
truncated = true
|
||||
mutex.unlock()
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
mutex.lock()
|
||||
var result := {"logs": entries.duplicate(true), "failed": had_error, "truncated": truncated}
|
||||
mutex.unlock()
|
||||
return result
|
||||
|
||||
func _log_message(message: String, error: bool) -> void:
|
||||
add("error" if error else "info", message, error)
|
||||
|
||||
func _log_error(function: String, file: String, line: int, code: String, rationale: String, _notify: bool, error_type: int, _backtraces: Array[ScriptBacktrace]) -> void:
|
||||
var failure := error_type != Logger.ERROR_TYPE_WARNING
|
||||
add("error" if failure else "warning", "%s (%s:%d %s)" % [rationale if not rationale.is_empty() else code, file, line, function], failure)
|
||||
|
||||
var server := TCPServer.new()
|
||||
var peers: Array = []
|
||||
var session: Dictionary = {}
|
||||
var session_path := ""
|
||||
var bridge_ready := false
|
||||
var busy := false
|
||||
var shutting_down := false
|
||||
var native_removed := false
|
||||
var shutdown_scheduled := false
|
||||
var execution: Dictionary = {}
|
||||
var evaluator: RefCounted
|
||||
var evaluation_script: GDScript
|
||||
var capture: Capture
|
||||
|
||||
# Called once by the native entry point, deferred beyond extension initialization.
|
||||
func bootstrap(build_id: String, started_file_time: String, cache_path: String) -> void:
|
||||
if not Engine.is_editor_hint():
|
||||
queue_free()
|
||||
return
|
||||
var root := EditorInterface.get_base_control().get_tree().root
|
||||
var previous := root.get_node_or_null(NODE_NAME)
|
||||
if previous != null:
|
||||
if previous.get_meta(OWNER_META, "") != PROTOCOL or not previous.has_method("_retire_for_handoff"):
|
||||
_bootstrap_failed("godot_bridge_name_conflict", "桥节点名称已被其它对象占用,未接管。")
|
||||
return
|
||||
if previous.get("busy") == true:
|
||||
_bootstrap_failed("godot_bridge_busy", "旧桥仍在执行,禁止覆盖或卸载。")
|
||||
return
|
||||
if not previous.call("_retire_for_handoff"):
|
||||
_bootstrap_failed("godot_bridge_generation_conflict", "旧桥尚未停机,未替换其会话。")
|
||||
return
|
||||
name = NODE_NAME
|
||||
set_meta(OWNER_META, PROTOCOL)
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
root.add_child(self, false, Node.INTERNAL_MODE_BACK)
|
||||
owner = null
|
||||
var generation_bytes := Crypto.new().generate_random_bytes(32)
|
||||
var token_bytes := Crypto.new().generate_random_bytes(32)
|
||||
if generation_bytes.size() != 32 or token_bytes.size() != 32:
|
||||
_bootstrap_failed("godot_bridge_entropy_failed", "无法创建安全会话身份。")
|
||||
return
|
||||
if server.listen(0, "127.0.0.1") != OK:
|
||||
_bootstrap_failed("godot_bridge_listen_failed", "无法监听本机回环端口。")
|
||||
return
|
||||
session_path = cache_path
|
||||
var engine_version := Engine.get_version_info()
|
||||
session = {"protocol": PROTOCOL, "buildId": build_id,
|
||||
"pid": OS.get_process_id(), "startedFileTime": started_file_time,
|
||||
"generation": generation_bytes.hex_encode(),
|
||||
"projectPath": ProjectSettings.globalize_path("res://").trim_suffix("/"),
|
||||
"version": "%d.%d.%d" % [engine_version.major, engine_version.minor, engine_version.patch],
|
||||
"port": server.get_local_port(), "token": token_bytes.hex_encode()}
|
||||
if not _write_session():
|
||||
server.stop()
|
||||
_bootstrap_failed("godot_bridge_cache_failed", "无法安全写入会话缓存。")
|
||||
return
|
||||
bridge_ready = true
|
||||
set_process(true)
|
||||
|
||||
func _bootstrap_failed(code: String, message: String) -> void:
|
||||
push_error("AGC Godot: %s: %s" % [code, message])
|
||||
queue_free()
|
||||
|
||||
# Only an already stopped, non-executing generation may surrender its fixed name.
|
||||
func _retire_for_handoff() -> bool:
|
||||
if busy or not (is_queued_for_deletion() or native_removed or (shutting_down and not server.is_listening())):
|
||||
return false
|
||||
_detach_retired_node()
|
||||
return true
|
||||
|
||||
func _detach_retired_node() -> void:
|
||||
if busy:
|
||||
return
|
||||
bridge_ready = false
|
||||
shutting_down = true
|
||||
set_process(false)
|
||||
_remove_session()
|
||||
server.stop()
|
||||
for connection in peers.duplicate():
|
||||
_drop_peer(connection)
|
||||
var parent := get_parent()
|
||||
if parent != null:
|
||||
# queue_free is end-of-frame; detach now so a same-flush bootstrap can claim the name.
|
||||
name = NODE_NAME + "_retired_" + str(get_instance_id())
|
||||
parent.remove_child(self)
|
||||
if not is_queued_for_deletion():
|
||||
queue_free()
|
||||
|
||||
func _is_link(path: String) -> bool:
|
||||
var directory := DirAccess.open(path.get_base_dir())
|
||||
return directory == null or directory.is_link(path.get_file())
|
||||
|
||||
func _write_session() -> bool:
|
||||
# The native side checks every Windows path component for reparse points.
|
||||
var directory := session_path.get_base_dir()
|
||||
if _is_link(directory) or _is_link(directory.get_base_dir()) or _is_link(session_path):
|
||||
return false
|
||||
var temporary := session_path + "." + str(session.generation) + ".tmp"
|
||||
if FileAccess.file_exists(temporary) or _is_link(temporary):
|
||||
return false
|
||||
var file := FileAccess.open(temporary, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_string(JSON.stringify(session))
|
||||
file.flush()
|
||||
var error := file.get_error()
|
||||
file.close()
|
||||
if error != OK or DirAccess.rename_absolute(temporary, session_path) != OK:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return false
|
||||
return true
|
||||
|
||||
func _remove_session() -> void:
|
||||
if session_path.is_empty() or session.is_empty():
|
||||
return
|
||||
var directory := session_path.get_base_dir()
|
||||
if _is_link(directory) or _is_link(directory.get_base_dir()) or _is_link(session_path):
|
||||
return
|
||||
var file := FileAccess.open(session_path, FileAccess.READ)
|
||||
if file == null or file.get_length() > 65536:
|
||||
return
|
||||
var stored: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if stored is Dictionary and stored.get("protocol") == PROTOCOL and stored.get("generation") == session.get("generation") and stored.get("pid") == OS.get_process_id():
|
||||
DirAccess.remove_absolute(session_path)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not bridge_ready:
|
||||
return
|
||||
if busy and not execution.get("replied", false) and Time.get_ticks_msec() >= int(execution.deadline):
|
||||
_reply_execution(_failure("godot_execution_timeout", "执行超过期限,状态待核对;不会自动重试。", true, "needs-reconciliation"))
|
||||
if server.is_listening():
|
||||
while server.is_connection_available():
|
||||
var incoming := server.take_connection()
|
||||
if shutting_down or peers.size() >= MAX_PEERS:
|
||||
incoming.disconnect_from_host()
|
||||
else:
|
||||
peers.append({"socket": incoming, "rx": PackedByteArray(), "tx": PackedByteArray(), "last": Time.get_ticks_msec(), "shutdown": false})
|
||||
for connection in peers.duplicate():
|
||||
_poll_peer(connection)
|
||||
|
||||
func _drop_peer(connection: Dictionary) -> void:
|
||||
connection.socket.disconnect_from_host()
|
||||
peers.erase(connection)
|
||||
|
||||
func _poll_peer(connection: Dictionary) -> void:
|
||||
var socket: StreamPeerTCP = connection.socket
|
||||
socket.poll()
|
||||
if socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
var available := socket.get_available_bytes()
|
||||
if available > 0:
|
||||
if connection.rx.size() + available > MAX_MESSAGE:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
var packet: Array = socket.get_data(available)
|
||||
if packet[0] != OK:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
connection.rx.append_array(packet[1])
|
||||
connection.last = Time.get_ticks_msec()
|
||||
if connection.tx.is_empty() and not connection.shutdown:
|
||||
var newline: int = connection.rx.find(10)
|
||||
if newline >= 0:
|
||||
var line: String = connection.rx.slice(0, newline).get_string_from_utf8()
|
||||
connection.rx = connection.rx.slice(newline + 1)
|
||||
_dispatch(connection, line)
|
||||
if not connection.tx.is_empty():
|
||||
var sent: Array = socket.put_partial_data(connection.tx)
|
||||
if sent[0] != OK:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
connection.tx = connection.tx.slice(int(sent[1]))
|
||||
if connection.tx.is_empty() and connection.shutdown and not shutdown_scheduled:
|
||||
shutdown_scheduled = true
|
||||
_finish_shutdown.call_deferred()
|
||||
if not busy and Time.get_ticks_msec() - int(connection.last) > 65000:
|
||||
_drop_peer(connection)
|
||||
|
||||
func _dispatch(connection: Dictionary, text: String) -> void:
|
||||
# Godot strings replace U+0000; reject it before JSON parsing can erase that evidence.
|
||||
var cursor := 0
|
||||
while cursor < text.length():
|
||||
if text.unicode_at(cursor) == 0:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
if text.unicode_at(cursor) == 92:
|
||||
if text.substr(cursor, 6).to_lower() == "\\u0000":
|
||||
_drop_peer(connection)
|
||||
return
|
||||
cursor += 1
|
||||
cursor += 1
|
||||
var request: Variant = JSON.parse_string(text)
|
||||
if not request is Dictionary or request.get("protocol") != PROTOCOL or request.get("generation") != session.generation or request.get("token") != session.token:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
var id: Variant = request.get("id")
|
||||
if not (id is float or id is int) or id < 1 or id != floor(id) or id > 9007199254740991:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
var params: Variant = request.get("params")
|
||||
if not params is Dictionary:
|
||||
_send(connection, int(id), _failure("godot_invalid_params", "params 必须是对象。", false))
|
||||
return
|
||||
match request.get("method", ""):
|
||||
"status":
|
||||
if not params.is_empty():
|
||||
_send(connection, int(id), _failure("godot_invalid_params", "status.params 必须为空。", false))
|
||||
return
|
||||
_send(connection, int(id), {"connected": true, "pid": session.pid,
|
||||
"projectPath": session.projectPath, "version": session.version,
|
||||
"generation": session.generation, "buildId": session.buildId, "executing": busy})
|
||||
"shutdown":
|
||||
if not params.is_empty() or busy:
|
||||
_send(connection, int(id), {"accepted": false, "error": {"code": "godot_execution_in_progress" if busy else "godot_invalid_params", "message": "执行尚未结束,不能卸载。" if busy else "shutdown.params 必须为空。"}})
|
||||
return
|
||||
shutting_down = true
|
||||
connection.shutdown = true
|
||||
_send(connection, int(id), {"accepted": true, "status": "shutting-down"})
|
||||
"execute":
|
||||
var code: Variant = params.get("code")
|
||||
var timeout: Variant = params.get("timeoutMs")
|
||||
if params.size() != 2 or not code is String or code.strip_edges().is_empty() or code.to_utf8_buffer().has(0) or code.to_utf8_buffer().size() > MAX_CODE or not (timeout is int or timeout is float) or timeout != floor(timeout) or timeout < 1 or timeout > 60000:
|
||||
_send(connection, int(id), _failure("godot_invalid_params", "code 或 timeoutMs 不符合执行协议。", false))
|
||||
return
|
||||
if busy or shutting_down or native_removed:
|
||||
_send(connection, int(id), _failure("godot_execution_in_progress", "已有执行或正在关闭,不接受新的执行。", false))
|
||||
return
|
||||
busy = true
|
||||
execution = {"connection": connection, "id": int(id), "deadline": Time.get_ticks_msec() + int(timeout), "replied": false}
|
||||
_execute.call_deferred(code)
|
||||
_:
|
||||
_send(connection, int(id), _failure("godot_unknown_method", "未知方法。", false))
|
||||
|
||||
func _failure(code: String, message: String, dispatched: bool, status := "failed") -> Dictionary:
|
||||
return {"ok": false, "status": status, "dispatched": dispatched, "retryAllowed": false, "error": {"code": code, "message": message}}
|
||||
|
||||
func _execute(code: String) -> void:
|
||||
if Time.get_ticks_msec() >= int(execution.deadline):
|
||||
_reply_execution(_failure("godot_execution_expired", "执行前期限已耗尽。", false))
|
||||
_complete_execution()
|
||||
return
|
||||
capture = Capture.new()
|
||||
capture.secret = session.token
|
||||
OS.add_logger(capture)
|
||||
evaluation_script = GDScript.new()
|
||||
var source := "@tool\nextends RefCounted\nfunc run():\n"
|
||||
for line in code.split("\n"):
|
||||
source += "\t" + line + "\n"
|
||||
evaluation_script.source_code = source
|
||||
var compile_error := evaluation_script.reload()
|
||||
if compile_error != OK:
|
||||
_reply_execution(_with_logs(_failure("godot_compile_error", "GDScript 编译失败。", true)))
|
||||
_complete_execution()
|
||||
return
|
||||
evaluator = evaluation_script.new()
|
||||
if evaluator == null:
|
||||
_reply_execution(_with_logs(_failure("godot_script_creation_failed", "无法创建执行实例。", true)))
|
||||
_complete_execution()
|
||||
return
|
||||
# Await also accepts immediate values; keep all strong references and busy until completion.
|
||||
var value: Variant = await evaluator.call("run")
|
||||
var captured := capture.snapshot()
|
||||
if captured.failed:
|
||||
_reply_execution(_with_logs(_failure("godot_runtime_error", "GDScript 运行失败。", true)))
|
||||
else:
|
||||
var budget := {"bytes": 0, "nodes": 0, "failed": false}
|
||||
var safe_value: Variant = _json_value(value, 0, [], budget)
|
||||
if budget.failed:
|
||||
_reply_execution(_with_logs(_failure("godot_result_not_serializable", "执行结果无法在有界 JSON 回执内表示。", true)))
|
||||
else:
|
||||
_reply_execution(_with_logs({"ok": true, "status": "completed", "dispatched": true, "retryAllowed": false, "result": safe_value}))
|
||||
_complete_execution()
|
||||
|
||||
func _with_logs(result: Dictionary) -> Dictionary:
|
||||
if capture != null:
|
||||
var data := capture.snapshot()
|
||||
result.logs = data.logs
|
||||
result.logsTruncated = data.truncated
|
||||
return result
|
||||
|
||||
func _json_value(value: Variant, depth: int, ancestors: Array, budget: Dictionary) -> Variant:
|
||||
budget.nodes += 1
|
||||
if depth > 24 or budget.nodes > 50000 or budget.bytes > MAX_MESSAGE - 131072:
|
||||
budget.failed = true
|
||||
return null
|
||||
match typeof(value):
|
||||
TYPE_NIL, TYPE_BOOL, TYPE_INT:
|
||||
budget.bytes += 24
|
||||
return value
|
||||
TYPE_FLOAT:
|
||||
if not is_finite(value):
|
||||
budget.failed = true
|
||||
budget.bytes += 32
|
||||
return value
|
||||
TYPE_STRING, TYPE_STRING_NAME:
|
||||
var text := str(value)
|
||||
if text.to_utf8_buffer().size() > MAX_MESSAGE - 131072:
|
||||
budget.failed = true
|
||||
return null
|
||||
budget.bytes += JSON.stringify(text).to_utf8_buffer().size()
|
||||
if budget.bytes > MAX_MESSAGE - 131072:
|
||||
budget.failed = true
|
||||
return text
|
||||
TYPE_ARRAY, TYPE_DICTIONARY:
|
||||
for ancestor in ancestors:
|
||||
if is_same(value, ancestor):
|
||||
budget.failed = true
|
||||
return null
|
||||
var next := ancestors.duplicate()
|
||||
next.append(value)
|
||||
if value is Array:
|
||||
var result: Array = []
|
||||
for item in value:
|
||||
result.append(_json_value(item, depth + 1, next, budget))
|
||||
if budget.failed:
|
||||
break
|
||||
return result
|
||||
var result: Dictionary = {}
|
||||
for key in value:
|
||||
if not (key is String or key is StringName):
|
||||
budget.failed = true
|
||||
break
|
||||
var safe_key: Variant = _json_value(str(key), depth + 1, next, budget)
|
||||
result[safe_key] = _json_value(value[key], depth + 1, next, budget)
|
||||
if budget.failed:
|
||||
break
|
||||
return result
|
||||
_:
|
||||
budget.failed = true
|
||||
return null
|
||||
|
||||
func _reply_execution(result: Dictionary) -> void:
|
||||
if execution.get("replied", true):
|
||||
return
|
||||
execution.replied = true
|
||||
var connection: Dictionary = execution.connection
|
||||
if peers.has(connection):
|
||||
_send(connection, int(execution.id), result)
|
||||
|
||||
func _complete_execution() -> void:
|
||||
if capture != null:
|
||||
OS.remove_logger(capture)
|
||||
capture = null
|
||||
evaluator = null
|
||||
evaluation_script = null
|
||||
busy = false
|
||||
execution = {}
|
||||
if native_removed:
|
||||
_detach_retired_node()
|
||||
|
||||
func _send(connection: Dictionary, id: int, result: Dictionary) -> void:
|
||||
var envelope := {"protocol": PROTOCOL, "id": id, "generation": session.generation,
|
||||
"pid": session.pid, "projectPath": session.projectPath, "buildId": session.buildId, "result": result}
|
||||
var bytes := (JSON.stringify(envelope) + "\n").to_utf8_buffer()
|
||||
if bytes.size() > MAX_MESSAGE:
|
||||
envelope.result = _failure("godot_result_too_large", "执行回执超过 2 MiB。", true)
|
||||
bytes = (JSON.stringify(envelope) + "\n").to_utf8_buffer()
|
||||
if connection.tx.size() + bytes.size() > MAX_MESSAGE:
|
||||
_drop_peer(connection)
|
||||
return
|
||||
connection.tx.append_array(bytes)
|
||||
|
||||
func _finish_shutdown() -> void:
|
||||
if busy:
|
||||
return
|
||||
_remove_session()
|
||||
server.stop()
|
||||
for connection in peers.duplicate():
|
||||
_drop_peer(connection)
|
||||
GDExtensionManager.unload_extension(DESCRIPTOR)
|
||||
_detach_retired_node()
|
||||
|
||||
func native_deinitialize() -> void:
|
||||
native_removed = true
|
||||
shutting_down = true
|
||||
_remove_session()
|
||||
server.stop()
|
||||
for connection in peers.duplicate():
|
||||
_drop_peer(connection)
|
||||
if not busy:
|
||||
_detach_retired_node()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_remove_session()
|
||||
server.stop()
|
||||
for connection in peers.duplicate():
|
||||
_drop_peer(connection)
|
||||
if capture != null:
|
||||
OS.remove_logger(capture)
|
||||
@@ -0,0 +1,257 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
#include "gdextension_interface.h"
|
||||
#include "embedded_bridge.h"
|
||||
|
||||
/* Windows x64 ABI storage is deliberately oversized and naturally aligned.
|
||||
* Objects are constructed/destructed solely through the official interface. */
|
||||
typedef union { max_align_t alignment; unsigned char bytes[128]; } Storage;
|
||||
static GDExtensionInterfacePrintWarning api_warning;
|
||||
static GDExtensionInterfaceVariantCall api_call;
|
||||
static GDExtensionInterfaceVariantDestroy api_destroy;
|
||||
static GDExtensionInterfaceVariantGetType api_type;
|
||||
static GDExtensionInterfaceGlobalGetSingleton api_singleton;
|
||||
static GDExtensionInterfaceStringNameNewWithLatin1Chars api_name;
|
||||
static GDExtensionInterfaceStringNewWithUtf8Chars api_string;
|
||||
static GDExtensionInterfaceStringToUtf8Chars api_utf8;
|
||||
static GDExtensionVariantFromTypeConstructorFunc from_object, from_string, from_name;
|
||||
static GDExtensionTypeFromVariantConstructorFunc to_int, to_string;
|
||||
static GDExtensionPtrDestructor destroy_name, destroy_string;
|
||||
static Storage retained_script, retained_node;
|
||||
static int script_live, node_live, started;
|
||||
|
||||
static void report_failure(const char *operation, int code) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message), "AGC Godot editor bridge: %s failed (%d).", operation, code);
|
||||
if (api_warning) api_warning(message, "agc_godot_editor", "native.c", 0, 0);
|
||||
}
|
||||
|
||||
static void name_variant(Storage *out, const char *text) {
|
||||
Storage name;
|
||||
api_name(&name, text, 0);
|
||||
from_name(out, &name);
|
||||
destroy_name(&name);
|
||||
}
|
||||
|
||||
static void string_variant(Storage *out, const char *text) {
|
||||
Storage string;
|
||||
api_string(&string, text);
|
||||
from_string(out, &string);
|
||||
destroy_string(&string);
|
||||
}
|
||||
|
||||
static int invoke(Storage *receiver, const char *method,
|
||||
const GDExtensionConstVariantPtr *arguments, int count, Storage *out) {
|
||||
Storage name;
|
||||
GDExtensionCallError error = { GDEXTENSION_CALL_OK, 0, 0 };
|
||||
api_name(&name, method, 0);
|
||||
api_call(receiver, &name, arguments, count, out, &error);
|
||||
destroy_name(&name);
|
||||
if (error.error != GDEXTENSION_CALL_OK) {
|
||||
report_failure(method, (int)error.error);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int singleton_variant(Storage *out, const char *text) {
|
||||
Storage name;
|
||||
api_name(&name, text, 0);
|
||||
GDExtensionObjectPtr object = api_singleton(&name);
|
||||
destroy_name(&name);
|
||||
if (!object) return 0;
|
||||
from_object(out, &object);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static char *variant_utf8(Storage *value) {
|
||||
if (api_type(value) != GDEXTENSION_VARIANT_TYPE_STRING) return NULL;
|
||||
Storage string;
|
||||
to_string(&string, value);
|
||||
GDExtensionInt length = api_utf8(&string, NULL, 0);
|
||||
char *text = NULL;
|
||||
if (length >= 0 && length < 131072) {
|
||||
text = (char *)malloc((size_t)length + 1);
|
||||
if (text) {
|
||||
api_utf8(&string, text, length);
|
||||
text[length] = '\0';
|
||||
}
|
||||
}
|
||||
destroy_string(&string);
|
||||
return text;
|
||||
}
|
||||
|
||||
static int plain_directory(const wchar_t *path) {
|
||||
DWORD attrs = GetFileAttributesW(path);
|
||||
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) &&
|
||||
!(attrs & FILE_ATTRIBUTE_REPARSE_POINT);
|
||||
}
|
||||
|
||||
static int ensure_cache_directory(wchar_t *path, size_t capacity, const wchar_t *part) {
|
||||
size_t length = wcslen(path), addition = wcslen(part);
|
||||
if (length + addition + 2 >= capacity) return 0;
|
||||
if (length && path[length - 1] != L'\\') path[length++] = L'\\';
|
||||
memcpy(path + length, part, (addition + 1) * sizeof(wchar_t));
|
||||
if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) return 0;
|
||||
return plain_directory(path);
|
||||
}
|
||||
|
||||
static char *prepare_cache_path(void) {
|
||||
Storage settings, argument, result;
|
||||
if (!singleton_variant(&settings, "ProjectSettings")) return NULL;
|
||||
string_variant(&argument, "res://");
|
||||
const GDExtensionConstVariantPtr args[] = { &argument };
|
||||
int ok = invoke(&settings, "globalize_path", args, 1, &result);
|
||||
char *root_utf8 = ok ? variant_utf8(&result) : NULL;
|
||||
api_destroy(&result);
|
||||
api_destroy(&argument);
|
||||
api_destroy(&settings);
|
||||
if (!root_utf8) return NULL;
|
||||
wchar_t path[32768];
|
||||
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, root_utf8, -1, path, 32768);
|
||||
free(root_utf8);
|
||||
if (length < 4 || path[1] != L':') return NULL;
|
||||
for (int index = 0; index < length; ++index) if (path[index] == L'/') path[index] = L'\\';
|
||||
/* Reject links/junctions in every existing directory, including project ancestors. */
|
||||
for (int index = 3; index < length; ++index) {
|
||||
if (path[index] != L'\\' && path[index] != L'\0') continue;
|
||||
wchar_t saved = path[index];
|
||||
path[index] = L'\0';
|
||||
int plain = plain_directory(path);
|
||||
path[index] = saved;
|
||||
if (!plain) return NULL;
|
||||
}
|
||||
if (!ensure_cache_directory(path, 32768, L".godot") ||
|
||||
!ensure_cache_directory(path, 32768, L"agc")) return NULL;
|
||||
wchar_t suffix[96];
|
||||
swprintf(suffix, 96, L"\\editor-bridge-%lu.json", (unsigned long)GetCurrentProcessId());
|
||||
if (wcslen(path) + wcslen(suffix) + 1 >= 32768) return NULL;
|
||||
wcscat(path, suffix);
|
||||
DWORD attrs = GetFileAttributesW(path);
|
||||
if (attrs != INVALID_FILE_ATTRIBUTES && (attrs & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY))) return NULL;
|
||||
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, NULL, 0, NULL, NULL);
|
||||
if (size <= 0) return NULL;
|
||||
char *cache = (char *)malloc((size_t)size);
|
||||
if (cache) WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, cache, size, NULL, NULL);
|
||||
return cache;
|
||||
}
|
||||
|
||||
static void release_references(void) {
|
||||
if (node_live) { api_destroy(&retained_node); node_live = 0; }
|
||||
if (script_live) { api_destroy(&retained_script); script_live = 0; }
|
||||
}
|
||||
|
||||
static int schedule_bridge(void) {
|
||||
FILETIME creation, exit_time, kernel, user;
|
||||
if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel, &user)) return 0;
|
||||
ULARGE_INTEGER timestamp;
|
||||
timestamp.LowPart = creation.dwLowDateTime;
|
||||
timestamp.HighPart = creation.dwHighDateTime;
|
||||
char started_file_time[32];
|
||||
snprintf(started_file_time, sizeof(started_file_time), "%llu", (unsigned long long)timestamp.QuadPart);
|
||||
char *cache_path = prepare_cache_path();
|
||||
if (!cache_path) { report_failure("session_cache_path", 0); return 0; }
|
||||
Storage classdb, class_arg, result, source;
|
||||
if (!singleton_variant(&classdb, "ClassDB")) { free(cache_path); return 0; }
|
||||
name_variant(&class_arg, "GDScript");
|
||||
const GDExtensionConstVariantPtr class_args[] = { &class_arg };
|
||||
int ok = invoke(&classdb, "instantiate", class_args, 1, &retained_script);
|
||||
script_live = 1;
|
||||
api_destroy(&class_arg);
|
||||
api_destroy(&classdb);
|
||||
if (!ok || api_type(&retained_script) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; }
|
||||
string_variant(&source, (const char *)AGC_EMBEDDED_BRIDGE);
|
||||
const GDExtensionConstVariantPtr source_args[] = { &source };
|
||||
ok = invoke(&retained_script, "set_source_code", source_args, 1, &result);
|
||||
api_destroy(&result);
|
||||
api_destroy(&source);
|
||||
if (!ok) { free(cache_path); return 0; }
|
||||
ok = invoke(&retained_script, "reload", NULL, 0, &result);
|
||||
int64_t reload_error = -1;
|
||||
if (ok && api_type(&result) == GDEXTENSION_VARIANT_TYPE_INT) to_int(&reload_error, &result);
|
||||
api_destroy(&result);
|
||||
if (!ok || reload_error != 0) { free(cache_path); report_failure("bridge_compile", (int)reload_error); return 0; }
|
||||
ok = invoke(&retained_script, "new", NULL, 0, &retained_node);
|
||||
node_live = 1;
|
||||
if (!ok || api_type(&retained_node) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; }
|
||||
Storage method, build, process_identity, cache;
|
||||
name_variant(&method, "bootstrap");
|
||||
string_variant(&build, AGC_BUILD_ID);
|
||||
string_variant(&process_identity, started_file_time);
|
||||
string_variant(&cache, cache_path);
|
||||
free(cache_path);
|
||||
const GDExtensionConstVariantPtr deferred[] = { &method, &build, &process_identity, &cache };
|
||||
ok = invoke(&retained_node, "call_deferred", deferred, 4, &result);
|
||||
api_destroy(&result);
|
||||
api_destroy(&method);
|
||||
api_destroy(&build);
|
||||
api_destroy(&process_identity);
|
||||
api_destroy(&cache);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void initialize_bridge(void *userdata, GDExtensionInitializationLevel level) {
|
||||
(void)userdata;
|
||||
if (level != GDEXTENSION_INITIALIZATION_EDITOR || started) return;
|
||||
started = 1;
|
||||
if (!schedule_bridge()) release_references();
|
||||
}
|
||||
|
||||
static void deinitialize_bridge(void *userdata, GDExtensionInitializationLevel level) {
|
||||
(void)userdata;
|
||||
if (level != GDEXTENSION_INITIALIZATION_EDITOR) return;
|
||||
if (node_live && api_type(&retained_node) == GDEXTENSION_VARIANT_TYPE_OBJECT) {
|
||||
Storage returned;
|
||||
invoke(&retained_node, "native_deinitialize", NULL, 0, &returned);
|
||||
api_destroy(&returned);
|
||||
}
|
||||
release_references();
|
||||
}
|
||||
|
||||
__declspec(dllexport) GDExtensionBool agc_godot_editor_init(
|
||||
GDExtensionInterfaceGetProcAddress get_proc_address,
|
||||
GDExtensionClassLibraryPtr library,
|
||||
GDExtensionInitialization *initialization) {
|
||||
(void)library;
|
||||
if (!get_proc_address || !initialization) return 0;
|
||||
#define LOAD(variable, type, symbol) do { \
|
||||
GDExtensionInterfaceFunctionPtr raw_function = get_proc_address(symbol); \
|
||||
_Static_assert(sizeof(type) == sizeof(raw_function), "Windows function pointer ABI mismatch"); \
|
||||
memcpy(&(variable), &raw_function, sizeof(variable)); \
|
||||
if (!variable) return 0; \
|
||||
} while (0)
|
||||
LOAD(api_warning, GDExtensionInterfacePrintWarning, "print_warning");
|
||||
LOAD(api_call, GDExtensionInterfaceVariantCall, "variant_call");
|
||||
LOAD(api_destroy, GDExtensionInterfaceVariantDestroy, "variant_destroy");
|
||||
LOAD(api_type, GDExtensionInterfaceVariantGetType, "variant_get_type");
|
||||
LOAD(api_singleton, GDExtensionInterfaceGlobalGetSingleton, "global_get_singleton");
|
||||
LOAD(api_name, GDExtensionInterfaceStringNameNewWithLatin1Chars, "string_name_new_with_latin1_chars");
|
||||
LOAD(api_string, GDExtensionInterfaceStringNewWithUtf8Chars, "string_new_with_utf8_chars");
|
||||
LOAD(api_utf8, GDExtensionInterfaceStringToUtf8Chars, "string_to_utf8_chars");
|
||||
GDExtensionInterfaceGetVariantFromTypeConstructor get_from;
|
||||
GDExtensionInterfaceGetVariantToTypeConstructor get_to;
|
||||
GDExtensionInterfaceVariantGetPtrDestructor get_destructor;
|
||||
LOAD(get_from, GDExtensionInterfaceGetVariantFromTypeConstructor, "get_variant_from_type_constructor");
|
||||
LOAD(get_to, GDExtensionInterfaceGetVariantToTypeConstructor, "get_variant_to_type_constructor");
|
||||
LOAD(get_destructor, GDExtensionInterfaceVariantGetPtrDestructor, "variant_get_ptr_destructor");
|
||||
#undef LOAD
|
||||
from_object = get_from(GDEXTENSION_VARIANT_TYPE_OBJECT);
|
||||
from_string = get_from(GDEXTENSION_VARIANT_TYPE_STRING);
|
||||
from_name = get_from(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
|
||||
to_int = get_to(GDEXTENSION_VARIANT_TYPE_INT);
|
||||
to_string = get_to(GDEXTENSION_VARIANT_TYPE_STRING);
|
||||
destroy_name = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
|
||||
destroy_string = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING);
|
||||
if (!from_object || !from_string || !from_name || !to_int || !to_string || !destroy_name || !destroy_string) return 0;
|
||||
initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_EDITOR;
|
||||
initialization->userdata = NULL;
|
||||
initialization->initialize = initialize_bridge;
|
||||
initialization->deinitialize = deinitialize_bridge;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const executable = process.env.AGC_GODOT_TEST_EXECUTABLE;
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(predicate, duration = 15000) {
|
||||
const end = Date.now() + duration;
|
||||
while (Date.now() < end) {
|
||||
const value = predicate();
|
||||
if (value) return value;
|
||||
await pause(25);
|
||||
}
|
||||
throw Error('Native Godot condition timed out');
|
||||
}
|
||||
|
||||
function call(session, method, params = {}, overrides = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({
|
||||
host: '127.0.0.1',
|
||||
port: session.port,
|
||||
});
|
||||
let data = '';
|
||||
socket.setTimeout(10000, () =>
|
||||
socket.destroy(Error('Native response timed out')),
|
||||
);
|
||||
socket.once('error', reject);
|
||||
socket.once('connect', () =>
|
||||
socket.write(
|
||||
JSON.stringify({
|
||||
protocol: session.protocol,
|
||||
id: 1,
|
||||
generation: session.generation,
|
||||
token: session.token,
|
||||
method,
|
||||
params,
|
||||
...overrides,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
socket.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
const newline = data.indexOf('\n');
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
const response = JSON.parse(data.slice(0, newline));
|
||||
assert.equal(response.protocol, session.protocol);
|
||||
assert.equal(response.generation, session.generation);
|
||||
assert.equal(response.pid, session.pid);
|
||||
assert.equal(response.buildId, session.buildId);
|
||||
assert.equal(
|
||||
path.resolve(response.projectPath).toLowerCase(),
|
||||
path.resolve(session.projectPath).toLowerCase(),
|
||||
);
|
||||
resolve(response.result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
socket.end();
|
||||
});
|
||||
socket.once('end', () => {
|
||||
if (!data.includes('\n')) reject(Error('No receipt'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'real headless Godot native bridge execution and lifecycle',
|
||||
{ skip: !executable, timeout: 90000 },
|
||||
async (t) => {
|
||||
assert.equal(process.platform, 'win32');
|
||||
const fixture = path.join(root, '.build', `smoke-${Date.now()}`);
|
||||
fs.mkdirSync(fixture, { recursive: true });
|
||||
const projectText =
|
||||
'config_version=5\n[application]\nconfig/name="AGC Native Bridge Smoke"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n';
|
||||
fs.writeFileSync(path.join(fixture, 'project.godot'), projectText);
|
||||
const dll = path.join(root, 'bin/win-x64/agc_godot_editor.dll');
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'bin/win-x64/metadata.json'), 'utf8'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(fixture, 'agc-editor-bridge.gdextension'),
|
||||
`[configuration]\nentry_symbol="agc_godot_editor_init"\ncompatibility_minimum="4.7"\nreloadable=false\n[libraries]\nwindows.editor.x86_64="${dll.replaceAll('\\', '/')}"\n`,
|
||||
);
|
||||
const child = spawn(
|
||||
executable,
|
||||
[
|
||||
'--headless',
|
||||
'--editor',
|
||||
'--path',
|
||||
fixture,
|
||||
'--log-file',
|
||||
path.join(fixture, 'editor.log'),
|
||||
],
|
||||
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
const exit = once(child, 'exit');
|
||||
let session;
|
||||
try {
|
||||
const sessionPath = path.join(
|
||||
fixture,
|
||||
'.godot/agc',
|
||||
`editor-bridge-${child.pid}.json`,
|
||||
);
|
||||
session = await until(() => {
|
||||
if (child.exitCode !== null) throw Error(`Godot exited: ${output}`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}).catch((error) => {
|
||||
throw Error(`${error.message}\n${output}`);
|
||||
});
|
||||
assert.equal(session.pid, child.pid);
|
||||
assert.equal(session.buildId, metadata.buildId);
|
||||
assert.match(session.startedFileTime, /^[0-9]+$/);
|
||||
assert.match(session.token, /^[0-9a-f]{64}$/);
|
||||
assert.match(session.generation, /^[0-9a-f]{64}$/);
|
||||
const execute = (code, timeoutMs = 5000) =>
|
||||
call(session, 'execute', { code, timeoutMs });
|
||||
const armReload = async (unloadFirst, delay = 0.2) => {
|
||||
const helper = `@tool\nextends Node\nfunc _ready():\n\tget_tree().create_timer(${delay}).timeout.connect(_swap)\nfunc _swap():\n${unloadFirst ? '\tGDExtensionManager.unload_extension("res://agc-editor-bridge.gdextension")\n' : ''}\tvar result = GDExtensionManager.load_extension("res://agc-editor-bridge.gdextension")\n\tif result != GDExtensionManager.LOAD_STATUS_OK:\n\t\tpush_error("AGC test reload failed")\n\tqueue_free()\n`;
|
||||
const reply = await execute(
|
||||
`var script := GDScript.new()\nscript.source_code = ${JSON.stringify(helper)}\nassert(script.reload() == OK)\nvar helper: Node = script.new()\nEditorInterface.get_base_control().get_tree().root.add_child(helper)\nreturn true`,
|
||||
);
|
||||
assert.equal(reply.ok, true);
|
||||
};
|
||||
const nextSession = async (previous) => {
|
||||
session = await until(() => {
|
||||
try {
|
||||
const found = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
||||
return found.generation !== previous ? found : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 5000);
|
||||
assert.equal(session.pid, child.pid);
|
||||
assert.equal(session.buildId, metadata.buildId);
|
||||
assert.equal((await execute('return 42')).result, 42);
|
||||
};
|
||||
await t.test(
|
||||
'finite code, null, and JSON values have trusted receipts',
|
||||
async () => {
|
||||
const status = await call(session, 'status');
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.executing, false);
|
||||
const result = await execute('return 6 * 7');
|
||||
assert.equal(result.status, 'completed');
|
||||
assert.equal(result.result, 42);
|
||||
const nil = await execute('return null');
|
||||
assert.equal(nil.ok, true);
|
||||
assert.equal(nil.result, null);
|
||||
const identity = await execute(
|
||||
'return {"pid": OS.get_process_id(), "editor": Engine.is_editor_hint()}',
|
||||
);
|
||||
assert.deepEqual(identity.result, { pid: child.pid, editor: true });
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'compile and runtime errors cannot become null success',
|
||||
async () => {
|
||||
const compilation = await execute('var broken =');
|
||||
assert.equal(compilation.ok, false);
|
||||
assert.equal(compilation.error.code, 'godot_compile_error');
|
||||
const runtime = await execute(
|
||||
'var values: Array = []\nreturn values[8]',
|
||||
);
|
||||
assert.equal(runtime.ok, false);
|
||||
assert.equal(runtime.error.code, 'godot_runtime_error');
|
||||
assert.ok(runtime.logs.some((entry) => entry.level === 'error'));
|
||||
assert.equal((await execute('return 42')).result, 42);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'await holds occupancy and refuses shutdown until completion',
|
||||
async () => {
|
||||
const pending = execute(
|
||||
'await (Engine.get_main_loop() as SceneTree).create_timer(0.25).timeout\nreturn 42',
|
||||
);
|
||||
await pause(60);
|
||||
assert.equal((await call(session, 'status')).executing, true);
|
||||
const concurrent = await execute('return 99');
|
||||
assert.equal(concurrent.dispatched, false);
|
||||
assert.equal(concurrent.error.code, 'godot_execution_in_progress');
|
||||
assert.equal((await call(session, 'shutdown')).accepted, false);
|
||||
assert.equal((await pending).result, 42);
|
||||
assert.equal((await call(session, 'status')).executing, false);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'async timeout does not release an in-flight execution',
|
||||
async () => {
|
||||
const timeout = await execute(
|
||||
'await (Engine.get_main_loop() as SceneTree).create_timer(0.3).timeout\nreturn 42',
|
||||
80,
|
||||
);
|
||||
assert.equal(timeout.status, 'needs-reconciliation');
|
||||
assert.equal(timeout.dispatched, true);
|
||||
assert.equal((await call(session, 'status')).executing, true);
|
||||
assert.equal((await call(session, 'shutdown')).accepted, false);
|
||||
await pause(350);
|
||||
assert.equal((await call(session, 'status')).executing, false);
|
||||
},
|
||||
);
|
||||
await t.test('captured logs, code, and results are bounded', async () => {
|
||||
const logged = await execute(
|
||||
'for i in range(150):\n\tprint("entry " + str(i))\nreturn 42',
|
||||
);
|
||||
assert.equal(logged.result, 42);
|
||||
assert.ok(logged.logs.length <= 128);
|
||||
assert.equal(logged.logsTruncated, true);
|
||||
const code = await execute('#'.repeat(128 * 1024 + 1));
|
||||
assert.equal(code.dispatched, false);
|
||||
const result = await execute('return "x".repeat(2 * 1024 * 1024)');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'godot_result_not_serializable');
|
||||
});
|
||||
await t.test('stale identity fails closed', async () => {
|
||||
await assert.rejects(
|
||||
call(
|
||||
session,
|
||||
'execute',
|
||||
{ code: 'return 99', timeoutMs: 1000 },
|
||||
{ generation: 'stale' },
|
||||
),
|
||||
/No receipt/,
|
||||
);
|
||||
await assert.rejects(
|
||||
call(session, 'status', {}, { token: 'wrong' }),
|
||||
/No receipt/,
|
||||
);
|
||||
assert.equal((await execute('return 42')).result, 42);
|
||||
});
|
||||
await t.test(
|
||||
'errors after await are still definite failures',
|
||||
async () => {
|
||||
const result = await execute(
|
||||
'var values: Array = []\nawait (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout\nreturn values[9]',
|
||||
1200,
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'godot_runtime_error');
|
||||
assert.equal((await call(session, 'status')).executing, false);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'bootstrap cannot replace a busy generation and reports the conflict',
|
||||
async () => {
|
||||
const generation = session.generation;
|
||||
const conflict = await execute(
|
||||
'var bridge = EditorInterface.get_base_control().get_tree().root.get_node("_AGC_GODOT_EDITOR_BRIDGE")\nvar duplicate = bridge.get_script().new()\nduplicate.bootstrap(bridge.session.buildId, bridge.session.startedFileTime, bridge.session_path)\nreturn true',
|
||||
);
|
||||
assert.equal(conflict.ok, false);
|
||||
assert.ok(
|
||||
conflict.logs.some((entry) =>
|
||||
entry.message.includes('godot_bridge_busy'),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(sessionPath, 'utf8')).generation,
|
||||
generation,
|
||||
);
|
||||
assert.equal((await execute('return 42')).result, 42);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'unload and reload in one deferred flush transfers the fixed node name',
|
||||
async () => {
|
||||
const previous = session.generation;
|
||||
await armReload(true);
|
||||
await nextSession(previous);
|
||||
assert.equal((await call(session, 'status')).executing, false);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'shutdown then a fresh controlled load reconnects without restarting the editor',
|
||||
async () => {
|
||||
const previous = session.generation;
|
||||
await armReload(false, 0.4);
|
||||
assert.equal((await call(session, 'shutdown')).accepted, true);
|
||||
await until(() => !fs.existsSync(sessionPath), 5000);
|
||||
await nextSession(previous);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
'shutdown receipt precedes listener and own cache removal',
|
||||
async () => {
|
||||
assert.deepEqual(await call(session, 'shutdown'), {
|
||||
accepted: true,
|
||||
status: 'shutting-down',
|
||||
});
|
||||
await until(() => !fs.existsSync(sessionPath), 5000);
|
||||
await assert.rejects(call(session, 'status'));
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(fixture, 'project.godot'), 'utf8'),
|
||||
projectText,
|
||||
);
|
||||
assert.ok(
|
||||
!output.includes(session.token),
|
||||
'Session token must not enter engine logs',
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
// This child was created by this test; no shared/user editor is touched.
|
||||
child.kill();
|
||||
await Promise.race([exit, pause(5000)]);
|
||||
fs.writeFileSync(path.join(fixture, 'test-output.log'), output);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md).
|
||||
Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"project": "Godot Engine",
|
||||
"version": "4.7.2-stable",
|
||||
"commit": "ed1daf0bf001b61586d9930840f2f1394092c079",
|
||||
"license": "MIT",
|
||||
"licenseFile": "LICENSE.txt",
|
||||
"interfaceSource": "https://github.com/godotengine/godot/blob/ed1daf0bf001b61586d9930840f2f1394092c079/core/extension/gdextension_interface.json",
|
||||
"headerGenerator": "https://github.com/godotengine/godot/blob/ed1daf0bf001b61586d9930840f2f1394092c079/core/extension/make_interface_header.py",
|
||||
"generation": "Official unmodified generator using local file IO helpers; include guard and provenance comments added. No godot-cpp dependency."
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "editor-adapter-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-editor-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"editor-adapter-api",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "godot-editor-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "UNLICENSED"
|
||||
publish = false
|
||||
description = "AGC Godot GDExtension 的受控原生适配器"
|
||||
|
||||
[dependencies]
|
||||
editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_Memory"] }
|
||||
|
||||
[workspace]
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
//! 仅操作调用者拥有、已打开且可丢弃的 Godot fixture,不启动或关闭编辑器。
|
||||
//! 用法:install_location_smoke <workspace> <pid> <old-dll> <new-dll> <outside-cache> --allow-fixture-mutations
|
||||
//! 两个安装源必须事先存在且属于同一可信包;本程序不复制、修改或删除安装源。
|
||||
//! 任一步失败立即停止,不重放 execute,不在未知卸载结果后继续配置或执行。
|
||||
use editor_adapter_api::EditorAdapter;
|
||||
use godot_editor_bridge::{
|
||||
configure_payload_candidates, configure_runtime_cache_dir, GodotEditorAdapter, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
const DESCRIPTOR: &str = "agc-editor-bridge.gdextension";
|
||||
const UID: &str = "agc-editor-bridge.gdextension.uid";
|
||||
|
||||
fn ensure(condition: bool, message: &str) -> Result<(), String> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn no_links(path: &Path) -> Result<(), String> {
|
||||
ensure(path.is_absolute(), "所有参数路径必须为绝对路径")?;
|
||||
ensure(
|
||||
!path
|
||||
.components()
|
||||
.any(|part| matches!(part, Component::ParentDir)),
|
||||
"参数路径不得包含父目录跳转",
|
||||
)?;
|
||||
for ancestor in path.ancestors() {
|
||||
match fs::symlink_metadata(ancestor) {
|
||||
Ok(metadata) => {
|
||||
ensure(
|
||||
!metadata.file_type().is_symlink(),
|
||||
"fixture 路径不能经过链接",
|
||||
)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
ensure(
|
||||
metadata.file_attributes() & 0x400 == 0,
|
||||
"fixture 路径不能经过 reparse point",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(format!("无法检查 fixture 路径:{error}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical(path: &Path) -> Result<PathBuf, String> {
|
||||
no_links(path)?;
|
||||
fs::canonicalize(path).map_err(|error| format!("fixture 路径不可访问:{error}"))
|
||||
}
|
||||
|
||||
fn canonical_cache(path: &Path) -> Result<PathBuf, String> {
|
||||
no_links(path)?;
|
||||
let ancestor = path
|
||||
.ancestors()
|
||||
.find(|candidate| candidate.exists())
|
||||
.ok_or("缓存没有已存在父目录")?;
|
||||
ensure(ancestor.is_dir(), "缓存的已存在父路径不是目录")?;
|
||||
Ok(canonical(ancestor)?.join(
|
||||
path.strip_prefix(ancestor)
|
||||
.map_err(|_| "缓存路径无法规范化")?,
|
||||
))
|
||||
}
|
||||
|
||||
fn sha256(path: &Path) -> Result<String, String> {
|
||||
no_links(path)?;
|
||||
let mut file = fs::File::open(path).map_err(|error| format!("读取 hash 文件失败:{error}"))?;
|
||||
ensure(
|
||||
file.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file(),
|
||||
"hash 目标不是普通文件",
|
||||
)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop {
|
||||
let length = file.read(&mut buffer).map_err(|error| error.to_string())?;
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..length]);
|
||||
}
|
||||
Ok(format!("{:x}", digest.finalize()))
|
||||
}
|
||||
|
||||
fn project_root(workspace: &Path) -> Result<PathBuf, String> {
|
||||
if workspace.join("project.godot").is_file() {
|
||||
canonical(&workspace.join("project.godot"))?;
|
||||
return Ok(workspace.into());
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
for entry in fs::read_dir(workspace).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_dir()
|
||||
&& entry.path().join("project.godot").is_file()
|
||||
{
|
||||
canonical(&entry.path().join("project.godot"))?;
|
||||
candidates.push(canonical(&entry.path())?);
|
||||
}
|
||||
}
|
||||
ensure(
|
||||
candidates.len() == 1,
|
||||
"根或唯一一层子目录必须有普通 project.godot",
|
||||
)?;
|
||||
Ok(candidates.remove(0))
|
||||
}
|
||||
|
||||
fn original_files(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
files: &mut BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
for entry in fs::read_dir(directory).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
let name = entry.file_name();
|
||||
if matches!(
|
||||
name.to_str(),
|
||||
Some(".godot" | ".agent" | ".git" | DESCRIPTOR | UID)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
no_links(&path)?;
|
||||
if path.is_dir() {
|
||||
original_files(root, &path, files)?;
|
||||
} else if path.is_file() {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "快照越过工作区")?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
files.insert(relative, sha256(&path)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot(workspace: &Path) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut files = BTreeMap::new();
|
||||
original_files(workspace, workspace, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn no_workspace_dll(directory: &Path) -> Result<(), String> {
|
||||
for entry in fs::read_dir(directory).map_err(|error| error.to_string())? {
|
||||
let path = entry.map_err(|error| error.to_string())?.path();
|
||||
no_links(&path)?;
|
||||
ensure(
|
||||
!path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll")),
|
||||
"fixture 工作区中出现 DLL",
|
||||
)?;
|
||||
if path.is_dir() {
|
||||
no_workspace_dll(&path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SourceSnapshot {
|
||||
dll_sha256: String,
|
||||
metadata_sha256: String,
|
||||
dll_readonly: bool,
|
||||
metadata_readonly: bool,
|
||||
}
|
||||
|
||||
fn source_snapshot(dll: &Path) -> Result<SourceSnapshot, String> {
|
||||
let metadata = dll
|
||||
.parent()
|
||||
.ok_or("安装 DLL 缺少父目录")?
|
||||
.join("metadata.json");
|
||||
Ok(SourceSnapshot {
|
||||
dll_sha256: sha256(dll)?,
|
||||
metadata_sha256: sha256(&metadata)?,
|
||||
dll_readonly: fs::metadata(dll)
|
||||
.map_err(|error| error.to_string())?
|
||||
.permissions()
|
||||
.readonly(),
|
||||
metadata_readonly: fs::metadata(metadata)
|
||||
.map_err(|error| error.to_string())?
|
||||
.permissions()
|
||||
.readonly(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ObservedDescriptor {
|
||||
protocol: String,
|
||||
build_id: String,
|
||||
sha256: String,
|
||||
source_dll_path: PathBuf,
|
||||
runtime_dll_path: PathBuf,
|
||||
}
|
||||
|
||||
fn descriptor(
|
||||
project: &Path,
|
||||
source: &Path,
|
||||
cache: &Path,
|
||||
workspace: &Path,
|
||||
source_hash: &str,
|
||||
) -> Result<ObservedDescriptor, String> {
|
||||
let file = project.join(DESCRIPTOR);
|
||||
no_links(&file)?;
|
||||
ensure(
|
||||
fs::metadata(&file)
|
||||
.map_err(|error| error.to_string())?
|
||||
.len()
|
||||
<= 65536,
|
||||
"描述文件超过 64 KiB",
|
||||
)?;
|
||||
let text = fs::read_to_string(file).map_err(|error| error.to_string())?;
|
||||
let encoded = text
|
||||
.strip_prefix("; AGC managed Godot editor bridge v1\n; ")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.ok_or("描述文件不是当前受管格式")?;
|
||||
let descriptor: ObservedDescriptor =
|
||||
serde_json::from_str(encoded).map_err(|error| error.to_string())?;
|
||||
ensure(
|
||||
descriptor.protocol == PROTOCOL_VERSION,
|
||||
"描述文件协议不匹配",
|
||||
)?;
|
||||
ensure(
|
||||
canonical(&descriptor.source_dll_path)? == source,
|
||||
"描述文件未引用期望安装来源",
|
||||
)?;
|
||||
let runtime = canonical(&descriptor.runtime_dll_path)?;
|
||||
ensure(
|
||||
runtime.starts_with(canonical(cache)?) && !runtime.starts_with(workspace),
|
||||
"加载副本没有处于工程外私有缓存",
|
||||
)?;
|
||||
ensure(
|
||||
descriptor.sha256 == source_hash && sha256(&runtime)? == source_hash,
|
||||
"加载副本与安装原件字节身份不符",
|
||||
)?;
|
||||
let godot_path = descriptor
|
||||
.runtime_dll_path
|
||||
.to_str()
|
||||
.ok_or("加载副本路径不是 UTF-8")?
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap_or(descriptor.runtime_dll_path.to_str().unwrap())
|
||||
.replace('\\', "/");
|
||||
ensure(
|
||||
text.lines()
|
||||
.any(|line| line == format!("windows.editor.x86_64 = \"{godot_path}\"")),
|
||||
"描述文件 libraries 未引用当前加载副本",
|
||||
)?;
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
fn no_descriptor(project: &Path) -> Result<(), String> {
|
||||
for name in [DESCRIPTOR, UID] {
|
||||
match fs::symlink_metadata(project.join(name)) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Ok(_) => return Err(format!("仍有 {name},保留文件并停止后续操作")),
|
||||
Err(error) => return Err(format!("无法确认 {name} 已清理:{error}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect(adapter: &GodotEditorAdapter, workspace: &Path, pid: u32) -> Result<Value, String> {
|
||||
let response = adapter.rpc(
|
||||
"connect",
|
||||
json!({"projectPath":workspace,"processId":pid,"timeoutMs":30000}),
|
||||
)?;
|
||||
ensure(
|
||||
response["connected"] == true && response["pid"] == pid,
|
||||
"连接未确认指定 fixture PID",
|
||||
)?;
|
||||
ensure(
|
||||
response["generation"]
|
||||
.as_str()
|
||||
.is_some_and(|generation| !generation.is_empty()),
|
||||
"连接缺少会话代次",
|
||||
)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn execute_42(
|
||||
adapter: &GodotEditorAdapter,
|
||||
workspace: &Path,
|
||||
pid: u32,
|
||||
label: &str,
|
||||
) -> Result<(), String> {
|
||||
let response = adapter.rpc(
|
||||
"execute",
|
||||
json!({"projectPath":workspace,"processId":pid,"timeoutMs":10000,"code":"return 42"}),
|
||||
)?;
|
||||
println!("{}", json!({"event":label,"receipt":response}));
|
||||
ensure(
|
||||
response["status"] == "completed"
|
||||
&& response["ok"] == true
|
||||
&& response["dispatched"] == true
|
||||
&& response["retryAllowed"] == false
|
||||
&& response["result"] == 42,
|
||||
"未收到可信 42 执行回执;保留现场,不重发代码、不继续切换来源",
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
ensure(args.len() == 6 && args[5] == "--allow-fixture-mutations", "需要 workspace、pid、old-dll、new-dll、工程外 cache 和 --allow-fixture-mutations;仅限自有可丢弃 fixture")?;
|
||||
ensure(
|
||||
cfg!(all(windows, target_arch = "x86_64")),
|
||||
"该实机示例仅支持 Windows x64",
|
||||
)?;
|
||||
let workspace = canonical(Path::new(&args[0]))?;
|
||||
let pid: u32 = args[1].parse().map_err(|_| "pid 必须是正整数")?;
|
||||
ensure(pid > 0, "pid 必须大于 0")?;
|
||||
let original = canonical(Path::new(&args[2]))?;
|
||||
let relocated = canonical(Path::new(&args[3]))?;
|
||||
let cache = canonical_cache(Path::new(&args[4]))?;
|
||||
ensure(original != relocated, "两个安装来源必须是不同绝对路径")?;
|
||||
ensure(
|
||||
!cache.starts_with(&workspace)
|
||||
&& !original.starts_with(&workspace)
|
||||
&& !relocated.starts_with(&workspace),
|
||||
"安装来源和运行缓存必须位于整个 fixture 工作区外",
|
||||
)?;
|
||||
ensure(
|
||||
!original.starts_with(&cache) && !relocated.starts_with(&cache),
|
||||
"安装来源不能放在测试运行缓存内",
|
||||
)?;
|
||||
let project = project_root(&workspace)?;
|
||||
no_descriptor(&project)?;
|
||||
no_workspace_dll(&workspace)?;
|
||||
let baseline = snapshot(&workspace)?;
|
||||
ensure(
|
||||
baseline
|
||||
.keys()
|
||||
.any(|file| file.ends_with(".tscn") || file.ends_with(".scn")),
|
||||
"fixture 必须已有原始场景文件以核对场景 hash",
|
||||
)?;
|
||||
let original_before = source_snapshot(&original)?;
|
||||
let relocated_before = source_snapshot(&relocated)?;
|
||||
ensure(
|
||||
original_before.dll_sha256 == relocated_before.dll_sha256
|
||||
&& original_before.metadata_sha256 == relocated_before.metadata_sha256,
|
||||
"两个安装来源不是同一可信包的相同 DLL 和元数据",
|
||||
)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({"event":"baseline","pid":pid,"workspace":workspace,"project":project,"files":baseline,"dllSha256":original_before.dll_sha256})
|
||||
);
|
||||
|
||||
configure_runtime_cache_dir(cache.clone())?;
|
||||
configure_payload_candidates(vec![original.clone()])?;
|
||||
let adapter = GodotEditorAdapter::new(Vec::new());
|
||||
let before = connect(&adapter, &workspace, pid)?;
|
||||
let old_descriptor = descriptor(
|
||||
&project,
|
||||
&original,
|
||||
&cache,
|
||||
&workspace,
|
||||
&original_before.dll_sha256,
|
||||
)?;
|
||||
let old_directory = old_descriptor
|
||||
.runtime_dll_path
|
||||
.parent()
|
||||
.ok_or("旧加载副本缺少父目录")?
|
||||
.to_path_buf();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"event":"original-connected","connection":before,"descriptor":old_descriptor})
|
||||
);
|
||||
execute_42(&adapter, &workspace, pid, "original-execute")?;
|
||||
|
||||
// 故意保持旧连接,由生产 configure API 负责先停机、确认卸载及清理。
|
||||
configure_payload_candidates(vec![relocated.clone()])?;
|
||||
ensure(
|
||||
!old_directory.exists(),
|
||||
"安装来源切换后旧加载目录仍在,停止重新连接",
|
||||
)?;
|
||||
no_descriptor(&project)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({"event":"source-reconfigured","oldRuntimeDirectoryRemoved":true,"oldGeneration":before["generation"]})
|
||||
);
|
||||
|
||||
let after = connect(&adapter, &workspace, pid)?;
|
||||
ensure(
|
||||
before["generation"] != after["generation"],
|
||||
"新来源连接错误复用了旧 generation",
|
||||
)?;
|
||||
ensure(
|
||||
before["startedFileTime"] == after["startedFileTime"],
|
||||
"fixture 编辑器启动身份发生变化",
|
||||
)?;
|
||||
let new_descriptor = descriptor(
|
||||
&project,
|
||||
&relocated,
|
||||
&cache,
|
||||
&workspace,
|
||||
&relocated_before.dll_sha256,
|
||||
)?;
|
||||
ensure(
|
||||
old_descriptor.source_dll_path != new_descriptor.source_dll_path
|
||||
&& old_descriptor.runtime_dll_path != new_descriptor.runtime_dll_path,
|
||||
"安装来源或加载缓存路径没有改变",
|
||||
)?;
|
||||
ensure(!old_directory.exists(), "重连后旧加载目录被复用")?;
|
||||
let new_directory = new_descriptor
|
||||
.runtime_dll_path
|
||||
.parent()
|
||||
.ok_or("新加载副本缺少父目录")?
|
||||
.to_path_buf();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"event":"relocated-connected","connection":after,"descriptor":new_descriptor,"oldGeneration":before["generation"],"newGeneration":after["generation"]})
|
||||
);
|
||||
execute_42(&adapter, &workspace, pid, "relocated-execute")?;
|
||||
no_workspace_dll(&workspace)?;
|
||||
|
||||
let disconnected = adapter.rpc(
|
||||
"disconnect",
|
||||
json!({"projectPath":workspace,"processId":pid,"timeoutMs":30000}),
|
||||
)?;
|
||||
ensure(
|
||||
disconnected["connected"] == false,
|
||||
"断开没有确认完成;保留现场,不继续操作",
|
||||
)?;
|
||||
no_descriptor(&project)?;
|
||||
ensure(
|
||||
!old_directory.exists() && !new_directory.exists(),
|
||||
"断开后仍有本测试加载目录",
|
||||
)?;
|
||||
no_workspace_dll(&workspace)?;
|
||||
let final_files = snapshot(&workspace)?;
|
||||
ensure(
|
||||
final_files == baseline,
|
||||
"原有工程文件或主场景 hash 发生变化",
|
||||
)?;
|
||||
ensure(
|
||||
source_snapshot(&original)? == original_before
|
||||
&& source_snapshot(&relocated)? == relocated_before,
|
||||
"安装原件字节或只读属性发生变化",
|
||||
)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({"event":"complete","passed":true,"pid":pid,"oldGeneration":before["generation"],"newGeneration":after["generation"],"oldDescriptor":old_descriptor,"newDescriptor":new_descriptor,"oldRuntimeDirectoryRemoved":true,"newRuntimeDirectoryRemoved":true,"descriptorAndUidRemoved":true,"noDllInWorkspace":true,"originalFilesUnchanged":true,"installationSourcesUnchanged":true,"finalFiles":final_files,"godotWasNotTerminated":true})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 只连接调用者明确指定的、已经打开的可丢弃 fixture,不启动或关闭 Godot。
|
||||
//! 首次引导或重连时,已处于前台的目标窗口会短暂最小化并恢复,以触发 Godot 的 FocusIn 扫描。
|
||||
//! 用法:cargo run --example live_smoke -- <fixture-workspace> <pid> <packaged-dll> <private-cache-dir> --allow-fixture-mutations
|
||||
use editor_adapter_api::EditorAdapter;
|
||||
use godot_editor_bridge::{
|
||||
configure_runtime_cache_dir, disconnect_godot_editor, GodotEditorAdapter,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if !(5..=6).contains(&args.len()) || args[4] != "--allow-fixture-mutations" {
|
||||
return Err("需要显式 fixture-workspace、pid、packaged-dll、工程外 private-cache-dir 和 --allow-fixture-mutations;仅使用可丢弃测试工程".into());
|
||||
}
|
||||
let project = PathBuf::from(&args[0]);
|
||||
let hold_ms = args
|
||||
.get(5)
|
||||
.map(|value| {
|
||||
value
|
||||
.strip_prefix("--hold-ms=")
|
||||
.ok_or("只接受 --hold-ms=1000..60000")?
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "hold-ms 必须为整数")
|
||||
})
|
||||
.transpose()?;
|
||||
if hold_ms.is_some_and(|ms| !(1000..=60000).contains(&ms)) {
|
||||
return Err("hold-ms 必须在 1000..60000".into());
|
||||
}
|
||||
let pid: u32 = args[1].parse().map_err(|_| "pid 必须为正整数")?;
|
||||
configure_runtime_cache_dir(PathBuf::from(&args[3]))?;
|
||||
let adapter = GodotEditorAdapter::new(vec![PathBuf::from(&args[2])]);
|
||||
let connected = adapter.rpc(
|
||||
"connect",
|
||||
json!({"projectPath":project,"processId":pid,"timeoutMs":20000}),
|
||||
)?;
|
||||
if connected["pid"] != pid {
|
||||
return Err("fixture PID 不匹配".into());
|
||||
}
|
||||
println!("connected: {}", connected);
|
||||
if let Some(ms) = hold_ms {
|
||||
let result = adapter.rpc(
|
||||
"execute",
|
||||
json!({"projectPath":project,"processId":pid,"code":"return 42","timeoutMs":10000}),
|
||||
)?;
|
||||
if result["status"] != "completed" || result["result"] != 42 {
|
||||
return Err("实例占用验证未收到真实执行回执".into());
|
||||
}
|
||||
println!("holding-live-instance: {result}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms));
|
||||
disconnect_godot_editor()?;
|
||||
println!("held-instance-unloaded");
|
||||
return Ok(());
|
||||
}
|
||||
for (label,code,expected_status,expected) in [
|
||||
("arithmetic","return 6 * 7","completed",Some(json!(42))),
|
||||
("null","return null","completed",Some(serde_json::Value::Null)),
|
||||
("scene-read","var scene = EditorInterface.get_edited_scene_root()\nreturn null if scene == null else scene.name","completed",None),
|
||||
("scene-undo", "var scene = EditorInterface.get_edited_scene_root()\nvar before = scene.get_child_count()\nvar probe = Node.new()\nprobe.name = \"AGC_Isolated_Verification\"\nvar history = UndoRedo.new()\nhistory.create_action(\"AGC isolated verification\")\nhistory.add_do_method(scene.add_child.bind(probe))\nhistory.add_do_property(probe, \"owner\", scene)\nhistory.add_undo_method(scene.remove_child.bind(probe))\nhistory.add_do_reference(probe)\nhistory.commit_action()\nvar added = probe.get_parent() == scene\nhistory.undo()\nvar undone = scene.get_child_count() == before and probe.get_parent() == null\nhistory.clear_history()\nif is_instance_valid(probe):\n\tprobe.free()\nreturn {\"added\": added, \"undone\": undone}", "completed", Some(json!({"added":true,"undone":true}))),
|
||||
("async","await EditorInterface.get_base_control().get_tree().process_frame\nreturn 42","completed",Some(json!(42))),
|
||||
("compile-error","var broken = ","failed",None),
|
||||
("runtime-error","var node: Node = null\nreturn node.get_name()","failed",None),
|
||||
] {
|
||||
let result=adapter.rpc("execute",json!({"projectPath":project,"processId":pid,"code":code,"timeoutMs":10000}))?;
|
||||
println!("{label}: {result}");
|
||||
if result["status"]!=expected_status || expected.is_some_and(|value| result["result"]!=value) {return Err(format!("{label} 没有产生预期真实回执;保留连接供诊断"));}
|
||||
}
|
||||
disconnect_godot_editor()?;
|
||||
println!("模块卸载和受管文件清理已确认");
|
||||
// 再次连接应触发正式扫描加载,证明生命周期可复用。
|
||||
adapter.rpc(
|
||||
"connect",
|
||||
json!({"projectPath":project,"processId":pid,"timeoutMs":20000}),
|
||||
)?;
|
||||
let result = adapter.rpc(
|
||||
"execute",
|
||||
json!({"projectPath":project,"processId":pid,"code":"return 42","timeoutMs":10000}),
|
||||
)?;
|
||||
if result["result"] != 42 {
|
||||
return Err("重连执行失败".into());
|
||||
}
|
||||
disconnect_godot_editor()?;
|
||||
println!("重连、执行和二次卸载完成");
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProcessIdentity {
|
||||
pub pid: u32,
|
||||
pub started_file_time: String,
|
||||
pub project: PathBuf,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub fn supported_version(version: &str) -> Option<String> {
|
||||
let mut parts = version.split(|c: char| !c.is_ascii_digit());
|
||||
let major: u32 = parts.next()?.parse().ok()?;
|
||||
let minor: u32 = parts.next()?.parse().ok()?;
|
||||
let patch: u32 = parts.next()?.parse().ok()?;
|
||||
(major == 4 && minor >= 7).then(|| format!("{major}.{minor}.{patch}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use windows_sys::Win32::Foundation::*;
|
||||
use windows_sys::Win32::System::ProcessStatus::{
|
||||
K32EnumProcessModulesEx, K32GetModuleBaseNameW, K32GetModuleFileNameExW, LIST_MODULES_ALL,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::*;
|
||||
use windows_sys::Win32::UI::Shell::CommandLineToArgvW;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
struct Handle(HANDLE);
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn started(pid: u32) -> Result<Option<String>, String> {
|
||||
unsafe {
|
||||
let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if process.is_null() {
|
||||
return if GetLastError() == ERROR_INVALID_PARAMETER {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err("Godot 进程身份无法读取".into())
|
||||
};
|
||||
}
|
||||
let process = Handle(process);
|
||||
let mut exit_code = 0;
|
||||
if GetExitCodeProcess(process.0, &mut exit_code) == 0 {
|
||||
return Err("Godot 进程状态无法读取".into());
|
||||
}
|
||||
if exit_code != STILL_ACTIVE as u32 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut creation: FILETIME = std::mem::zeroed();
|
||||
let mut exit: FILETIME = std::mem::zeroed();
|
||||
let mut kernel: FILETIME = std::mem::zeroed();
|
||||
let mut user: FILETIME = std::mem::zeroed();
|
||||
if GetProcessTimes(process.0, &mut creation, &mut exit, &mut kernel, &mut user) == 0 {
|
||||
return Err("Godot 进程启动身份无法读取".into());
|
||||
}
|
||||
Ok(Some(
|
||||
(((creation.dwHighDateTime as u64) << 32) | creation.dwLowDateTime as u64)
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Candidate {
|
||||
pid: u32,
|
||||
command_line: String,
|
||||
executable: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
pub fn detect(
|
||||
project: &Path,
|
||||
requested: Option<u32>,
|
||||
deadline: Instant,
|
||||
) -> Result<ProcessIdentity, String> {
|
||||
crate::remaining(deadline)?;
|
||||
// 固定查询,不把项目路径或模型参数插入 PowerShell 代码。只启动本服务自己的只读查询子进程。
|
||||
let script = "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $rows=@(Get-CimInstance Win32_Process -Filter \"Name LIKE 'Godot%.exe'\" | ForEach-Object { if ($_.ExecutablePath -and $_.CommandLine) { $v=[System.Diagnostics.FileVersionInfo]::GetVersionInfo($_.ExecutablePath); [pscustomobject]@{pid=$_.ProcessId;commandLine=$_.CommandLine;executable=$_.ExecutablePath;version=$v.ProductVersion} } }); ConvertTo-Json -InputObject $rows -Compress";
|
||||
let mut child = Command::new("powershell.exe")
|
||||
.env_remove("PSModulePath")
|
||||
.args([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|_| "无法查询已打开的 Godot 编辑器")?;
|
||||
let stdout = child.stdout.take().ok_or("Godot 进程查询输出不可读")?;
|
||||
let reader = thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = stdout.take(1024 * 1024 + 1).read_to_end(&mut bytes);
|
||||
bytes
|
||||
});
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = reader.join();
|
||||
return Err("Godot 进程发现超时,未发送执行请求".into());
|
||||
}
|
||||
}
|
||||
};
|
||||
let bytes = reader.join().map_err(|_| "Godot 进程查询失败")?;
|
||||
if !status.success() || bytes.len() > 1024 * 1024 {
|
||||
return Err("Godot 进程查询失败或超出大小限制".into());
|
||||
}
|
||||
let candidates: Vec<Candidate> =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "Godot 进程查询返回无效结果")?;
|
||||
let mut found = Vec::new();
|
||||
for candidate in candidates {
|
||||
let args = parse_arguments(&candidate.command_line)?;
|
||||
if !args.iter().any(|a| a == "--editor" || a == "-e") {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = command_project(&args) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(target) = crate::files::canonical(&target) else {
|
||||
continue;
|
||||
};
|
||||
if target != project {
|
||||
continue;
|
||||
}
|
||||
let executable = Path::new(&candidate.executable)
|
||||
.file_name()
|
||||
.ok_or("Godot 编辑器可执行文件路径无效")?
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase();
|
||||
if executable.contains("mono")
|
||||
|| executable.contains("dotnet")
|
||||
|| args.iter().any(|a| a == "--headless")
|
||||
{
|
||||
return Err("Godot 桥仅支持标准 GUI 编辑器,不支持 .NET 或 headless 编辑器".into());
|
||||
}
|
||||
let version = supported_version(&candidate.version)
|
||||
.ok_or("Godot 桥要求 Godot 4.7 及以上的 4.x 编辑器")?;
|
||||
let Some(started_file_time) = started(candidate.pid)? else {
|
||||
continue;
|
||||
};
|
||||
found.push(ProcessIdentity {
|
||||
pid: candidate.pid,
|
||||
started_file_time,
|
||||
project: target,
|
||||
version,
|
||||
});
|
||||
}
|
||||
if found.len() != 1 {
|
||||
return Err(if found.is_empty() {
|
||||
"没有找到唯一匹配项目的已打开 Godot 编辑器(需要 --editor 与明确项目路径)"
|
||||
} else {
|
||||
"同一项目存在多个 Godot 编辑器,拒绝选择不明确目标"
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let identity = found.remove(0);
|
||||
if requested.is_some_and(|pid| pid != identity.pid) {
|
||||
return Err("指定 Godot PID 与项目的编辑器不匹配".into());
|
||||
}
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
fn parse_arguments(command: &str) -> Result<Vec<String>, String> {
|
||||
let wide: Vec<u16> = command.encode_utf16().chain(Some(0)).collect();
|
||||
unsafe {
|
||||
let mut count = 0;
|
||||
let args = CommandLineToArgvW(wide.as_ptr(), &mut count);
|
||||
if args.is_null() {
|
||||
return Err("Godot 进程命令行不可解析".into());
|
||||
}
|
||||
let result = (0..count)
|
||||
.map(|i| {
|
||||
let ptr = *args.add(i as usize);
|
||||
let mut len = 0;
|
||||
while *ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
|
||||
})
|
||||
.collect();
|
||||
LocalFree(args.cast());
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn command_project(args: &[String]) -> Option<PathBuf> {
|
||||
for pair in args.windows(2) {
|
||||
if pair[0] == "--path" {
|
||||
return Some(PathBuf::from(&pair[1]));
|
||||
}
|
||||
}
|
||||
args.iter().skip(1).find_map(|arg| {
|
||||
let path = Path::new(arg);
|
||||
if path.file_name().is_some_and(|s| s == "project.godot") {
|
||||
path.parent().map(Path::to_path_buf)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn module_loaded(
|
||||
identity: &ProcessIdentity,
|
||||
descriptor: &crate::files::Descriptor,
|
||||
) -> Result<bool, String> {
|
||||
if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) {
|
||||
return Ok(false);
|
||||
}
|
||||
unsafe {
|
||||
// ToolHelp 的模块快照会因长路径返回 ERROR_MORE_DATA;使用动态句柄数组和宽路径读取。
|
||||
let process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, identity.pid);
|
||||
if process.is_null() {
|
||||
return Err(format!("无法核验 Godot 模块(Win32 {})", GetLastError()));
|
||||
}
|
||||
let process = Handle(process);
|
||||
let mut modules: Vec<HMODULE> = vec![std::ptr::null_mut(); 256];
|
||||
let mut last_error = 0;
|
||||
for attempt in 0..5 {
|
||||
if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut needed = 0u32;
|
||||
if K32EnumProcessModulesEx(
|
||||
process.0,
|
||||
modules.as_mut_ptr(),
|
||||
(modules.len() * std::mem::size_of::<HMODULE>()) as u32,
|
||||
&mut needed,
|
||||
LIST_MODULES_ALL,
|
||||
) == 0
|
||||
{
|
||||
last_error = GetLastError();
|
||||
} else {
|
||||
let count = needed as usize / std::mem::size_of::<HMODULE>();
|
||||
if count > 8192 || needed as usize % std::mem::size_of::<HMODULE>() != 0 {
|
||||
return Err("Godot 模块列表大小无效,保留受管文件".into());
|
||||
}
|
||||
if count > modules.len() {
|
||||
modules.resize(count, std::ptr::null_mut());
|
||||
continue;
|
||||
}
|
||||
let mut loaded = false;
|
||||
let mut name = [0u16; 260];
|
||||
let mut path = vec![0u16; 32768];
|
||||
last_error = 0;
|
||||
for module in modules.iter().take(count) {
|
||||
let len = K32GetModuleBaseNameW(
|
||||
process.0,
|
||||
*module,
|
||||
name.as_mut_ptr(),
|
||||
name.len() as u32,
|
||||
) as usize;
|
||||
if len == 0 {
|
||||
last_error = GetLastError();
|
||||
break;
|
||||
}
|
||||
if len >= name.len() {
|
||||
return Err("Godot 模块名被截断,不能确认卸载".into());
|
||||
}
|
||||
let module_name =
|
||||
String::from_utf16_lossy(&name[..len]).to_ascii_lowercase();
|
||||
if !matches!(
|
||||
module_name.as_str(),
|
||||
"agc_godot_editor.dll" | "~agc_godot_editor.dll"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let length = K32GetModuleFileNameExW(
|
||||
process.0,
|
||||
*module,
|
||||
path.as_mut_ptr(),
|
||||
path.len() as u32,
|
||||
) as usize;
|
||||
if length == 0 {
|
||||
last_error = GetLastError();
|
||||
break;
|
||||
}
|
||||
if length >= path.len() {
|
||||
return Err("Godot 模块路径被截断,不能确认卸载".into());
|
||||
}
|
||||
let path = PathBuf::from(std::ffi::OsString::from_wide(&path[..length]));
|
||||
if crate::files::verified_module_path(&path, descriptor)? {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
if last_error == 0 {
|
||||
return if started(identity.pid)?.as_deref()
|
||||
== Some(&identity.started_file_time)
|
||||
{
|
||||
Ok(loaded)
|
||||
} else {
|
||||
Ok(false)
|
||||
};
|
||||
}
|
||||
}
|
||||
if attempt == 4
|
||||
|| !matches!(
|
||||
last_error,
|
||||
ERROR_PARTIAL_COPY | ERROR_BAD_LENGTH | ERROR_INVALID_HANDLE
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(format!(
|
||||
"无法完整核对 Godot 模块,保留受管文件(Win32 {last_error})"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(identity: &ProcessIdentity, deadline: Instant) -> Result<bool, String> {
|
||||
crate::remaining(deadline)?;
|
||||
if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) {
|
||||
return Err("Godot 编辑器已退出或 PID 被复用".into());
|
||||
}
|
||||
struct Find {
|
||||
pid: u32,
|
||||
window: HWND,
|
||||
}
|
||||
unsafe extern "system" fn visit(window: HWND, parameter: LPARAM) -> i32 {
|
||||
let state = &mut *(parameter as *mut Find);
|
||||
let mut pid = 0;
|
||||
GetWindowThreadProcessId(window, &mut pid);
|
||||
if pid == state.pid
|
||||
&& IsWindowVisible(window) != 0
|
||||
&& GetWindow(window, GW_OWNER).is_null()
|
||||
{
|
||||
state.window = window;
|
||||
return 0;
|
||||
}
|
||||
1
|
||||
}
|
||||
let mut find = Find {
|
||||
pid: identity.pid,
|
||||
window: std::ptr::null_mut(),
|
||||
};
|
||||
unsafe {
|
||||
EnumWindows(Some(visit), &mut find as *mut Find as LPARAM);
|
||||
if find.window.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut window_pid = 0;
|
||||
GetWindowThreadProcessId(find.window, &mut window_pid);
|
||||
if window_pid != identity.pid
|
||||
|| started(identity.pid)?.as_deref() != Some(&identity.started_file_time)
|
||||
{
|
||||
return Err("Godot 窗口归属在聚焦前变化".into());
|
||||
}
|
||||
let already_foreground = GetForegroundWindow() == find.window;
|
||||
if already_foreground {
|
||||
// 同窗 SetForegroundWindow 不会产生 FocusIn。只短暂切换已核验目标窗口,
|
||||
// 由 Godot 自己的焦点事件启动资源扫描,不切换或发送消息给其它应用窗口。
|
||||
let mut placement: WINDOWPLACEMENT = std::mem::zeroed();
|
||||
placement.length = std::mem::size_of::<WINDOWPLACEMENT>() as u32;
|
||||
if GetWindowPlacement(find.window, &mut placement) == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
crate::remaining(deadline)?;
|
||||
if ShowWindowAsync(find.window, SW_MINIMIZE) == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let focus_deadline = deadline.min(Instant::now() + Duration::from_millis(500));
|
||||
while IsIconic(find.window) == 0 && Instant::now() < focus_deadline {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
GetWindowThreadProcessId(find.window, &mut window_pid);
|
||||
if window_pid != identity.pid
|
||||
|| started(identity.pid)?.as_deref() != Some(&identity.started_file_time)
|
||||
{
|
||||
return Err("Godot 窗口在重新聚焦期间退出或改变归属".into());
|
||||
}
|
||||
// 即使等待预算耗尽也先恢复同一个目标,不能因连接超时把用户窗口留在最小化状态。
|
||||
let restore = if placement.showCmd == SW_SHOWMAXIMIZED as u32 {
|
||||
SW_SHOWMAXIMIZED
|
||||
} else {
|
||||
SW_RESTORE
|
||||
};
|
||||
ShowWindowAsync(find.window, restore);
|
||||
crate::remaining(deadline)?;
|
||||
}
|
||||
if IsIconic(find.window) != 0 {
|
||||
ShowWindowAsync(find.window, SW_RESTORE);
|
||||
let restore_deadline = deadline.min(Instant::now() + Duration::from_millis(500));
|
||||
while IsIconic(find.window) != 0 && Instant::now() < restore_deadline {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
crate::remaining(deadline)?;
|
||||
Ok(SetForegroundWindow(find.window) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows::{detect, focus, module_loaded, started};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn detect(_: &Path, _: Option<u32>, _: Instant) -> Result<ProcessIdentity, String> {
|
||||
Err("Godot 原生桥仅支持 Windows x64".into())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn started(_: u32) -> Result<Option<String>, String> {
|
||||
Err("Godot 原生桥仅支持 Windows x64".into())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn module_loaded(_: &ProcessIdentity, _: &crate::files::Descriptor) -> Result<bool, String> {
|
||||
Err("Godot 原生桥仅支持 Windows x64".into())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn focus(_: &ProcessIdentity, _: Instant) -> Result<bool, String> {
|
||||
Err("Godot 原生桥仅支持 Windows x64".into())
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! 安装原件仅作为可信来源。每个编辑器实例使用宿主私有、带归属证明的可写副本。
|
||||
use crate::{
|
||||
files::{self, Descriptor, Payload, PayloadMetadata},
|
||||
platform::ProcessIdentity,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DLL: &str = "agc_godot_editor.dll";
|
||||
const COPY: &str = "~agc_godot_editor.dll";
|
||||
const MARKER: &str = "runtime-ownership.json";
|
||||
|
||||
#[derive(Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CacheOwner {
|
||||
protocol: String,
|
||||
source_dll_path: PathBuf,
|
||||
runtime_dll_path: PathBuf,
|
||||
pid: u32,
|
||||
started_file_time: String,
|
||||
project_path: PathBuf,
|
||||
version: String,
|
||||
metadata: PayloadMetadata,
|
||||
}
|
||||
|
||||
fn cache_directory(root: &Path, source: &Payload, identity: &ProcessIdentity) -> PathBuf {
|
||||
let mut key = Sha256::new();
|
||||
key.update(source.source_path.to_string_lossy().as_bytes());
|
||||
key.update([0]);
|
||||
key.update(source.metadata.build_id.as_bytes());
|
||||
key.update([0]);
|
||||
key.update(source.metadata.sha256.as_bytes());
|
||||
root.join(format!("p{}-{}", identity.pid, identity.started_file_time))
|
||||
.join(format!("b{:x}", key.finalize()))
|
||||
}
|
||||
|
||||
fn owner(root: &Path, source: &Payload, identity: &ProcessIdentity) -> Result<CacheOwner, String> {
|
||||
files::validate_payload_metadata(&source.metadata)?;
|
||||
files::validate_candidate(&source.source_path)?;
|
||||
if identity.pid == 0 || identity.started_file_time.parse::<u64>().is_err() {
|
||||
return Err("Godot 实例缓存身份无效".into());
|
||||
}
|
||||
Ok(CacheOwner {
|
||||
protocol: PROTOCOL_VERSION.into(),
|
||||
source_dll_path: source.source_path.clone(),
|
||||
runtime_dll_path: cache_directory(root, source, identity).join(DLL),
|
||||
pid: identity.pid,
|
||||
started_file_time: identity.started_file_time.clone(),
|
||||
project_path: identity.project.clone(),
|
||||
version: identity.version.clone(),
|
||||
metadata: source.metadata.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 配置只来自宿主;先规范化已存在的私有目录,再用于路径归属比较。
|
||||
pub fn configure_root(root: &Path) -> Result<PathBuf, String> {
|
||||
files::no_links(root)?;
|
||||
let ancestor = root
|
||||
.ancestors()
|
||||
.find(|path| path.exists())
|
||||
.ok_or("Godot 缓存路径没有可核验的父目录")?;
|
||||
if !ancestor.is_dir() {
|
||||
return Err("Godot 运行缓存的已存在父路径必须是目录".into());
|
||||
}
|
||||
let canonical = files::canonical(ancestor)?;
|
||||
let suffix = root
|
||||
.strip_prefix(ancestor)
|
||||
.map_err(|_| "Godot 缓存路径无法规范化")?;
|
||||
Ok(canonical.join(suffix))
|
||||
}
|
||||
|
||||
pub fn desired(
|
||||
root: &Path,
|
||||
source: &Payload,
|
||||
identity: &ProcessIdentity,
|
||||
workspace: &Path,
|
||||
) -> Result<Descriptor, String> {
|
||||
let root = root_outside_workspace(root, workspace, &identity.project)?;
|
||||
if source.source_path.starts_with(workspace) {
|
||||
return Err("Godot 安装原件必须位于受控工作区外".into());
|
||||
}
|
||||
let expected = owner(&root, source, identity)?;
|
||||
Ok(Descriptor::from_payload(&Payload {
|
||||
path: expected.runtime_dll_path,
|
||||
source_path: source.source_path.clone(),
|
||||
metadata: source.metadata.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn root_outside_workspace(
|
||||
root: &Path,
|
||||
workspace: &Path,
|
||||
project: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
// 未创建的末端也先按现有父目录规范化,防止 Windows 大小写和短路径别名绕过工作区边界。
|
||||
let root = configure_root(root)?;
|
||||
let workspace = files::canonical(workspace)?;
|
||||
let project = files::canonical(project)?;
|
||||
if root.starts_with(&workspace) || root.starts_with(&project) {
|
||||
return Err("Godot 运行副本缓存必须位于整个受控工作区外".into());
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// AppData 在打包桌面进程中可能发生文件系统虚拟化;创建后以真实落点固定后续身份。
|
||||
pub fn prepare_root(root: &Path, workspace: &Path, project: &Path) -> Result<PathBuf, String> {
|
||||
let root = root_outside_workspace(root, workspace, project)?;
|
||||
fs::create_dir_all(&root).map_err(|_| "无法创建 Godot 宿主私有运行缓存")?;
|
||||
root_outside_workspace(&root, workspace, project)
|
||||
}
|
||||
|
||||
fn verify_contents(directory: &Path, expected: &CacheOwner) -> Result<(), String> {
|
||||
files::no_links(directory)?;
|
||||
let actual: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?)
|
||||
.map_err(|_| "Godot 运行缓存归属记录无效")?;
|
||||
if actual != *expected {
|
||||
return Err("Godot 运行缓存归属与来源/进程/构建身份不匹配".into());
|
||||
}
|
||||
for entry in fs::read_dir(directory).map_err(|_| "Godot 运行缓存不可读取")? {
|
||||
let entry = entry.map_err(|_| "Godot 运行缓存目录项不可读取")?;
|
||||
files::no_links(&entry.path())?;
|
||||
if ![DLL, COPY, MARKER]
|
||||
.iter()
|
||||
.any(|name| entry.file_name() == *name)
|
||||
|| !entry
|
||||
.file_type()
|
||||
.map_err(|_| "Godot 缓存类型不可读取")?
|
||||
.is_file()
|
||||
{
|
||||
return Err("Godot 运行缓存存在未知文件,保留目录供核对".into());
|
||||
}
|
||||
}
|
||||
files::verify_dll_hash(&directory.join(DLL), &expected.metadata.sha256)?;
|
||||
let copy = directory.join(COPY);
|
||||
if copy.exists() {
|
||||
files::verify_dll_hash(©, &expected.metadata.sha256)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
root: &Path,
|
||||
source: &Payload,
|
||||
identity: &ProcessIdentity,
|
||||
workspace: &Path,
|
||||
) -> Result<Payload, String> {
|
||||
let root = root_outside_workspace(root, workspace, &identity.project)?;
|
||||
if source.source_path.starts_with(workspace) {
|
||||
return Err("Godot 安装原件必须位于受控工作区外".into());
|
||||
}
|
||||
let root = prepare_root(&root, workspace, &identity.project)?;
|
||||
let expected = owner(&root, source, identity)?;
|
||||
let directory = expected
|
||||
.runtime_dll_path
|
||||
.parent()
|
||||
.ok_or("Godot 运行缓存路径无效")?;
|
||||
files::no_links(directory)?;
|
||||
if directory.exists() {
|
||||
verify_contents(directory, &expected)?;
|
||||
} else {
|
||||
let parent = directory.parent().ok_or("Godot 运行缓存父目录无效")?;
|
||||
fs::create_dir_all(parent).map_err(|_| "无法创建 Godot 实例缓存目录")?;
|
||||
files::no_links(parent)?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".prepare-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|_| "无法准备 Godot 缓存副本")?;
|
||||
fs::copy(&source.source_path, staging.path().join(DLL))
|
||||
.map_err(|_| "无法复制已验证的 Godot 安装原件")?;
|
||||
// Windows copy 保留只读位;临时副本可写,安装原件的权限完全不变。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let path = staging.path().join(DLL);
|
||||
let mut permissions = fs::metadata(&path)
|
||||
.map_err(|_| "Godot 运行副本属性不可读")?
|
||||
.permissions();
|
||||
permissions.set_readonly(false);
|
||||
fs::set_permissions(&path, permissions).map_err(|_| "Godot 运行副本不能设置为可写")?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = staging.path().join(DLL);
|
||||
let mode = fs::metadata(&path)
|
||||
.map_err(|_| "Godot 运行副本属性不可读")?
|
||||
.permissions()
|
||||
.mode();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(mode | 0o200))
|
||||
.map_err(|_| "Godot 运行副本不能设置为可写")?;
|
||||
}
|
||||
files::verify_dll_hash(&staging.path().join(DLL), &source.metadata.sha256)?;
|
||||
let mut marker = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(staging.path().join(MARKER))
|
||||
.map_err(|_| "无法创建 Godot 缓存归属记录")?;
|
||||
marker
|
||||
.write_all(&serde_json::to_vec(&expected).map_err(|_| "Godot 缓存归属编码失败")?)
|
||||
.and_then(|_| marker.sync_all())
|
||||
.map_err(|_| "无法持久保存 Godot 缓存归属")?;
|
||||
drop(marker);
|
||||
// 目标目录不存在才创建;同实例竞争者或未知目录出现时不覆盖。
|
||||
if directory.exists() {
|
||||
return Err("Godot 实例缓存被并发创建,未覆盖任何文件".into());
|
||||
}
|
||||
fs::rename(staging.path(), directory).map_err(|_| "无法提交 Godot 实例缓存")?;
|
||||
verify_contents(directory, &expected)?;
|
||||
}
|
||||
Ok(Payload {
|
||||
path: expected.runtime_dll_path,
|
||||
source_path: source.source_path.clone(),
|
||||
metadata: source.metadata.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify(
|
||||
root: &Path,
|
||||
source: &Payload,
|
||||
identity: &ProcessIdentity,
|
||||
descriptor: &Descriptor,
|
||||
) -> Result<(), String> {
|
||||
let root = files::canonical(root)?;
|
||||
let expected = owner(&root, source, identity)?;
|
||||
if descriptor.dll_path != expected.runtime_dll_path
|
||||
|| descriptor.source_dll_path != source.source_path
|
||||
|| descriptor.sha256 != source.metadata.sha256
|
||||
|| descriptor.build_id != source.metadata.build_id
|
||||
{
|
||||
return Err("Godot 描述文件不属于宿主受控运行副本".into());
|
||||
}
|
||||
verify_contents(
|
||||
expected
|
||||
.runtime_dll_path
|
||||
.parent()
|
||||
.ok_or("Godot 运行缓存路径无效")?,
|
||||
&expected,
|
||||
)
|
||||
}
|
||||
|
||||
/// 私有缓存中的来源快照允许安装原件更新/移动后仍安全卸载旧实例;它不能用于加载新代码。
|
||||
pub fn verify_owned(
|
||||
root: &Path,
|
||||
identity: &ProcessIdentity,
|
||||
descriptor: &Descriptor,
|
||||
) -> Result<(), String> {
|
||||
let root = files::canonical(root)?;
|
||||
files::no_links(&descriptor.dll_path)?;
|
||||
let directory = descriptor
|
||||
.dll_path
|
||||
.parent()
|
||||
.ok_or("Godot 运行缓存路径无效")?;
|
||||
if !directory.starts_with(&root) || directory == root {
|
||||
return Err("Godot 缓存清理越出宿主私有目录".into());
|
||||
}
|
||||
let record: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?)
|
||||
.map_err(|_| "Godot 缓存归属记录无效")?;
|
||||
let source = Payload {
|
||||
path: record.source_dll_path.clone(),
|
||||
source_path: record.source_dll_path.clone(),
|
||||
metadata: record.metadata.clone(),
|
||||
};
|
||||
let expected = owner(&root, &source, identity)?;
|
||||
if expected.runtime_dll_path != descriptor.dll_path
|
||||
|| expected.source_dll_path != descriptor.source_dll_path
|
||||
|| expected.metadata.sha256 != descriptor.sha256
|
||||
|| expected.metadata.build_id != descriptor.build_id
|
||||
{
|
||||
return Err("Godot 缓存归属不匹配,保留所有文件".into());
|
||||
}
|
||||
verify_contents(directory, &expected)
|
||||
}
|
||||
|
||||
/// 首次握手前 owner 退出时,缓存仍保有经过进程发现验证的原实例身份。
|
||||
pub fn identity(root: &Path, descriptor: &Descriptor) -> Result<ProcessIdentity, String> {
|
||||
let root = files::canonical(root)?;
|
||||
files::no_links(&descriptor.dll_path)?;
|
||||
let directory = descriptor
|
||||
.dll_path
|
||||
.parent()
|
||||
.ok_or("Godot 运行缓存路径无效")?;
|
||||
if !directory.starts_with(&root) || directory == root {
|
||||
return Err("Godot 缓存身份越出宿主私有目录".into());
|
||||
}
|
||||
let record: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?)
|
||||
.map_err(|_| "Godot 缓存归属记录无效")?;
|
||||
if crate::platform::supported_version(&record.version).as_deref()
|
||||
!= Some(record.version.as_str())
|
||||
{
|
||||
return Err("Godot 缓存版本身份无效".into());
|
||||
}
|
||||
let identity = ProcessIdentity {
|
||||
pid: record.pid,
|
||||
started_file_time: record.started_file_time,
|
||||
project: record.project_path,
|
||||
version: record.version,
|
||||
};
|
||||
verify_owned(&root, &identity, descriptor)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// 调用方已确认模块和会话均消失;只删除这个实例、内容仍匹配的已知文件。
|
||||
pub fn cleanup(
|
||||
root: &Path,
|
||||
identity: &ProcessIdentity,
|
||||
descriptor: &Descriptor,
|
||||
) -> Result<(), String> {
|
||||
verify_owned(root, identity, descriptor)?;
|
||||
let root = files::canonical(root)?;
|
||||
let directory = descriptor
|
||||
.dll_path
|
||||
.parent()
|
||||
.ok_or("Godot 运行缓存路径无效")?;
|
||||
for name in [COPY, DLL, MARKER] {
|
||||
let path = directory.join(name);
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|_| "Godot 模块已卸载但运行缓存无法清理")?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir(directory).map_err(|_| "Godot 缓存目录仍有文件,未递归删除")?;
|
||||
if let Some(parent) = directory.parent() {
|
||||
if parent != root {
|
||||
let _ = fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
use crate::{files::Session, remaining, MAX_MESSAGE_BYTES, PROTOCOL_VERSION};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct ExchangeError {
|
||||
pub message: String,
|
||||
pub dispatched: bool,
|
||||
}
|
||||
|
||||
pub fn exchange(
|
||||
session: &Session,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
deadline: Instant,
|
||||
) -> Result<Value, ExchangeError> {
|
||||
let mut sent = false;
|
||||
let mut operation = || -> Result<Value, String> {
|
||||
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), session.port);
|
||||
let mut stream = TcpStream::connect_timeout(&address, remaining(deadline)?)
|
||||
.map_err(|_| "Godot 桥连接失败")?;
|
||||
let request = json!({"protocol":PROTOCOL_VERSION,"id":id,"generation":session.generation,"token":session.token,"method":method,"params":params});
|
||||
let mut bytes = serde_json::to_vec(&request).map_err(|_| "Godot 请求编码失败")?;
|
||||
bytes.push(b'\n');
|
||||
if bytes.len() > MAX_MESSAGE_BYTES {
|
||||
return Err("Godot 请求超过 2 MiB".into());
|
||||
}
|
||||
stream
|
||||
.set_write_timeout(Some(remaining(deadline)?))
|
||||
.map_err(|_| "Godot 桥无法设置发送期限")?;
|
||||
// 第一次 write 可能只发送一部分;此后任何错误都不能证明编辑器没有接收请求。
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
stream
|
||||
.set_write_timeout(Some(remaining(deadline)?))
|
||||
.map_err(|_| "Godot 桥发送期限无效")?;
|
||||
sent = true;
|
||||
let n = stream
|
||||
.write(&bytes[offset..])
|
||||
.map_err(|_| "Godot 请求发送中断")?;
|
||||
if n == 0 {
|
||||
return Err("Godot 请求发送中断".into());
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0; 8192];
|
||||
loop {
|
||||
stream
|
||||
.set_read_timeout(Some(remaining(deadline)?))
|
||||
.map_err(|_| "Godot 桥读取期限无效")?;
|
||||
let count = stream
|
||||
.read(&mut buf)
|
||||
.map_err(|_| "Godot 执行回执超时或读取中断")?;
|
||||
if count == 0 {
|
||||
return Err("Godot 在完整回执前关闭连接".into());
|
||||
}
|
||||
if let Some(end) = buf[..count].iter().position(|&b| b == b'\n') {
|
||||
response.extend_from_slice(&buf[..end]);
|
||||
if end + 1 != count {
|
||||
return Err("Godot 桥返回多余协议数据".into());
|
||||
}
|
||||
break;
|
||||
}
|
||||
response.extend_from_slice(&buf[..count]);
|
||||
if response.len() >= MAX_MESSAGE_BYTES {
|
||||
return Err("Godot 回执超过 2 MiB".into());
|
||||
}
|
||||
}
|
||||
parse_response(&response, session, id, method)
|
||||
};
|
||||
operation().map_err(|message| ExchangeError {
|
||||
message,
|
||||
dispatched: sent,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_response(
|
||||
bytes: &[u8],
|
||||
session: &Session,
|
||||
id: u64,
|
||||
method: &str,
|
||||
) -> Result<Value, String> {
|
||||
if bytes.len() > MAX_MESSAGE_BYTES {
|
||||
return Err("Godot 回执超过 2 MiB".into());
|
||||
}
|
||||
let value: Value = serde_json::from_slice(bytes).map_err(|_| "Godot 回执 JSON 损坏")?;
|
||||
if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL_VERSION)
|
||||
|| value.get("id").and_then(Value::as_u64) != Some(id)
|
||||
|| value.get("generation").and_then(Value::as_str) != Some(&session.generation)
|
||||
|| value.get("pid").and_then(Value::as_u64) != Some(session.pid.into())
|
||||
|| value.get("projectPath").and_then(Value::as_str) != Some(&session.project_path)
|
||||
|| value.get("buildId").and_then(Value::as_str) != Some(&session.build_id)
|
||||
|| value.get("error").is_some()
|
||||
{
|
||||
return Err("Godot 回执协议、请求或目标身份不匹配".into());
|
||||
}
|
||||
let result = value
|
||||
.get("result")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or("Godot 回执缺少结果对象")?;
|
||||
match method {
|
||||
"execute" => {
|
||||
if result.get("retryAllowed").and_then(Value::as_bool) != Some(false) {
|
||||
return Err("Godot 回执缺少禁止重放标记".into());
|
||||
}
|
||||
let ok = result.get("ok").and_then(Value::as_bool);
|
||||
let dispatched = result.get("dispatched").and_then(Value::as_bool);
|
||||
match result.get("status").and_then(Value::as_str) {
|
||||
Some("completed")
|
||||
if ok == Some(true)
|
||||
&& dispatched == Some(true)
|
||||
&& result.get("result").is_some()
|
||||
&& result.get("error").is_none() => {}
|
||||
Some("failed")
|
||||
if ok == Some(false)
|
||||
&& dispatched.is_some()
|
||||
&& valid_error(result.get("error"))
|
||||
&& result.get("result").is_none() => {}
|
||||
Some("needs-reconciliation")
|
||||
if ok == Some(false)
|
||||
&& dispatched == Some(true)
|
||||
&& valid_error(result.get("error"))
|
||||
&& result.get("result").is_none() => {}
|
||||
_ => return Err("Godot 返回矛盾或不完整的执行状态".into()),
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if result.get("connected").and_then(Value::as_bool) != Some(true)
|
||||
|| result.get("pid").and_then(Value::as_u64) != Some(session.pid.into())
|
||||
|| result.get("projectPath").and_then(Value::as_str) != Some(&session.project_path)
|
||||
|| result.get("version").and_then(Value::as_str) != Some(&session.version)
|
||||
|| result.get("generation").and_then(Value::as_str) != Some(&session.generation)
|
||||
|| result.get("buildId").and_then(Value::as_str) != Some(&session.build_id)
|
||||
|| result.get("executing").and_then(Value::as_bool).is_none()
|
||||
{
|
||||
return Err("Godot status 身份或状态不匹配".into());
|
||||
}
|
||||
}
|
||||
"shutdown" => {
|
||||
if !((result.get("accepted").and_then(Value::as_bool) == Some(true)
|
||||
&& result.get("status").and_then(Value::as_str) == Some("shutting-down"))
|
||||
|| (result.get("accepted").and_then(Value::as_bool) == Some(false)
|
||||
&& valid_error(result.get("error"))))
|
||||
{
|
||||
return Err("Godot shutdown 回执无效".into());
|
||||
}
|
||||
}
|
||||
_ => return Err("Godot 协议方法无效".into()),
|
||||
}
|
||||
Ok(result.clone())
|
||||
}
|
||||
|
||||
fn valid_error(value: Option<&Value>) -> bool {
|
||||
value.is_some_and(|v| {
|
||||
["code", "message"].iter().all(|k| {
|
||||
v.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@genarrative/agc-plugin-godot-editor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "AGC Godot 编辑器插件",
|
||||
"scripts": {
|
||||
"test": "node --test src/entry.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@genarrative/agc-plugin-sdk": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "agc-godot-editor",
|
||||
"version": "0.1.0",
|
||||
"description": "在当前已打开的 Godot 项目中执行编辑器操作",
|
||||
"extensions": {
|
||||
"com.openai": { "interface": { "displayName": "Godot 编辑器" } },
|
||||
"world.genarrative.agc": {
|
||||
"apiVersion": "v1",
|
||||
"entry": "./src/entry.mjs",
|
||||
"adapter": "godot-editor",
|
||||
"permissions": [
|
||||
"events.subscribe",
|
||||
"editor.rpc",
|
||||
"ui.register",
|
||||
"capability.register"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* AGC Godot 插件的 agc.plugin.v1 入口。
|
||||
* 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用
|
||||
* 经 host.rpc 交给统一 Runner,入口不查找进程、不准备扩展文件、不持有凭据。
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const GODOT_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
|
||||
export const GODOT_EXECUTE_COMMAND_ID = 'godot.editor.execute';
|
||||
export const GODOT_CONNECTION_CAPABILITY_ID = 'godot.editor.connection';
|
||||
const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_CODE_BYTES = 128 * 1024;
|
||||
|
||||
export function createGodotEditorPlugin({ 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('Godot 插件宿主 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('Godot 参数必须是对象');
|
||||
}
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowed.includes(key)) throw new Error(`Godot 不接受参数:${key}`);
|
||||
}
|
||||
if (!activeProjectPath) throw new Error('当前没有受控 Godot 项目');
|
||||
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 : 'Godot 执行结果待核对',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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('Godot GDScript 代码必须非空、不含 NUL 且不超过 128 KiB');
|
||||
}
|
||||
if (uncertain)
|
||||
return reconcile('先前执行结果待核对,请核对 Godot 后重启客户端');
|
||||
if (executing) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
dispatched: false,
|
||||
retryAllowed: false,
|
||||
error: { code: 'editor-busy', message: 'Godot 已有执行正在处理' },
|
||||
};
|
||||
}
|
||||
executing = true;
|
||||
const epoch = projectEpoch;
|
||||
try {
|
||||
const result = await request('host.rpc', {
|
||||
method: 'editor.execute',
|
||||
params,
|
||||
});
|
||||
if (epoch !== projectEpoch)
|
||||
return reconcile('Godot 执行期间项目已切换,原项目结果待核对');
|
||||
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('Godot 执行回执无效');
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return reconcile('Godot 执行连接中断或超时,结果待核对');
|
||||
} finally {
|
||||
executing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function connection(input = {}) {
|
||||
const params = editorParams(input, ['operation', 'timeoutMs']);
|
||||
const operation = params.operation ?? 'detect';
|
||||
if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) {
|
||||
throw new Error('不支持的 Godot 连接操作');
|
||||
}
|
||||
if (operation === 'disconnect' && (executing || uncertain))
|
||||
throw new Error('Godot 执行尚未完成或结果待核对,不能卸载连接桥');
|
||||
delete params.operation;
|
||||
const epoch = projectEpoch;
|
||||
const result = await request('host.rpc', {
|
||||
method: `editor.${operation}`,
|
||||
params,
|
||||
});
|
||||
if (epoch !== projectEpoch)
|
||||
throw new Error('Godot 连接期间项目已切换,回执不属于当前项目');
|
||||
if (
|
||||
operation === 'disconnect' &&
|
||||
(result?.connected !== false ||
|
||||
Object.hasOwn(result, 'accepted') ||
|
||||
result?.error ||
|
||||
result?.status === 'needs-reconciliation')
|
||||
) {
|
||||
uncertain = true;
|
||||
throw new Error('Godot 原生扩展尚未确认卸载,连接状态待核对');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 === GODOT_EXECUTE_COMMAND_ID
|
||||
? execute
|
||||
: value.method === GODOT_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 === GODOT_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: GODOT_EXECUTE_COMMAND_ID,
|
||||
title: '执行 Godot GDScript',
|
||||
description: '在当前 Godot 项目的编辑器主线程执行 GDScript',
|
||||
});
|
||||
await request('host.registerCapability', {
|
||||
id: GODOT_CONNECTION_CAPABILITY_ID,
|
||||
description: '当前 Godot 项目的编辑器连接与状态',
|
||||
});
|
||||
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 startGodotEditorStdioPlugin({
|
||||
stdin = process.stdin,
|
||||
stdout = process.stdout,
|
||||
} = {}) {
|
||||
const plugin = createGodotEditorPlugin({
|
||||
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
|
||||
) {
|
||||
startGodotEditorStdioPlugin();
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createGodotEditorPlugin,
|
||||
GODOT_CONNECTION_CAPABILITY_ID,
|
||||
GODOT_EXECUTE_COMMAND_ID,
|
||||
} from './entry.mjs';
|
||||
|
||||
const success = {
|
||||
ok: true,
|
||||
status: 'completed',
|
||||
dispatched: true,
|
||||
retryAllowed: false,
|
||||
result: 2,
|
||||
};
|
||||
|
||||
async function fixture(t, rpc = () => success, options = {}) {
|
||||
const requests = [];
|
||||
const replies = new Map();
|
||||
let inbound = 1000;
|
||||
const plugin = createGodotEditorPlugin({
|
||||
...options,
|
||||
send(message) {
|
||||
if (!message.method) {
|
||||
replies.set(message.id, message);
|
||||
return;
|
||||
}
|
||||
requests.push(message);
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const result =
|
||||
message.method === 'host.rpc'
|
||||
? await rpc(message.params)
|
||||
: message.method === 'host.events.subscribe'
|
||||
? { projectPath: 'C:/Godot/A', subscriptionId: 'project' }
|
||||
: {};
|
||||
await plugin.handleMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
});
|
||||
t.after(() => plugin.dispose());
|
||||
await plugin.start();
|
||||
return {
|
||||
plugin,
|
||||
requests,
|
||||
async call(params, method = GODOT_EXECUTE_COMMAND_ID) {
|
||||
const id = inbound++;
|
||||
await plugin.handleMessage({ jsonrpc: '2.0', id, method, params });
|
||||
return replies.get(id);
|
||||
},
|
||||
async project(projectPath) {
|
||||
await plugin.handleMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'host.event',
|
||||
params: { type: 'project.changed', payload: { projectPath } },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('注册声明的命令与连接能力,并使用宿主提供的当前项目', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.deepEqual(
|
||||
f.requests.slice(0, 3).map((item) => item.method),
|
||||
[
|
||||
'host.registerCommand',
|
||||
'host.registerCapability',
|
||||
'host.events.subscribe',
|
||||
],
|
||||
);
|
||||
assert.deepEqual((await f.call({ code: 'return 1 + 1;' })).result, success);
|
||||
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/A');
|
||||
await f.project('C:/Godot/B');
|
||||
await f.call({ operation: 'detect' }, GODOT_CONNECTION_CAPABILITY_ID);
|
||||
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/B');
|
||||
const manifest = JSON.parse(
|
||||
await readFile(new URL('../plugin.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.equal(
|
||||
manifest.extensions['world.genarrative.agc'].adapter,
|
||||
'godot-editor',
|
||||
);
|
||||
});
|
||||
|
||||
test('显式项目、任意payload和非法代码在宿主派发前拒绝', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const input of [
|
||||
{ code: 'return 1;', projectPath: 'C:/Other' },
|
||||
{ code: 'return 1;', payloadPath: 'C:/evil.dll' },
|
||||
{ code: '' },
|
||||
{ code: 'x\0y' },
|
||||
{ code: '中'.repeat(128 * 1024) },
|
||||
{ code: 'return 1;', timeoutMs: 60_001 },
|
||||
])
|
||||
assert.equal((await f.call(input)).result.dispatched, false);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
0,
|
||||
);
|
||||
await f.project(null);
|
||||
assert.equal((await f.call({ code: 'return 1;' })).result.dispatched, false);
|
||||
});
|
||||
|
||||
test('并发写请求立即失败,不会在上一请求结束后补发', async (t) => {
|
||||
let finish;
|
||||
const f = await fixture(
|
||||
t,
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = f.call({ code: 'return 1;' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal((await f.call({ code: 'return 2;' })).result.dispatched, false);
|
||||
finish(success);
|
||||
await first;
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('未知结果经过断开及项目切换仍阻断写入', async (t) => {
|
||||
const f = await fixture(t, ({ method }) =>
|
||||
method === 'editor.execute'
|
||||
? {
|
||||
ok: false,
|
||||
status: 'needs-reconciliation',
|
||||
retryAllowed: false,
|
||||
dispatched: true,
|
||||
error: 'lost',
|
||||
}
|
||||
: { connected: false },
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 1;' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID);
|
||||
await f.project('C:/Godot/B');
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 2;' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.params?.method === 'editor.execute')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('可信运行失败可以修正代码后再次执行', async (t) => {
|
||||
let count = 0;
|
||||
const f = await fixture(t, () =>
|
||||
++count === 1
|
||||
? {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
retryAllowed: false,
|
||||
dispatched: true,
|
||||
error: { code: 'runtime-error', message: 'division by zero' },
|
||||
}
|
||||
: success,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 1 / 0;' })).result.status,
|
||||
'failed',
|
||||
);
|
||||
assert.equal((await f.call({ code: 'return 2;' })).result.ok, true);
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
test('超时回执与不完整终态均保守阻断,不自行重放', async (t) => {
|
||||
for (const rpc of [
|
||||
() => ({ ...success, error: { code: 'conflict', message: 'both' } }),
|
||||
() => ({
|
||||
...success,
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: { code: 'conflict', message: 'both' },
|
||||
}),
|
||||
() => new Promise(() => {}),
|
||||
() => ({ ok: true }),
|
||||
() => ({
|
||||
ok: true,
|
||||
status: 'completed',
|
||||
dispatched: true,
|
||||
retryAllowed: false,
|
||||
}),
|
||||
() => ({
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
dispatched: true,
|
||||
retryAllowed: false,
|
||||
error: 'untrusted',
|
||||
}),
|
||||
]) {
|
||||
const f = await fixture(t, rpc, { timeoutMs: 10 });
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 1;' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 2;' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('迟到的订阅快照不能覆盖已收到的新项目事件', async (t) => {
|
||||
const requests = [];
|
||||
const plugin = createGodotEditorPlugin({
|
||||
send(message) {
|
||||
requests.push(message);
|
||||
if (!message.method) return;
|
||||
queueMicrotask(async () => {
|
||||
if (message.method === 'host.events.subscribe') {
|
||||
await plugin.handleMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'host.event',
|
||||
params: {
|
||||
type: 'project.changed',
|
||||
payload: { projectPath: 'C:/New' },
|
||||
},
|
||||
});
|
||||
}
|
||||
await plugin.handleMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result:
|
||||
message.method === 'host.events.subscribe'
|
||||
? { projectPath: 'C:/Old' }
|
||||
: success,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
t.after(() => plugin.dispose());
|
||||
await plugin.start();
|
||||
await plugin.handleMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 500,
|
||||
method: GODOT_EXECUTE_COMMAND_ID,
|
||||
params: { code: 'return 2;' },
|
||||
});
|
||||
assert.equal(
|
||||
requests.find((item) => item.method === 'host.rpc').params.params
|
||||
.projectPath,
|
||||
'C:/New',
|
||||
);
|
||||
});
|
||||
|
||||
test('连接身份由宿主绑定,不接受进程、端口、令牌或库路径覆盖', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const forbidden of [
|
||||
'processId',
|
||||
'projectPath',
|
||||
'port',
|
||||
'token',
|
||||
'dllPath',
|
||||
]) {
|
||||
const reply = await f.call(
|
||||
{ operation: 'connect', [forbidden]: 123 },
|
||||
GODOT_CONNECTION_CAPABILITY_ID,
|
||||
);
|
||||
assert.equal(reply.error.code, -32602);
|
||||
}
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('nil 返回值保留真实完成语义', async (t) => {
|
||||
const result = { ...success, result: null };
|
||||
const f = await fixture(t, () => result);
|
||||
assert.deepEqual((await f.call({ code: 'return null' })).result, result);
|
||||
});
|
||||
|
||||
test('await 未完成时拒绝断开,不卸载在途执行', async (t) => {
|
||||
let finish;
|
||||
const f = await fixture(
|
||||
t,
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = f.call({ code: 'await get_tree().process_frame\nreturn 42' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
(await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID))
|
||||
.error.code,
|
||||
-32602,
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
1,
|
||||
);
|
||||
finish(success);
|
||||
assert.equal((await first).result.status, 'completed');
|
||||
});
|
||||
|
||||
test('shutdown 受理和不确定回执不能冒充卸载完成,也不能解除执行阻断', async (t) => {
|
||||
for (const result of [
|
||||
{ accepted: true, status: 'shutting-down' },
|
||||
{ accepted: true, connected: false, status: 'shutting-down' },
|
||||
{
|
||||
accepted: false,
|
||||
connected: false,
|
||||
error: { code: 'busy', message: 'busy' },
|
||||
},
|
||||
{ connected: false, status: 'needs-reconciliation' },
|
||||
]) {
|
||||
const f = await fixture(t, () => result);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
{ operation: 'disconnect' },
|
||||
GODOT_CONNECTION_CAPABILITY_ID,
|
||||
)
|
||||
).error.code,
|
||||
-32602,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 42' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const f = await fixture(t, () => ({
|
||||
adapter: 'godot-editor',
|
||||
connected: false,
|
||||
}));
|
||||
assert.equal(
|
||||
(await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID))
|
||||
.result.connected,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('项目切换后旧项目的执行回执不能当成新项目成功', async (t) => {
|
||||
let finish;
|
||||
const f = await fixture(
|
||||
t,
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = f.call({ code: 'return 42' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await f.project('C:/Godot/B');
|
||||
finish(success);
|
||||
assert.equal((await first).result.status, 'needs-reconciliation');
|
||||
assert.equal(
|
||||
(await f.call({ code: 'return 43' })).result.status,
|
||||
'needs-reconciliation',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((item) => item.method === 'host.rpc').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -115,8 +115,12 @@ export function createUnityEditorPlugin({ send, timeoutMs = 85_000 }) {
|
||||
(result.status === 'completed' &&
|
||||
result.ok &&
|
||||
result.dispatched &&
|
||||
!Object.hasOwn(result, 'error') &&
|
||||
Object.hasOwn(result, 'result')) ||
|
||||
(result.status === 'failed' && !result.ok && validError)
|
||||
(result.status === 'failed' &&
|
||||
!result.ok &&
|
||||
validError &&
|
||||
!Object.hasOwn(result, 'result'))
|
||||
)
|
||||
) {
|
||||
return reconcile('Unity 执行回执无效');
|
||||
|
||||
@@ -180,6 +180,13 @@ test('可信运行失败可以修正代码后再次执行', async (t) => {
|
||||
|
||||
test('超时回执与不完整终态均保守阻断,不自行重放', async (t) => {
|
||||
for (const rpc of [
|
||||
() => ({ ...success, error: { code: 'conflict', message: 'both' } }),
|
||||
() => ({
|
||||
...success,
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: { code: 'conflict', message: 'both' },
|
||||
}),
|
||||
() => new Promise(() => {}),
|
||||
() => ({ ok: true }),
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user