feat: rewrite application as modular fcco-Converter V2 with new GUI, metadata handling, and updater logic
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# Aktuelle Version des Programms
|
||||
CURRENT_VERSION = "2.0.0"
|
||||
|
||||
# Gitea API-URL für Releases
|
||||
GITEA_API_BASE = "https://git.joelunger.de/api/v1"
|
||||
REPO_OWNER = "public"
|
||||
REPO_NAME = "fcco-Converter"
|
||||
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)
|
||||
Reference in New Issue
Block a user