218 lines
7.2 KiB
GDScript
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", "?"))
|