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

218 lines
7.2 KiB
GDScript

@tool
extends Node
## Puts the final v3 add-on back when this Godot is below v4's floor. The
## user's own updater already overlaid the capsule over their v3 tree and
## discarded its per-file backups, so without this the plugin stays dead
## until they upgrade Godot. Parented outside the EditorPlugin so it survives
## the capsule being disabled. Leaves with the capsule once the fleet is on
## v4 (docs/self-update.md).
const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
const LIVE_ROOT := "res://addons/godot_ai"
const FALLBACK_ZIP := "res://addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip"
const LIVE_PREFIX := "addons/godot_ai/"
## Every file is written and read back here before the live tree is touched.
## The update directory is hidden from Godot's scanner and from git exactly
## as utils/update_installer.gd hides it.
const UPDATE_ROOT := "res://addons/.godot_ai_update"
const STAGE_ROOT := UPDATE_ROOT + "/v3-restore"
const UPDATE_IGNORE_FILES := [[".gdignore", ""], [".gitignore", "*\n"]]
const MAX_FILES := 4096
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
## Capsule-only files; the restored v3 tree must not carry them.
const CAPSULE_ONLY := [
"migration_bridge.gd", "migration_bridge.gd.uid",
"migration_coordinator.gd", "migration_coordinator.gd.uid",
"migration_fallback.gd", "migration_fallback.gd.uid",
"utils/release_verifier.gd", "utils/release_verifier.gd.uid",
"utils/update_installer.gd", "utils/update_installer.gd.uid",
"migration_payload",
]
static func available() -> bool:
return FileAccess.file_exists(FALLBACK_ZIP)
func start() -> void:
_restore.call_deferred()
func _restore() -> void:
var entries: Array = []
var error := _read_payload(entries)
if error.is_empty():
error = _prepare_update_root()
if error.is_empty():
error = _write_all(STAGE_ROOT, entries)
if error.is_empty():
error = _write_all(LIVE_ROOT, entries)
if not error.is_empty():
## The capsule payload and any stage stay for a retry; nothing is
## removed until the restored plugin has been enabled.
push_error("Godot AI v4 migration: could not restore the previous version: %s" % error)
queue_free()
return
print(
"MCP | v3 bridge restored Godot AI v%s; update again on Godot 4.7 or newer"
% _restored_version()
)
## Disabling the capsule removed its dock; re-enabling after a scan loads
## the restored plugin.gd, exactly as final v3's own updater re-enables.
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
var filesystem := EditorInterface.get_resource_filesystem()
if filesystem == null:
_enable_after_scan.call_deferred()
return
filesystem.filesystem_changed.connect(_enable_after_scan, CONNECT_ONE_SHOT)
filesystem.scan()
func _enable_after_scan() -> void:
EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
push_error(
"Godot AI v4 migration: the restored plugin could not be enabled;"
+ " the capsule payload is kept for another attempt"
)
queue_free()
return
## Only a running restored plugin makes the capsule material disposable.
_remove_capsule_only()
_discard_stage()
queue_free()
## Every entry must be a regular file under the live add-on path: no absolute
## paths, no `..`, nothing outside `addons/godot_ai/`. Read everything before
## writing anything, so a bad archive changes nothing.
func _read_payload(entries: Array) -> String:
var reader := ZIPReader.new()
if reader.open(FALLBACK_ZIP) != OK:
return "cannot open %s" % FALLBACK_ZIP
var files := reader.get_files()
if files.size() > MAX_FILES:
reader.close()
return "too many entries"
var total := 0
for name in files:
if name.ends_with("/"):
continue
var segments := name.split("/")
if (
not name.begins_with(LIVE_PREFIX)
or name.contains("\\")
or segments.has("..")
or segments.has(".")
or segments.has("")
):
reader.close()
return "unsafe entry %s" % name
var data := reader.read_file(name)
total += data.size()
if total > MAX_TOTAL_BYTES:
reader.close()
return "payload too large"
entries.append([name, data])
reader.close()
if entries.is_empty():
return "empty archive"
return ""
static func _prepare_update_root() -> String:
var root := ProjectSettings.globalize_path(UPDATE_ROOT)
if DirAccess.make_dir_recursive_absolute(root) != OK:
return "cannot create %s" % root
for marker in UPDATE_IGNORE_FILES:
var path := root.path_join(str(marker[0]))
if FileAccess.file_exists(path):
continue
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return "cannot write %s" % path
file.store_string(str(marker[1]))
file.close()
_remove_dir(ProjectSettings.globalize_path(STAGE_ROOT))
return ""
## Drop the stage; when nothing but the ignore markers is left, drop the
## update directory too, so a refused or restored crossing leaves no trace.
static func _discard_stage() -> void:
_remove_dir(ProjectSettings.globalize_path(STAGE_ROOT))
var root := ProjectSettings.globalize_path(UPDATE_ROOT)
var dir := DirAccess.open(root)
if dir == null:
return
dir.include_hidden = true
dir.include_navigational = false
if not dir.get_directories().is_empty():
return
var markers := PackedStringArray()
for marker in UPDATE_IGNORE_FILES:
markers.append(str(marker[0]))
for name in dir.get_files():
if not markers.has(name):
return
_remove_dir(root)
## Write every entry under `root`, checking each write and reading it back,
## so a full disk or a refused file stops before anything else is touched.
static func _write_all(root: String, entries: Array) -> String:
for entry in entries:
var relative: String = str(entry[0]).trim_prefix(LIVE_PREFIX)
var error := _write_checked(root + "/" + relative, entry[1])
if not error.is_empty():
return error
return ""
static func _write_checked(target: String, data: PackedByteArray) -> String:
var parent := ProjectSettings.globalize_path(target.get_base_dir())
if DirAccess.make_dir_recursive_absolute(parent) != OK:
return "cannot create %s" % parent
var file := FileAccess.open(target, FileAccess.WRITE)
if file == null:
return "cannot write %s (%s)" % [target, error_string(FileAccess.get_open_error())]
file.store_buffer(data)
var error := file.get_error()
file.close()
if error != OK:
return "write failed for %s (%s)" % [target, error_string(error)]
var written := FileAccess.get_file_as_bytes(target)
if written.size() != data.size():
return "short write for %s (%d of %d bytes)" % [target, written.size(), data.size()]
return ""
func _remove_capsule_only() -> void:
for relative in CAPSULE_ONLY:
var path := ProjectSettings.globalize_path(LIVE_ROOT + "/" + str(relative))
if DirAccess.dir_exists_absolute(path):
_remove_dir(path)
elif FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
static func _remove_dir(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.include_hidden = true
dir.include_navigational = false
for name in dir.get_directories():
_remove_dir(path.path_join(name))
for name in dir.get_files():
DirAccess.remove_absolute(path.path_join(name))
DirAccess.remove_absolute(path)
static func _restored_version() -> String:
var config := ConfigFile.new()
if config.load(PLUGIN_CFG) != OK:
return "?"
return str(config.get_value("plugin", "version", "?"))