From 04bc96d33ea6e26638f0c19af91d40bc35a40f14 Mon Sep 17 00:00:00 2001 From: fojadrachi Date: Sat, 19 Sep 2026 19:25:05 +0200 Subject: [PATCH] Setup-Installer, Einzelinstanz-Schutz und Update-System mit einer einzigen Installation - Inno-Setup-Installer (packaging/playtube.iss): installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube, feste AppId -> jede neue Version ueberschreibt die vorhandene Installation an Ort und Stelle, raeumt den alten _internal-Ordner auf und beendet laufende Playtube-Prozesse vor dem Kopieren - Updater: installierte Kopie nimmt das kleine Patch (Versionseintrag in "Apps & Features" wird nachgezogen), portable Kopie wechselt per stillem Setup in die regulaere Installation; Patch nur bei passender PySide6-Version (steht im Dateinamen), sonst Setup; Aufraeumen des heruntergeladenen Installers - Einzelinstanz-Schutz (playtube/single_instance.py): zwei Prozesse auf demselben Browser-Profil liessen auf YouTube alle Icons verschwinden; ein zweiter Start holt jetzt das laufende Fenster nach vorn (auch .play-Patchdateien werden weitergereicht) - build.ps1 baut den Installer mit, Release-Pipeline haengt ihn an jedes Release - README: Installation, Update-Ablauf, Troubleshooting fuer fehlende Icons --- .github/workflows/release.yml | 34 +++++- README.md | 75 ++++++++++-- main.py | 22 +++- packaging/build.ps1 | 36 ++++++ packaging/playtube.iss | 134 +++++++++++++++++++++ playtube/mainwindow.py | 5 + playtube/shortcuts.py | 16 ++- playtube/single_instance.py | 86 +++++++++++++ playtube/updater.py | 220 +++++++++++++++++++++++++++------- 9 files changed, 564 insertions(+), 64 deletions(-) create mode 100644 packaging/playtube.iss create mode 100644 playtube/single_instance.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fe294b..3bb34bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,27 @@ jobs: - name: Playtube.exe bauen run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build - - name: Als .zip packen (volles Paket) + - name: Setup-Installer bauen (Inno Setup) + # Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was + # Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige + # Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in + # packaging/playtube.iss) - keine parallelen Versionsordner mehr. + shell: pwsh + run: | + $version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value + $iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { + choco install innosetup -y --no-progress + } + & $iscc "/DAppVersion=$version" packaging\playtube.iss + if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } + + - uses: actions/upload-artifact@v4 + with: + name: windows-setup + path: dist/installer/Playtube-Setup-v*.exe + + - name: Als .zip packen (volles Paket, portabel) run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip - uses: actions/upload-artifact@v4 @@ -46,14 +66,20 @@ jobs: # 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. + # Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der + # installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und + # der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible). + shell: pwsh 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 + $pyside = python -c "import PySide6; print(PySide6.__version__)" + $name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch" + Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip" + Rename-Item -Path "$name.zip" -NewName "$name.play" - uses: actions/upload-artifact@v4 with: name: windows-patch - path: Playtube-${{ github.ref_name }}-win64-patch.play + path: Playtube-*-win64-pyside*-patch.play build-linux: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3e1040c..816f4bb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ Erweiterung) mit: - **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python". +## Installation (Windows) + +Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases) +`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer + +- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine + Admin-Rechte noetig) und legt Startmenue-Eintrag (optional Desktop-Verknuepfung) an, +- ersetzt bei jeder spaeteren Ausfuehrung die vorhandene Installation **an Ort und + Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie + mehrere nebeneinander, +- beendet dafuer ein noch laufendes Playtube selbst und raeumt den alten Programmordner + auf, damit keine Reste alter Versionen uebrig bleiben, +- laesst Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren + ueber "Apps & Features" wird gefragt, ob sie mitgeloescht werden sollen. + +Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die +Setup-Datei wird dafuer nicht erneut von Hand gebraucht. Das ZIP-Paket +(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten; +Playtube bietet einer portablen Kopie beim naechsten Update automatisch an, in die +regulaere Installation zu wechseln (danach kann der alte Ordner geloescht werden). + ## Schnellstart (Entwicklung) ```powershell @@ -55,10 +76,14 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae powershell -ExecutionPolicy Bypass -File packaging\build.ps1 ``` -Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser -`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube -wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es -sonst keinen Startmenue-Eintrag). +Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Ist +[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert +(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den +Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript: +[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` ueberspringbar). +Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine +Verknuepfung im Windows-Startmenue an - eine per Setup installierte Kopie hat den +Eintrag bereits vom Installer. Das Build-Skript benennt zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu @@ -82,14 +107,24 @@ Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button m Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version, fragt ein Dialog, ob sie installiert werden soll: -- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket** - herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt - ~200 MB) und ersetzt ausschliesslich diese - der riesige PySide6/QtWebEngine- - Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen normalen - Patch-Releases nicht aendert. Nur wenn kein Patch-Paket verfuegbar ist (z.B. beim - allerersten Release oder nach einem Wechsel der PySide6-Version), wird automatisch - auf das volle Release-Paket zurueckgefallen und der komplette Installationsordner - ersetzt. Die App startet sich in beiden Faellen danach selbst neu. +- **Windows, per Setup installiert** (siehe "Installation"): laedt bevorzugt das kleine + **Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB) + und ueberschreibt damit die Datei im Installationsordner - der riesige PySide6/ + QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen + normalen Patch-Releases nicht aendert. Die PySide6-Version steht im Dateinamen des + Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab, + ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den + **Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und + Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach + einem Patch ebenfalls nachgezogen. +- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): laedt den + **Setup-Installer** und fuehrt ihn still aus. Playtube liegt danach in der regulaeren + Installation - Startmenue-Eintrag und Dateizuordnung zeigen dorthin, die alte portable + Kopie ist nicht mehr noetig (und kann geloescht werden). So entstehen nicht laenger + versionierte Ordner nebeneinander. Gibt es (bei aelteren Releases) keinen Installer, + wird wie frueher das volle ZIP ueber den vorhandenen Ordner kopiert. +- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`. + Die App startet sich in allen Faellen danach selbst neu. - **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r requirements.txt` aus und startet den Python-Prozess neu. @@ -126,6 +161,11 @@ eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem G Release veroeffentlicht - Fortschritt unter https://github.com/fojadrachi/Playtube/actions. +Fuer Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, fuer +Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine +`...-pyside-patch.play` (schnelles Update). Der Installer bekommt seine +Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt). + Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die neue Version danach automatisch angeboten. @@ -166,6 +206,17 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im Entwicklungsmodus) loeschen und neu anmelden. +- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menue ...):** Fast + sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die + zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des + Fensters Playtube nur in den Tray minimiert: eine aeltere Version laeuft unsichtbar + weiter, waehrend eine neu heruntergeladene zusaetzlich gestartet wird. Abhilfe: alle + Playtube-Instanzen ueber das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager + beenden und nur EINE neu starten. Ab der Version mit + [playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den + Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine + aeltere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell + beendet werden. - 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). - Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py` diff --git a/main.py b/main.py index b15989f..2b81ae4 100644 --- a/main.py +++ b/main.py @@ -11,11 +11,13 @@ from pathlib import Path # Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren. 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.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402 +from playtube.single_instance import SingleInstanceGuard # noqa: E402 from playtube.shortcuts import ( # noqa: E402 ensure_play_file_association, ensure_start_menu_shortcut, ) +from playtube.updater import cleanup_old_staging # noqa: E402 app_id.set_app_user_model_id() app_id.configure_webengine_process_path() @@ -57,6 +59,18 @@ def main() -> int: if icon_path.exists(): app.setWindowIcon(QIcon(str(icon_path))) + # Nur EINE Playtube-Instanz pro Datenordner: laufen zwei gleichzeitig auf demselben + # Browser-Profil, zeigt die zweite auf YouTube keine Icons mehr (siehe + # playtube/single_instance.py). Muss vor jedem Profil-Zugriff (Cache-Reset, Browser) + # passieren. Die Instanz eines aelteren Playtube ohne diesen Schutz wird dadurch + # allerdings nicht erkannt - die muss einmalig ueber das Tray-Icon beendet werden. + local_patch = _pending_local_patch() + instance_guard = SingleInstanceGuard(f"{APP_NAME}.SingleInstance.{app_data_dir().name}", app) + if not instance_guard.acquire(local_patch): + return 0 + + cleanup_old_staging() # Reste eines frueheren Updates (heruntergeladenes Setup) entfernen + config = load_config() # Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login # bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst @@ -68,9 +82,13 @@ def main() -> int: window = MainWindow(config) window.show() + # Ein zweiter Start (Verknuepfung, .play-Doppelklick) landet hier in der laufenden + # Instanz: Fenster aus dem Tray holen bzw. den Patch installieren. + instance_guard.showRequested.connect(window.show_and_raise) + instance_guard.patchRequested.connect(window.install_local_patch) + # 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) diff --git a/packaging/build.ps1 b/packaging/build.ps1 index 71d7d83..d46575a 100644 --- a/packaging/build.ps1 +++ b/packaging/build.ps1 @@ -12,6 +12,11 @@ # stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den # Dateinamen "PlaytubeHelper" an). +param( + # Ueberspringt den Bau des Setup-Installers (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6). + [switch]$SkipInstaller +) + $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root @@ -59,3 +64,34 @@ if (-not $engineProcess) { Write-Host "" Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green + +# --- Setup-Installer (Inno Setup) ------------------------------------------------------ +# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und +# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine +# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen. +if (-not $SkipInstaller) { + $versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1 + if (-not $versionLine) { throw "Konnte __version__ in playtube\__init__.py nicht lesen." } + $version = $versionLine.Matches[0].Groups[1].Value + + $isccCandidates = @( + (Get-Command iscc -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }), + (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"), + (Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"), + (Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe") + ) + $iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 + + if (-not $iscc) { + Write-Host "" + Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow + Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow + Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow + } else { + Write-Host "" + Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan + & $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss") + if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } + Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green + } +} diff --git a/packaging/playtube.iss b/packaging/playtube.iss new file mode 100644 index 0000000..0cb92d4 --- /dev/null +++ b/packaging/playtube.iss @@ -0,0 +1,134 @@ +; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe). +; +; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php): +; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss +; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1). +; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe +; +; Wichtig fuer "es soll nie mehrere Versionen geben": +; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene +; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen). +; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte +; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar). +; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner +; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben. +; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten. +; +; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von +; Inno Setup nicht ins Spiel kommt. + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif + +#define AppName "Playtube" +#define AppExeName "Playtube.exe" +#define AppHelperName "PlaytubeHelper.exe" + +[Setup] +; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit +; _INNO_APP_ID in playtube/updater.py uebereinstimmen. +AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher=Playtube +AppPublisherURL=https://github.com/fojadrachi/Playtube +AppSupportURL=https://github.com/fojadrachi/Playtube/issues +AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases +VersionInfoVersion={#AppVersion} +DefaultDirName={localappdata}\Programs\{#AppName} +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir=..\dist\installer +OutputBaseFilename=Playtube-Setup-v{#AppVersion} +SetupIconFile=..\assets\icon.ico +UninstallDisplayIcon={app}\{#AppExeName} +UninstallDisplayName={#AppName} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des +; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu +; schliessen, die Inno per CloseApplications senden wuerde. +CloseApplications=no +RestartApplications=no + +[Languages] +Name: "german"; MessagesFile: "compiler:Languages\German.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[InstallDelete] +; Vor dem Kopieren die PyInstaller-Laufzeit der alten Version entfernen, damit nach einem +; Update keine Dateien einer aelteren Version uebrig bleiben. +Type: filesandordirs; Name: "{app}\_internal" + +[Files] +Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die +; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die +; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt. +Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Registry] +; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py). +Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}" +Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1""" + +[Run] +; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende. +Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent +; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch +; wieder starten. +Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch + +[Code] +function ShouldRelaunch(): Boolean; +begin + Result := ExpandConstant('{param:RELAUNCH|0}') = '1'; +end; + +procedure StopRunningPlaytube(); +var + ResultCode: Integer; +begin + { /T beendet auch die Kindprozesse (QtWebEngine); der Hilfsprozess wird zusaetzlich + einzeln beendet, falls er als Waise ueberlebt hat. } + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /T /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + { Dateisperren freigeben lassen, bevor kopiert wird. } + Sleep(800); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + StopRunningPlaytube(); + Result := ''; +end; + +function InitializeUninstall(): Boolean; +begin + StopRunningPlaytube(); + Result := True; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then + begin + if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 + + '(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')', + mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then + DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True); + end; +end; diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index 9ec992c..778d281 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -221,6 +221,11 @@ class MainWindow(QMainWindow): self.raise_() self.activateWindow() + def show_and_raise(self) -> None: + """Oeffentlicher Einstieg fuer den Einzelinstanz-Schutz (main.py): holt das Fenster + aus dem Tray, wenn Playtube ein zweites Mal gestartet wird.""" + self._show_and_raise() + def _quit(self) -> None: if self._rpc_worker is not None: self._rpc_worker.stop() diff --git a/playtube/shortcuts.py b/playtube/shortcuts.py index a67d42d..8279057 100644 --- a/playtube/shortcuts.py +++ b/playtube/shortcuts.py @@ -1,10 +1,12 @@ """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" -Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent - -prueft vorher, ob die Verknuepfung schon existiert).""" +Playtube-Setup (packaging/playtube.iss) legt Startmenue-Eintrag und Dateizuordnung +selbst an. Fuer eine portable, aus einem ZIP entpackte Kopie gaebe es sonst nie einen +Eintrag im Windows-Startmenue - der wird beim Start der gepackten .exe einmalig +nachgeholt (idempotent - prueft vorher, ob die Verknuepfung schon existiert). Eine durch +Setup installierte Kopie fasst die Verknuepfung nicht an, damit sie nicht versehentlich +wieder auf eine andere (portable) Kopie umgebogen wird.""" from __future__ import annotations import os @@ -21,6 +23,12 @@ def _find_icon(exe_dir: Path) -> Path | None: def ensure_start_menu_shortcut(app_name: str) -> None: if sys.platform != "win32" or not getattr(sys, "frozen", False): return + # Import erst hier: shortcuts.py wird vor dem restlichen Qt-Setup importiert (siehe + # main.py), updater.py zieht PySide6 nach. + from .updater import is_installed_via_setup + + if is_installed_via_setup(): + return # Verknuepfung gehoert dem Installer (siehe Moduldoku) try: appdata = os.environ.get("APPDATA") if not appdata: diff --git a/playtube/single_instance.py b/playtube/single_instance.py new file mode 100644 index 0000000..5b7335c --- /dev/null +++ b/playtube/single_instance.py @@ -0,0 +1,86 @@ +"""Einzelinstanz-Schutz: verhindert, dass zwei Playtube-Prozesse gleichzeitig auf +demselben Browser-Profil (webprofile/) laufen. + +Hintergrund: Laufen zwei Instanzen auf demselben QtWebEngine-Profil (Cache, GPUCache, +Service Worker, Cookies), zeigt die ZWEITE Instanz auf YouTube/YouTube Music sichtbar +keine Icons mehr (Play/Pause, Suche, Menue ...). Das passiert leicht, weil das Schliessen +des Fensters Playtube nur in den Tray minimiert - eine alte Version laeuft dann +unsichtbar weiter, waehrend eine neu heruntergeladene Version zusaetzlich gestartet wird. + +Ansatz: ein lokaler Server (Named Pipe unter Windows, Unix-Socket unter Linux) pro +Datenordner. Der erste Prozess lauscht darauf; jeder weitere verbindet sich damit, +bittet die laufende Instanz, ihr Fenster zu zeigen, und beendet sich selbst. +""" +from __future__ import annotations + +from PySide6.QtCore import QObject, Signal +from PySide6.QtNetwork import QLocalServer, QLocalSocket + +_CONNECT_TIMEOUT_MS = 500 +_WRITE_TIMEOUT_MS = 500 +_MSG_SHOW = "show" +_MSG_PATCH = "patch" +_SEPARATOR = "\t" + + +class SingleInstanceGuard(QObject): + """Der erste Prozess mit einem bestimmten `key` "besitzt" die Instanz; jeder weitere + Aufruf von acquire() liefert False und reicht seine Anfrage an den Besitzer weiter.""" + + showRequested = Signal() + patchRequested = Signal(str) + + def __init__(self, key: str, parent: QObject | None = None) -> None: + super().__init__(parent) + self._key = key + self._server: QLocalServer | None = None + + def acquire(self, local_patch: str | None = None) -> bool: + """True, wenn dieser Prozess die (einzige) Instanz sein darf. False, wenn bereits + eine laeuft - dann wurde ihr "Fenster zeigen" (bzw. der Patch-Pfad) gemeldet.""" + if self._notify_running_instance(local_patch): + return False + + # Kein Besitzer erreichbar: ggf. verwaisten Socket (Linux) aufraeumen und selbst + # lauschen. + QLocalServer.removeServer(self._key) + server = QLocalServer(self) + if not server.listen(self._key): + # Knappes Rennen zweier gleichzeitiger Starts: der andere war einen Tick + # frueher - nochmal versuchen, ihn zu erreichen, sonst trotzdem starten + # (lieber ohne Schutz laufen als gar nicht). + return not self._notify_running_instance(local_patch) + + server.newConnection.connect(self._on_new_connection) + self._server = server + return True + + def _notify_running_instance(self, local_patch: str | None) -> bool: + socket = QLocalSocket(self) + socket.connectToServer(self._key) + if not socket.waitForConnected(_CONNECT_TIMEOUT_MS): + socket.abort() + return False + + message = f"{_MSG_PATCH}{_SEPARATOR}{local_patch}" if local_patch else _MSG_SHOW + socket.write(message.encode("utf-8")) + socket.waitForBytesWritten(_WRITE_TIMEOUT_MS) + socket.disconnectFromServer() + return True + + def _on_new_connection(self) -> None: + if self._server is None: + return + while self._server.hasPendingConnections(): + socket = self._server.nextPendingConnection() + socket.waitForReadyRead(_CONNECT_TIMEOUT_MS) + message = bytes(socket.readAll().data()).decode("utf-8", errors="replace") + socket.disconnectFromServer() + socket.deleteLater() + self._dispatch(message) + + def _dispatch(self, message: str) -> None: + command, _, argument = message.partition(_SEPARATOR) + if command == _MSG_PATCH and argument: + self.patchRequested.emit(argument) + self.showRequested.emit() diff --git a/playtube/updater.py b/playtube/updater.py index a88374b..d8f740c 100644 --- a/playtube/updater.py +++ b/playtube/updater.py @@ -5,16 +5,22 @@ Ablauf: 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. + - Gepackte App unter Windows: Es gibt EINE Installation (Playtube-Setup, siehe + packaging/playtube.iss, Ordner %LOCALAPPDATA%/Programs/Playtube), die jedes + Update an Ort und Stelle ueberschreibt - keine parallelen Versionsordner. + * Installierte Kopie: laedt bevorzugt das kleine "Patch"-Paket (nur die + Playtube.exe, ca. 2-3 MB) und ersetzt lediglich diese Datei; der grosse + PySide6/QtWebEngine-Ordner (_internal/) bleibt unangetastet. Passt die + PySide6-Version des Patches nicht zur installierten Laufzeit (siehe + _patch_is_compatible), wird stattdessen der Setup-Installer benutzt. + * Portable Kopie (aus einem ZIP entpackt, z.B. in Downloads): laedt den + Setup-Installer und fuehrt ihn still aus. Danach liegt Playtube in der + regulaeren Installation und Startmenue/Desktop zeigen dorthin - die alte + portable Kopie wird nicht mehr gebraucht. + * Ohne Setup-Asset (aeltere Releases): Fallback auf das volle ZIP wie frueher. + - Gepackte App unter Linux: Patch-Paket (nur das Binary) bzw. volles .tar.gz. + Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu - + PowerShell unter Windows (Patch/ZIP), Inno Setup selbst (Setup), Shell unter Linux. - Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r requirements.txt' aus, die App startet sich danach selbst neu (os.execv). @@ -30,6 +36,8 @@ from __future__ import annotations import json import os +import re +import shutil import stat import subprocess import sys @@ -52,6 +60,17 @@ _USER_AGENT = "Playtube-Updater" _LINUX_BINARY_NAME = "Playtube" _WINDOWS_BINARY_NAME = "Playtube.exe" +# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno +# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update +# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows). +_INNO_APP_ID = "{87FBA502-2B84-4C42-A66B-2F03F490912D}" + +_STAGING_PREFIX = "playtube_update_" + +# Release-Asset-Arten (siehe _asset_kind): "setup" = Inno-Installer (nur Windows), +# "patch" = kleines Paket mit nur der .exe/dem Binary, "full" = komplettes ZIP/.tar.gz. +_KIND_SETUP, _KIND_PATCH, _KIND_FULL = "setup", "patch", "full" + def _parse_version(v: str) -> tuple[int, ...]: v = v.strip().lstrip("vV") @@ -66,6 +85,20 @@ def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool: return _parse_version(remote) > _parse_version(local) +def _version_from_asset_name(name: str) -> str | None: + """Zieht "X.Y.Z" aus einem Release-Asset-Namen wie "Playtube-v2.4.0-win64-patch.play" + (die PySide6-Version im Patch-Namen, "pyside6.11.2", wird dabei uebersprungen).""" + match = re.search(r"v(\d+\.\d+\.\d+)", name, flags=re.IGNORECASE) + return match.group(1) if match else None + + +def cleanup_old_staging() -> None: + """Loescht Reste frueherer Update-Laeufe im Temp-Ordner (u.a. den heruntergeladenen + Setup-Installer - er laesst sich nicht selbst loeschen, solange er laeuft).""" + for old in Path(tempfile.gettempdir()).glob(f"{_STAGING_PREFIX}*"): + shutil.rmtree(old, ignore_errors=True) + + def fetch_latest_release() -> dict[str, Any] | None: req = urllib.request.Request( _API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"} @@ -77,41 +110,91 @@ def fetch_latest_release() -> dict[str, Any] | None: 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", []) +def is_installed_via_setup() -> bool: + """True, wenn diese Playtube.exe aus einer Installation durch Playtube-Setup stammt + (Inno Setup legt im Installationsordner immer unins000.exe ab) - im Gegensatz zu + einer portablen, aus einem ZIP entpackten Kopie.""" + if sys.platform != "win32" or not getattr(sys, "frozen", False): + return False + return (Path(sys.executable).resolve().parent / "unins000.exe").exists() + + +def _asset_kind(name: str) -> str | None: + """Ordnet ein Release-Asset (nach Dateiname) einer Art zu - oder None, wenn es fuer + die laufende Plattform nicht in Frage kommt. Windows: Setup -> *Setup*.exe, Patch -> + *.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip. + Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket.""" + name = name.lower() if sys.platform == "win32": - hints = ("win",) - patch_exts, full_exts = (".play",), (".zip",) + if name.endswith(".exe") and "setup" in name: + return _KIND_SETUP + if name.endswith(".play"): + return _KIND_PATCH + if name.endswith(".zip") and "patch" not in name: + return _KIND_FULL + return None + if sys.platform.startswith("linux") and name.endswith((".tar.gz", ".tgz")): + return _KIND_PATCH if "patch" in name else _KIND_FULL + return None + + +def _patch_is_compatible(name: str) -> bool: + """Patch-Pakete tragen die PySide6-Version, gegen die sie gebaut wurden, im Namen + (z.B. "Playtube-v2.4.0-win64-pyside6.11.2-patch.play"). Weicht sie von der Laufzeit + der installierten App ab, waere die neue Playtube.exe mit dem vorhandenen _internal/ + inkompatibel - dann muss stattdessen der Setup-Installer die Laufzeit mit erneuern. + Ohne Angabe im Namen (aeltere Releases) wird wie frueher Kompatibilitaet angenommen.""" + match = re.search(r"pyside(\d+\.\d+\.\d+)", name.lower()) + if not match: + return True + try: + import PySide6 + + return match.group(1) == PySide6.__version__ + except Exception: # noqa: BLE001 - im Zweifel wie frueher patchen + return True + + +def _preferred_kinds() -> tuple[str, ...]: + """Reihenfolge, in der Release-Asset-Arten ausprobiert werden.""" + if sys.platform == "win32": + if is_installed_via_setup(): + # Installierte Kopie: kleines Patch zuerst (schnell, ueberschreibt nur die + # .exe im Installationsordner). + return (_KIND_PATCH, _KIND_SETUP, _KIND_FULL) + # Portable Kopie: Setup zuerst - danach liegt Playtube in der regulaeren + # Installation, statt dass weiter versionierte Ordner nebeneinander entstehen. + return (_KIND_SETUP, _KIND_PATCH, _KIND_FULL) + return (_KIND_PATCH, _KIND_FULL) + + +def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None: + """Sucht das zur laufenden Plattform passende Release-Paket (Reihenfolge der Arten + siehe _preferred_kinds). Innerhalb einer Art wird ein Asset mit Plattformhinweis + ("win"/"linux") im Namen bevorzugt.""" + if sys.platform == "win32": + hint = "win" elif sys.platform.startswith("linux"): - hints = ("linux",) - patch_exts = full_exts = (".tar.gz", ".tgz") + hint = "linux" 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 + candidates: list[tuple[str, bool, dict[str, Any]]] = [] + for asset in release.get("assets", []): + name = asset.get("name", "") + kind = _asset_kind(name) + if kind is None: + continue + if kind == _KIND_PATCH and not _patch_is_compatible(name): + continue + candidates.append((kind, hint in name.lower(), asset)) - return ( - find(True, True) - or find(True, False) - or find(False, True) - or find(False, False) - ) + for kind in _preferred_kinds(): + matching = [(has_hint, asset) for k, has_hint, asset in candidates if k == kind] + if matching: + matching.sort(key=lambda item: not item[0]) # stabil: Treffer mit Hinweis zuerst + return matching[0][1] + return None class UpdateChecker(QThread): @@ -172,7 +255,8 @@ class UpdateInstaller(QThread): def _run_packaged_update(self) -> None: install_dir = Path(sys.executable).resolve().parent - staging = Path(tempfile.mkdtemp(prefix="playtube_update_")) + cleanup_old_staging() + staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)) extract_dir = staging / "extracted" if self._local_archive_path: @@ -214,6 +298,16 @@ class UpdateInstaller(QThread): last_emitted = percent self.progress_percent.emit(100) + if sys.platform == "win32" and archive_name.lower().endswith(".exe"): + # Setup-Installer: nichts zu entpacken - er installiert selbst ueber die + # vorhandene Installation (bzw. richtet bei einer portablen Kopie die + # regulaere Installation ein) und startet Playtube danach neu. + self.progress.emit("Starte Installer …") + self.progress_percent.emit(-1) + self._install_setup_windows(archive_path) + self.finished_ok.emit() + return + self.progress.emit("Entpacke Update …") # Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt # dafuer einen "laufenden" Balken statt einer Prozentzahl. @@ -243,7 +337,9 @@ class UpdateInstaller(QThread): ) return if sys.platform == "win32": - self._install_patch_windows(matches[0], install_dir, staging) + self._install_patch_windows( + matches[0], install_dir, staging, _version_from_asset_name(archive_name) + ) else: self._install_patch_posix(matches[0], install_dir, staging) else: @@ -291,8 +387,23 @@ $form.Refresh() # -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) -- - def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None: + def _install_patch_windows( + self, new_exe: Path, install_dir: Path, staging: Path, version: str | None = None + ) -> None: exe_path = install_dir / _WINDOWS_BINARY_NAME + # Nach einem Patch zeigt "Apps & Features" sonst weiter die Version des zuletzt + # ausgefuehrten Setups an. Nur relevant fuer Installationen durch Playtube-Setup + # (der Eintrag existiert bei portablen Kopien nicht - dann ist Test-Path $false). + registry_update = "" + if version and is_installed_via_setup(): + registry_update = f""" +if ($copied) {{ + $uninstallKey = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{_INNO_APP_ID}_is1" + if (Test-Path -LiteralPath $uninstallKey) {{ + Set-ItemProperty -LiteralPath $uninstallKey -Name DisplayVersion -Value "{version}" + }} +}} +""" script = self._WINDOWS_PROGRESS_FORM_PS + f""" $ErrorActionPreference = "SilentlyContinue" $targetPid = {os.getpid()} @@ -304,6 +415,8 @@ $label.Text = "Kopiere aktualisierte Datei …" $form.Refresh() [System.Windows.Forms.Application]::DoEvents() Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force +$copied = $? +{registry_update} $label.Text = "Fertig – Playtube wird neu gestartet …" $form.Refresh() [System.Windows.Forms.Application]::DoEvents() @@ -344,6 +457,29 @@ rm -rf "{staging}" """ self._spawn_posix_script(script, staging) + # -- Setup-Installer (Windows): eine einzige, ueberschriebene Installation -- + + def _install_setup_windows(self, setup_path: Path) -> None: + """Startet Playtube-Setup still (nur Fortschrittsfenster, keine Rueckfragen). + Der Installer beendet laufende Playtube-Prozesse selbst, ueberschreibt die + vorhandene Installation im selben Ordner (gleiche AppId, siehe + packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die + laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss + hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das + Beenden dieses Prozesses ueberlebt.""" + subprocess.Popen( + [ + str(setup_path), + "/SILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/NOCANCEL", + "/RELAUNCH=1", + ], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + close_fds=True, + ) + # -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen -- def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: