Files
fcco-Converter/fcco_converter/updater.py
T
2026-07-20 18:03:38 +02:00

235 lines
7.5 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.
"""
fcco-Converter V2 Update-Modul
Prüft auf neue Releases auf dem Gitea-Server und lädt diese herunter.
Unterstützt Self-Update für Standalone-Builds (PyInstaller).
"""
import os
import sys
import platform
import tempfile
import zipfile
import shutil
import subprocess
from pathlib import Path
from typing import Callable
from urllib.request import urlopen, Request
from urllib.error import URLError
import json
from .config import APP_INFO
# Aktuelle Version des Programms und Repos (aus config.json)
CURRENT_VERSION = APP_INFO["app_version"]
GITEA_API_BASE = APP_INFO["gitea_api_base"]
REPO_OWNER = APP_INFO["repo_owner"]
REPO_NAME = APP_INFO["repo_name"]
RELEASES_URL = f"{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases"
def get_current_version() -> str:
"""Gibt die aktuelle Programmversion zurück."""
return CURRENT_VERSION
def _fetch_json(url: str) -> list | dict:
"""Holt JSON-Daten von einer URL."""
req = Request(url, headers={"Accept": "application/json"})
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def _parse_version(v: str) -> list[int]:
parts = []
for p in v.lstrip("vV").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return parts
def check_for_updates() -> dict | None:
"""Prüft, ob auf Gitea ein neues Release (nach SemVer) vorliegt.
Returns:
Das Release-Dict (von Gitea) wenn ein Update verfügbar ist, sonst None.
"""
try:
req = Request(RELEASES_URL, headers={'User-Agent': 'fcco-Updater/1.0'})
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if not data:
return None
# Gitea gibt Releases sortiert zurück, 0 = neuestes
release_info = data[0]
tag_name = release_info.get("tag_name", "")
# Entferne eventuelles "v" Präfix
latest_version = tag_name.lstrip("vV")
# Intelligenter Versions-Vergleich
if _parse_version(latest_version) > _parse_version(CURRENT_VERSION):
return release_info
return None
except Exception as e:
print(f"Update Check Error: {e}")
return None
def _get_platform_asset_name() -> str:
"""Ermittelt den erwarteten Asset-Dateinamen für das aktuelle Betriebssystem."""
system = platform.system().lower()
if system == "windows":
return "fcco-Converter-windows.zip"
elif system == "darwin":
return "fcco-Converter-macos.zip"
else:
return "fcco-Converter-linux.zip"
def _find_download_url(release: dict) -> str | None:
"""Findet die Download-URL für das passende Plattform-Asset."""
expected_name = _get_platform_asset_name()
for asset in release.get("assets", []):
if asset.get("name", "").lower() == expected_name.lower():
return asset.get("browser_download_url")
return None
def download_and_install_update(
release: dict,
progress_callback: Callable[[int, str], None] | None = None,
) -> bool:
"""Lädt das neueste Release herunter und installiert es.
Für Standalone-Builds (PyInstaller): Ersetzt die aktuelle Executable.
Für Skript-Modus: Entpackt in das Projektverzeichnis.
Args:
release: Release-Dict von der Gitea-API.
progress_callback: Callback(percentage, status_message).
Returns:
True bei Erfolg, False bei Fehler.
"""
download_url = _find_download_url(release)
if not download_url:
if progress_callback:
progress_callback(0, "Kein passendes Asset für dieses Betriebssystem gefunden.")
return False
try:
if progress_callback:
progress_callback(10, "Download wird gestartet...")
# Download in temporäres Verzeichnis
tmp_dir = Path(tempfile.mkdtemp(prefix="fcco_update_"))
zip_path = tmp_dir / "update.zip"
req = Request(download_url)
with urlopen(req, timeout=120) as resp:
total_size = int(resp.headers.get("Content-Length", 0))
downloaded = 0
chunk_size = 8192
with open(zip_path, "wb") as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total_size > 0 and progress_callback:
pct = min(int((downloaded / total_size) * 70) + 10, 80)
progress_callback(pct, f"Heruntergeladen: {downloaded // 1024} KB")
if progress_callback:
progress_callback(85, "Entpacken...")
# Entpacken
extract_dir = tmp_dir / "extracted"
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(extract_dir)
if progress_callback:
progress_callback(90, "Installation...")
# Bestimme, wo das Programm läuft
if getattr(sys, "frozen", False):
# PyInstaller-Bundle: Ersetze die aktuelle Executable
current_exe = Path(sys.executable)
_install_frozen(extract_dir, current_exe)
else:
# Skript-Modus: Ersetze die Projektdateien
project_dir = Path(__file__).parent.parent
_install_script(extract_dir, project_dir)
if progress_callback:
progress_callback(100, "Update abgeschlossen! Bitte Programm neu starten.")
return True
except Exception as e:
if progress_callback:
progress_callback(0, f"Fehler: {e}")
return False
finally:
# Aufräumen (best-effort)
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
def _install_frozen(extract_dir: Path, current_exe: Path) -> None:
"""Installiert ein Update für eine PyInstaller-gepackte Anwendung."""
# Suche die neue Executable im entpackten Verzeichnis
system = platform.system().lower()
if system == "windows":
new_exe_name = "fcco-Converter.exe"
elif system == "darwin":
new_exe_name = "fcco-Converter"
else:
new_exe_name = "fcco-Converter"
new_exe = None
for item in extract_dir.rglob(new_exe_name):
new_exe = item
break
if not new_exe:
raise FileNotFoundError(f"Neue Programmdatei '{new_exe_name}' nicht im Update gefunden.")
# Auf Windows: Aktuelle Exe umbenennen, dann neue kopieren
backup = current_exe.with_suffix(".old")
if backup.exists():
backup.unlink()
if system == "windows":
current_exe.rename(backup)
shutil.copy2(new_exe, current_exe)
else:
# macOS/Linux: Direkt ersetzen
shutil.copy2(new_exe, current_exe)
os.chmod(current_exe, 0o755)
def _install_script(extract_dir: Path, project_dir: Path) -> None:
"""Installiert ein Update im Skript-Modus (Quelldateien ersetzen)."""
# Alle .py-Dateien aus dem entpackten Verzeichnis kopieren
for item in extract_dir.rglob("*"):
if item.is_file():
rel = item.relative_to(extract_dir)
# Überspringe versteckte Dateien und Verzeichnisse
if any(part.startswith(".") for part in rel.parts):
continue
dest = project_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)