feat(updater): stage3 resilient gui update flow
Some checks failed
Desktop Dev Pre-release / prerelease (push) Failing after 2m18s
Some checks failed
Desktop Dev Pre-release / prerelease (push) Failing after 2m18s
- added retry-based file copy, rollback restart, and version marker validation in updater GUI - added build step to write dist/version.txt for post-update validation - added unit tests for updater helpers
This commit is contained in:
13
build.py
13
build.py
@@ -24,6 +24,18 @@ REMOVE_LIST = [
|
||||
]
|
||||
|
||||
|
||||
def write_version_marker():
|
||||
marker_path = os.path.join(DIST_DIR, "version.txt")
|
||||
try:
|
||||
os.makedirs(DIST_DIR, exist_ok=True)
|
||||
with open(marker_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(VERSION).strip() + "\n")
|
||||
print(f"[OK] Обновлен маркер версии: {marker_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Не удалось записать version.txt: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_project_root():
|
||||
missing = [name for name in SAFE_CLEAN_ROOT_FILES if not os.path.exists(name)]
|
||||
if missing:
|
||||
@@ -126,6 +138,7 @@ if __name__ == "__main__":
|
||||
run_build()
|
||||
run_updater_build()
|
||||
run_cleanup()
|
||||
write_version_marker()
|
||||
create_archive()
|
||||
|
||||
print("\n" + "=" * 30)
|
||||
|
||||
38
tests/test_updater_gui.py
Normal file
38
tests/test_updater_gui.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = Path("updater_gui.py")
|
||||
SPEC = importlib.util.spec_from_file_location("updater_gui_under_test", MODULE_PATH)
|
||||
updater_gui = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(updater_gui)
|
||||
|
||||
|
||||
class UpdaterGuiTests(unittest.TestCase):
|
||||
def test_read_version_marker(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
marker = Path(tmp_dir) / "version.txt"
|
||||
marker.write_text("2.0.1\n", encoding="utf-8")
|
||||
value = updater_gui._read_version_marker(tmp_dir)
|
||||
self.assertEqual(value, "2.0.1")
|
||||
|
||||
def test_mirror_tree_skips_selected_file(self):
|
||||
with tempfile.TemporaryDirectory() as src_tmp, tempfile.TemporaryDirectory() as dst_tmp:
|
||||
src = Path(src_tmp)
|
||||
dst = Path(dst_tmp)
|
||||
(src / "keep.txt").write_text("ok", encoding="utf-8")
|
||||
(src / "skip.bin").write_text("x", encoding="utf-8")
|
||||
(src / "sub").mkdir()
|
||||
(src / "sub" / "nested.txt").write_text("nested", encoding="utf-8")
|
||||
|
||||
updater_gui._mirror_tree(str(src), str(dst), skip_names={"skip.bin"})
|
||||
|
||||
self.assertTrue((dst / "keep.txt").exists())
|
||||
self.assertTrue((dst / "sub" / "nested.txt").exists())
|
||||
self.assertFalse((dst / "skip.bin").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
104
updater_gui.py
104
updater_gui.py
@@ -15,7 +15,8 @@ def _write_log(log_path, message):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(message.rstrip() + "\n")
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
f.write(f"[{ts}] {message.rstrip()}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -36,7 +37,20 @@ def _is_pid_running(pid):
|
||||
return False
|
||||
|
||||
|
||||
def _mirror_tree(src_dir, dst_dir, skip_names=None):
|
||||
def _copy_file_with_retries(source_file, target_file, retries=20, delay=0.5):
|
||||
last_error = None
|
||||
for _ in range(max(1, retries)):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
shutil.copy2(source_file, target_file)
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
time.sleep(delay)
|
||||
raise last_error if last_error else RuntimeError(f"Не удалось скопировать файл: {source_file}")
|
||||
|
||||
|
||||
def _mirror_tree(src_dir, dst_dir, skip_names=None, retries=20, delay=0.5):
|
||||
skip_set = {name.lower() for name in (skip_names or [])}
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
for root, dirs, files in os.walk(src_dir):
|
||||
@@ -48,8 +62,18 @@ def _mirror_tree(src_dir, dst_dir, skip_names=None):
|
||||
continue
|
||||
source_file = os.path.join(root, file_name)
|
||||
target_file = os.path.join(target_root, file_name)
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
shutil.copy2(source_file, target_file)
|
||||
_copy_file_with_retries(source_file, target_file, retries=retries, delay=delay)
|
||||
|
||||
|
||||
def _read_version_marker(base_dir):
|
||||
marker_path = os.path.join(base_dir, "version.txt")
|
||||
if not os.path.exists(marker_path):
|
||||
return ""
|
||||
try:
|
||||
with open(marker_path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class UpdateWorker(QObject):
|
||||
@@ -67,9 +91,23 @@ class UpdateWorker(QObject):
|
||||
self.work_dir = work_dir or ""
|
||||
self.log_path = os.path.join(app_dir, "update_error.log")
|
||||
|
||||
def _start_app(self):
|
||||
app_exe = os.path.join(self.app_dir, self.exe_name)
|
||||
if not os.path.exists(app_exe):
|
||||
raise RuntimeError(f"Не найден файл приложения: {app_exe}")
|
||||
creation_flags = 0
|
||||
if hasattr(subprocess, "DETACHED_PROCESS"):
|
||||
creation_flags |= subprocess.DETACHED_PROCESS
|
||||
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen([app_exe], cwd=self.app_dir, creationflags=creation_flags)
|
||||
|
||||
def run(self):
|
||||
backup_dir = os.path.join(tempfile.gettempdir(), f"anabasis_backup_{int(time.time())}")
|
||||
skip_names = {"anabasisupdater.exe"}
|
||||
prev_version = _read_version_marker(self.app_dir)
|
||||
source_version = _read_version_marker(self.source_dir)
|
||||
expected_version = (self.version or "").strip()
|
||||
try:
|
||||
self.status.emit(1, "Ожидание завершения приложения...")
|
||||
wait_loops = 0
|
||||
@@ -90,23 +128,35 @@ class UpdateWorker(QObject):
|
||||
raise RuntimeError(f"Процесс {self.target_pid} не завершился.")
|
||||
break
|
||||
|
||||
self.status.emit(2, "Создание резервной копии...")
|
||||
self.status.emit(2, "Проверка содержимого обновления...")
|
||||
source_app_exe = os.path.join(self.source_dir, self.exe_name)
|
||||
if not os.path.exists(source_app_exe):
|
||||
raise RuntimeError(f"В обновлении отсутствует {self.exe_name}")
|
||||
if expected_version and source_version and source_version != expected_version:
|
||||
raise RuntimeError(
|
||||
f"Версия пакета ({source_version}) не совпадает с ожидаемой ({expected_version})."
|
||||
)
|
||||
|
||||
self.status.emit(3, "Создание резервной копии...")
|
||||
_mirror_tree(self.app_dir, backup_dir, skip_names=skip_names)
|
||||
|
||||
self.status.emit(3, "Применение обновления...")
|
||||
_mirror_tree(self.source_dir, self.app_dir, skip_names=skip_names)
|
||||
self.status.emit(4, "Применение обновления...")
|
||||
_mirror_tree(self.source_dir, self.app_dir, skip_names=skip_names, retries=30, delay=0.6)
|
||||
|
||||
self.status.emit(4, "Запуск обновленного приложения...")
|
||||
app_exe = os.path.join(self.app_dir, self.exe_name)
|
||||
creation_flags = 0
|
||||
if hasattr(subprocess, "DETACHED_PROCESS"):
|
||||
creation_flags |= subprocess.DETACHED_PROCESS
|
||||
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen([app_exe], cwd=self.app_dir, creationflags=creation_flags)
|
||||
self.status.emit(5, "Проверка установленной версии...")
|
||||
installed_version = _read_version_marker(self.app_dir)
|
||||
if expected_version and installed_version and installed_version != expected_version:
|
||||
raise RuntimeError(
|
||||
f"После обновления версия {installed_version}, ожидалась {expected_version}."
|
||||
)
|
||||
if expected_version and prev_version and prev_version == expected_version:
|
||||
_write_log(self.log_path, f"Предупреждение: версия до обновления уже была {expected_version}.")
|
||||
|
||||
_write_log(self.log_path, f"Update success to version {self.version or 'unknown'}")
|
||||
self.status.emit(5, "Очистка временных файлов...")
|
||||
self.status.emit(6, "Запуск обновленного приложения...")
|
||||
self._start_app()
|
||||
|
||||
_write_log(self.log_path, f"Update success to version {expected_version or source_version or 'unknown'}")
|
||||
self.status.emit(7, "Очистка временных файлов...")
|
||||
try:
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
if self.work_dir and os.path.isdir(self.work_dir):
|
||||
@@ -117,8 +167,15 @@ class UpdateWorker(QObject):
|
||||
except Exception as exc:
|
||||
_write_log(self.log_path, f"Update failed: {exc}")
|
||||
try:
|
||||
self.status.emit(4, "Восстановление из резервной копии...")
|
||||
_mirror_tree(backup_dir, self.app_dir, skip_names=skip_names)
|
||||
self.status.emit(6, "Восстановление из резервной копии...")
|
||||
if os.path.isdir(backup_dir):
|
||||
_mirror_tree(backup_dir, self.app_dir, skip_names=skip_names, retries=20, delay=0.5)
|
||||
_write_log(self.log_path, "Rollback completed.")
|
||||
try:
|
||||
self._start_app()
|
||||
_write_log(self.log_path, "Restored app started after rollback.")
|
||||
except Exception as start_exc:
|
||||
_write_log(self.log_path, f"Failed to start app after rollback: {start_exc}")
|
||||
except Exception as rollback_exc:
|
||||
_write_log(self.log_path, f"Rollback failed: {rollback_exc}")
|
||||
self.failed.emit(str(exc))
|
||||
@@ -134,7 +191,7 @@ class UpdaterWindow(QWidget):
|
||||
self.label = QLabel("Подготовка обновления...")
|
||||
self.label.setWordWrap(True)
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 5)
|
||||
self.progress.setRange(0, 7)
|
||||
self.progress.setValue(0)
|
||||
|
||||
self.open_log_btn = QPushButton("Открыть лог")
|
||||
@@ -166,11 +223,12 @@ class UpdaterWindow(QWidget):
|
||||
|
||||
def on_status(self, step, text):
|
||||
self.label.setText(text)
|
||||
self.progress.setValue(max(0, min(5, int(step))))
|
||||
self.progress.setValue(max(0, min(7, int(step))))
|
||||
|
||||
def on_done(self):
|
||||
self.label.setText("Обновление успешно применено. Запускаю приложение...")
|
||||
self.progress.setValue(5)
|
||||
self.label.setText("Обновление успешно применено. Приложение запущено.")
|
||||
self.progress.setValue(7)
|
||||
self.open_log_btn.setEnabled(True)
|
||||
QTimer.singleShot(900, self.close)
|
||||
|
||||
def on_failed(self, error_text):
|
||||
|
||||
Reference in New Issue
Block a user