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():
|
def ensure_project_root():
|
||||||
missing = [name for name in SAFE_CLEAN_ROOT_FILES if not os.path.exists(name)]
|
missing = [name for name in SAFE_CLEAN_ROOT_FILES if not os.path.exists(name)]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -126,6 +138,7 @@ if __name__ == "__main__":
|
|||||||
run_build()
|
run_build()
|
||||||
run_updater_build()
|
run_updater_build()
|
||||||
run_cleanup()
|
run_cleanup()
|
||||||
|
write_version_marker()
|
||||||
create_archive()
|
create_archive()
|
||||||
|
|
||||||
print("\n" + "=" * 30)
|
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:
|
try:
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
with open(log_path, "a", encoding="utf-8") as f:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -36,7 +37,20 @@ def _is_pid_running(pid):
|
|||||||
return False
|
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 [])}
|
skip_set = {name.lower() for name in (skip_names or [])}
|
||||||
os.makedirs(dst_dir, exist_ok=True)
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
for root, dirs, files in os.walk(src_dir):
|
for root, dirs, files in os.walk(src_dir):
|
||||||
@@ -48,8 +62,18 @@ def _mirror_tree(src_dir, dst_dir, skip_names=None):
|
|||||||
continue
|
continue
|
||||||
source_file = os.path.join(root, file_name)
|
source_file = os.path.join(root, file_name)
|
||||||
target_file = os.path.join(target_root, file_name)
|
target_file = os.path.join(target_root, file_name)
|
||||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
_copy_file_with_retries(source_file, target_file, retries=retries, delay=delay)
|
||||||
shutil.copy2(source_file, target_file)
|
|
||||||
|
|
||||||
|
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):
|
class UpdateWorker(QObject):
|
||||||
@@ -67,9 +91,23 @@ class UpdateWorker(QObject):
|
|||||||
self.work_dir = work_dir or ""
|
self.work_dir = work_dir or ""
|
||||||
self.log_path = os.path.join(app_dir, "update_error.log")
|
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):
|
def run(self):
|
||||||
backup_dir = os.path.join(tempfile.gettempdir(), f"anabasis_backup_{int(time.time())}")
|
backup_dir = os.path.join(tempfile.gettempdir(), f"anabasis_backup_{int(time.time())}")
|
||||||
skip_names = {"anabasisupdater.exe"}
|
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:
|
try:
|
||||||
self.status.emit(1, "Ожидание завершения приложения...")
|
self.status.emit(1, "Ожидание завершения приложения...")
|
||||||
wait_loops = 0
|
wait_loops = 0
|
||||||
@@ -90,23 +128,35 @@ class UpdateWorker(QObject):
|
|||||||
raise RuntimeError(f"Процесс {self.target_pid} не завершился.")
|
raise RuntimeError(f"Процесс {self.target_pid} не завершился.")
|
||||||
break
|
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)
|
_mirror_tree(self.app_dir, backup_dir, skip_names=skip_names)
|
||||||
|
|
||||||
self.status.emit(3, "Применение обновления...")
|
self.status.emit(4, "Применение обновления...")
|
||||||
_mirror_tree(self.source_dir, self.app_dir, skip_names=skip_names)
|
_mirror_tree(self.source_dir, self.app_dir, skip_names=skip_names, retries=30, delay=0.6)
|
||||||
|
|
||||||
self.status.emit(4, "Запуск обновленного приложения...")
|
self.status.emit(5, "Проверка установленной версии...")
|
||||||
app_exe = os.path.join(self.app_dir, self.exe_name)
|
installed_version = _read_version_marker(self.app_dir)
|
||||||
creation_flags = 0
|
if expected_version and installed_version and installed_version != expected_version:
|
||||||
if hasattr(subprocess, "DETACHED_PROCESS"):
|
raise RuntimeError(
|
||||||
creation_flags |= subprocess.DETACHED_PROCESS
|
f"После обновления версия {installed_version}, ожидалась {expected_version}."
|
||||||
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
)
|
||||||
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
if expected_version and prev_version and prev_version == expected_version:
|
||||||
subprocess.Popen([app_exe], cwd=self.app_dir, creationflags=creation_flags)
|
_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(6, "Запуск обновленного приложения...")
|
||||||
self.status.emit(5, "Очистка временных файлов...")
|
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:
|
try:
|
||||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||||
if self.work_dir and os.path.isdir(self.work_dir):
|
if self.work_dir and os.path.isdir(self.work_dir):
|
||||||
@@ -117,8 +167,15 @@ class UpdateWorker(QObject):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_write_log(self.log_path, f"Update failed: {exc}")
|
_write_log(self.log_path, f"Update failed: {exc}")
|
||||||
try:
|
try:
|
||||||
self.status.emit(4, "Восстановление из резервной копии...")
|
self.status.emit(6, "Восстановление из резервной копии...")
|
||||||
_mirror_tree(backup_dir, self.app_dir, skip_names=skip_names)
|
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:
|
except Exception as rollback_exc:
|
||||||
_write_log(self.log_path, f"Rollback failed: {rollback_exc}")
|
_write_log(self.log_path, f"Rollback failed: {rollback_exc}")
|
||||||
self.failed.emit(str(exc))
|
self.failed.emit(str(exc))
|
||||||
@@ -134,7 +191,7 @@ class UpdaterWindow(QWidget):
|
|||||||
self.label = QLabel("Подготовка обновления...")
|
self.label = QLabel("Подготовка обновления...")
|
||||||
self.label.setWordWrap(True)
|
self.label.setWordWrap(True)
|
||||||
self.progress = QProgressBar()
|
self.progress = QProgressBar()
|
||||||
self.progress.setRange(0, 5)
|
self.progress.setRange(0, 7)
|
||||||
self.progress.setValue(0)
|
self.progress.setValue(0)
|
||||||
|
|
||||||
self.open_log_btn = QPushButton("Открыть лог")
|
self.open_log_btn = QPushButton("Открыть лог")
|
||||||
@@ -166,11 +223,12 @@ class UpdaterWindow(QWidget):
|
|||||||
|
|
||||||
def on_status(self, step, text):
|
def on_status(self, step, text):
|
||||||
self.label.setText(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):
|
def on_done(self):
|
||||||
self.label.setText("Обновление успешно применено. Запускаю приложение...")
|
self.label.setText("Обновление успешно применено. Приложение запущено.")
|
||||||
self.progress.setValue(5)
|
self.progress.setValue(7)
|
||||||
|
self.open_log_btn.setEnabled(True)
|
||||||
QTimer.singleShot(900, self.close)
|
QTimer.singleShot(900, self.close)
|
||||||
|
|
||||||
def on_failed(self, error_text):
|
def on_failed(self, error_text):
|
||||||
|
|||||||
Reference in New Issue
Block a user