""" 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 check_for_updates() -> dict | None: """Prüft, ob ein neues Release verfügbar ist. Returns: Dict mit Release-Infos (tag_name, name, body, assets) oder None. """ try: releases = _fetch_json(RELEASES_URL) if not releases: return None # Neuestes (nicht als Draft/Prerelease markiertes) Release for release in releases: if release.get("draft") or release.get("prerelease"): continue tag = release.get("tag_name", "").lstrip("v") if tag and tag != CURRENT_VERSION: # Einfacher Versionsvergleich try: remote_parts = [int(x) for x in tag.split(".")] local_parts = [int(x) for x in CURRENT_VERSION.split(".")] if remote_parts > local_parts: return release except ValueError: continue break # Nur das neueste Release prüfen return None except (URLError, json.JSONDecodeError, OSError): 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)