392 lines
16 KiB
GDScript
392 lines
16 KiB
GDScript
@tool
|
|
extends Node
|
|
|
|
## Compile this source into a GDScript with no resource_path before creating
|
|
## the node. Its named callbacks must survive replacement of the file itself.
|
|
## The caller verifies/stages/quiesces and owns the update lock. No plugin,
|
|
## dock, handler, or suspended caller frame crosses the deferred handoff.
|
|
const LIVE_ROOT := "res://addons/godot_ai"
|
|
const PLUGIN_CFG := LIVE_ROOT + "/plugin.cfg"
|
|
const STAGE_ROOT := "res://addons/.godot_ai_update/stage/addons/godot_ai"
|
|
const INSTALLER_PATH := LIVE_ROOT + "/utils/update_installer.gd"
|
|
const VERIFIER_PATH := LIVE_ROOT + "/utils/release_verifier.gd"
|
|
const RECEIPT_PATH := "res://addons/.godot_ai_update/activation.json"
|
|
const SCAN_TIMEOUT_MS := 60000
|
|
|
|
enum Phase { IDLE, DRAIN, REQUEST_OLD_SCAN, WAIT_OLD_SCAN, SWAPPING, REQUEST_NEW_SCAN, WAIT_NEW_SCAN, ENABLING, DONE }
|
|
|
|
var _phase := Phase.IDLE
|
|
var _package: Dictionary = {}
|
|
var _installer: Script
|
|
var _verifier: Script
|
|
var _frames := 0
|
|
var _deadline := 0
|
|
var _filesystem: EditorFileSystem
|
|
var _failure := ""
|
|
var _outcome := ""
|
|
var _backup_root := ""
|
|
var _retired_scripts: Array[Script] = []
|
|
var _inspected_objects := {}
|
|
var _inspection_budget := 100000
|
|
## Copied by a caller when start() refuses, before the runner is freed.
|
|
var refusal_reason := "The independent activation handoff was refused."
|
|
|
|
|
|
func start(package: Dictionary) -> bool:
|
|
if _phase != Phase.IDLE:
|
|
refusal_reason = "The activation runner has already started."
|
|
return false
|
|
if not is_inside_tree():
|
|
refusal_reason = "The activation runner must belong to the editor scene tree."
|
|
return false
|
|
if not str(get_script().resource_path).is_empty():
|
|
refusal_reason = "The activation runner must be source-free before replacing its files."
|
|
return false # A file-backed runner can be replaced by the scan it starts.
|
|
if package.get("stage_root") != STAGE_ROOT:
|
|
refusal_reason = "The update stage root does not match the verified activation location."
|
|
return false
|
|
if not package.get("record") is Dictionary:
|
|
refusal_reason = "The update handoff requires an installer record Dictionary."
|
|
return false
|
|
var record: Dictionary = package.record
|
|
for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]:
|
|
if not record.get(key) is String or str(record[key]).is_empty():
|
|
refusal_reason = "The update record requires a nonempty String field: %s." % key
|
|
return false
|
|
if not record.get("replace_owned_mismatches") is bool:
|
|
refusal_reason = "The update record requires a boolean replace_owned_mismatches field."
|
|
return false
|
|
# Copy only values that belong to the existing installer's swap record.
|
|
_package = {"stage_root": STAGE_ROOT, "record": {}}
|
|
for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce", "replace_owned_mismatches"]:
|
|
_package.record[key] = record[key]
|
|
_installer = ResourceLoader.load(INSTALLER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script
|
|
_verifier = ResourceLoader.load(VERIFIER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script
|
|
if _installer == null or _verifier == null:
|
|
refusal_reason = "The update installer or release verifier script could not be loaded."
|
|
return false
|
|
for method in ["swap", "verify_after_restart", "release_lock", "read_lock", "discard_stage"]:
|
|
if not _declares(_installer, method):
|
|
refusal_reason = "The update installer is missing its required %s method." % method
|
|
return false
|
|
if not _declares(_verifier, "hash_tree"):
|
|
refusal_reason = "The release verifier is missing its required hash_tree method."
|
|
return false
|
|
var lock: Dictionary = _installer.call("read_lock")
|
|
if int(lock.get("pid", 0)) != OS.get_process_id() or str(lock.get("fingerprint", "")).is_empty():
|
|
refusal_reason = "The update lock does not identify this editor with a process fingerprint."
|
|
return false
|
|
_inspected_objects.clear()
|
|
_inspection_budget = 100000
|
|
if not _user_resources_can_keep_paths():
|
|
return false
|
|
refusal_reason = ""
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
_phase = Phase.DRAIN
|
|
set_process(false)
|
|
_begin.call_deferred()
|
|
return true
|
|
|
|
|
|
func _declares(script: Script, method: String) -> bool:
|
|
for entry in script.get_script_method_list():
|
|
if str(entry.get("name", "")) == method:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _begin() -> void:
|
|
_filesystem = EditorInterface.get_resource_filesystem()
|
|
if _filesystem == null:
|
|
_stop("The editor filesystem is unavailable; no update swap was attempted.")
|
|
return
|
|
_filesystem.sources_changed.connect(_scan_completed)
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
|
|
if EditorInterface.is_plugin_enabled(PLUGIN_CFG):
|
|
_stop("Godot could not disable the plugin; the verified stage was retained.")
|
|
return
|
|
_frames = 2
|
|
set_process(true)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _phase == Phase.DRAIN:
|
|
_frames -= 1
|
|
if _frames <= 0:
|
|
_phase = Phase.REQUEST_OLD_SCAN
|
|
_deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS
|
|
elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.WAIT_OLD_SCAN, Phase.REQUEST_NEW_SCAN, Phase.WAIT_NEW_SCAN]:
|
|
if Time.get_ticks_msec() >= _deadline:
|
|
_stop("The filesystem scan did not complete. The plugin remains disabled; the staged update and any backup were retained.")
|
|
elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.REQUEST_NEW_SCAN] and not _filesystem.is_scanning():
|
|
_phase = Phase.WAIT_OLD_SCAN if _phase == Phase.REQUEST_OLD_SCAN else Phase.WAIT_NEW_SCAN
|
|
# scan() may decline while a finished worker still awaits GUI
|
|
# completion. Its sources_changed signal remains a valid barrier.
|
|
_filesystem.scan()
|
|
|
|
|
|
func _swap() -> void:
|
|
set_process(false)
|
|
# A cache lookup may resolve dependencies even when the requested script
|
|
# is cached. Retain all old references while their complete tree exists;
|
|
# never load a canonical path after the swap to recover an old script.
|
|
if not _collect_cached_scripts(LIVE_ROOT):
|
|
_stop("The previous script cache could not be captured safely; no update swap was attempted.")
|
|
return
|
|
var before: Dictionary = _verifier.call("hash_tree", LIVE_ROOT)
|
|
if not bool(before.get("ok", false)):
|
|
_stop("Could not hash the previous tree before activation; no swap was attempted.")
|
|
return
|
|
var swapped: Dictionary = _installer.call("swap", STAGE_ROOT, LIVE_ROOT, _package.record)
|
|
if not bool(swapped.get("ok", false)):
|
|
_failure = "Update swap failed: %s" % str(swapped.get("error", "unknown error"))
|
|
var restored: Dictionary = _verifier.call("hash_tree", LIVE_ROOT)
|
|
if not bool(restored.get("ok", false)) or restored.get("tree_sha256") != before.get("tree_sha256"):
|
|
_stop(_failure + "; the original tree could not be proven restored. Explicit repair is required.")
|
|
return
|
|
_installer.call("discard_stage")
|
|
_outcome = "previous_tree_retained"
|
|
else:
|
|
print("MCP | update to %s swapped in; reloading the plugin in this editor" % str(_package.record.to_version))
|
|
var verified: Dictionary = _installer.call("verify_after_restart", LIVE_ROOT)
|
|
_outcome = str(verified.get("status", ""))
|
|
if _outcome != "success" and _outcome != "rolled_back":
|
|
_stop("The installed tree could not be verified: %s" % str(verified.get("error", "unknown outcome")))
|
|
return
|
|
if _outcome == "rolled_back":
|
|
_failure = "Update verification failed; the previous tree was restored: %s" % str(verified.get("error", ""))
|
|
else:
|
|
_backup_root = str(verified.get("backup_root", ""))
|
|
var expected_backup := "res://addons/.godot_ai_update/backup/" + str(_package.record.from_version)
|
|
if _backup_root != expected_backup:
|
|
_stop("The update marker names an unexpected backup. The plugin remains disabled.")
|
|
return
|
|
var backup: Dictionary = _verifier.call("hash_tree", _backup_root)
|
|
if not bool(backup.get("ok", false)) or backup.get("tree_sha256") != before.get("tree_sha256"):
|
|
_stop("The retained backup could not be proven to match the previous tree. The plugin remains disabled.")
|
|
return
|
|
if _outcome == "success" and not _retire_cached_scripts(_backup_root):
|
|
_stop("The previous script cache could not be retained safely. The plugin remains disabled.")
|
|
return
|
|
_release_scripts()
|
|
_phase = Phase.REQUEST_NEW_SCAN
|
|
_deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS
|
|
set_process(true)
|
|
|
|
|
|
func _scan_completed(_sources_changed: bool) -> void:
|
|
if _phase not in [Phase.WAIT_OLD_SCAN, Phase.WAIT_NEW_SCAN]:
|
|
return
|
|
if _filesystem.is_scanning():
|
|
return # Another completion listener already started a newer scan.
|
|
if Time.get_ticks_msec() >= _deadline:
|
|
_stop("The filesystem scan completion arrived after the activation deadline. The plugin remains disabled.")
|
|
return
|
|
set_process(false)
|
|
# sources_changed follows scan actions, documentation, and GUI updates.
|
|
# filesystem_changed can occur earlier; a deferred completion can become
|
|
# stale during a newer GUI scan. Consume this boundary synchronously.
|
|
if _phase == Phase.WAIT_OLD_SCAN:
|
|
_phase = Phase.SWAPPING
|
|
_swap()
|
|
else:
|
|
_phase = Phase.ENABLING
|
|
_filesystem.sources_changed.disconnect(_scan_completed)
|
|
_enable()
|
|
|
|
|
|
func _enable() -> void:
|
|
if not _failure.is_empty() and str(_package.record.from_version).get_slice(".", 0).to_int() < 4:
|
|
# A restored capsule must show Retry instead of automatically repeating
|
|
# the failed update. This is its existing project-scoped status key.
|
|
var settings := EditorInterface.get_editor_settings()
|
|
if settings != null:
|
|
var key := "godot_ai/v4_migration_bridge_status_" + ProjectSettings.globalize_path("res://").sha256_text()
|
|
settings.set_setting(key, JSON.stringify({"error": _failure}))
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
|
|
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
|
|
_stop("The scanned plugin could not be enabled. The installed tree and backup were retained.")
|
|
return
|
|
if _outcome == "success":
|
|
var plugin := _find_plugin(get_tree().root)
|
|
if plugin == null or str(plugin.get("_loaded_plugin_version")) != str(_package.record.to_version):
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
|
|
_stop("The enabled plugin did not report the installed version. The update evidence was retained.")
|
|
return
|
|
var saved := ProjectSettings.save()
|
|
if saved != OK:
|
|
_stop("The plugin is enabled, but saving its enabled state failed: %s" % error_string(saved))
|
|
return
|
|
_phase = Phase.DONE
|
|
_record(_outcome, _failure)
|
|
if not _failure.is_empty():
|
|
_show_failure(_failure)
|
|
return
|
|
print("MCP | update activation completed in editor PID %d" % OS.get_process_id())
|
|
queue_free()
|
|
|
|
|
|
func _user_resources_can_keep_paths() -> bool:
|
|
# Retiring a Script changes resource_path for all its existing users.
|
|
# Refuse serializable scene/resource references instead of silently making
|
|
# a user's next save point into the updater's retained backup directory.
|
|
var roots: Array = []
|
|
if EditorInterface.has_method("get_open_scene_roots"):
|
|
roots = EditorInterface.call("get_open_scene_roots")
|
|
else:
|
|
if EditorInterface.get_open_scenes().size() > 1:
|
|
refusal_reason = "This Godot version cannot inspect every open scene safely. Close the other scene tabs before retrying the update."
|
|
return false
|
|
var current := EditorInterface.get_edited_scene_root()
|
|
if current != null:
|
|
roots.append(current)
|
|
for root in roots:
|
|
if not _inspect_user_node(root):
|
|
return false
|
|
var inspected := EditorInterface.get_inspector().get_edited_object()
|
|
if inspected is Resource and not _inspect_user_value(inspected, 0):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _inspect_user_node(node: Node) -> bool:
|
|
if not _inspect_user_value(node, 0):
|
|
return false
|
|
for child in node.get_children():
|
|
if not _inspect_user_node(child):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _inspect_user_value(value: Variant, depth: int) -> bool:
|
|
_inspection_budget -= 1
|
|
if _inspection_budget < 0 or depth > 64:
|
|
refusal_reason = "Open scene or resource data exceeded the safe inspection limit. Close the affected scenes or resources before retrying the update."
|
|
return false
|
|
if value is Script:
|
|
if value.resource_path.begins_with(LIVE_ROOT + "/"):
|
|
refusal_reason = "An open scene or resource references addon script %s. In-editor activation would change its saved script path. Close the scene or resource before retrying; the update was not installed." % value.resource_path
|
|
return false
|
|
return true
|
|
if value is Resource or value is Node:
|
|
var identity: int = value.get_instance_id()
|
|
if _inspected_objects.has(identity):
|
|
return true
|
|
_inspected_objects[identity] = true
|
|
for property in value.get_property_list():
|
|
if int(property.usage) & PROPERTY_USAGE_STORAGE:
|
|
if not _inspect_user_value(value.get(property.name), depth + 1):
|
|
return false
|
|
elif value is Array:
|
|
for entry in value:
|
|
if not _inspect_user_value(entry, depth + 1):
|
|
return false
|
|
elif value is Dictionary:
|
|
for key in value:
|
|
if not _inspect_user_value(key, depth + 1) or not _inspect_user_value(value[key], depth + 1):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _retire_cached_scripts(backup_root: String) -> bool:
|
|
# All references were captured before replacement. Godot moves
|
|
# both its resource and GDScript cache entries through take_over_path().
|
|
# Existing UndoRedo callbacks keep the old compiled code and typed state;
|
|
# canonical loads create a fresh graph, including fresh static variables.
|
|
for script in _retired_scripts:
|
|
var previous_path := script.resource_path
|
|
var retained_path := backup_root + previous_path.trim_prefix(LIVE_ROOT)
|
|
if not previous_path.begins_with(LIVE_ROOT + "/") or not FileAccess.file_exists(retained_path):
|
|
return false
|
|
if ResourceLoader.has_cached(retained_path):
|
|
return false # Never displace a different retained generation.
|
|
# A later update in this same editor must not prune source files still
|
|
# referenced by an earlier generation's undo callbacks.
|
|
get_tree().root.set_meta("godot_ai_retained_update_scripts", true)
|
|
for script in _retired_scripts:
|
|
var retained_path := backup_root + script.resource_path.trim_prefix(LIVE_ROOT)
|
|
script.take_over_path(retained_path)
|
|
if script.resource_path != retained_path:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _collect_cached_scripts(folder: String) -> bool:
|
|
var directory := DirAccess.open(folder)
|
|
if directory == null:
|
|
return false
|
|
for filename in directory.get_files():
|
|
if not filename.ends_with(".gd"):
|
|
continue
|
|
var canonical := folder.path_join(filename)
|
|
if ResourceLoader.has_cached(canonical):
|
|
var script := ResourceLoader.load(canonical) as Script
|
|
if script == null or script.resource_path != canonical:
|
|
return false
|
|
_retired_scripts.append(script)
|
|
for child in directory.get_directories():
|
|
if not _collect_cached_scripts(folder.path_join(child)):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _find_plugin(node: Node) -> Node:
|
|
var script := node.get_script() as Script
|
|
if script != null and script.resource_path == LIVE_ROOT + "/plugin.gd":
|
|
return node
|
|
for child in node.get_children():
|
|
var found := _find_plugin(child)
|
|
if found != null:
|
|
return found
|
|
return null
|
|
|
|
|
|
func _release_scripts() -> void:
|
|
if _installer != null:
|
|
_installer.call("release_lock")
|
|
_installer = null
|
|
_verifier = null
|
|
|
|
|
|
func _stop(message: String) -> void:
|
|
_phase = Phase.DONE
|
|
set_process(false)
|
|
if _filesystem != null and _filesystem.sources_changed.is_connected(_scan_completed):
|
|
_filesystem.sources_changed.disconnect(_scan_completed)
|
|
_release_scripts()
|
|
_record("activation_failed", message)
|
|
_show_failure(message)
|
|
|
|
|
|
func _record(status: String, message: String) -> void:
|
|
var file := FileAccess.open(RECEIPT_PATH, FileAccess.WRITE)
|
|
if file != null:
|
|
file.store_string(JSON.stringify({"status": status, "error": message,
|
|
"editor_pid": OS.get_process_id(), "from_version": _package.record.from_version,
|
|
"to_version": _package.record.to_version, "unix": Time.get_unix_time_from_system()}))
|
|
else:
|
|
push_error("MCP | could not write update activation evidence: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
|
|
func _show_failure(message: String) -> void:
|
|
push_error("MCP | %s" % message)
|
|
if DisplayServer.get_name() == "headless":
|
|
queue_free()
|
|
return
|
|
var dialog := AcceptDialog.new()
|
|
dialog.title = "Godot AI update needs attention"
|
|
dialog.dialog_text = message + "\nUpdate evidence: res://addons/.godot_ai_update/"
|
|
add_child(dialog)
|
|
dialog.confirmed.connect(_dismiss_failure)
|
|
dialog.canceled.connect(_dismiss_failure)
|
|
dialog.popup_centered(Vector2i(620, 220))
|
|
|
|
|
|
func _dismiss_failure() -> void:
|
|
## Godot may have left its shared progress dialog under the failure window.
|
|
## Return it before freeing our subtree and leaving the editor's pointer dangling.
|
|
if is_inside_tree():
|
|
for dialog in find_children("*", "ProgressDialog", true, false):
|
|
dialog.reparent(get_tree().root)
|
|
queue_free()
|