Files
agame/addons/godot_ai/migration_bridge.gd
T
2026-09-21 16:16:04 +08:00

213 lines
8.4 KiB
GDScript

@tool
extends Node
## Verifies and stages the embedded canonical v4 release while the migration
## capsule is still the enabled plugin. Every step runs in GDScript on the
## editor's main thread through the same installer scripts the v4 tree ships
## (`utils/release_verifier.gd`, `utils/update_installer.gd`); nothing here
## spawns a process, and nothing here writes a live add-on file.
const PAYLOAD_ROOT := "res://addons/godot_ai/migration_payload"
const ARCHIVE_NAME := "godot-ai-v4-plugin.zip"
const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json"
const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig"
const VERIFIER_SCRIPT := "res://addons/godot_ai/utils/release_verifier.gd"
const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd"
const REPOSITORY := "hi-godot/godot-ai"
const CHANNEL := "stable"
const MAX_MANIFEST_BYTES := 1024 * 1024
const SIGNATURE_BYTES := 512
const PENDING_V3_UPDATE_SETTING := "godot_ai/pending_self_update_event"
const INCOMPLETE_CAPSULE := (
"The migration capsule is incomplete (%s). Download the release again, then click Retry migration."
)
## Kept byte-identical to `utils/update_manager.gd::RELEASE_SIGNING_PUBLIC_KEY_PEM`
## in the v4 tree; `tests/unit/test_release_support.py` pins the two copies
## together. The capsule cannot preload the v4 updater to read it there.
const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p
e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy
wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9
yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk
lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu
qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc
ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx
h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P
584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86
XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k
YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG
1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ==
-----END PUBLIC KEY-----
"""
signal state_changed(message: String, failed: bool)
signal prepared(package: Dictionary)
enum Step { IDLE, VERIFY, STAGE, DONE }
var _step := Step.IDLE
var _manifest: Dictionary = {}
var _manifest_sha256 := ""
var _from_version := ""
func start() -> void:
if _step != Step.IDLE:
return
_from_version = _previous_version()
_step = Step.VERIFY
state_changed.emit("Verifying the signed Godot AI v4 release…", false)
set_process(true)
func _process(_delta: float) -> void:
## One step per frame so the dock renders each status line before the
## next synchronous hash/extract pass runs.
match _step:
Step.VERIFY:
_verify()
Step.STAGE:
_stage()
_:
set_process(false)
func _verify() -> void:
var verifier := _load_script_with(VERIFIER_SCRIPT, "verify_manifest")
if verifier == null or not _script_declares(verifier, "verify_archive"):
_fail(INCOMPLETE_CAPSULE % "utils/release_verifier.gd is missing or invalid")
return
var manifest_bytes := _read_bounded(PAYLOAD_ROOT.path_join(MANIFEST_NAME), MAX_MANIFEST_BYTES)
var signature := _read_bounded(PAYLOAD_ROOT.path_join(SIGNATURE_NAME), SIGNATURE_BYTES)
if manifest_bytes.is_empty() or signature.size() != SIGNATURE_BYTES:
_fail(INCOMPLETE_CAPSULE % "the embedded v4 manifest or signature is unreadable")
return
var expected := {"repository": REPOSITORY, "channel": CHANNEL}
if not _from_version.is_empty():
## Only the final v3 runners publish the pre-capsule plugin.cfg
## version; older ones cannot, and the verifier must not be told a
## guess.
expected["current_version"] = _from_version
var verified: Variant = verifier.call(
"verify_manifest", manifest_bytes, signature, expected, RELEASE_SIGNING_PUBLIC_KEY_PEM
)
if not verified is Dictionary or not bool(verified.get("ok", false)):
_fail(_error_of(verified, "The embedded v4 release manifest could not be verified."))
return
var manifest: Variant = (verified as Dictionary).get("manifest", {})
if not manifest is Dictionary or str(manifest.get("version", "")).is_empty():
_fail("The verified v4 manifest is missing its release version.")
return
_manifest = (manifest as Dictionary).duplicate(true)
_manifest_sha256 = _sha256_hex(manifest_bytes)
var archive_checked: Variant = verifier.call(
"verify_archive", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest
)
if not archive_checked is Dictionary or not bool(archive_checked.get("ok", false)):
_fail(_error_of(archive_checked, "The embedded v4 archive does not match its signed manifest."))
return
_step = Step.STAGE
state_changed.emit("Staging the verified Godot AI v4 tree…", false)
func _stage() -> void:
var installer := _load_script_with(INSTALLER_SCRIPT, "stage")
if installer == null:
_fail(INCOMPLETE_CAPSULE % "utils/update_installer.gd is missing or invalid")
return
var staged: Variant = installer.call("stage", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest)
if not staged is Dictionary or not bool(staged.get("ok", false)):
_fail(_error_of(staged, "The verified v4 tree could not be staged."))
return
var stage_root := str((staged as Dictionary).get("stage_root", ""))
var tree_sha256 := str((staged as Dictionary).get("tree_sha256", ""))
if stage_root.is_empty() or tree_sha256.is_empty():
_fail("The installer staged the v4 tree without reporting where or what it wrote.")
return
_step = Step.DONE
set_process(false)
state_changed.emit("Activating the verified Godot AI v4 tree…", false)
prepared.emit({
"stage_root": stage_root,
"expected_tree_sha256": tree_sha256,
"manifest_sha256": _manifest_sha256,
"from_version": _from_version,
"to_version": str(_manifest.get("version", "")),
})
func _fail(message: String) -> void:
_step = Step.DONE
set_process(false)
state_changed.emit(message, true)
## The pre-capsule plugin.cfg version, as recorded by the final v3 self-update
## runner before it extracted the capsule over the tree. Empty when unknown.
static func _previous_version() -> String:
var settings := EditorInterface.get_editor_settings()
if settings == null or not settings.has_setting(PENDING_V3_UPDATE_SETTING):
return ""
var parsed: Variant = JSON.parse_string(str(settings.get_setting(PENDING_V3_UPDATE_SETTING)))
if not parsed is Dictionary:
return ""
var value := str(parsed.get("from_version", ""))
return value if _is_pre_v4_version(value) else ""
static func _is_pre_v4_version(value: String) -> bool:
var expression := RegEx.new()
return expression.compile("^[0-3]\\.\\d+\\.\\d+$") == OK and expression.search(value) != null
## Load one of the v4 installer scripts by path. The capsule cannot name them
## by `class_name`: those globals only exist once the v4 tree is live, and the
## bridge must still parse when a truncated capsule lacks them entirely.
static func _load_script_with(path: String, method: String) -> Script:
if not ResourceLoader.exists(path):
return null
## Final v3 ships older files at some of these paths (utils/port_resolver.gd),
## already compiled into the resource cache before the capsule was extracted
## over the tree. Ignore the cache, deeply, so the capsule's copies and
## their preloads are the ones that run.
var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP)
if not loaded is Script or not _script_declares(loaded, method):
return null
return loaded
static func _script_declares(script: Script, method: String) -> bool:
for entry in script.get_script_method_list():
if str(entry.get("name", "")) == method:
return true
return false
static func _read_bounded(path: String, maximum: int) -> PackedByteArray:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return PackedByteArray()
var length := file.get_length()
if length <= 0 or length > maximum:
file.close()
return PackedByteArray()
var data := file.get_buffer(length)
file.close()
return data if data.size() == length else PackedByteArray()
static func _sha256_hex(data: PackedByteArray) -> String:
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
context.update(data)
return context.finish().hex_encode()
static func _error_of(result: Variant, fallback: String) -> String:
if result is Dictionary:
var error := str((result as Dictionary).get("error", "")).strip_edges()
if not error.is_empty():
return error
return fallback