zapret-kvn/zapret_update/swap.py
loop-uh ced48cb34c
Some checks failed
Windows project source guards / test (push) Failing after 25s
Установщик обновления — на общем ядре zapret_update, том же, что у Zapret 2 GUI
Замена файлов, порядок шагов, проверка запуска и откат теперь в общем
ядре (байт-в-байт копия). Своё у Zapret KVN — только прослойка runner:
план прежнего вида (его пишет ещё прежняя версия), журнал update.log,
хвост startup.log упавшего запуска и сообщение об ошибке. Журнал замены
теперь пишется на диск: замену, оборванную выключением, можно откатить.
Замок окна обновления — общая переменная ZAPRET_UPDATE_HOLD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:54:26 +03:00

348 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Замена файлов программы: подготовка, замена, откат — с журналом на диске.
Основа — замена из Zapret KVN, проверенная на живых обновлениях. Каталоги
целиком не переименовываются никогда: Windows не даёт переименовать каталог,
пока внутри открыт хоть один файл, и одного антивируса хватало, чтобы сорвать
всё обновление. Отдельный файл переименовывается почти всегда, поэтому
замена идёт пофайлово и только для изменившегося:
1. ``prepare`` сравнивает пробное дерево новой версии с программой и копирует
изменённые файлы в ``<рабочая папка>/new`` — на тот же диск. Программа при
этом не затрагивается.
2. ``commit`` выносит заменяемые и лишние файлы в ``<рабочая папка>/old`` и
ставит на их место подготовленные. Это одни переименования; каждое сразу
пишется в журнал ``journal.json``.
3. ``rollback`` проходит журнал в обратном порядке. Журнал на диске: если
питание пропало посреди замены, ``recover`` вернёт всё при следующем
запуске.
Чужого замена не трогает: убираются только файлы прежней версии, которые
поставила сама программа (``remove``), а папки из ``preserve`` (настройки и
данные человека) не читаются и не меняются.
"""
from __future__ import annotations
import errno
import hashlib
import json
import os
import shutil
import time
from collections.abc import Callable, Iterable
from pathlib import Path
JOURNAL_NAME = "journal.json"
_CHUNK = 1024 * 1024
_FREE_SPACE_MARGIN = 32 * 1024 * 1024
_RETRY_PAUSE_S = 0.25
class InstallError(Exception):
"""Установка невозможна; ``path`` — файл, на котором она остановилась."""
def __init__(self, message: str, *, path: Path | None = None) -> None:
super().__init__(message)
self.path = path
def _scan(root: Path, preserve: frozenset[str]) -> dict[str, Path]:
"""Файлы дерева по относительному пути (регистр — как принято в ОС)."""
files: dict[str, Path] = {}
for current, dir_names, file_names in os.walk(root):
if Path(current) == root:
dir_names[:] = [name for name in dir_names if name.casefold() not in preserve]
for name in file_names:
path = Path(current) / name
files[os.path.normcase(str(path.relative_to(root)))] = path
return files
def _sha256(path: Path) -> bytes:
digest = hashlib.sha256()
with open(path, "rb") as handle:
while chunk := handle.read(_CHUNK):
digest.update(chunk)
return digest.digest()
def _same_content(left: Path, right: Path) -> bool:
try:
if left.stat().st_size != right.stat().st_size:
return False
if os.path.samefile(left, right):
return True # жёсткая ссылка пробного дерева на этот же файл
return _sha256(left) == _sha256(right)
except OSError:
# Нечитаемый файл программы считается изменённым: его заменят.
return False
class FileSwap:
def __init__(
self,
source_dir: Path,
app_dir: Path,
work_dir: Path,
*,
preserve: Iterable[str] = (),
remove: Iterable[str] | None = None,
lock_wait_s: float = 30.0,
on_locked: Callable[[Path], None] | None = None,
) -> None:
"""``remove`` — пути прежней версии, которых нет в новой (``None`` —
считать лишним всё в папке программы, чего нет в новой, кроме
``preserve``)."""
self._source_dir = Path(source_dir)
self._app_dir = Path(app_dir)
self._work_dir = Path(work_dir)
self._new_dir = self._work_dir / "new"
self._old_dir = self._work_dir / "old"
self._preserve = frozenset(name.casefold() for name in preserve)
self._remove = None if remove is None else [str(item) for item in remove]
self._lock_wait_s = lock_wait_s
self._on_locked = on_locked
self._wait_left = 0.0
self._outgoing: list[tuple[Path, bool]] = []
self._incoming: list[Path] = []
self._journal: list[tuple[Path, Path]] = []
self._created_dirs: list[Path] = []
self.unchanged = 0
self.replaced = 0
self.added = 0
self.removed = 0
self.left_behind: list[Path] = []
@property
def work_dir(self) -> Path:
return self._work_dir
# ── подготовка ──────────────────────────────────────────────
def prepare(self, progress: Callable[[float], None] | None = None) -> None:
report = progress or (lambda fraction: None)
# Папки данных человека новая версия не несёт, а если несёт (Zapret KVN
# кладёт в архив data/ с шаблонами), то не трогает: шаблоны программа
# переносит сама при запуске.
source = _scan(self._source_dir, self._preserve)
if not source:
raise InstallError("В новой версии нет файлов")
skipped = set(self._preserve)
try:
# Рабочая папка обновления внутри программы — не часть программы.
skipped.add(self._work_dir.resolve().relative_to(self._app_dir.resolve()).parts[0].casefold())
except (ValueError, IndexError):
pass
installed = _scan(self._app_dir, frozenset(skipped))
total = sum(path.stat().st_size for path in source.values()) or 1
done = 0
to_stage: list[tuple[Path, Path]] = []
replaced_keys: set[str] = set()
for key, new_file in source.items():
current = installed.get(key)
if current is not None and _same_content(new_file, current):
self.unchanged += 1
else:
to_stage.append((new_file, new_file.relative_to(self._source_dir)))
if current is not None:
replaced_keys.add(key)
done += new_file.stat().st_size
report(0.5 * done / total)
if self._remove is None:
extra = [key for key in installed if key not in source]
else:
extra = [os.path.normcase(str(Path(*item.split("/")))) for item in self._remove]
extra = [key for key in extra if key in installed and key not in source]
for key in replaced_keys:
self._outgoing.append((installed[key], True))
for key in extra:
self._outgoing.append((installed[key], False))
self.replaced = len(replaced_keys)
self.added = len(to_stage) - self.replaced
self.removed = len(extra)
stage_bytes = sum(path.stat().st_size for path, _ in to_stage)
self._require_free_space(stage_bytes)
shutil.rmtree(self._new_dir, ignore_errors=True)
shutil.rmtree(self._old_dir, ignore_errors=True)
self._new_dir.mkdir(parents=True, exist_ok=True)
self._old_dir.mkdir(parents=True, exist_ok=True)
done = 0
for new_file, relative in to_stage:
staged = self._new_dir / relative
staged.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(new_file, staged)
self._incoming.append(relative)
done += new_file.stat().st_size
report(0.5 + 0.5 * done / (stage_bytes or 1))
report(1.0)
def _require_free_space(self, needed: int) -> None:
try:
free = shutil.disk_usage(self._app_dir).free
except OSError:
return
if free < needed + _FREE_SPACE_MARGIN:
raise InstallError(
"Недостаточно места на диске: нужно "
f"{(needed + _FREE_SPACE_MARGIN) // (1024 * 1024)} МБ, "
f"свободно {free // (1024 * 1024)} МБ"
)
# ── замена ──────────────────────────────────────────────────
def commit(self) -> None:
self._wait_left = self._lock_wait_s
self._save_journal()
for current, required in self._outgoing:
relative = current.relative_to(self._app_dir)
try:
self._move(current, self._old_dir / relative)
except FileNotFoundError:
continue
except OSError as error:
if required:
raise InstallError(f"Файл занят другой программой: {relative}", path=current) from error
self.left_behind.append(current)
continue
self._record(self._old_dir / relative, current)
for relative in self._incoming:
target = self._app_dir / relative
try:
self._make_room(target)
self._move(self._new_dir / relative, target)
except OSError as error:
raise InstallError(f"Не удалось записать файл: {relative} ({error})", path=target) from error
self._record(target, self._new_dir / relative)
self._prune_emptied_dirs()
def _record(self, current: Path, origin: Path) -> None:
self._journal.append((current, origin))
self._save_journal()
def _save_journal(self) -> None:
payload = {
"app_dir": str(self._app_dir),
"moves": [[str(current), str(origin)] for current, origin in self._journal],
"created_dirs": [str(path) for path in self._created_dirs],
}
self._work_dir.mkdir(parents=True, exist_ok=True)
target = self._work_dir / JOURNAL_NAME
temporary = target.with_name(target.name + ".tmp")
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
def _make_room(self, target: Path) -> None:
missing = [parent for parent in target.parents if not parent.exists()]
target.parent.mkdir(parents=True, exist_ok=True)
self._created_dirs.extend(reversed(missing))
self._remove_empty_tree(target)
@staticmethod
def _remove_empty_tree(target: Path) -> None:
"""Убрать каталог, занявший место файла; файлы из него уже вынесены."""
if not target.is_dir():
return
for current, dir_names, _ in os.walk(target, topdown=False):
for name in dir_names:
os.rmdir(Path(current) / name)
os.rmdir(target)
def _move(self, source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
announced = False
while True:
try:
os.replace(source, destination)
return
except FileNotFoundError:
raise
except OSError as error:
if error.errno == errno.EXDEV:
shutil.copy2(source, destination)
os.unlink(source)
return
if not announced and self._on_locked is not None:
announced = True
self._on_locked(source)
if self._wait_left <= 0:
raise
time.sleep(_RETRY_PAUSE_S)
self._wait_left -= _RETRY_PAUSE_S
def _prune_emptied_dirs(self) -> None:
parents = {
parent
for current, _ in self._outgoing
for parent in current.parents
if parent != self._app_dir and self._app_dir in parent.parents
}
for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True):
try:
os.rmdir(parent)
except OSError:
pass
# ── откат и уборка ──────────────────────────────────────────
def rollback(self) -> list[str]:
"""Вернуть программу к исходному виду; вернуть список сбоев."""
self._wait_left = self._lock_wait_s
errors = _undo(self._journal, self._created_dirs, self._move, self._remove_empty_tree)
self._journal.clear()
self._created_dirs.clear()
self._save_journal()
return errors
def discard(self) -> None:
"""Удалить подготовленные и вынесенные файлы этой попытки вместе с журналом."""
shutil.rmtree(self._work_dir, ignore_errors=True)
def _undo(journal, created_dirs, move, remove_empty_tree) -> list[str]:
errors: list[str] = []
for current, origin in reversed(list(journal)):
try:
remove_empty_tree(Path(origin))
move(Path(current), Path(origin))
except OSError as error:
errors.append(f"{origin}: {error}")
for created in reversed(list(created_dirs)):
try:
os.rmdir(created)
except OSError:
pass
return errors
def recover(work_dir: Path) -> list[str] | None:
"""Откатить замену, оборванную на полпути (выключение, сбой).
``None`` — оборванной замены нет. Иначе — список сбоев отката (пустой —
всё возвращено); журнал после этого убирается.
"""
journal_path = Path(work_dir) / JOURNAL_NAME
try:
payload = json.loads(journal_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
moves = [(Path(a), Path(b)) for a, b in payload.get("moves") or ()]
if not moves:
journal_path.unlink(missing_ok=True)
return None
swap = FileSwap(Path(work_dir), Path(payload.get("app_dir") or work_dir), Path(work_dir))
swap._wait_left = 30.0
errors = _undo(moves, [Path(item) for item in payload.get("created_dirs") or ()], swap._move, swap._remove_empty_tree)
if not errors:
journal_path.unlink(missing_ok=True)
return errors
__all__ = ["FileSwap", "InstallError", "JOURNAL_NAME", "recover"]