Initial SyncVito release with installer updater

This commit is contained in:
Davito committed 2026-09-23 19:40:36 +02:00
commit b9e4c09f49
41 files changed
+4331

No files matched your search

+426
View File
@@ -0,0 +1,426 @@
"""Sicherer Gitea-Updater für den installierten SyncVito-Client."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from syncvito.config_store import default_data_directory
__all__ = [
"GiteaUpdateService",
"UpdateError",
"UpdateFormatError",
"UpdateInfo",
"UpdateInstallError",
"UpdateIntegrityError",
"UpdateNetworkError",
"parse_version",
]
GITEA_BASE_URL = "https://gitea.davito.synology.me"
GITEA_OWNER = "Davito"
GITEA_REPOSITORY = "SyncVito"
MAX_RELEASE_BYTES = 2 * 1024 * 1024
MAX_CHECKSUM_BYTES = 1024 * 1024
MAX_INSTALLER_BYTES = 1024 * 1024 * 1024
USER_AGENT = "SyncVito-AutoUpdater"
ProgressCallback = Callable[[int, str], None]
_VERSION_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")
_MANAGED_INSTALLER_PATTERN = re.compile(
r"^SyncVitoSetup-\d+\.\d+\.\d+\.exe(?:\.part)?$",
re.IGNORECASE,
)
class UpdateError(RuntimeError):
"""Basisklasse für erwartete Updatefehler."""
class UpdateNetworkError(UpdateError):
"""Gitea oder ein Release-Artefakt war nicht erreichbar."""
class UpdateFormatError(UpdateError):
"""Release-Metadaten waren unvollständig oder ungültig."""
class UpdateIntegrityError(UpdateError):
"""Die heruntergeladene Datei bestand die Integritätsprüfung nicht."""
class UpdateInstallError(UpdateError):
"""Der externe Installer-Wächter konnte nicht gestartet werden."""
@dataclass(frozen=True, slots=True)
class UpdateInfo:
"""Validierte Metadaten eines installierbaren Gitea-Releases."""
version: str
title: str
notes: str
release_url: str
installer_name: str
download_url: str
checksum_url: str
size: int
def parse_version(value: str) -> tuple[int, int, int]:
"""Konvertiert stabile SemVer-Tags wie ``v1.2.3`` in Vergleichswerte."""
match = _VERSION_PATTERN.fullmatch(str(value or "").strip())
if match is None:
raise ValueError(f"Ungültige stabile Versionsnummer: {value}")
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
def _powershell_literal(value: str | Path) -> str:
return "'" + str(value).replace("'", "''") + "'"
def _start_detached(executable: str, arguments: list[str]) -> None:
"""Startet einen Prozess unabhängig vom Lebenszyklus der Anwendung."""
if os.name == "nt":
os.startfile(
executable,
operation="open",
arguments=subprocess.list2cmdline(arguments),
show_cmd=0,
)
return
subprocess.Popen(
[executable, *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
def _start_installer_after_exit(
installer_path: Path,
arguments: list[str],
wait_pids: Iterable[int],
bootstrap_log_path: Path,
) -> None:
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
if os.name != "nt":
_start_detached(str(installer_path), arguments)
return
system_root = os.environ.get("SYSTEMROOT", r"C:\Windows")
powershell_path = Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
if not powershell_path.is_file():
raise UpdateInstallError(f"Windows PowerShell wurde nicht gefunden: {powershell_path}")
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
pid_list = ", ".join(str(pid) for pid in normalized_pids)
setup_arguments = ", ".join(_powershell_literal(argument) for argument in arguments)
script = f"""
$ErrorActionPreference = 'Stop'
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
try {{
[System.IO.File]::WriteAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
)
foreach ($processId in @({pid_list})) {{
$deadline = [DateTime]::UtcNow.AddMinutes(2)
while (Get-Process -Id $processId -ErrorAction SilentlyContinue) {{
if ([DateTime]::UtcNow -ge $deadline) {{
throw "SyncVito-Prozess $processId wurde nicht innerhalb von zwei Minuten beendet."
}}
Start-Sleep -Milliseconds 200
}}
}}
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
)
$setupArguments = @({setup_arguments})
& {_powershell_literal(installer_path)} @setupArguments
$setupExitCode = $LASTEXITCODE
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
)
exit $setupExitCode
}}
catch {{
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
)
exit 1
}}
"""
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
_start_detached(
str(powershell_path),
[
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-EncodedCommand",
encoded_script,
],
)
class _TrustedRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, trusted_host: str) -> None:
super().__init__()
self.trusted_host = trusted_host
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
_validate_update_url(newurl, self.trusted_host)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _validate_update_url(url: str, trusted_host: str) -> str:
parsed = urllib.parse.urlparse(str(url or ""))
try:
port = parsed.port
except ValueError as exc:
raise UpdateFormatError("Gitea lieferte eine ungültige Downloadadresse.") from exc
if (
parsed.scheme.lower() != "https"
or parsed.hostname != trusted_host
or port not in (None, 443)
or parsed.username is not None
or parsed.password is not None
):
raise UpdateFormatError("Gitea lieferte eine nicht vertrauenswürdige Downloadadresse.")
return parsed.geturl()
class GiteaUpdateService:
"""Kapselt das öffentliche Installer-Updateprotokoll für SyncVito."""
def __init__(
self,
current_version: str,
base_url: str = GITEA_BASE_URL,
owner: str = GITEA_OWNER,
repository: str = GITEA_REPOSITORY,
update_directory: Path | None = None,
) -> None:
self.current_version = current_version
self.base_url = base_url.rstrip("/")
self.owner = owner
self.repository = repository
self.update_directory = update_directory or default_data_directory() / "updates"
parsed_base = urllib.parse.urlparse(self.base_url)
if parsed_base.scheme.lower() != "https" or not parsed_base.hostname:
raise ValueError("Die Updatequelle muss eine gültige HTTPS-Adresse sein.")
self.trusted_host = parsed_base.hostname
parse_version(current_version)
def check(self) -> UpdateInfo | None:
"""Liefert den neuesten stabilen Installer, sofern er neuer ist."""
endpoint = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repository}/releases/latest"
try:
payload = self._read_json(endpoint)
except UpdateNetworkError as exc:
if "HTTP 404" in str(exc):
return None
raise
if not isinstance(payload, dict) or payload.get("draft") or payload.get("prerelease"):
raise UpdateFormatError("Gitea lieferte keinen stabilen Release.")
try:
version_tuple = parse_version(str(payload.get("tag_name", "")))
except ValueError as exc:
raise UpdateFormatError("Der neueste Gitea-Release besitzt keine gültige Version.") from exc
if version_tuple <= parse_version(self.current_version):
return None
version = ".".join(str(part) for part in version_tuple)
return self._parse_release(payload, version)
def download(self, info: UpdateInfo, progress: ProgressCallback | None = None) -> Path:
"""Lädt den Installer und veröffentlicht ihn erst nach SHA-256-Prüfung."""
self.update_directory.mkdir(parents=True, exist_ok=True)
expected_hash = self._read_checksum(info.checksum_url, info.installer_name)
destination = self.update_directory / info.installer_name
temporary = destination.with_suffix(".exe.part")
digest = hashlib.sha256()
received = 0
try:
with self._request(info.download_url, timeout=30) as response, temporary.open("wb") as output:
announced = int(response.headers.get("Content-Length", info.size or 0))
if announced > MAX_INSTALLER_BYTES:
raise UpdateFormatError("Der angebotene Installer ist unerwartet groß.")
while chunk := response.read(1024 * 1024):
received += len(chunk)
if received > MAX_INSTALLER_BYTES:
raise UpdateFormatError("Der geladene Installer ist unerwartet groß.")
output.write(chunk)
digest.update(chunk)
if progress and announced > 0:
progress(min(99, round(received / announced * 100)), "Installer wird heruntergeladen …")
if received <= 0:
raise UpdateFormatError("Der heruntergeladene Installer ist leer.")
if digest.hexdigest().lower() != expected_hash:
raise UpdateIntegrityError(
"Die SHA-256-Prüfung des Installers ist fehlgeschlagen; die Datei wird nicht ausgeführt."
)
os.replace(temporary, destination)
except UpdateError:
temporary.unlink(missing_ok=True)
raise
except (OSError, TimeoutError, urllib.error.URLError, ValueError) as exc:
temporary.unlink(missing_ok=True)
raise UpdateNetworkError(f"Der Installer konnte nicht heruntergeladen werden: {exc}") from exc
if progress:
progress(100, "Installer geprüft und bereit")
self._remove_old_downloads(destination)
return destination
def launch_installer(self, installer_path: Path, version: str) -> None:
"""Startet einen Wächter, der nach dem App-Ende Inno Setup ausführt."""
if os.name != "nt" or not getattr(sys, "frozen", False):
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
installer = installer_path.resolve()
expected_name = f"SyncVitoSetup-{version}.exe"
if installer.parent != self.update_directory.resolve() or installer.name != expected_name or not installer.is_file():
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
setup_log = installer.with_name(f"SyncVitoSetup-{safe_version}.log")
bootstrap_log = installer.with_name(f"SyncVitoUpdater-{safe_version}-bootstrap.log")
current_executable = Path(sys.executable).resolve()
wait_pids = [os.getpid()]
parent_pid = os.getppid()
if parent_pid > 0 and parent_pid not in wait_pids:
wait_pids.append(parent_pid)
arguments = [
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/CLOSEAPPLICATIONS",
f"/DIR={current_executable.parent}",
f"/LOG={setup_log}",
]
try:
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log)
except (OSError, ValueError, UpdateError) as exc:
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
def cleanup_installers(self) -> tuple[int, int]:
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
try:
candidates = tuple(self.update_directory.resolve().iterdir())
except FileNotFoundError:
return 0, 0
except OSError:
return 0, 1
removed = 0
pending = 0
for candidate in candidates:
if not _MANAGED_INSTALLER_PATTERN.fullmatch(candidate.name) or candidate.is_dir():
continue
try:
candidate.unlink()
except OSError:
pending += 1
else:
removed += 1
return removed, pending
def _parse_release(self, release: dict[str, Any], version: str) -> UpdateInfo:
assets = release.get("assets")
if not isinstance(assets, list):
raise UpdateFormatError(f"Release {version} enthält keine Dateianhänge.")
by_name = {str(asset.get("name")): asset for asset in assets if isinstance(asset, dict)}
installer_name = f"SyncVitoSetup-{version}.exe"
checksum_name = f"{installer_name}.sha256"
installer = by_name.get(installer_name)
checksum = by_name.get(checksum_name)
if installer is None or checksum is None:
raise UpdateFormatError(f"Release {version} benötigt {installer_name} und {checksum_name}.")
download_url = _validate_update_url(str(installer.get("browser_download_url", "")), self.trusted_host)
checksum_url = _validate_update_url(str(checksum.get("browser_download_url", "")), self.trusted_host)
return UpdateInfo(
version=version,
title=str(release.get("name") or f"SyncVito {version}"),
notes=str(release.get("body") or "Keine Versionshinweise vorhanden."),
release_url=str(release.get("html_url") or self.base_url),
installer_name=installer_name,
download_url=download_url,
checksum_url=checksum_url,
size=max(0, int(installer.get("size") or 0)),
)
def _request(self, url: str, timeout: int): # type: ignore[no-untyped-def]
request = urllib.request.Request(
_validate_update_url(url, self.trusted_host),
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
)
opener = urllib.request.build_opener(_TrustedRedirectHandler(self.trusted_host))
try:
response = opener.open(request, timeout=timeout)
_validate_update_url(response.geturl(), self.trusted_host)
return response
except urllib.error.HTTPError as exc:
raise UpdateNetworkError(f"Gitea antwortete mit HTTP {exc.code}.") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise UpdateNetworkError(f"Der Gitea-Server ist nicht erreichbar: {exc}") from exc
def _read_json(self, url: str) -> Any:
try:
with self._request(url, timeout=12) as response:
payload = response.read(MAX_RELEASE_BYTES + 1)
if len(payload) > MAX_RELEASE_BYTES:
raise UpdateFormatError("Die Release-Antwort ist unerwartet groß.")
return json.loads(payload)
except UpdateError:
raise
except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exc:
raise UpdateFormatError("Gitea hat ungültige Release-Daten geliefert.") from exc
def _read_checksum(self, url: str, filename: str) -> str:
try:
with self._request(url, timeout=12) as response:
payload = response.read(MAX_CHECKSUM_BYTES + 1)
if len(payload) > MAX_CHECKSUM_BYTES:
raise UpdateFormatError("Die Prüfsummendatei ist unerwartet groß.")
content = payload.decode("utf-8")
except UpdateError:
raise
except UnicodeDecodeError as exc:
raise UpdateFormatError("Die Update-Prüfsumme ist ungültig.") from exc
fallback = ""
for raw_line in content.splitlines():
parts = raw_line.strip().replace(" ", " ").split()
if not parts or not _SHA256_PATTERN.fullmatch(parts[0]):
continue
digest = parts[0].lower()
if len(parts) == 1:
fallback = digest
elif " ".join(parts[1:]).lstrip("*").strip().casefold() == filename.casefold():
return digest
if fallback:
return fallback
raise UpdateFormatError(f"Keine SHA-256-Prüfsumme für {filename} gefunden.")
def _remove_old_downloads(self, keep: Path) -> None:
for candidate in self.update_directory.glob("SyncVitoSetup-*.exe"):
if candidate != keep:
try:
candidate.unlink()
except OSError:
continue