225 lines
7.3 KiB
Python
225 lines
7.3 KiB
Python
"""
|
||
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)
|