Extract language packs to a temp directory and verify before swapping

Follow-up to the previous download fix: the old pack was no longer
deleted before the download, but it was still deleted at the start of
the extraction step. If the app crashed mid-extraction or the archive
was truncated, the device was left without a valid pack; worse, a
partially extracted directory containing language.db would pass
is_language_directory_valid() on the next offline launch and be
accepted as a working pack.

The extraction thread now follows an extract / verify / swap sequence:

1. Extract the archive into <lang>_tmp instead of the final location,
   leaving the current pack untouched and avoiding any risk of merging
   old and new files.
2. Validate the extracted pack (non-empty, language.db present) before
   touching the current one; on failure, discard the temp directory and
   the archive, show the invalid-directory error, and keep playing with
   the current pack.
3. Only then delete the previous pack and rename the new one into
   place. The window without a valid pack shrinks from the whole
   extraction to two filesystem operations.

If the final rename fails, the temp directory is kept (the data is
intact on disk) and the error popup is shown; the next launch detects
the missing pack and downloads it again. A leftover temp directory from
a crash is removed at the start of the next extraction.

Verified with the GUT test suite (62/62 passing).
This commit is contained in:
Adrien Ufferte
2026-06-11 09:44:50 +02:00
parent 896e2740b3
commit 0b9d5f216e
@@ -176,29 +176,46 @@ func _copy_data(this: PackageDownloader) -> void:
mutex.unlock()
)
# Cleanup previous files
if DirAccess.dir_exists_absolute(current_language_path):
Log.trace("PackageDownloader: Removing existing language directory before extraction")
Utils.delete_directory_recursive(ProjectSettings.globalize_path(current_language_path))
# Extract the archive
var subfolder: String = unzipper.extract(language_zip_path, USER_LANGUAGE_RESOURCES_PATH, false)
# Extract to a temporary directory so the current pack stays usable if
# the extraction fails or is interrupted
var temp_extract_path: String = USER_LANGUAGE_RESOURCES_PATH.path_join(language + "_tmp")
if DirAccess.dir_exists_absolute(temp_extract_path):
Utils.delete_directory_recursive(ProjectSettings.globalize_path(temp_extract_path))
var subfolder: String = unzipper.extract(language_zip_path, temp_extract_path, false)
if subfolder == "":
Log.error("PackageDownloader: Extraction failed for %s" % language_zip_path)
this.call_thread_safe("_show_error", 2) # Error downloading
return
# Move the data to the locale folder of the user
var error: Error = DirAccess.rename_absolute(USER_LANGUAGE_RESOURCES_PATH.path_join(subfolder), current_language_path)
# Check the new pack before replacing the current one
var new_pack_path: String = temp_extract_path.path_join(subfolder)
if not is_language_directory_valid(new_pack_path):
Log.error("PackageDownloader: Extracted package at %s is invalid, keeping the current language pack" % new_pack_path)
Utils.delete_directory_recursive(ProjectSettings.globalize_path(temp_extract_path))
DirAccess.remove_absolute(language_zip_path)
this.call_thread_safe("_show_error", 3) # Invalid language directory
return
# Replace the previous pack, now that the new one is fully extracted
if DirAccess.dir_exists_absolute(current_language_path):
Log.trace("PackageDownloader: Removing previous language directory")
Utils.delete_directory_recursive(ProjectSettings.globalize_path(current_language_path))
var error: Error = DirAccess.rename_absolute(new_pack_path, current_language_path)
if error != OK:
Log.error("PackageDownloader: Error " + error_string(error) + " while renaming folder from %s to %s" % [USER_LANGUAGE_RESOURCES_PATH.path_join(subfolder), current_language_path])
else:
Log.trace("PackageDownloader: Package extracted to %s" % current_language_path)
# Keep the temporary directory so the data is not lost; the next
# launch will detect the missing pack and download it again
Log.error("PackageDownloader: Error " + error_string(error) + " while renaming folder from %s to %s" % [new_pack_path, current_language_path])
this.call_thread_safe("_show_error", 2) # Error downloading
return
Log.trace("PackageDownloader: Package extracted to %s" % current_language_path)
# Cleanup unnecessary files
Utils.delete_directory_recursive(ProjectSettings.globalize_path(temp_extract_path))
DirAccess.remove_absolute(language_zip_path)
Log.trace("PackageDownloader: Removed temporary archive %s" % language_zip_path)
# Go to main menu
this.call_thread_safe("_go_to_next_scene")