summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/update
diff options
context:
space:
mode:
authorradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
committerradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
commita89e744ed0e8c759caf041a580112365bbfe4303 (patch)
treedd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src/update
parent6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff)
escoria updateHEADmain
Diffstat (limited to 'addons/gdUnit4/src/update')
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd405
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd20
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd75
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd305
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.tscn100
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd98
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd206
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn97
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.pngbin0 -> 1757 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.pngbin0 -> 1749 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.pngbin0 -> 730 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.pngbin0 -> 883 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.pngbin0 -> 287 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.pngbin0 -> 332 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.png.import40
26 files changed, 1552 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd b/addons/gdUnit4/src/update/GdMarkDownReader.gd
new file mode 100644
index 0000000..dab19e4
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd
@@ -0,0 +1,405 @@
+@tool
+extends RefCounted
+
+const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+
+const FONT_H1 := 22
+const FONT_H2 := 20
+const FONT_H3 := 18
+const FONT_H4 := 16
+const FONT_H5 := 14
+const FONT_H6 := 12
+
+const HORIZONTAL_RULE := "[img=4000x2]res://addons/gdUnit4/src/update/assets/horizontal-line2.png[/img]"
+const HEADER_RULE := "[font_size=%d]$1[/font_size]"
+const HEADER_CENTERED_RULE := "[font_size=%d][center]$1[/center][/font_size]"
+
+const image_download_folder := "res://addons/gdUnit4/tmp-update/"
+
+const exclude_font_size := "\b(?!(?:(font_size))\b)"
+
+var md_replace_patterns := [
+ # comments
+ [regex("(?m)^\\n?\\s*<!--[\\s\\S]*?-->\\s*\\n?"), ""],
+
+ # horizontal rules
+ [regex("(?m)^[ ]{0,3}---$"), HORIZONTAL_RULE],
+ [regex("(?m)^[ ]{0,3}___$"), HORIZONTAL_RULE],
+ [regex("(?m)^[ ]{0,3}\\*\\*\\*$"), HORIZONTAL_RULE],
+
+ # headers
+ [regex("(?m)^###### (.*)"), HEADER_RULE % FONT_H6],
+ [regex("(?m)^##### (.*)"), HEADER_RULE % FONT_H5],
+ [regex("(?m)^#### (.*)"), HEADER_RULE % FONT_H4],
+ [regex("(?m)^### (.*)"), HEADER_RULE % FONT_H3],
+ [regex("(?m)^## (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2],
+ [regex("(?m)^# (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("(?m)^(.+)=={2,}$"), HEADER_RULE % FONT_H1],
+ [regex("(?m)^(.+)--{2,}$"), HEADER_RULE % FONT_H2],
+ # html headers
+ [regex("<h1>((.*?\\R?)+)<\\/h1>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h1[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h1>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h2>((.*?\\R?)+)<\\/h2>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2],
+ [regex("<h2[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h2>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h3>((.*?\\R?)+)<\\/h3>"), HEADER_RULE % FONT_H3],
+ [regex("<h3[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h3>"), HEADER_CENTERED_RULE % FONT_H3],
+ [regex("<h4>((.*?\\R?)+)<\\/h4>"), HEADER_RULE % FONT_H4],
+ [regex("<h4[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h4>"), HEADER_CENTERED_RULE % FONT_H4],
+ [regex("<h5>((.*?\\R?)+)<\\/h5>"), HEADER_RULE % FONT_H5],
+ [regex("<h5[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h5>"), HEADER_CENTERED_RULE % FONT_H5],
+ [regex("<h6>((.*?\\R?)+)<\\/h6>"), HEADER_RULE % FONT_H6],
+ [regex("<h6[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h6>"), HEADER_CENTERED_RULE % FONT_H6],
+
+ # asterics
+ #[regex("(\\*)"), "xxx$1xxx"],
+
+ # extract/compile image references
+ [regex("!\\[(.*?)\\]\\[(.*?)\\]"), process_image_references],
+ # extract images with path and optional tool tip
+ [regex("!\\[(.*?)\\]\\((.*?)(( )+(.*?))?\\)"), process_image],
+
+ # links
+ [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)\\)"), "[url={\"url\":\"$3\"}]$2[/url]"],
+ # links with tool tip
+ [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)( \"(.+)\")?\\)"), "[url={\"url\":\"$3\", \"tool_tip\":\"$5\"}]$2[/url]"],
+ # links to github, as shorted link
+ [regex("(https://github.*/?/(\\S+))"), '[url={"url":"$1", "tool_tip":"$1"}]#$2[/url]'],
+
+ # embeded text
+ [regex("(?m)^[ ]{0,3}>(.*?)$"), "[img=50x14]res://addons/gdUnit4/src/update/assets/embedded.png[/img][i]$1[/i]"],
+
+ # italic + bold font
+ [regex("[_]{3}(.*?)[_]{3}"), "[i][b]$1[/b][/i]"],
+ [regex("[\\*]{3}(.*?)[\\*]{3}"), "[i][b]$1[/b][/i]"],
+ # bold font
+ [regex("<b>(.*?)<\\/b>"), "[b]$1[/b]"],
+ [regex("[_]{2}(.*?)[_]{2}"), "[b]$1[/b]"],
+ [regex("[\\*]{2}(.*?)[\\*]{2}"), "[b]$1[/b]"],
+ # italic font
+ [regex("<i>(.*?)<\\/i>"), "[i]$1[/i]"],
+ [regex(exclude_font_size+"_(.*?)_"), "[i]$1[/i]"],
+ [regex("\\*(.*?)\\*"), "[i]$1[/i]"],
+
+ # strikethrough font
+ [regex("<s>(.*?)</s>"), "[s]$1[/s]"],
+ [regex("~~(.*?)~~"), "[s]$1[/s]"],
+ [regex("~(.*?)~"), "[s]$1[/s]"],
+
+ # handling lists
+ # using an image for dots
+ [regex("(?m)^[ ]{0,1}[*\\-+] (.*)$"), list_replace(0)],
+ [regex("(?m)^[ ]{2,3}[*\\-+] (.*)$"), list_replace(1)],
+ [regex("(?m)^[ ]{4,5}[*\\-+] (.*)$"), list_replace(2)],
+ [regex("(?m)^[ ]{6,7}[*\\-+] (.*)$"), list_replace(3)],
+ [regex("(?m)^[ ]{8,9}[*\\-+] (.*)$"), list_replace(4)],
+
+ # code
+ [regex("``([\\s\\S]*?)``"), code_block("$1")],
+ [regex("`([\\s\\S]*?)`{1,2}"), code_block("$1")],
+]
+
+var code_block_patterns := [
+ # code blocks, code blocks looks not like code blocks in richtext
+ [regex("```(javascript|python|shell|gdscript|gd)([\\s\\S]*?\n)```"), code_block("$2", true)],
+]
+
+var _img_replace_regex := RegEx.new()
+var _image_urls := PackedStringArray()
+var _on_table_tag := false
+var _client: GdUnitUpdateClient
+
+
+static func regex(pattern: String) -> RegEx:
+ var regex_ := RegEx.new()
+ var err := regex_.compile(pattern)
+ if err != OK:
+ push_error("error '%s' checked pattern '%s'" % [err, pattern])
+ return null
+ return regex_
+
+
+func _init() -> void:
+ @warning_ignore("return_value_discarded")
+ _img_replace_regex.compile("\\[img\\]((.*?))\\[/img\\]")
+
+
+func set_http_client(client: GdUnitUpdateClient) -> void:
+ _client = client
+
+
+@warning_ignore("return_value_discarded")
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ # finally remove_at the downloaded images
+ for image in _image_urls:
+ DirAccess.remove_absolute(image)
+ DirAccess.remove_absolute(image + ".import")
+
+
+func list_replace(indent: int) -> String:
+ var replace_pattern := "[img=12x12]res://addons/gdUnit4/src/update/assets/dot2.png[/img]" if indent %2 else "[img=12x12]res://addons/gdUnit4/src/update/assets/dot1.png[/img]"
+ replace_pattern += " $1"
+
+ for index in indent:
+ replace_pattern = replace_pattern.insert(0, " ")
+ return replace_pattern
+
+
+func code_block(replace: String, border: bool = false) -> String:
+ if border:
+ return """
+ [img=1400x14]res://addons/gdUnit4/src/update/assets/border_top.png[/img]
+ [indent][color=GRAY][font_size=16]%s[/font_size][/color][/indent]
+ [img=1400x14]res://addons/gdUnit4/src/update/assets/border_bottom.png[/img]
+ """.dedent() % replace
+ return "[code][bgcolor=DARK_SLATE_GRAY][color=GRAY][font_size=16]%s[/font_size][/color][/bgcolor][/code]" % replace
+
+
+func convert_text(input: String) -> String:
+ input = process_tables(input)
+
+ for pattern: Array in md_replace_patterns:
+ var regex_: RegEx = pattern[0]
+ var bb_replace: Variant = pattern[1]
+ if bb_replace is Callable:
+ @warning_ignore("unsafe_method_access")
+ input = await bb_replace.call(regex_, input)
+ else:
+ @warning_ignore("unsafe_cast")
+ input = regex_.sub(input, bb_replace as String, true)
+ return input
+
+
+func convert_code_block(input: String) -> String:
+ for pattern: Array in code_block_patterns:
+ var regex_: RegEx = pattern[0]
+ var bb_replace: Variant = pattern[1]
+ if bb_replace is Callable:
+ @warning_ignore("unsafe_method_access")
+ input = await bb_replace.call(regex_, input)
+ else:
+ @warning_ignore("unsafe_cast")
+ input = regex_.sub(input, bb_replace as String, true)
+ return input
+
+
+func to_bbcode(input: String) -> String:
+ var re := regex("(?m)```[\\s\\S]*?```")
+ var current_pos := 0
+ var as_bbcode := ""
+
+ # we split by code blocks to handle this blocks customized
+ for result in re.search_all(input):
+ # Add text before code block
+ if result.get_start() > current_pos:
+ as_bbcode += await convert_text(input.substr(current_pos, result.get_start() - current_pos))
+ # Add code block
+ as_bbcode += await convert_code_block(result.get_string())
+ current_pos = result.get_end()
+
+ # Add remaining text after last code block
+ if current_pos < input.length():
+ as_bbcode += await convert_text(input.substr(current_pos))
+ return as_bbcode
+
+
+func process_tables(input: String) -> String:
+ var bbcode := PackedStringArray()
+ var lines: Array[String] = Array(input.split("\n") as Array, TYPE_STRING, "", null)
+ while not lines.is_empty():
+ if is_table(lines[0]):
+ bbcode.append_array(parse_table(lines))
+ continue
+ @warning_ignore("return_value_discarded", "unsafe_cast")
+ bbcode.append(lines.pop_front() as String)
+ return "\n".join(bbcode)
+
+
+class GdUnitMDReaderTable:
+ var _columns: int
+ var _rows: Array[Row] = []
+
+ class Row:
+ var _cells := PackedStringArray()
+
+
+ func _init(cells: PackedStringArray, columns: int) -> void:
+ _cells = cells
+ for i in range(_cells.size(), columns):
+ @warning_ignore("return_value_discarded")
+ _cells.append("")
+
+
+ func to_bbcode(cell_sizes: PackedInt32Array, bold: bool) -> String:
+ var cells := PackedStringArray()
+ for cell_index in _cells.size():
+ var cell: String = _cells[cell_index]
+ if cell.strip_edges() == "--":
+ cell = create_line(cell_sizes[cell_index])
+ if bold:
+ cell = "[b]%s[/b]" % cell
+ @warning_ignore("return_value_discarded")
+ cells.append("[cell]%s[/cell]" % cell)
+ return "|".join(cells)
+
+
+ func create_line(length: int) -> String:
+ var line := ""
+ for i in length:
+ line += "-"
+ return line
+
+
+ func _init(columns: int) -> void:
+ _columns = columns
+
+
+ func parse_row(line :String) -> bool:
+ # is line containing cells?
+ if line.find("|") == -1:
+ return false
+ _rows.append(Row.new(line.split("|"), _columns))
+ return true
+
+
+ func calculate_max_cell_sizes() -> PackedInt32Array:
+ var cells_size := PackedInt32Array()
+ for column in _columns:
+ @warning_ignore("return_value_discarded")
+ cells_size.append(0)
+
+ for row_index in _rows.size():
+ var row: Row = _rows[row_index]
+ for cell_index in row._cells.size():
+ var cell_size: int = cells_size[cell_index]
+ var size := row._cells[cell_index].length()
+ if size > cell_size:
+ cells_size[cell_index] = size
+ return cells_size
+
+
+ @warning_ignore("return_value_discarded")
+ func to_bbcode() -> PackedStringArray:
+ var cell_sizes := calculate_max_cell_sizes()
+ var bb_code := PackedStringArray()
+
+ bb_code.append("[table=%d]" % _columns)
+ for row_index in _rows.size():
+ bb_code.append(_rows[row_index].to_bbcode(cell_sizes, row_index==0))
+ bb_code.append("[/table]\n")
+ return bb_code
+
+
+func parse_table(lines: Array) -> PackedStringArray:
+ var line: String = lines[0]
+ var table := GdUnitMDReaderTable.new(line.count("|") + 1)
+ while not lines.is_empty():
+ line = lines.pop_front()
+ if not table.parse_row(line):
+ break
+ return table.to_bbcode()
+
+
+func is_table(line: String) -> bool:
+ return line.find("|") != -1
+
+
+func open_table(line: String) -> String:
+ _on_table_tag = true
+ return "[table=%d]" % (line.count("|") + 1)
+
+
+func close_table() -> String:
+ _on_table_tag = false
+ return "[/table]"
+
+
+func extract_cells(line: String, bold := false) -> String:
+ var cells := ""
+ for cell in line.split("|"):
+ if bold:
+ cell = "[b]%s[/b]" % cell
+ cells += "[cell]%s[/cell]" % cell
+ return cells
+
+
+func process_image_references(p_regex: RegEx, p_input: String) -> String:
+ #return p_input
+
+ # exists references?
+ var matches := p_regex.search_all(p_input)
+ if matches.is_empty():
+ return p_input
+ # collect image references and remove_at it
+ var references := Dictionary()
+ var link_regex := regex("\\[(\\S+)\\]:(\\S+)([ ]\"(.*)\")?")
+ # create copy of original source to replace checked it
+ var input := p_input.replace("\r", "")
+ var extracted_references := p_input.replace("\r", "")
+ for reg_match in link_regex.search_all(input):
+ var line := reg_match.get_string(0) + "\n"
+ var ref := reg_match.get_string(1)
+ #var topl_tip = reg_match.get_string(4)
+ # collect reference and url
+ references[ref] = reg_match.get_string(2)
+ extracted_references = extracted_references.replace(line, "")
+
+ # replace image references by collected url's
+ for reference_key: String in references.keys():
+ var regex_key := regex("\\](\\[%s\\])" % reference_key)
+ for reg_match in regex_key.search_all(extracted_references):
+ var ref: String = reg_match.get_string(0)
+ var image_url: String = "](%s)" % references.get(reference_key)
+ extracted_references = extracted_references.replace(ref, image_url)
+ return extracted_references
+
+
+@warning_ignore("return_value_discarded")
+func process_image(p_regex: RegEx, p_input: String) -> String:
+ #return p_input
+ var to_replace := PackedStringArray()
+ var tool_tips := PackedStringArray()
+ # find all matches
+ var matches := p_regex.search_all(p_input)
+ if matches.is_empty():
+ return p_input
+ for reg_match in matches:
+ # grap the parts to replace and store temporay because a direct replace will distort the offsets
+ to_replace.append(p_input.substr(reg_match.get_start(0), reg_match.get_end(0)))
+ # grap optional tool tips
+ tool_tips.append(reg_match.get_string(5))
+ # finally replace all findings
+ for replace in to_replace:
+ var re := p_regex.sub(replace, "[img]$2[/img]")
+ p_input = p_input.replace(replace, re)
+ return await _process_external_image_resources(p_input)
+
+
+func _process_external_image_resources(input: String) -> String:
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(image_download_folder)
+ # scan all img for external resources and download it
+ for value in _img_replace_regex.search_all(input):
+ if value.get_group_count() >= 1:
+ var image_url: String = value.get_string(1)
+ # if not a local resource we need to download it
+ if image_url.begins_with("http"):
+ if OS.is_stdout_verbose():
+ prints("download image:", image_url)
+ var response := await _client.request_image(image_url)
+ if response.status() == 200:
+ var image := Image.new()
+ var error := image.load_png_from_buffer(response.get_body())
+ if error != OK:
+ prints("Error creating image from response", error)
+ # replace characters where format characters
+ var new_url := image_download_folder + image_url.get_file().replace("_", "-")
+ if new_url.get_extension() != 'png':
+ new_url = new_url + '.png'
+ var err := image.save_png(new_url)
+ if err:
+ push_error("Can't save image to '%s'. Error: %s" % [new_url, error_string(err)])
+ @warning_ignore("return_value_discarded")
+ _image_urls.append(new_url)
+ input = input.replace(image_url, new_url)
+ return input
diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid
new file mode 100644
index 0000000..6817573
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid
@@ -0,0 +1 @@
+uid://d1dpjlnngiokr
diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd b/addons/gdUnit4/src/update/GdUnitPatch.gd
new file mode 100644
index 0000000..daa20f7
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatch.gd
@@ -0,0 +1,20 @@
+class_name GdUnitPatch
+extends RefCounted
+
+const PATCH_VERSION = "patch_version"
+
+var _version :GdUnit4Version
+
+
+func _init(version_ :GdUnit4Version) -> void:
+ _version = version_
+
+
+func version() -> GdUnit4Version:
+ return _version
+
+
+# this function needs to be implement
+func execute() -> bool:
+ push_error("The function 'execute()' is not implemented at %s" % self)
+ return false
diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd.uid b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid
new file mode 100644
index 0000000..ca688a3
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid
@@ -0,0 +1 @@
+uid://b0il3gifqhk77
diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd b/addons/gdUnit4/src/update/GdUnitPatcher.gd
new file mode 100644
index 0000000..73d25c9
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd
@@ -0,0 +1,75 @@
+class_name GdUnitPatcher
+extends RefCounted
+
+
+const _base_dir := "res://addons/gdUnit4/src/update/patches/"
+
+var _patches := Dictionary()
+
+
+func scan(current :GdUnit4Version) -> void:
+ _scan(_base_dir, current)
+
+
+func _scan(scan_path :String, current :GdUnit4Version) -> void:
+ _patches = Dictionary()
+ var patch_paths := _collect_patch_versions(scan_path, current)
+ for path in patch_paths:
+ prints("scan for patches checked '%s'" % path)
+ _patches[path] = _scan_patches(path)
+
+
+func patch_count() -> int:
+ var count := 0
+ for key :String in _patches.keys():
+ @warning_ignore("unsafe_method_access")
+ count += _patches[key].size()
+ return count
+
+
+func execute() -> void:
+ for key :String in _patches.keys():
+ for path :String in _patches[key]:
+ var patch :GdUnitPatch = (load(key + "/" + path) as GDScript).new()
+ if patch:
+ prints("execute patch", patch.version(), patch.get_script().resource_path)
+ if not patch.execute():
+ prints("error checked execution patch %s" % key + "/" + path)
+
+
+func _collect_patch_versions(scan_path :String, current :GdUnit4Version) -> PackedStringArray:
+ if not DirAccess.dir_exists_absolute(scan_path):
+ return PackedStringArray()
+ var patches := Array()
+ var dir := DirAccess.open(scan_path)
+ if dir != null:
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin() # TODO GODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ if next.is_empty() or next == "." or next == "..":
+ continue
+ var version := GdUnit4Version.parse(next)
+ if version.is_greater(current):
+ patches.append(scan_path + next)
+ patches.sort()
+ return PackedStringArray(patches)
+
+
+func _scan_patches(path :String) -> PackedStringArray:
+ var patches := Array()
+ var dir := DirAccess.open(path)
+ if dir != null:
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ # step over directory links and .uid files
+ if next.is_empty() or next == "." or next == ".." or next.ends_with(".uid"):
+ continue
+ patches.append(next)
+ # make sorted from lowest to high version
+ patches.sort()
+ return PackedStringArray(patches)
diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid
new file mode 100644
index 0000000..241850e
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid
@@ -0,0 +1 @@
+uid://b81y3i61xqoiw
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd b/addons/gdUnit4/src/update/GdUnitUpdate.gd
new file mode 100644
index 0000000..97b7fdd
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd
@@ -0,0 +1,305 @@
+@tool
+extends Container
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const GdUnitUpdateClient := preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+const GDUNIT_TEMP := "user://tmp"
+
+@onready var _progress_content: RichTextLabel = %message
+@onready var _progress_bar: TextureProgressBar = %progress
+@onready var _cancel_btn: Button = %cancel
+@onready var _update_btn: Button = %update
+@onready var _spinner_img := GdUnitUiTools.get_spinner()
+
+
+var _debug_mode := false
+var _update_client :GdUnitUpdateClient
+var _download_url :String
+
+
+func _ready() -> void:
+ init_progress(6)
+
+
+func _process(_delta :float) -> void:
+ if _progress_content != null and _progress_content.is_visible_in_tree():
+ _progress_content.queue_redraw()
+
+
+func init_progress(max_value: int) -> void:
+ _cancel_btn.disabled = false
+ _update_btn.disabled = false
+ _progress_bar.max_value = max_value
+ _progress_bar.value = 1
+ message_h4("Press [Update] to start.", Color.GREEN, false)
+
+
+func setup(update_client: GdUnitUpdateClient, download_url: String) -> void:
+ _update_client = update_client
+ _download_url = download_url
+
+
+func update_progress(message: String, color := Color.GREEN) -> void:
+ message_h4(message, color)
+ _progress_bar.value += 1
+ if _debug_mode:
+ await get_tree().create_timer(3).timeout
+ await get_tree().create_timer(.2).timeout
+
+
+func _colored(message: String, color: Color) -> String:
+ return "[color=#%s]%s[/color]" % [color.to_html(), message]
+
+
+func message_h4(message: String, color: Color, show_spinner := true) -> void:
+ _progress_content.clear()
+ if show_spinner:
+ _progress_content.add_image(_spinner_img)
+ _progress_content.append_text(" [font_size=16]%s[/font_size]" % _colored(message, color))
+ if _debug_mode:
+ prints(message)
+
+
+@warning_ignore("return_value_discarded")
+func run_update() -> void:
+ _cancel_btn.disabled = true
+ _update_btn.disabled = true
+
+ await update_progress("Downloading the update.")
+ await download_release()
+ await update_progress("Extracting")
+ var zip_file := temp_dir() + "/update.zip"
+ var tmp_path := create_temp_dir("update")
+ var result :Variant = extract_zip(zip_file, tmp_path)
+ if result == null:
+ await update_progress("Update failed! .. Rollback.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+ _cancel_btn.disabled = false
+ _update_btn.disabled = false
+ init_progress(5)
+ hide()
+ return
+
+ await update_progress("Uninstall GdUnit4.")
+ disable_gdUnit()
+ if not _debug_mode:
+ GdUnitFileAccess.delete_directory("res://addons/gdUnit4/")
+ # give editor time to react on deleted files
+ await get_tree().create_timer(1).timeout
+
+ await update_progress("Install new GdUnit4 version.")
+ if _debug_mode:
+ copy_directory(tmp_path, "res://debug")
+ else:
+ copy_directory(tmp_path, "res://")
+
+ await update_progress("Patch invalid UID's")
+ await patch_uids()
+
+ await rebuild_project()
+
+ await update_progress("New GdUnit version successfully installed, Restarting Godot please wait.")
+ await get_tree().create_timer(3).timeout
+ enable_gdUnit()
+ hide()
+ GdUnitFileAccess.delete_directory("res://addons/.gdunit_update")
+ restart_godot()
+
+
+func patch_uids(path := "res://addons/gdUnit4/src/") -> void:
+ var to_reimport: PackedStringArray
+ for file in DirAccess.get_files_at(path):
+ var file_path := path.path_join(file)
+ var ext := file.get_extension()
+
+ if ext == "tscn" or ext == "scn" or ext == "tres" or ext == "res":
+ message_h4("Patch GdUnit4 scene: '%s'" % file, Color.WEB_GREEN)
+ remove_uids_from_file(file_path)
+ elif FileAccess.file_exists(file_path + ".import"):
+ to_reimport.append(file_path)
+
+ if not to_reimport.is_empty():
+ message_h4("Reimport resources '%s'" % ", ".join(to_reimport), Color.WEB_GREEN)
+ if Engine.is_editor_hint():
+ EditorInterface.get_resource_filesystem().reimport_files(to_reimport)
+
+ for dir in DirAccess.get_directories_at(path):
+ if not dir.begins_with("."):
+ patch_uids(path.path_join(dir))
+ await get_tree().process_frame
+
+
+func remove_uids_from_file(file_path: String) -> bool:
+ var file := FileAccess.open(file_path, FileAccess.READ)
+ if file == null:
+ print("Failed to open file: ", file_path)
+ return false
+
+ var original_content := file.get_as_text()
+ file.close()
+
+ # Remove UIDs using regex
+ var regex := RegEx.new()
+ regex.compile("(\\[ext_resource[^\\]]*?)\\s+uid=\"uid://[^\"]*\"")
+
+ var modified_content := regex.sub(original_content, "$1", true)
+
+ # Check if any changes were made
+ if original_content != modified_content:
+ prints("Patched invalid uid's out in '%s'" % file_path)
+ # Write the modified content back
+ file = FileAccess.open(file_path, FileAccess.WRITE)
+ if file == null:
+ print("Failed to write to file: ", file_path)
+ return false
+
+ file.store_string(modified_content)
+ file.close()
+ return true
+
+ return false
+
+
+func restart_godot() -> void:
+ prints("Force restart Godot")
+ EditorInterface.restart_editor(true)
+
+
+@warning_ignore("return_value_discarded")
+func enable_gdUnit() -> void:
+ var enabled_plugins := PackedStringArray()
+ if ProjectSettings.has_setting("editor_plugins/enabled"):
+ enabled_plugins = ProjectSettings.get_setting("editor_plugins/enabled")
+ if not enabled_plugins.has("res://addons/gdUnit4/plugin.cfg"):
+ enabled_plugins.append("res://addons/gdUnit4/plugin.cfg")
+ ProjectSettings.set_setting("editor_plugins/enabled", enabled_plugins)
+ ProjectSettings.save()
+
+
+func disable_gdUnit() -> void:
+ EditorInterface.set_plugin_enabled("gdUnit4", false)
+
+
+func temp_dir() -> String:
+ if not DirAccess.dir_exists_absolute(GDUNIT_TEMP):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP)
+ return GDUNIT_TEMP
+
+
+func create_temp_dir(folder_name :String) -> String:
+ var new_folder := temp_dir() + "/" + folder_name
+ GdUnitFileAccess.delete_directory(new_folder)
+ if not DirAccess.dir_exists_absolute(new_folder):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_folder)
+ return new_folder
+
+
+func copy_directory(from_dir: String, to_dir: String) -> bool:
+ if not DirAccess.dir_exists_absolute(from_dir):
+ printerr("Source directory not found '%s'" % from_dir)
+ return false
+ # check if destination exists
+ if not DirAccess.dir_exists_absolute(to_dir):
+ # create it
+ var err := DirAccess.make_dir_recursive_absolute(to_dir)
+ if err != OK:
+ printerr("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)])
+ return false
+ var source_dir := DirAccess.open(from_dir)
+ var dest_dir := DirAccess.open(to_dir)
+ if source_dir != null:
+ @warning_ignore("return_value_discarded")
+ source_dir.list_dir_begin()
+ var next := "."
+
+ while next != "":
+ next = source_dir.get_next()
+ if next == "" or next == "." or next == "..":
+ continue
+ var source := source_dir.get_current_dir() + "/" + next
+ var dest := dest_dir.get_current_dir() + "/" + next
+ if source_dir.current_is_dir():
+ @warning_ignore("return_value_discarded")
+ copy_directory(source + "/", dest)
+ continue
+ var err := source_dir.copy(source, dest)
+ if err != OK:
+ printerr("Error checked copy file '%s' to '%s'" % [source, dest])
+ return false
+ return true
+ else:
+ printerr("Directory not found: " + from_dir)
+ return false
+
+
+func extract_zip(zip_package: String, dest_path: String) -> Variant:
+ var zip: ZIPReader = ZIPReader.new()
+ var err := zip.open(zip_package)
+ if err != OK:
+ printerr("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err])
+ return null
+ var zip_entries: PackedStringArray = zip.get_files()
+ # Get base path and step over archive folder
+ var archive_path := zip_entries[0]
+ zip_entries.remove_at(0)
+
+ for zip_entry in zip_entries:
+ var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "")
+ if zip_entry.ends_with("/"):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_file_path)
+ continue
+ var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE)
+ file.store_buffer(zip.read_file(zip_entry))
+ @warning_ignore("return_value_discarded")
+ zip.close()
+ return dest_path
+
+
+func download_release() -> void:
+ var zip_file := GdUnitFileAccess.temp_dir() + "/update.zip"
+ var response :GdUnitUpdateClient.HttpResponse
+ if _debug_mode:
+ response = GdUnitUpdateClient.HttpResponse.new(200, PackedByteArray())
+ zip_file = "res://update.zip"
+ return
+
+ response = await _update_client.request_zip_package(_download_url, zip_file)
+ if response.status() != 200:
+ push_warning("Update information cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()])
+ message_h4("Download the update failed! Try it later again.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+
+
+func rebuild_project() -> void:
+ # Check if this is a Godot .NET runtime instance
+ if not ClassDB.class_exists("CSharpScript"):
+ return
+
+ update_progress("Rebuild the project ...")
+ await get_tree().process_frame
+
+ var output := []
+ var exit_code := OS.execute("dotnet", ["build"], output)
+ if exit_code == -1:
+ message_h4("Rebuild the project failed, check your project dependencies.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+ return
+
+ for out: String in output:
+ print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges())
+ await get_tree().process_frame
+
+
+func _on_confirmed() -> void:
+ await run_update()
+
+
+func _on_cancel_pressed() -> void:
+ hide()
+
+
+func _on_update_pressed() -> void:
+ await run_update()
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid
new file mode 100644
index 0000000..a72d4a5
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid
@@ -0,0 +1 @@
+uid://dyr407n7f3td6
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.tscn b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
new file mode 100644
index 0000000..dc0511a
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
@@ -0,0 +1,100 @@
+[gd_scene format=3 uid="uid://2eahgaw88y6q"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdate.gd" id="1"]
+
+[sub_resource type="Gradient" id="Gradient_wilsr"]
+colors = PackedColorArray(0.151276, 0.151276, 0.151276, 1, 1, 1, 1, 1)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_45cww"]
+gradient = SubResource("Gradient_wilsr")
+fill_to = Vector2(0.75641, 0)
+
+[sub_resource type="Gradient" id="Gradient_i0qp8"]
+colors = PackedColorArray(1, 1, 1, 1, 0.20871, 0.20871, 0.20871, 1)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_wilsr"]
+gradient = SubResource("Gradient_i0qp8")
+fill_from = Vector2(0.794872, 0)
+fill_to = Vector2(0, 0)
+
+[node name="GdUnitUpdate" type="MarginContainer"]
+clip_contents = true
+custom_minimum_size = Vector2(0, 80)
+anchors_preset = 10
+anchor_right = 1.0
+offset_bottom = 80.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_right = 10
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="Panel" type="Panel" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="message" type="RichTextLabel" parent="VBoxContainer/Panel"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+bbcode_enabled = true
+text = "aaaaa"
+fit_content = true
+scroll_active = false
+shortcut_keys_enabled = false
+
+[node name="Panel2" type="Panel" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="progress" type="TextureProgressBar" parent="VBoxContainer/Panel2"]
+unique_name_in_owner = true
+auto_translate_mode = 2
+clip_contents = true
+custom_minimum_size = Vector2(0, 20)
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+localize_numeral_system = false
+min_value = 1.0
+max_value = 5.0
+value = 1.0
+rounded = true
+allow_greater = true
+nine_patch_stretch = true
+texture_under = SubResource("GradientTexture2D_45cww")
+texture_progress = SubResource("GradientTexture2D_wilsr")
+tint_under = Color(0.0235294, 0.145098, 0.168627, 1)
+tint_progress = Color(0.288912, 0.233442, 0.533772, 1)
+
+[node name="PanelContainer" type="MarginContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/PanelContainer"]
+layout_mode = 2
+theme_override_constants/separation = 10
+alignment = 2
+
+[node name="update" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Update"
+
+[node name="cancel" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Cancel"
+
+[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/update" to="." method="_on_update_pressed"]
+[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/cancel" to="." method="_on_cancel_pressed"]
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd
new file mode 100644
index 0000000..b4f54b7
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd
@@ -0,0 +1,98 @@
+@tool
+extends Node
+
+signal request_completed(response: HttpResponse)
+
+class HttpResponse:
+ var _http_status: int
+ var _body: PackedByteArray
+
+
+ func _init(http_status: int, body: PackedByteArray) -> void:
+ _http_status = http_status
+ _body = body
+
+
+ func status() -> int:
+ return _http_status
+
+
+ func response() -> Variant:
+ if _http_status != 200:
+ return _body.get_string_from_utf8()
+
+ var test_json_conv := JSON.new()
+ @warning_ignore("return_value_discarded")
+ var error := test_json_conv.parse(_body.get_string_from_utf8())
+ if error != OK:
+ return "HttpResponse: %s Error: %s" % [error_string(error), _body.get_string_from_utf8()]
+ return test_json_conv.get_data()
+
+ func get_body() -> PackedByteArray:
+ return _body
+
+
+var _http_request := HTTPRequest.new()
+
+
+func _ready() -> void:
+ add_child(_http_request)
+ @warning_ignore("return_value_discarded")
+ _http_request.request_completed.connect(_on_request_completed)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ if is_instance_valid(_http_request):
+ _http_request.queue_free()
+
+
+#func list_tags() -> void:
+# _http_request.connect("request_completed",Callable(self,"_response_request_tags"))
+# var error = _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags")
+# if error != OK:
+# push_error("An error occurred in the HTTP request.")
+
+
+func request_latest_version() -> HttpResponse:
+ var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags")
+ if error != OK:
+ var message := "Request latest version failed, %s" % error_string(error)
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_releases() -> HttpResponse:
+ var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/releases")
+ if error != OK:
+ var message := "request_releases failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_image(url: String) -> HttpResponse:
+ var error := _http_request.request(url)
+ if error != OK:
+ var message := "request_image failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_zip_package(url: String, file: String) -> HttpResponse:
+ _http_request.set_download_file(file)
+ var error := _http_request.request(url)
+ if error != OK:
+ var message := "request_zip_package failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func extract_latest_version(response: HttpResponse) -> GdUnit4Version:
+ var body: Array = response.response()
+ return GdUnit4Version.parse(str(body[0]["name"]))
+
+
+func _on_request_completed(_result: int, response_http_status: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
+ if _http_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
+ _http_request.set_download_file("")
+ request_completed.emit(HttpResponse.new(response_http_status, body))
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid
new file mode 100644
index 0000000..5b6e2d4
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid
@@ -0,0 +1 @@
+uid://c0aqh8gtyaxls
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd
new file mode 100644
index 0000000..f4ae9ac
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd
@@ -0,0 +1,206 @@
+@tool
+extends MarginContainer
+
+#signal request_completed(response)
+
+const GdMarkDownReader = preload("res://addons/gdUnit4/src/update/GdMarkDownReader.gd")
+const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+const GdUnitUpdateProgress = preload("res://addons/gdUnit4/src/update/GdUnitUpdate.gd")
+
+@onready var _md_reader: GdMarkDownReader = GdMarkDownReader.new()
+@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient
+@onready var _header: Label = $Panel/GridContainer/PanelContainer/header
+@onready var _update_button: Button = $Panel/GridContainer/Panel/HBoxContainer/update
+@onready var _content: RichTextLabel = $Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content
+@onready var _update_progress :GdUnitUpdateProgress = %update_banner
+
+var _debug_mode := false
+var _patcher := GdUnitPatcher.new()
+var _current_version := GdUnit4Version.current()
+
+
+func _ready() -> void:
+ _update_button.set_disabled(false)
+ _md_reader.set_http_client(_update_client)
+ @warning_ignore("return_value_discarded")
+ #GdUnitFonts.init_fonts(_content)
+ _update_progress.set_visible(false)
+ _update_progress.hidden.connect(func() -> void:
+ _update_button.set_disabled(false)
+ )
+
+
+func request_releases() -> bool:
+ if _debug_mode:
+ _update_progress._debug_mode = _debug_mode
+ _header.text = "A new version 'v4.4.4' is available"
+ _update_button.set_disabled(false)
+ return true
+
+ var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version()
+ if response.status() != 200:
+ _header.text = "Update information cannot be retrieved from GitHub!"
+ message_h4("\n\nError: %s" % response.response(), Color.INDIAN_RED)
+ return false
+ var latest_version := _update_client.extract_latest_version(response)
+ # if same version exit here no update need
+ if latest_version.is_greater(_current_version):
+ _patcher.scan(_current_version)
+ _header.text = "A new version '%s' is available" % latest_version
+ var download_zip_url := extract_zip_url(response)
+ _update_progress.setup(_update_client, download_zip_url)
+ _update_button.set_disabled(false)
+ return true
+ else:
+ _header.text = "No update is available."
+ _update_button.set_disabled(true)
+ return false
+
+
+func _colored(message_: String, color: Color) -> String:
+ return "[color=#%s]%s[/color]" % [color.to_html(), message_]
+
+
+func message_h4(message_: String, color: Color, clear := true) -> void:
+ if clear:
+ _content.clear()
+ _content.append_text("[font_size=16]%s[/font_size]" % _colored(message_, color))
+
+
+func message(message_: String, color: Color) -> void:
+ _content.clear()
+ _content.append_text(_colored(message_, color))
+
+
+func _process(_delta: float) -> void:
+ if _content != null and _content.is_visible_in_tree():
+ _content.queue_redraw()
+
+
+func show_update() -> void:
+ if not GdUnitSettings.is_update_notification_enabled():
+ _header.text = "No update is available."
+ message_h4("The search for updates is deactivated.", Color.CORNFLOWER_BLUE)
+ _update_button.set_disabled(true)
+ return
+
+ if not await request_releases():
+ return
+ _update_button.set_disabled(true)
+
+ prints("Scan for GdUnit4 Update ...")
+ message_h4("\n\n\nRequest release infos ... ", Color.SNOW)
+ _content.add_image(GdUnitUiTools.get_spinner(), 32, 32)
+
+ var content: String
+ if _debug_mode:
+ await get_tree().create_timer(.2).timeout
+ var template := FileAccess.open("res://addons/gdUnit4/test/update/resources/http_response_releases.txt", FileAccess.READ).get_as_text()
+ content = await _md_reader.to_bbcode(template)
+ else:
+ var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_releases()
+ if response.status() == 200:
+ content = await extract_releases(response, _current_version)
+ else:
+ message_h4("\n\n\nError checked request available releases!", Color.INDIAN_RED)
+ return
+
+ # finally force rescan to import images as textures
+ if Engine.is_editor_hint():
+ await rescan()
+ message(content, Color.CADET_BLUE)
+ _update_button.set_disabled(false)
+
+
+
+func extract_zip_url(response: GdUnitUpdateClient.HttpResponse) -> String:
+ var body :Array = response.response()
+ return body[0]["zipball_url"]
+
+
+func extract_releases(response: GdUnitUpdateClient.HttpResponse, current_version: GdUnit4Version) -> String:
+ await get_tree().process_frame
+ var result := ""
+ for release :Dictionary in response.response():
+ var release_version := str(release["tag_name"])
+ if GdUnit4Version.parse(release_version).equals(current_version):
+ break
+ var release_description := _colored("<h1>GdUnit Release %s</h1>" % release_version, Color.CORNFLOWER_BLUE)
+ release_description += "\n"
+ release_description += release["body"]
+ release_description += "\n\n"
+ result += await _md_reader.to_bbcode(release_description)
+ return result
+
+
+func rescan() -> void:
+ if Engine.is_editor_hint():
+ if OS.is_stdout_verbose():
+ prints(".. reimport release resources")
+ var fs := EditorInterface.get_resource_filesystem()
+ fs.scan()
+ while fs.is_scanning():
+ if OS.is_stdout_verbose():
+ progressBar(fs.get_scanning_progress() * 100 as int)
+ await get_tree().process_frame
+ await get_tree().process_frame
+ await get_tree().create_timer(1).timeout
+
+
+func progressBar(p_progress: int) -> void:
+ if p_progress < 0:
+ p_progress = 0
+ if p_progress > 100:
+ p_progress = 100
+ printraw("scan [%-50s] %-3d%%\r" % ["".lpad(int(p_progress/2.0), "#").rpad(50, "-"), p_progress])
+
+
+@warning_ignore("return_value_discarded")
+func _on_update_pressed() -> void:
+ _update_button.set_disabled(true)
+ # close all opend scripts before start the update
+ if not _debug_mode:
+ ScriptEditorControls.close_open_editor_scripts()
+ # copy update source to a temp because the update is deleting the whole gdUnit folder
+ DirAccess.make_dir_absolute("res://addons/.gdunit_update")
+ DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", "res://addons/.gdunit_update/GdUnitUpdate.tscn")
+ DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd")
+ var source := FileAccess.open("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", FileAccess.READ)
+ var content := source.get_as_text().replace("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd")
+ var dest := FileAccess.open("res://addons/.gdunit_update/GdUnitUpdate.tscn", FileAccess.WRITE)
+ dest.store_string(content)
+ _update_progress.set_visible(true)
+
+
+func _on_show_next_toggled(enabled: bool) -> void:
+ GdUnitSettings.set_update_notification(enabled)
+
+
+func _on_cancel_pressed() -> void:
+ hide()
+
+
+func _on_content_meta_clicked(meta: String) -> void:
+ var properties: Dictionary = str_to_var(meta)
+ if properties.has("url"):
+ @warning_ignore("return_value_discarded")
+ OS.shell_open(str(properties.get("url")))
+
+
+func _on_content_meta_hover_started(meta: String) -> void:
+ var properties: Dictionary = str_to_var(meta)
+ if properties.has("tool_tip"):
+ _content.set_tooltip_text(str(properties.get("tool_tip")))
+
+
+@warning_ignore("unused_parameter")
+func _on_content_meta_hover_ended(meta: String) -> void:
+ _content.set_tooltip_text("")
+
+
+func _on_visibility_changed() -> void:
+ if not is_visible_in_tree():
+ return
+ if _update_progress != null:
+ _update_progress.set_visible(false)
+ await show_update()
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid
new file mode 100644
index 0000000..fa36885
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid
@@ -0,0 +1 @@
+uid://ciyg27plnsvyr
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
new file mode 100644
index 0000000..7305f61
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
@@ -0,0 +1,97 @@
+[gd_scene format=3 uid="uid://0xyeci1tqebj"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateNotify.gd" id="1_112wo"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd" id="2_18asx"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/update/GdUnitUpdate.tscn" id="3_x87h6"]
+
+[node name="Control" type="MarginContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_112wo")
+
+[node name="GdUnitUpdateClient" type="Node" parent="."]
+script = ExtResource("2_18asx")
+
+[node name="Panel" type="Panel" parent="."]
+layout_mode = 2
+
+[node name="GridContainer" type="VBoxContainer" parent="Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+alignment = 1
+
+[node name="PanelContainer" type="MarginContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="header" type="Label" parent="Panel/GridContainer/PanelContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 9
+
+[node name="PanelContainer2" type="PanelContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="ScrollContainer" type="ScrollContainer" parent="Panel/GridContainer/PanelContainer2"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="MarginContainer" type="MarginContainer" parent="Panel/GridContainer/PanelContainer2/ScrollContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="content" type="RichTextLabel" parent="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+bbcode_enabled = true
+
+[node name="update_banner" parent="Panel/GridContainer" instance=ExtResource("3_x87h6")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+size_flags_horizontal = 1
+size_flags_vertical = 8
+
+[node name="Panel" type="MarginContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Panel/GridContainer/Panel"]
+use_parent_material = true
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="update" type="Button" parent="Panel/GridContainer/Panel/HBoxContainer"]
+custom_minimum_size = Vector2(100, 40)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+text = "Update"
+
+[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"]
+[connection signal="meta_clicked" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_clicked"]
+[connection signal="meta_hover_ended" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_ended"]
+[connection signal="meta_hover_started" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_started"]
+[connection signal="pressed" from="Panel/GridContainer/Panel/HBoxContainer/update" to="." method="_on_update_pressed"]
diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png b/addons/gdUnit4/src/update/assets/border_bottom.png
new file mode 100644
index 0000000..aa16bb7
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png.import b/addons/gdUnit4/src/update/assets/border_bottom.png.import
new file mode 100644
index 0000000..752bf40
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dns6mtm6xif3"
+path="res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/border_bottom.png"
+dest_files=["res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/border_top.png b/addons/gdUnit4/src/update/assets/border_top.png
new file mode 100644
index 0000000..b1b1039
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/border_top.png.import b/addons/gdUnit4/src/update/assets/border_top.png.import
new file mode 100644
index 0000000..dae66df
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cvg6r1fdk1p01"
+path="res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/border_top.png"
+dest_files=["res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/dot1.png b/addons/gdUnit4/src/update/assets/dot1.png
new file mode 100644
index 0000000..d5ea77b
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/dot1.png.import b/addons/gdUnit4/src/update/assets/dot1.png.import
new file mode 100644
index 0000000..a7f74d7
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://t4fmqmj3ffcd"
+path="res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/dot1.png"
+dest_files=["res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/dot2.png b/addons/gdUnit4/src/update/assets/dot2.png
new file mode 100644
index 0000000..4a74498
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/dot2.png.import b/addons/gdUnit4/src/update/assets/dot2.png.import
new file mode 100644
index 0000000..8b5b001
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c2usgitbic18b"
+path="res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/dot2.png"
+dest_files=["res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/embedded.png b/addons/gdUnit4/src/update/assets/embedded.png
new file mode 100644
index 0000000..15cb9ab
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/embedded.png.import b/addons/gdUnit4/src/update/assets/embedded.png.import
new file mode 100644
index 0000000..a02de65
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bm8tdlw36fu7q"
+path="res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/embedded.png"
+dest_files=["res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png b/addons/gdUnit4/src/update/assets/horizontal-line2.png
new file mode 100644
index 0000000..66aa098
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png.import b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import
new file mode 100644
index 0000000..3148524
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://w1d7ughjf1b3"
+path="res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/horizontal-line2.png"
+dest_files=["res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1