533 lines
21 KiB
GDScript
533 lines
21 KiB
GDScript
@tool
|
|
extends Node
|
|
|
|
## EditorSetting key used to defer a self_update telemetry event across the
|
|
## disable -> enable boundary. The runner runs while the plugin is disabled,
|
|
## so it can't send WebSocket events directly; it writes the outcome here
|
|
## and the re-enabled plugin's `_enter_tree` flushes it. See
|
|
## `plugin.gd::_flush_pending_self_update_telemetry`.
|
|
const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event"
|
|
|
|
## Self-update runner. Owns the install-and-reload sequence from
|
|
## `start(zip_path, temp_dir, detached_dock)` onward: extract files into
|
|
## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem,
|
|
## re-enable the plugin, and clean up the detached dock.
|
|
##
|
|
## Single-phase install: writes the full `_new_file_paths +
|
|
## _existing_file_paths` set before issuing exactly one
|
|
## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one
|
|
## consistent v(N+1) snapshot, so new files and existing files can resolve
|
|
## each other's same-release API changes regardless of parse order.
|
|
##
|
|
## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI
|
|
## (in `mcp_dock.gd`), or server stop prep (called by
|
|
## `plugin.gd::install_downloaded_update` before this runner starts via
|
|
## `_lifecycle.prepare_for_update_reload()`).
|
|
##
|
|
## This node is deliberately tiny and not parented under the EditorPlugin:
|
|
## it survives `set_plugin_enabled(false)`, extracts the downloaded release,
|
|
## waits for Godot's filesystem scan, then enables the plugin again. The old
|
|
## dock is detached before this runner starts, kept alive while deferred
|
|
## Callables drain, and freed only after the new plugin instance is loaded.
|
|
|
|
const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg"
|
|
const PRE_DISABLE_DRAIN_FRAMES := 8
|
|
const POST_DISABLE_DRAIN_FRAMES := 2
|
|
const POST_ENABLE_FREE_FRAMES := 8
|
|
const INSTALL_BASE_PATH := "res://"
|
|
const ZIP_ADDON_PREFIX := "addons/godot_ai/"
|
|
const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp"
|
|
const INSTALL_BACKUP_SUFFIX := ".update_backup"
|
|
|
|
## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced.
|
|
## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously
|
|
## written file was rolled back to its vN content (or removed, if the file
|
|
## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons
|
|
## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable
|
|
## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss
|
|
## scenario this guards against.
|
|
enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED }
|
|
|
|
var _zip_path := ""
|
|
var _temp_dir := ""
|
|
var _detached_dock = null
|
|
var _started := false
|
|
var _next_step := ""
|
|
var _frames_remaining := 0
|
|
var _waiting_for_scan := false
|
|
var _scan_next_step := ""
|
|
## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed`
|
|
## signal never fires (slow disk, NFS, AV holding the just-extracted addon
|
|
## files open), the runner used to hang in `_waiting_for_scan = true`
|
|
## forever and the dock stayed disabled. After this timeout we disconnect
|
|
## the signal and proceed anyway — worst case the new files aren't visible
|
|
## on the first frame, but they get picked up on the next scan. See
|
|
## audit-v2 finding #9 (issue #353). Untyped to match the codebase's
|
|
## defensive pattern for state that survives `fs.scan()` during update.
|
|
const SCAN_WATCHDOG_SECS := 30.0
|
|
var _scan_watchdog_timer = null
|
|
## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent
|
|
## `_start_filesystem_scan` calls in the same update bypass connect+scan
|
|
## so a delayed `filesystem_changed` emission from the timed-out scan
|
|
## can't fire on a freshly-armed listener for the next scan and falsely
|
|
## settle it before that scan actually completed. See PR #381 review for
|
|
## the cross-scan race this guards against.
|
|
var _scan_timed_out := false
|
|
## Keep Array fields untyped: this runner survives fs.scan() during update,
|
|
## and typed Variant storage is part of the hot-reload crash class.
|
|
var _new_file_paths = []
|
|
var _existing_file_paths = []
|
|
## Per-file install records accumulated during install so a later failure
|
|
## can roll back files already replaced earlier in the same update.
|
|
## Each entry is an untyped Dictionary with target_path / backup_path /
|
|
## had_original keys. Cleared by `_finalize_install_success` on full success
|
|
## and by `_rollback_paths_written` on failure.
|
|
var _paths_written = []
|
|
## Set true if `_install_zip_file`'s inner restore-from-backup couldn't
|
|
## complete (backup gone, copy failed). The failed file is NOT recorded in
|
|
## `_paths_written` because the function bails at that point — without this
|
|
## flag, `_rollback_paths_written` would walk only the prior records, all
|
|
## restore cleanly, and report FAILED_CLEAN even though the current target
|
|
## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses
|
|
## to re-enable the plugin against a half-installed tree.
|
|
var _restore_failed := false
|
|
## Test-only opt-out for the scan-watchdog `push_warning` lines. The
|
|
## watchdog unit tests in `test_update_reload_runner.gd` invoke
|
|
## `_on_scan_watchdog_timeout()` and the post-timeout
|
|
## `_start_filesystem_scan` bypass branch directly to pin their behavior
|
|
## — but those code paths' `push_warning` calls then appear as yellow
|
|
## console noise in every `test_run`, training reviewers to ignore the
|
|
## runner's real production warnings. Tests set this true; production
|
|
## leaves it false so genuine scan timeouts during a real self-update
|
|
## still surface loudly. See issue #413.
|
|
var _suppress_scan_warnings := false
|
|
|
|
|
|
func start(zip_path: String, temp_dir: String, detached_dock) -> void:
|
|
if _started:
|
|
return
|
|
_started = true
|
|
_zip_path = zip_path
|
|
_temp_dir = temp_dir
|
|
_detached_dock = detached_dock
|
|
_wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin")
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _frames_remaining <= 0:
|
|
set_process(false)
|
|
return
|
|
|
|
_frames_remaining -= 1
|
|
if _frames_remaining <= 0:
|
|
var step := _next_step
|
|
_next_step = ""
|
|
set_process(false)
|
|
call(step)
|
|
|
|
|
|
func _wait_frames(frame_count: int, next_step: String) -> void:
|
|
_next_step = next_step
|
|
_frames_remaining = max(1, frame_count)
|
|
set_process(true)
|
|
|
|
|
|
func _disable_old_plugin() -> void:
|
|
## Disable before writing or scanning new scripts. This avoids both the
|
|
## Dict/Array field-storage hot-reload crash (#245) and cached handler
|
|
## constructor shape mismatches (#247) for plugin-owned instances.
|
|
print("MCP | update runner disabling old plugin")
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false)
|
|
_wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan")
|
|
|
|
|
|
func _extract_and_scan() -> void:
|
|
if not _read_update_manifest():
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
|
|
_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
|
|
return
|
|
|
|
var install_paths := []
|
|
install_paths.append_array(_new_file_paths)
|
|
install_paths.append_array(_existing_file_paths)
|
|
|
|
var status := _install_zip_paths(install_paths)
|
|
if status != InstallStatus.OK:
|
|
_handle_install_failure(status)
|
|
return
|
|
|
|
_finalize_install_success()
|
|
_cleanup_update_temp()
|
|
## One scan covers both dependency directions: plugin.gd's preloads of
|
|
## new files resolve because those files are already present, and new
|
|
## files' references to new members or static-ness changes on existing
|
|
## load-surface scripts resolve because those existing files are also
|
|
## already at v(N+1). The goal is a consistent snapshot before scan, not
|
|
## a tree-atomic install; per-file writes still use `.tmp` + rename and
|
|
## rollback on failure.
|
|
_start_filesystem_scan("_enable_new_plugin")
|
|
|
|
|
|
func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void:
|
|
var fs := EditorInterface.get_resource_filesystem()
|
|
var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin"
|
|
if fs == null:
|
|
call_deferred(deferred_step)
|
|
return
|
|
|
|
## Bypass: a previous scan in this update already watchdog'd, so the
|
|
## editor's filesystem is unresponsive. Re-arming a `filesystem_changed`
|
|
## listener now would race with a delayed emission from the timed-out
|
|
## scan: that single emission would fire whichever listener is currently
|
|
## connected to the shared signal, falsely settling this scan before it
|
|
## actually completed. Skip the wait; Godot's normal background scan
|
|
## catches up after the plugin re-enables. See PR #381 review.
|
|
if _scan_timed_out:
|
|
if not _suppress_scan_warnings:
|
|
push_warning(
|
|
"MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)"
|
|
% deferred_step
|
|
)
|
|
call_deferred(deferred_step)
|
|
return
|
|
|
|
_waiting_for_scan = true
|
|
_scan_next_step = deferred_step
|
|
if not fs.filesystem_changed.is_connected(_on_filesystem_changed):
|
|
fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT)
|
|
_arm_scan_watchdog()
|
|
fs.scan()
|
|
|
|
|
|
func _arm_scan_watchdog() -> void:
|
|
if _scan_watchdog_timer == null:
|
|
_scan_watchdog_timer = Timer.new()
|
|
_scan_watchdog_timer.one_shot = true
|
|
_scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout)
|
|
add_child(_scan_watchdog_timer)
|
|
_scan_watchdog_timer.start(SCAN_WATCHDOG_SECS)
|
|
|
|
|
|
func _stop_scan_watchdog() -> void:
|
|
if _scan_watchdog_timer != null:
|
|
_scan_watchdog_timer.stop()
|
|
|
|
|
|
func _on_scan_watchdog_timeout() -> void:
|
|
## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the
|
|
## filesystem scan is blocked behind a slow disk / NFS / AV scanner
|
|
## still reading the just-extracted addon files.
|
|
## Set the sticky `_scan_timed_out` flag so any subsequent
|
|
## `_start_filesystem_scan` in this update skips its connect+scan
|
|
## (otherwise a delayed emission from this scan would falsely settle
|
|
## the next scan's listener — see PR #381 review).
|
|
## Disconnect the current listener too, so this scan's listener can't
|
|
## double-call `_finish_scan_wait` if the signal arrives quickly after
|
|
## the timeout fires. `_finish_scan_wait` is idempotent on
|
|
## `_waiting_for_scan == false`.
|
|
if not _waiting_for_scan:
|
|
return
|
|
if not _suppress_scan_warnings:
|
|
push_warning(
|
|
"MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation"
|
|
% int(SCAN_WATCHDOG_SECS)
|
|
)
|
|
_scan_timed_out = true
|
|
var fs := EditorInterface.get_resource_filesystem()
|
|
if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed):
|
|
fs.filesystem_changed.disconnect(_on_filesystem_changed)
|
|
_finish_scan_wait()
|
|
|
|
|
|
func _read_update_manifest() -> bool:
|
|
var zip_path := ProjectSettings.globalize_path(_zip_path)
|
|
var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH)
|
|
|
|
var reader := ZIPReader.new()
|
|
if reader.open(zip_path) != OK:
|
|
print("MCP | update extract failed: could not open %s" % zip_path)
|
|
return false
|
|
|
|
_new_file_paths.clear()
|
|
_existing_file_paths.clear()
|
|
var has_plugin_cfg := false
|
|
var has_plugin_script := false
|
|
var files := reader.get_files()
|
|
for file_path in files:
|
|
if not file_path.begins_with(ZIP_ADDON_PREFIX):
|
|
continue
|
|
var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX)
|
|
## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand-
|
|
## built archives) emit zero-byte directory entries like
|
|
## `addons/godot_ai/`. Skip those before the safety check; the
|
|
## empty-segment guard in `_is_safe_zip_addon_file` would otherwise
|
|
## flag the bare prefix as unsafe and abort the extract. Current
|
|
## release.yml passes `-D` to strip them, but installed runners must
|
|
## still tolerate older or manually built zips.
|
|
if rel_path.is_empty() or file_path.ends_with("/"):
|
|
continue
|
|
if not _is_safe_zip_addon_file(file_path):
|
|
print("MCP | update extract failed: unsafe zip path %s" % file_path)
|
|
reader.close()
|
|
return false
|
|
if rel_path == "plugin.cfg":
|
|
has_plugin_cfg = true
|
|
elif rel_path == "plugin.gd":
|
|
has_plugin_script = true
|
|
var target_path := install_base.path_join(file_path)
|
|
if FileAccess.file_exists(target_path):
|
|
_existing_file_paths.append(file_path)
|
|
else:
|
|
_new_file_paths.append(file_path)
|
|
reader.close()
|
|
if not has_plugin_cfg:
|
|
print("MCP | update extract failed: zip is missing plugin.cfg")
|
|
return false
|
|
if not has_plugin_script:
|
|
print("MCP | update extract failed: zip is missing plugin.gd")
|
|
return false
|
|
return true
|
|
|
|
|
|
func _handle_install_failure(status: int) -> void:
|
|
_record_pending_self_update({
|
|
"status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean",
|
|
})
|
|
if status == InstallStatus.FAILED_MIXED:
|
|
## Half-installed addon tree on disk: re-enabling the plugin would
|
|
## load a mix of vN and vN+1 files. Print a load-bearing diagnostic
|
|
## and bail without re-enabling — user must restore manually. See
|
|
## issue #297 finding #9 for the data-loss scenario.
|
|
push_error(
|
|
"MCP | self-update failed mid-install AND rollback could not"
|
|
+ " restore the previous addons/godot_ai/ contents. The plugin"
|
|
+ " is left disabled. Inspect addons/godot_ai/ for"
|
|
+ " *.update_backup / *.godot_ai_update_tmp files and restore"
|
|
+ " manually before re-enabling the plugin."
|
|
)
|
|
print(
|
|
"MCP | self-update aborted: addons/godot_ai/ is in a mixed state;"
|
|
+ " plugin left disabled (manual intervention required)."
|
|
)
|
|
_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
|
|
return
|
|
## FAILED_CLEAN: rollback restored every previously-written file. Safe
|
|
## to re-enable the previous plugin version.
|
|
print("MCP | self-update rolled back; re-enabling previous plugin version")
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
|
|
_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
|
|
|
|
|
|
func _is_safe_zip_addon_file(file_path: String) -> bool:
|
|
if file_path.is_absolute_path() or file_path.contains("\\"):
|
|
return false
|
|
if not file_path.begins_with(ZIP_ADDON_PREFIX):
|
|
return false
|
|
var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX)
|
|
if rel_path.is_empty() or rel_path.ends_with("/"):
|
|
return false
|
|
for segment in rel_path.split("/", true):
|
|
if segment.is_empty() or segment == "." or segment == "..":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _install_zip_paths(paths: Array) -> int:
|
|
if paths.is_empty():
|
|
return InstallStatus.OK
|
|
|
|
var zip_path := ProjectSettings.globalize_path(_zip_path)
|
|
var reader := ZIPReader.new()
|
|
if reader.open(zip_path) != OK:
|
|
print("MCP | update extract failed: could not reopen %s" % zip_path)
|
|
## Nothing else can be written, but earlier files from this update
|
|
## may have landed on disk; roll those back too.
|
|
return _rollback_paths_written()
|
|
|
|
var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH)
|
|
for file_path in paths:
|
|
var record := _install_zip_file(reader, String(file_path), install_base)
|
|
if record.is_empty():
|
|
reader.close()
|
|
return _rollback_paths_written()
|
|
_paths_written.append(record)
|
|
reader.close()
|
|
return InstallStatus.OK
|
|
|
|
|
|
func _install_zip_file(
|
|
reader: ZIPReader, file_path: String, install_base: String
|
|
) -> Dictionary:
|
|
var target_path := install_base.path_join(file_path)
|
|
var dir := target_path.get_base_dir()
|
|
if DirAccess.make_dir_recursive_absolute(dir) != OK:
|
|
print("MCP | update extract failed: could not create %s" % dir)
|
|
return {}
|
|
|
|
var temp_path := target_path + TEMP_FILE_SUFFIX
|
|
DirAccess.remove_absolute(temp_path)
|
|
var content := reader.read_file(file_path)
|
|
var f := FileAccess.open(temp_path, FileAccess.WRITE)
|
|
if f == null:
|
|
print("MCP | update extract failed: could not write %s (error %d)" % [
|
|
temp_path,
|
|
FileAccess.get_open_error(),
|
|
])
|
|
return {}
|
|
f.store_buffer(content)
|
|
var write_error := f.get_error()
|
|
f.close()
|
|
if write_error != OK:
|
|
print("MCP | update extract failed: write error %d for %s" % [
|
|
write_error,
|
|
temp_path,
|
|
])
|
|
DirAccess.remove_absolute(temp_path)
|
|
return {}
|
|
|
|
## Back up the original via COPY (not rename) so the source of truth
|
|
## stays in place if a later step fails. Rolled back via
|
|
## `_rollback_paths_written` if a subsequent file in this batch — or a
|
|
## later batch — can't be installed.
|
|
var had_original := FileAccess.file_exists(target_path)
|
|
var backup_path := target_path + INSTALL_BACKUP_SUFFIX
|
|
if had_original:
|
|
DirAccess.remove_absolute(backup_path)
|
|
if DirAccess.copy_absolute(target_path, backup_path) != OK:
|
|
DirAccess.remove_absolute(temp_path)
|
|
print("MCP | update extract failed: could not back up %s" % target_path)
|
|
return {}
|
|
|
|
if DirAccess.rename_absolute(temp_path, target_path) != OK:
|
|
## POSIX and APFS replace atomically. Some filesystems reject
|
|
## rename-over-existing; keep a fallback so the update can still
|
|
## proceed, but the common path never exposes a truncated target.
|
|
DirAccess.remove_absolute(target_path)
|
|
if DirAccess.rename_absolute(temp_path, target_path) != OK:
|
|
DirAccess.remove_absolute(temp_path)
|
|
## Target was removed above; restore from the COPY backup so the
|
|
## addons dir is left in its vN state before we surface failure.
|
|
## Only delete the backup if the restore copy actually succeeded
|
|
## — if it didn't, target_path is missing, and `_restore_failed`
|
|
## tells `_rollback_paths_written` to surface FAILED_MIXED so the
|
|
## runner refuses to re-enable the plugin. Leaving the backup on
|
|
## disk also gives the user a manual recovery path. Without this
|
|
## guard the failed file isn't tracked anywhere (we return `{}`,
|
|
## not appended to `_paths_written`) and the caller would
|
|
## erroneously see FAILED_CLEAN.
|
|
if had_original:
|
|
if (
|
|
FileAccess.file_exists(backup_path)
|
|
and DirAccess.copy_absolute(backup_path, target_path) == OK
|
|
):
|
|
DirAccess.remove_absolute(backup_path)
|
|
else:
|
|
_restore_failed = true
|
|
print("MCP | update extract failed: could not replace %s" % target_path)
|
|
return {}
|
|
return {
|
|
"target_path": target_path,
|
|
"backup_path": backup_path,
|
|
"had_original": had_original,
|
|
}
|
|
|
|
|
|
## Restore (or remove) every file already touched in this update. Safe to
|
|
## call after a partial install — entries are processed in reverse so a
|
|
## given target is restored before the next earlier write of the same path
|
|
## could resurrect a stale value. Returns FAILED_CLEAN if every entry was
|
|
## restored AND no in-flight `_install_zip_file` left a target stranded
|
|
## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT
|
|
## re-enable the plugin in the MIXED case.
|
|
func _rollback_paths_written() -> int:
|
|
var any_failed := false
|
|
var i := _paths_written.size() - 1
|
|
while i >= 0:
|
|
var record = _paths_written[i]
|
|
var target := String(record.get("target_path", ""))
|
|
var backup := String(record.get("backup_path", ""))
|
|
var had_original := bool(record.get("had_original", false))
|
|
if had_original:
|
|
if not FileAccess.file_exists(backup):
|
|
print("MCP | update rollback failed: backup missing for %s" % target)
|
|
any_failed = true
|
|
else:
|
|
DirAccess.remove_absolute(target)
|
|
if DirAccess.copy_absolute(backup, target) != OK:
|
|
print("MCP | update rollback failed: could not restore %s" % target)
|
|
any_failed = true
|
|
else:
|
|
DirAccess.remove_absolute(backup)
|
|
else:
|
|
if FileAccess.file_exists(target):
|
|
if DirAccess.remove_absolute(target) != OK:
|
|
print(
|
|
"MCP | update rollback failed: could not delete %s" % target
|
|
)
|
|
any_failed = true
|
|
i -= 1
|
|
_paths_written.clear()
|
|
if any_failed or _restore_failed:
|
|
return InstallStatus.FAILED_MIXED
|
|
return InstallStatus.FAILED_CLEAN
|
|
|
|
|
|
## Discard accumulated backups after the combined install succeeds. Backups
|
|
## are best-effort: a failure here doesn't compromise the new install, just
|
|
## leaves stray *.update_backup files for the user to clean up.
|
|
func _finalize_install_success() -> void:
|
|
for record in _paths_written:
|
|
if record.get("had_original", false):
|
|
DirAccess.remove_absolute(String(record.get("backup_path", "")))
|
|
_paths_written.clear()
|
|
_record_pending_self_update({"status": "success"})
|
|
|
|
|
|
## Persist a self_update event description so the re-enabled plugin can
|
|
## emit it once its WebSocket is connected. Survives the disable -> enable
|
|
## window where the runner cannot send anything itself.
|
|
func _record_pending_self_update(data: Dictionary) -> void:
|
|
var settings := EditorInterface.get_editor_settings()
|
|
if settings == null:
|
|
return
|
|
settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data))
|
|
|
|
|
|
func _cleanup_update_temp() -> void:
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path))
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir))
|
|
|
|
|
|
func _on_filesystem_changed() -> void:
|
|
_finish_scan_wait()
|
|
|
|
|
|
func _finish_scan_wait() -> void:
|
|
if not _waiting_for_scan:
|
|
return
|
|
_waiting_for_scan = false
|
|
_stop_scan_watchdog()
|
|
var next_step := _scan_next_step
|
|
_scan_next_step = ""
|
|
set_process(false)
|
|
if next_step.is_empty():
|
|
next_step = "_enable_new_plugin"
|
|
call_deferred(next_step)
|
|
|
|
|
|
func _enable_new_plugin() -> void:
|
|
print("MCP | update runner enabling new plugin")
|
|
EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
|
|
_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
|
|
|
|
|
|
func _cleanup_and_finish() -> void:
|
|
_cleanup_detached_dock()
|
|
queue_free()
|
|
|
|
|
|
func _cleanup_detached_dock() -> void:
|
|
if _detached_dock != null and is_instance_valid(_detached_dock):
|
|
_detached_dock.queue_free()
|
|
_detached_dock = null
|