Windows-Patch-Pakete heissen jetzt Playtube-vX.Y.Z-win64-patch.play statt .zip (technisch weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die Magic Bytes, nicht auf die Endung). Playtube registriert .play beim ersten Start als Windows-Dateizuordnung (HKCU, keine Admin-Rechte noetig), sodass eine manuell heruntergeladene Patchdatei per Doppelklick installiert werden kann, ohne dass Playtube selbst etwas herunterladen muss: - shortcuts.py: ensure_play_file_association() registriert ProgID + DefaultIcon + shell/open/command und stoesst SHChangeNotify an, damit die Zuordnung sofort greift. - updater.py: UpdateInstaller akzeptiert jetzt optional local_archive_path statt einer download_url und ueberspringt dann den Download komplett. - main.py: erkennt eine .play-Datei als Kommandozeilenargument (Doppelklick- Start) und uebergibt sie an MainWindow.install_local_patch(). - release.yml: Windows-Patch wird als .zip gepackt und anschliessend zu .play umbenannt; _find_platform_asset() sucht fuer Windows-Patches jetzt nach .play statt .zip. Co-Authored-By: Claude Sonnet 5 <[email protected]>
448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube).
|
||
|
||
Ablauf:
|
||
1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere
|
||
Version als die aktuell laufende (playtube.__version__).
|
||
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
|
||
3. UpdateInstaller installiert das Update:
|
||
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
|
||
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
|
||
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
|
||
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
|
||
Ordner (_internal/) bleibt unangetastet, da er sich zwischen Patch-Releases
|
||
normalerweise nicht aendert. Gibt es kein Patch-Paket (z.B. beim allerersten
|
||
Release oder wenn CI es nicht gebaut hat), faellt es automatisch auf das
|
||
volle Release-Paket zurueck und ersetzt den kompletten Installationsordner.
|
||
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu -
|
||
PowerShell unter Windows, ein Shell-Skript unter Linux.
|
||
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
|
||
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
||
|
||
Windows-Patch-Pakete tragen die eigene Dateiendung ".play" statt ".zip" (technisch
|
||
weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die
|
||
Magic Bytes, nicht auf die Endung). shortcuts.py registriert ".play" beim ersten Start
|
||
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann so
|
||
auch per Doppelklick installiert werden, ohne dass Playtube selbst etwas herunterladen
|
||
muss (siehe main.py: wird eine .play-Datei als Kommandozeilenargument uebergeben,
|
||
installiert MainWindow.install_local_patch() sie direkt ueber UpdateInstaller).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
import tempfile
|
||
import urllib.error
|
||
import urllib.request
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from PySide6.QtCore import QThread, Signal
|
||
|
||
from . import __version__ as CURRENT_VERSION
|
||
|
||
GITHUB_REPO = "fojadrachi/Playtube"
|
||
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||
_USER_AGENT = "Playtube-Updater"
|
||
|
||
_LINUX_BINARY_NAME = "Playtube"
|
||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
||
|
||
|
||
def _parse_version(v: str) -> tuple[int, ...]:
|
||
v = v.strip().lstrip("vV")
|
||
parts: list[int] = []
|
||
for p in v.split("."):
|
||
digits = "".join(ch for ch in p if ch.isdigit())
|
||
parts.append(int(digits) if digits else 0)
|
||
return tuple(parts) or (0,)
|
||
|
||
|
||
def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
|
||
return _parse_version(remote) > _parse_version(local)
|
||
|
||
|
||
def fetch_latest_release() -> dict[str, Any] | None:
|
||
req = urllib.request.Request(
|
||
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
|
||
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
|
||
Moduldoku. Windows: volles Paket -> *.zip, Patch -> *.play (unsere eigene
|
||
Dateiendung, technisch ein ganz normales .zip - siehe Moduldoku), beide mit "win" im
|
||
Namen. Linux -> *.tar.gz mit "linux"."""
|
||
assets = release.get("assets", [])
|
||
if sys.platform == "win32":
|
||
hints = ("win",)
|
||
patch_exts, full_exts = (".play",), (".zip",)
|
||
elif sys.platform.startswith("linux"):
|
||
hints = ("linux",)
|
||
patch_exts = full_exts = (".tar.gz", ".tgz")
|
||
else:
|
||
return None
|
||
|
||
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
|
||
exts = patch_exts if want_patch else full_exts
|
||
for asset in assets:
|
||
name = asset.get("name", "").lower()
|
||
if not name.endswith(exts):
|
||
continue
|
||
if require_hint and not any(h in name for h in hints):
|
||
continue
|
||
if ("patch" in name) != want_patch:
|
||
continue
|
||
return asset
|
||
return None
|
||
|
||
return (
|
||
find(True, True)
|
||
or find(True, False)
|
||
or find(False, True)
|
||
or find(False, False)
|
||
)
|
||
|
||
|
||
class UpdateChecker(QThread):
|
||
"""Prueft einmalig im Hintergrund auf eine neue Version."""
|
||
|
||
updateAvailable = Signal(str, str, str) # version, release_notes, download_url
|
||
checkFailed = Signal()
|
||
upToDate = Signal()
|
||
|
||
def run(self) -> None:
|
||
release = fetch_latest_release()
|
||
if not release:
|
||
self.checkFailed.emit()
|
||
return
|
||
tag = release.get("tag_name") or release.get("name") or ""
|
||
if not tag or not is_newer(tag):
|
||
self.upToDate.emit()
|
||
return
|
||
asset = _find_platform_asset(release)
|
||
download_url = asset["browser_download_url"] if asset else ""
|
||
notes = (release.get("body") or "").strip()
|
||
self.updateAvailable.emit(tag, notes, download_url)
|
||
|
||
|
||
class UpdateInstaller(QThread):
|
||
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
|
||
|
||
progress = Signal(str)
|
||
# Download-Fortschritt in Prozent (0-100); -1 = unbestimmt (z.B. waehrend
|
||
# Entpacken/Installieren, wo sich kein Prozentsatz sinnvoll angeben laesst).
|
||
progress_percent = Signal(int)
|
||
finished_ok = Signal()
|
||
failed = Signal(str)
|
||
|
||
def __init__(
|
||
self,
|
||
download_url: str = "",
|
||
local_archive_path: str | None = None,
|
||
parent=None,
|
||
):
|
||
super().__init__(parent)
|
||
self._download_url = download_url
|
||
# Gesetzt, wenn der Nutzer eine bereits heruntergeladene .play-Patchdatei per
|
||
# Doppelklick geoeffnet hat (siehe shortcuts.py-Dateiverknuepfung) - dann wird
|
||
# nichts heruntergeladen, sondern direkt diese lokale Datei installiert.
|
||
self._local_archive_path = local_archive_path
|
||
|
||
def run(self) -> None:
|
||
try:
|
||
if getattr(sys, "frozen", False):
|
||
self._run_packaged_update()
|
||
else:
|
||
self._run_dev_update()
|
||
except Exception as exc: # noqa: BLE001 - Fehler soll in der UI landen, nicht crashen
|
||
self.failed.emit(str(exc))
|
||
|
||
# ---------------------------------------------------------- gepackter Modus
|
||
|
||
def _run_packaged_update(self) -> None:
|
||
install_dir = Path(sys.executable).resolve().parent
|
||
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
||
extract_dir = staging / "extracted"
|
||
|
||
if self._local_archive_path:
|
||
archive_path = Path(self._local_archive_path)
|
||
if not archive_path.exists():
|
||
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
|
||
return
|
||
archive_name = archive_path.name
|
||
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
|
||
self.progress_percent.emit(-1)
|
||
else:
|
||
if not self._download_url:
|
||
self.failed.emit(
|
||
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
|
||
)
|
||
return
|
||
|
||
self.progress.emit("Lade Update herunter …")
|
||
self.progress_percent.emit(0)
|
||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||
archive_path = staging / archive_name
|
||
|
||
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
total = int(resp.headers.get("Content-Length") or 0)
|
||
downloaded = 0
|
||
last_emitted = -1
|
||
with open(archive_path, "wb") as out:
|
||
while True:
|
||
chunk = resp.read(256 * 1024)
|
||
if not chunk:
|
||
break
|
||
out.write(chunk)
|
||
downloaded += len(chunk)
|
||
if total:
|
||
percent = int(downloaded * 100 / total)
|
||
if percent != last_emitted:
|
||
self.progress_percent.emit(percent)
|
||
last_emitted = percent
|
||
self.progress_percent.emit(100)
|
||
|
||
self.progress.emit("Entpacke Update …")
|
||
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
|
||
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
|
||
self.progress_percent.emit(-1)
|
||
if archive_name.endswith((".tar.gz", ".tgz")):
|
||
with tarfile.open(archive_path) as tf:
|
||
tf.extractall(extract_dir)
|
||
else:
|
||
# .zip UND .play (unsere eigene Dateiendung fuer per Doppelklick startbare
|
||
# Patchdateien - technisch ein ganz normales .zip, siehe Moduldoku) werden
|
||
# beide als ZIP entpackt.
|
||
with zipfile.ZipFile(archive_path) as zf:
|
||
zf.extractall(extract_dir)
|
||
|
||
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
|
||
self.progress.emit("Bereite Installation vor …")
|
||
|
||
if is_patch:
|
||
binary_name = _WINDOWS_BINARY_NAME if sys.platform == "win32" else _LINUX_BINARY_NAME
|
||
matches = list(extract_dir.rglob(binary_name))
|
||
if not matches:
|
||
# Unerwartet leeres/falsches Patch-Paket -> nicht kaputt installieren,
|
||
# lieber sauber fehlschlagen und den Nutzer auf das volle Paket
|
||
# hinweisen (naechster Check faellt automatisch darauf zurueck).
|
||
self.failed.emit(
|
||
f"Patch-Paket enthielt kein '{binary_name}'. Bitte erneut versuchen."
|
||
)
|
||
return
|
||
if sys.platform == "win32":
|
||
self._install_patch_windows(matches[0], install_dir, staging)
|
||
else:
|
||
self._install_patch_posix(matches[0], install_dir, staging)
|
||
else:
|
||
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
||
entries = list(extract_dir.iterdir())
|
||
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
|
||
if sys.platform == "win32":
|
||
self._install_full_windows(source_dir, install_dir, staging)
|
||
else:
|
||
self._install_full_posix(source_dir, install_dir, staging)
|
||
|
||
self.finished_ok.emit()
|
||
|
||
# Kleines, immer sichtbares Fortschrittsfenster fuer den Windows-Installationsschritt.
|
||
# Der Hauptprozess ist zu diesem Zeitpunkt schon beendet (Datei-Sperren!), daher
|
||
# laeuft das komplett im separaten PowerShell-Skript - ohne dieses Fenster wuerde
|
||
# der Nutzer nach dem Schliessen der App fuer die Dauer der Installation (bei
|
||
# einem vollen Paket ggf. mehrere Sekunden) gar nichts sehen, was wie ein Absturz
|
||
# oder Haenger wirkt.
|
||
_WINDOWS_PROGRESS_FORM_PS = """
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
Add-Type -AssemblyName System.Drawing
|
||
$form = New-Object System.Windows.Forms.Form
|
||
$form.Text = "Playtube-Update"
|
||
$form.Size = New-Object System.Drawing.Size(380,130)
|
||
$form.StartPosition = "CenterScreen"
|
||
$form.FormBorderStyle = "FixedDialog"
|
||
$form.ControlBox = $false
|
||
$form.TopMost = $true
|
||
$label = New-Object System.Windows.Forms.Label
|
||
$label.Text = "Playtube wird aktualisiert – bitte warten …"
|
||
$label.AutoSize = $false
|
||
$label.Size = New-Object System.Drawing.Size(340,20)
|
||
$label.Location = New-Object System.Drawing.Point(20,15)
|
||
$form.Controls.Add($label)
|
||
$bar = New-Object System.Windows.Forms.ProgressBar
|
||
$bar.Style = "Marquee"
|
||
$bar.MarqueeAnimationSpeed = 30
|
||
$bar.Size = New-Object System.Drawing.Size(340,20)
|
||
$bar.Location = New-Object System.Drawing.Point(20,50)
|
||
$form.Controls.Add($bar)
|
||
$form.Show()
|
||
$form.Refresh()
|
||
"""
|
||
|
||
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
|
||
|
||
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
|
||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||
$ErrorActionPreference = "SilentlyContinue"
|
||
$targetPid = {os.getpid()}
|
||
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||
Start-Sleep -Milliseconds 300
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
}}
|
||
$label.Text = "Kopiere aktualisierte Datei …"
|
||
$form.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
|
||
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||
$form.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
Start-Process -FilePath "{exe_path}"
|
||
Start-Sleep -Milliseconds 800
|
||
$form.Close()
|
||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||
"""
|
||
self._spawn_windows_script(script, staging)
|
||
|
||
# Falls zenity installiert ist (auf den meisten Desktop-Distros vorhanden), waehrend
|
||
# Wartezeit/Installation ein pulsierendes Fortschrittsfenster zeigen - rein optisch,
|
||
# das Update funktioniert auch ohne (dann passiert der Neustart einfach unsichtbar).
|
||
_POSIX_PROGRESS_HEADER = """#!/bin/sh
|
||
ZPID=""
|
||
if command -v zenity >/dev/null 2>&1; then
|
||
tail -f /dev/null | zenity --progress --title="Playtube-Update" \
|
||
--text="Playtube wird aktualisiert - bitte warten ..." --pulsate --no-cancel \
|
||
>/dev/null 2>&1 &
|
||
ZPID=$!
|
||
fi
|
||
"""
|
||
_POSIX_PROGRESS_FOOTER = """
|
||
[ -n "$ZPID" ] && kill "$ZPID" 2>/dev/null
|
||
"""
|
||
|
||
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
|
||
exe_path = install_dir / _LINUX_BINARY_NAME
|
||
script = self._POSIX_PROGRESS_HEADER + f"""
|
||
while kill -0 {os.getpid()} 2>/dev/null; do
|
||
sleep 0.5
|
||
done
|
||
cp -f "{new_binary}" "{exe_path}"
|
||
chmod +x "{exe_path}"
|
||
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||
nohup "{exe_path}" >/dev/null 2>&1 &
|
||
rm -rf "{staging}"
|
||
"""
|
||
self._spawn_posix_script(script, staging)
|
||
|
||
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
|
||
|
||
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||
$ErrorActionPreference = "SilentlyContinue"
|
||
$targetPid = {os.getpid()}
|
||
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||
Start-Sleep -Milliseconds 300
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
}}
|
||
$label.Text = "Kopiere Programmdateien … (kann etwas dauern)"
|
||
$form.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
|
||
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
|
||
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
|
||
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
||
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
||
while (-not $roboProc.HasExited) {{
|
||
Start-Sleep -Milliseconds 200
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
}}
|
||
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||
$form.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
Start-Process -FilePath "{exe_path}"
|
||
Start-Sleep -Milliseconds 800
|
||
$form.Close()
|
||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||
"""
|
||
self._spawn_windows_script(script, staging)
|
||
|
||
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||
exe_path = install_dir / _LINUX_BINARY_NAME
|
||
script = self._POSIX_PROGRESS_HEADER + f"""
|
||
while kill -0 {os.getpid()} 2>/dev/null; do
|
||
sleep 0.5
|
||
done
|
||
rm -rf "{install_dir}"/*
|
||
cp -a "{source_dir}"/. "{install_dir}"/
|
||
chmod +x "{exe_path}"
|
||
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||
nohup "{exe_path}" >/dev/null 2>&1 &
|
||
rm -rf "{staging}"
|
||
"""
|
||
self._spawn_posix_script(script, staging)
|
||
|
||
# -- Skript-Ausfuehrung: PowerShell (wartet+robocopy/Copy) unter Windows, Shell
|
||
# (wartet+cp) unter Linux; wird detached gestartet, damit es diesen Prozess
|
||
# ueberlebt, wenn dieser sich gleich beendet. --
|
||
|
||
def _spawn_windows_script(self, script: str, staging: Path) -> None:
|
||
script_path = staging / "apply_update.ps1"
|
||
script_path.write_text(script, encoding="utf-8")
|
||
subprocess.Popen(
|
||
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
|
||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||
)
|
||
|
||
def _spawn_posix_script(self, script: str, staging: Path) -> None:
|
||
script_path = staging / "apply_update.sh"
|
||
script_path.write_text(script, encoding="utf-8")
|
||
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||
subprocess.Popen(
|
||
["/bin/sh", str(script_path)],
|
||
start_new_session=True,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
|
||
# ------------------------------------------------------------- Entwicklungsmodus
|
||
|
||
def _run_dev_update(self) -> None:
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
if not (project_root / ".git").exists():
|
||
self.failed.emit(
|
||
"Kein Git-Repository gefunden - im Entwicklungsmodus bitte manuell "
|
||
"'git pull' im Projektordner ausfuehren."
|
||
)
|
||
return
|
||
|
||
self.progress.emit("Lade Aenderungen per git pull …")
|
||
result = subprocess.run(
|
||
["git", "pull", "--ff-only"], cwd=project_root, capture_output=True, text=True
|
||
)
|
||
if result.returncode != 0:
|
||
self.failed.emit(f"git pull fehlgeschlagen:\n{result.stderr.strip()}")
|
||
return
|
||
|
||
self.progress.emit("Installiere Abhaengigkeiten …")
|
||
pip_result = subprocess.run(
|
||
[sys.executable, "-m", "pip", "install", "-q", "-r", str(project_root / "requirements.txt")],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if pip_result.returncode != 0:
|
||
self.failed.emit(f"pip install fehlgeschlagen:\n{pip_result.stderr.strip()}")
|
||
return
|
||
|
||
self.finished_ok.emit()
|