Eigene .play-Dateiendung fuer Windows-Patchdateien + manuelle Installation per Doppelklick
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]>
This commit is contained in:
@@ -42,12 +42,18 @@ jobs:
|
||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
||||
|
||||
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
|
||||
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
|
||||
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
|
||||
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
|
||||
# Technisch bleibt es ein ganz normales ZIP-Archiv.
|
||||
run: |
|
||||
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
Rename-Item -Path Playtube-${{ github.ref_name }}-win64-patch.zip -NewName Playtube-${{ github.ref_name }}-win64-patch.play
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-patch
|
||||
path: Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
path: Playtube-${{ github.ref_name }}-win64-patch.play
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -102,6 +102,16 @@ zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt d
|
||||
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
|
||||
(`webprofile/storage`) liegen.
|
||||
|
||||
### Patch-Dateien manuell installieren (`.play`)
|
||||
|
||||
Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technisch
|
||||
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
|
||||
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
|
||||
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
|
||||
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) laesst sich also
|
||||
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
|
||||
muss.
|
||||
|
||||
### Eine neue Version veroeffentlichen
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -12,7 +12,10 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from playtube import __version__, app_id # noqa: E402
|
||||
from playtube.config import APP_NAME, clear_cache_on_update, load_config # noqa: E402
|
||||
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
|
||||
from playtube.shortcuts import ( # noqa: E402
|
||||
ensure_play_file_association,
|
||||
ensure_start_menu_shortcut,
|
||||
)
|
||||
|
||||
app_id.set_app_user_model_id()
|
||||
app_id.configure_webengine_process_path()
|
||||
@@ -33,6 +36,15 @@ from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
from playtube.mainwindow import MainWindow # noqa: E402
|
||||
|
||||
|
||||
def _pending_local_patch() -> str | None:
|
||||
"""Falls Playtube per Doppelklick auf eine ".play"-Patchdatei gestartet wurde
|
||||
(siehe shortcuts.ensure_play_file_association), liefert den Pfad dazu."""
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.lower().endswith(".play") and Path(arg).is_file():
|
||||
return str(Path(arg).resolve())
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||
app = QApplication(sys.argv)
|
||||
@@ -51,10 +63,17 @@ def main() -> int:
|
||||
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
|
||||
clear_cache_on_update(__version__)
|
||||
ensure_start_menu_shortcut(APP_NAME)
|
||||
ensure_play_file_association(APP_NAME)
|
||||
|
||||
window = MainWindow(config)
|
||||
window.show()
|
||||
|
||||
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
|
||||
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
|
||||
local_patch = _pending_local_patch()
|
||||
if local_patch:
|
||||
window.install_local_patch(local_patch)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
|
||||
+18
-5
@@ -311,20 +311,33 @@ class MainWindow(QMainWindow):
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self._start_update_install(download_url)
|
||||
|
||||
def _start_update_install(self, download_url: str) -> None:
|
||||
if not download_url:
|
||||
def _start_update_install(self, download_url: str = "", local_path: str | None = None) -> None:
|
||||
if not download_url and not local_path:
|
||||
return
|
||||
self._settings_tab.hide_install_button()
|
||||
self._settings_tab.set_update_status("Lade Update herunter …")
|
||||
self._settings_tab.set_download_progress(0)
|
||||
if local_path:
|
||||
self._settings_tab.set_update_status("Installiere lokale Patch-Datei …")
|
||||
self._settings_tab.set_download_progress(-1)
|
||||
else:
|
||||
self._settings_tab.set_update_status("Lade Update herunter …")
|
||||
self._settings_tab.set_download_progress(0)
|
||||
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
|
||||
self._update_installer = UpdateInstaller(download_url, self)
|
||||
self._update_installer = UpdateInstaller(
|
||||
download_url, local_archive_path=local_path, parent=self
|
||||
)
|
||||
self._update_installer.progress.connect(self._on_install_progress_text)
|
||||
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
|
||||
self._update_installer.finished_ok.connect(self._on_update_finished)
|
||||
self._update_installer.failed.connect(self._on_update_failed)
|
||||
self._update_installer.start()
|
||||
|
||||
def install_local_patch(self, path: str) -> None:
|
||||
"""Wird von main.py aufgerufen, wenn Playtube per Doppelklick auf eine
|
||||
heruntergeladene .play-Patchdatei gestartet wurde (siehe
|
||||
shortcuts.ensure_play_file_association)."""
|
||||
self._tabs.setCurrentWidget(self._settings_tab)
|
||||
self._start_update_install(local_path=path)
|
||||
|
||||
def _on_install_progress_text(self, msg: str) -> None:
|
||||
self._tray.setToolTip(f"{APP_NAME} – {msg}")
|
||||
self._settings_tab.set_update_status(msg)
|
||||
|
||||
+43
-1
@@ -1,4 +1,5 @@
|
||||
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung.
|
||||
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung sowie die Dateizuordnung
|
||||
fuer die eigene ".play"-Patchdateiendung.
|
||||
|
||||
Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es
|
||||
also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten"
|
||||
@@ -50,3 +51,44 @@ def ensure_start_menu_shortcut(app_name: str) -> None:
|
||||
except Exception:
|
||||
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
|
||||
pass
|
||||
|
||||
|
||||
def ensure_play_file_association(app_name: str) -> None:
|
||||
"""Registriert ".play" (unsere eigene Endung fuer Patch-Pakete, siehe updater.py)
|
||||
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann
|
||||
danach per Doppelklick installiert werden (Playtube startet dann mit dem Dateipfad
|
||||
als Kommandozeilenargument, siehe main.py). Nur unter HKEY_CURRENT_USER, damit keine
|
||||
Admin-Rechte noetig sind. Wird bei jedem Start erneut geschrieben (billig, idempotent
|
||||
und heilt sich selbst, falls die .exe z.B. nach einem Update an einem neuen Pfad
|
||||
liegt)."""
|
||||
if sys.platform != "win32" or not getattr(sys, "frozen", False):
|
||||
return
|
||||
try:
|
||||
import winreg
|
||||
|
||||
exe_path = str(Path(sys.executable).resolve())
|
||||
prog_id = f"{app_name}.PatchFile"
|
||||
icon_path = _find_icon(Path(exe_path).parent) or Path(exe_path)
|
||||
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\.play") as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, prog_id)
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}") as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f"{app_name}-Patchdatei")
|
||||
with winreg.CreateKey(
|
||||
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\DefaultIcon"
|
||||
) as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(icon_path))
|
||||
with winreg.CreateKey(
|
||||
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\shell\open\command"
|
||||
) as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f'"{exe_path}" "%1"')
|
||||
|
||||
# Explorer informieren, damit die neue Zuordnung sofort (ohne Neustart) greift.
|
||||
import ctypes
|
||||
|
||||
SHCNE_ASSOCCHANGED = 0x08000000
|
||||
SHCNF_IDLIST = 0x0000
|
||||
ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None)
|
||||
except Exception:
|
||||
# Keine Dateizuordnung ist kein Grund, den App-Start scheitern zu lassen.
|
||||
pass
|
||||
|
||||
+68
-33
@@ -17,6 +17,14 @@ Ablauf:
|
||||
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
|
||||
|
||||
@@ -72,16 +80,21 @@ def fetch_latest_release() -> dict[str, Any] | 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 -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
|
||||
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, exts = ("win",), (".zip",)
|
||||
hints = ("win",)
|
||||
patch_exts, full_exts = (".play",), (".zip",)
|
||||
elif sys.platform.startswith("linux"):
|
||||
hints, exts = ("linux",), (".tar.gz", ".tgz")
|
||||
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):
|
||||
@@ -133,9 +146,18 @@ class UpdateInstaller(QThread):
|
||||
finished_ok = Signal()
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, download_url: str, parent=None):
|
||||
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:
|
||||
@@ -149,39 +171,49 @@ class UpdateInstaller(QThread):
|
||||
# ---------------------------------------------------------- gepackter Modus
|
||||
|
||||
def _run_packaged_update(self) -> None:
|
||||
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)
|
||||
install_dir = Path(sys.executable).resolve().parent
|
||||
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||
archive_path = staging / archive_name
|
||||
extract_dir = staging / "extracted"
|
||||
|
||||
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
|
||||
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_percent.emit(100)
|
||||
self.progress.emit("Entpacke Update …")
|
||||
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
|
||||
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
|
||||
@@ -190,10 +222,13 @@ class UpdateInstaller(QThread):
|
||||
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()
|
||||
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
|
||||
self.progress.emit("Bereite Installation vor …")
|
||||
|
||||
if is_patch:
|
||||
|
||||
Reference in New Issue
Block a user