fix: stabilize OTA updates in v2.0.2

- Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии.
- Обеспечены откат при ошибке и корректное завершение фоновых задач.
- Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1.
This commit is contained in:
Данил Ярош 2026-09-17 10:30:18 +03:00
parent 3a4491f0c4
commit 35c9693740
8 changed files with 177 additions and 44 deletions

View File

@ -61,8 +61,10 @@
Приложение скачивает ZIP-пакет текущей платформы, проверяет его целостность и
безопасность, завершает старый процесс, заменяет бинарник и все каталоги из
пакета, а затем запускается заново. Перед заменой создаётся резервная копия для
автоматического восстановления при ошибке. Файл `settings.json` и другие файлы,
пакета, а затем запускается заново. Перед заменой создаётся резервная копия;
она удаляется только после подтверждения запуска новой версии. Если запуск
не удался, предыдущая версия восстанавливается, а сообщение записывается
в `update-error.log`. Файл `settings.json` и другие файлы,
которых нет в архиве, сохраняются. Для работы проверки релизы репозитория должны
быть доступны без авторизации.
@ -71,6 +73,11 @@
- Windows x86_64: `plate_config_app-windows-x86_64.zip`;
- Linux x86_64: `plate_config_app-linux-x86_64.zip`.
Для перехода с уже установленной v2.0.1 на v2.0.2 обновите приложение вручную:
закройте его, скачайте ZIP-пакет своей платформы и распакуйте содержимое в
каталог приложения с заменой прежних файлов. `settings.json` удалять не нужно.
Начиная с v2.0.2 последующие обновления можно устанавливать кнопкой в приложении.
---
## Интерфейс приложения

View File

@ -1,3 +1,3 @@
"""Plate Config App package."""
__version__ = "2.0.1"
__version__ = "2.0.2"

View File

@ -2,11 +2,13 @@ from __future__ import annotations
import sys
from PySide6.QtCore import QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication
from .main_window import MainWindow
from .services import settings_service
from .services.update_service import signal_update_ready
from .ui.icons import ICON_COLOR_PRIMARY, icon
from .ui.theme import build_stylesheet, get_palette, create_qpalette
@ -27,4 +29,5 @@ def main() -> None:
if not app_icon.isNull():
window.setWindowIcon(app_icon)
window.show()
QTimer.singleShot(0, signal_update_ready)
sys.exit(app.exec())

View File

@ -31,6 +31,7 @@ DOWNLOAD_CHUNK_SIZE = 1024 * 1024
MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024
MAX_UNPACKED_BYTES = 600 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 10_000
UPDATE_READY_ENV = "PLATE_CONFIG_UPDATE_READY_FILE"
VERSION_PATTERN = re.compile(
r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
)
@ -353,18 +354,29 @@ def current_executable() -> Path:
return Path(sys.executable).resolve()
def signal_update_ready() -> None:
ready_file = os.environ.pop(UPDATE_READY_ENV, None)
if ready_file:
try:
Path(ready_file).write_text(str(os.getpid()), encoding="ascii")
except OSError:
pass
def _write_windows_updater(script_path: Path) -> None:
script = r'''param(
[int]$ApplicationProcessId,
[string]$Staging,
[string]$Target,
[string]$Backup
[string]$Backup,
[string]$ReadyFile
)
$ErrorActionPreference = "Stop"
$applicationDirectory = Split-Path -Parent $Target
$errorLog = Join-Path $applicationDirectory "update-error.log"
$installed = [System.Collections.Generic.List[string]]::new()
$backedUp = [System.Collections.Generic.List[string]]::new()
$restarted = $null
try {
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
@ -373,35 +385,81 @@ try {
foreach ($entry in $entries) {
$destination = Join-Path $applicationDirectory $entry.Name
if (Test-Path -LiteralPath $destination) {
Move-Item -LiteralPath $destination -Destination $Backup -Force
$deadline = (Get-Date).AddSeconds(30)
while ($true) {
try {
Move-Item -LiteralPath $destination -Destination $Backup -Force -ErrorAction Stop
break
} catch {
if ((Get-Date) -ge $deadline) { throw }
Start-Sleep -Milliseconds 250
}
}
$backedUp.Add($entry.Name)
}
Move-Item -LiteralPath $entry.FullName -Destination $destination -Force
Move-Item -LiteralPath $entry.FullName -Destination $destination -Force -ErrorAction Stop
$installed.Add($entry.Name)
}
if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) {
throw "В пакете обновления отсутствует исполняемый файл"
}
Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
$previousReadyFile = $env:PLATE_CONFIG_UPDATE_READY_FILE
$previousResetEnvironment = $env:PYINSTALLER_RESET_ENVIRONMENT
try {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $ReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
$restarted = Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory -PassThru
} finally {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $previousReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = $previousResetEnvironment
}
$deadline = (Get-Date).AddSeconds(60)
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
if ($restarted.HasExited) { throw "Новая версия завершилась до запуска интерфейса" }
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
Start-Sleep -Milliseconds 250
}
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
} catch {
$failure = $_
if ($restarted -and -not $restarted.HasExited) {
& taskkill.exe /PID $restarted.Id /T /F *> $null
Start-Sleep -Milliseconds 500
}
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
foreach ($name in $installed) {
$destination = Join-Path $applicationDirectory $name
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $destination) {
try {
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
}
}
foreach ($name in $backedUp) {
$backupItem = Join-Path $Backup $name
$destination = Join-Path $applicationDirectory $name
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $backupItem) {
Move-Item -LiteralPath $backupItem -Destination $destination -Force
try {
Move-Item -LiteralPath $backupItem -Destination $destination -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
}
}
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
$_ | Out-File -LiteralPath $errorLog -Encoding utf8
if ($rollbackErrors.Count -eq 0) {
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
}
@($failure.ToString()) + @($rollbackErrors) | Out-File -LiteralPath $errorLog -Encoding utf8
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
exit 1
}
'''
@ -415,28 +473,40 @@ application_pid="$1"
staging_path="$2"
target_path="$3"
backup_path="$4"
ready_path="$5"
application_dir="$(dirname -- "$target_path")"
error_log="$application_dir/update-error.log"
installed_file="$backup_path/.installed"
backed_up_file="$backup_path/.backed_up"
new_pid=""
rollback() {
exit_code="$?"
trap - EXIT HUP INT TERM
set +e
if [ -n "$new_pid" ]; then
kill "$new_pid" 2>/dev/null
wait "$new_pid" 2>/dev/null
fi
rollback_failed=0
if [ -f "$installed_file" ]; then
while IFS= read -r name; do
rm -rf -- "$application_dir/$name"
rm -rf -- "$application_dir/$name" || rollback_failed=1
done < "$installed_file"
fi
if [ -f "$backed_up_file" ]; then
while IFS= read -r name; do
rm -rf -- "$application_dir/$name"
if [ -e "$backup_path/$name" ]; then
mv -f -- "$backup_path/$name" "$application_dir/$name"
mv -f -- "$backup_path/$name" "$application_dir/$name" || rollback_failed=1
fi
done < "$backed_up_file"
fi
rm -rf -- "$staging_path" "$backup_path"
rm -f -- "$ready_path"
rm -rf -- "$staging_path"
if [ "$rollback_failed" -eq 0 ]; then
rm -rf -- "$backup_path"
fi
printf '%s\n' "Не удалось заменить приложение" >"$error_log"
rm -f -- "$0"
if [ "$exit_code" -eq 0 ]; then
exit_code=1
fi
@ -462,8 +532,25 @@ for source_path in "$staging_path"/*; do
done
test -f "$target_path"
chmod 755 -- "$target_path"
"$target_path" >/dev/null 2>&1 &
rm -f -- "$ready_path"
PLATE_CONFIG_UPDATE_READY_FILE="$ready_path" PYINSTALLER_RESET_ENVIRONMENT=1 "$target_path" >/dev/null 2>&1 &
new_pid=$!
attempts=0
while [ ! -f "$ready_path" ]; do
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
attempts=$((attempts + 1))
if [ "$attempts" -ge 240 ]; then
exit 1
fi
sleep 0.25
done
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
trap - EXIT HUP INT TERM
rm -f -- "$ready_path"
rm -rf -- "$backup_path" "$staging_path"
rm -f -- "$0"
'''
@ -494,6 +581,7 @@ def launch_update(
staging_path = target.parent / f".plate_config_update-{updater_id}"
backup_path = target.parent / f".plate_config_backup-{updater_id}"
ready_path = target.parent / f".plate_config_ready-{updater_id}"
script_path: Path | None = None
try:
_extract_update_archive(archive_path, staging_path, target.name)
@ -522,6 +610,7 @@ def launch_update(
str(staging_path),
str(target),
str(backup_path),
str(ready_path),
],
close_fds=True,
creationflags=creation_flags,
@ -543,6 +632,7 @@ def launch_update(
str(staging_path),
str(target),
str(backup_path),
str(ready_path),
],
cwd=str(target.parent),
close_fds=True,

View File

@ -5,7 +5,6 @@ from pathlib import Path
from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QApplication,
QFrame,
QGraphicsDropShadowEffect,
QHBoxLayout,
@ -230,9 +229,7 @@ class StartTab(QWidget):
path.unlink(missing_ok=True)
self._on_update_failed(str(exc))
return
app = QApplication.instance()
if app is not None:
app.quit()
self.window().close()
def _on_update_failed(self, detail: str) -> None:
self._update_btn.setText("Скачать обновление")

View File

@ -54,10 +54,28 @@ if ($InstallBuildDeps) {
# ── Сборка ─────────────────────────────────────────────────────────
Write-Host ""
Write-Host "=== Сборка EXE ==="
& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
$pythonHome = (& $venvPython -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $pythonHome -PathType Container)) {
throw "Не удалось определить каталог Python для сборки."
}
$buildPath = @(
(Split-Path -Parent $venvPython),
$pythonHome,
(Join-Path $pythonHome "DLLs"),
(Join-Path $env:SystemRoot "System32"),
$env:SystemRoot
) -join ";"
$originalPath = $env:PATH
try {
$env:PATH = $buildPath
& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
$buildExitCode = $LASTEXITCODE
} finally {
$env:PATH = $originalPath
}
if ($LASTEXITCODE -ne 0) {
throw "PyInstaller завершился с ошибкой (код $LASTEXITCODE)."
if ($buildExitCode -ne 0) {
throw "PyInstaller завершился с ошибкой (код $buildExitCode)."
}
$builtExe = Join-Path $projectRoot "dist\plate_config_app.exe"

View File

@ -4,7 +4,7 @@ import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@ -98,20 +98,18 @@ class StartTabUpdateTests(unittest.TestCase):
warning.assert_called_once()
tab.shutdown()
def test_successful_download_launches_update_and_quits(self) -> None:
def test_successful_download_launches_update_and_closes_window(self) -> None:
tab = StartTab(lambda: None)
downloaded = Path("downloaded-update")
application = Mock()
with (
patch("app.widgets.start_tab.launch_update") as launcher,
patch("app.widgets.start_tab.QApplication") as application_class,
patch.object(tab, "close") as close_window,
):
application_class.instance.return_value = application
tab._on_update_downloaded(downloaded)
launcher.assert_called_once_with(downloaded)
application.quit.assert_called_once_with()
close_window.assert_called_once_with()
self.assertEqual("Перезапуск…", tab._update_btn.text())
tab.shutdown()

View File

@ -29,6 +29,7 @@ from app.services.update_service import (
parse_version,
platform_asset_name,
platform_executable_name,
signal_update_ready,
)
@ -199,6 +200,17 @@ class UpdateServiceTests(unittest.TestCase):
with self.assertRaisesRegex(UpdateError, "Неизвестное"):
archive_executable_name("update.zip")
def test_signals_restart_only_when_ready_file_is_requested(self) -> None:
with tempfile.TemporaryDirectory() as directory:
marker = Path(directory) / "ready"
with patch.dict(os.environ, {update_service.UPDATE_READY_ENV: str(marker)}):
signal_update_ready()
self.assertEqual(str(os.getpid()), marker.read_text(encoding="ascii"))
self.assertNotIn(update_service.UPDATE_READY_ENV, os.environ)
marker.unlink()
signal_update_ready()
self.assertFalse(marker.exists())
def test_downloads_and_validates_windows_zip(self) -> None:
info = self._update_info()
data = make_update_archive(
@ -412,6 +424,10 @@ class UpdateServiceTests(unittest.TestCase):
script = scripts[0].read_text(encoding="utf-8-sig")
self.assertIn("$Staging", script)
self.assertIn("$backedUp", script)
self.assertIn("AddSeconds(30)", script)
self.assertIn("$restarted.HasExited", script)
self.assertIn("$ReadyFile", script)
self.assertIn('$env:PYINSTALLER_RESET_ENVIRONMENT = "1"', script)
popen.assert_called_once()
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
@ -445,6 +461,8 @@ class UpdateServiceTests(unittest.TestCase):
script = scripts[0].read_text(encoding="utf-8")
self.assertIn("staging_path", script)
self.assertIn("rollback", script)
self.assertIn("PLATE_CONFIG_UPDATE_READY_FILE", script)
self.assertIn("PYINSTALLER_RESET_ENVIRONMENT=1", script)
popen.assert_called_once()
def test_extracts_external_archive_next_to_executable(self) -> None:
@ -561,7 +579,7 @@ class ReplacementIntegrationTests(unittest.TestCase):
return hashlib.sha256(path.read_bytes()).hexdigest()
@staticmethod
def _wait_for_replacement(
def _wait_for_rollback(
target: Path,
expected_digest: str,
replaced_resource: Path,
@ -571,17 +589,19 @@ class ReplacementIntegrationTests(unittest.TestCase):
if (
target.is_file()
and ReplacementIntegrationTests._digest(target) == expected_digest
and replaced_resource.read_text(encoding="utf-8") == "new"
and replaced_resource.is_file()
and replaced_resource.read_text(encoding="utf-8") == "old"
and not list(target.parent.glob(".plate_config_updater-*"))
and not list(target.parent.glob(".plate_config_update-*"))
and not list(target.parent.glob(".plate_config_backup-*"))
and (target.parent / "update-error.log").is_file()
):
return
time.sleep(0.1)
raise AssertionError("Внешний helper не заменил пакет за 20 секунд")
raise AssertionError("Внешний helper не откатил пакет за 20 секунд")
@unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows")
def test_windows_helper_replaces_full_package(self) -> None:
def test_windows_helper_rolls_back_when_new_app_exits_early(self) -> None:
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@ -597,20 +617,20 @@ class ReplacementIntegrationTests(unittest.TestCase):
extra_files={"commands/version.txt": b"new"},
)
)
expected_digest = hashlib.sha256(new_executable).hexdigest()
expected_digest = self._digest(target)
processes = self._launch_and_capture(archive, target, "Windows")
self._wait_for_replacement(
self._wait_for_rollback(
target,
expected_digest,
root / "commands/version.txt",
)
processes[0].wait(timeout=5)
self._assert_package_replaced(root)
self._assert_package_preserved(root)
self._wait_for_windows_unlock(target)
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
def test_linux_helper_replaces_full_package(self) -> None:
def test_linux_helper_rolls_back_when_new_app_exits_early(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-linux-x86_64"
@ -625,16 +645,16 @@ class ReplacementIntegrationTests(unittest.TestCase):
extra_files={"commands/version.txt": b"new"},
)
)
expected_digest = hashlib.sha256(new_executable).hexdigest()
expected_digest = self._digest(target)
processes = self._launch_and_capture(archive, target, "Linux")
self._wait_for_replacement(
self._wait_for_rollback(
target,
expected_digest,
root / "commands/version.txt",
)
processes[0].wait(timeout=5)
self._assert_package_replaced(root)
self._assert_package_preserved(root)
self.assertTrue(target.stat().st_mode & stat.S_IXUSR)
@staticmethod
@ -646,12 +666,12 @@ class ReplacementIntegrationTests(unittest.TestCase):
(root / "settings.json").write_text("preserve", encoding="utf-8")
@staticmethod
def _assert_package_replaced(root: Path) -> None:
def _assert_package_preserved(root: Path) -> None:
self_settings = (root / "settings.json").read_text(encoding="utf-8")
if self_settings != "preserve":
raise AssertionError("settings.json не сохранён")
if (root / "commands/obsolete.txt").exists():
raise AssertionError("Старое содержимое каталога не удалено")
if not (root / "commands/obsolete.txt").exists():
raise AssertionError("Старое содержимое каталога не восстановлено")
@staticmethod
def _launch_and_capture(