diff --git a/build.py b/build.py index b30ddb6..3ce627e 100644 --- a/build.py +++ b/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) diff --git a/tests/test_updater_gui.py b/tests/test_updater_gui.py new file mode 100644 index 0000000..8ec5e2e --- /dev/null +++ b/tests/test_updater_gui.py @@ -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() diff --git a/updater_gui.py b/updater_gui.py index faa8582..1b2deca 100644 --- a/updater_gui.py +++ b/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):