Compare commits
8
Commits
v2.2.0
...
a4ba6c296e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4ba6c296e | ||
|
|
c1465a4e49 | ||
|
|
8840ba130c | ||
|
|
04bc96d33e | ||
|
|
31797cdabb | ||
|
|
771c438257 | ||
|
|
5379be6c11 | ||
|
|
c6fe895334 |
@@ -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
|
||||
@@ -42,12 +62,24 @@ 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.
|
||||
# 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: |
|
||||
$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.zip
|
||||
path: Playtube-*-win64-pyside*-patch.play
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -102,6 +137,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
|
||||
@@ -116,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<Version>-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.
|
||||
|
||||
@@ -156,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`
|
||||
|
||||
@@ -11,8 +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.shortcuts import ensure_start_menu_shortcut # 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()
|
||||
@@ -33,6 +38,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)
|
||||
@@ -45,16 +59,39 @@ 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
|
||||
# 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()
|
||||
|
||||
# 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.
|
||||
if local_patch:
|
||||
window.install_local_patch(local_patch)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "2.2.0"
|
||||
__version__ = "2.3.2"
|
||||
|
||||
@@ -259,9 +259,10 @@ class DiscordRPCWorker(QThread):
|
||||
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
||||
self._presence.update(**payload)
|
||||
log_line(
|
||||
"[send] title=%r thumbnail=%r large_image=%r start=%s end=%s"
|
||||
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
|
||||
% (
|
||||
item.get("title"),
|
||||
item.get("url"),
|
||||
item.get("thumbnail"),
|
||||
payload.get("large_image"),
|
||||
start_ts,
|
||||
|
||||
+23
-5
@@ -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()
|
||||
@@ -311,20 +316,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)
|
||||
|
||||
+10
-2
@@ -82,10 +82,18 @@ MEDIA_PROBE_JS = r"""
|
||||
var videoId = videoIdFromUrl(location.href);
|
||||
var title = null, subtitle = null, thumbnail = null;
|
||||
|
||||
// WICHTIG: die Video-ID aus der URL ist die zuverlaessigste Thumbnail-Quelle - sie
|
||||
// wechselt garantiert synchron mit dem Titel. Das <img> in der YT-Music-Playerleiste
|
||||
// (fruehere Praeferenz) wird von YouTube per Crossfade/Shadow-DOM animiert und
|
||||
// aktualisiert sein src-Attribut dabei nachweislich NICHT zuverlaessig pro Titel -
|
||||
// empirisch bestaetigt: dieselbe Thumbnail-URL blieb ueber mehrere komplett
|
||||
// unterschiedliche Songs hinweg stehen, obwohl Titel/Zeit schon laengst gewechselt
|
||||
// hatten. Das DOM-<img> dient nur noch als Fallback, falls keine Video-ID in der
|
||||
// URL steckt.
|
||||
if (isMusic) {
|
||||
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
||||
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
||||
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
|
||||
thumbnail = ytThumbnailUrl(videoId) || imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
||||
} else {
|
||||
var t = document.title.replace(/ - YouTube$/, '');
|
||||
title = t || null;
|
||||
@@ -94,7 +102,7 @@ MEDIA_PROBE_JS = r"""
|
||||
|| txt('#channel-name a')
|
||||
|| txt('ytd-channel-name#channel-name a');
|
||||
var imgY = document.querySelector('link[rel="image_src"]');
|
||||
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
|
||||
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
|
||||
}
|
||||
|
||||
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
|
||||
|
||||
@@ -25,6 +25,28 @@ from . import __version__ as APP_VERSION
|
||||
from .config import APP_NAME, save_config
|
||||
from .updater import GITHUB_REPO
|
||||
|
||||
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
|
||||
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
|
||||
# palette(mid) war ein dunkles Grau, das auf dem dunklen Hintergrund kaum lesbar war.
|
||||
_LIGHT_TEXT_STYLE = "color: palette(text);"
|
||||
|
||||
# Update-Fortschrittsbalken: helle Beschriftung und gut sichtbarer Balken. Sobald ein
|
||||
# Stylesheet gesetzt ist, zeichnet Qt den Balken nicht mehr mit dem nativen Stil - deshalb
|
||||
# hier Rahmen, Hintergrund und Balken (chunk) vollstaendig angegeben.
|
||||
_PROGRESS_BAR_STYLE = """
|
||||
QProgressBar {
|
||||
color: palette(text);
|
||||
background-color: palette(base);
|
||||
border: 1px solid palette(mid);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #2f81f7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class SettingsTab(QWidget):
|
||||
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
||||
@@ -48,7 +70,7 @@ class SettingsTab(QWidget):
|
||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||
header.addWidget(title)
|
||||
version_label = QLabel(f"Version {APP_VERSION}")
|
||||
version_label.setStyleSheet("color: palette(mid);")
|
||||
version_label.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||
header.addWidget(version_label)
|
||||
outer.addLayout(header)
|
||||
|
||||
@@ -97,13 +119,14 @@ class SettingsTab(QWidget):
|
||||
update_form.addRow(update_actions)
|
||||
|
||||
self._update_status_label = QLabel("")
|
||||
self._update_status_label.setStyleSheet("color: palette(mid);")
|
||||
self._update_status_label.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||
self._update_status_label.setWordWrap(True)
|
||||
update_form.addRow(self._update_status_label)
|
||||
|
||||
self._update_progress = QProgressBar()
|
||||
self._update_progress.setRange(0, 100)
|
||||
self._update_progress.setTextVisible(True)
|
||||
self._update_progress.setStyleSheet(_PROGRESS_BAR_STYLE)
|
||||
self._update_progress.setVisible(False)
|
||||
update_form.addRow(self._update_progress)
|
||||
|
||||
@@ -127,7 +150,7 @@ class SettingsTab(QWidget):
|
||||
outer.addLayout(button_row)
|
||||
|
||||
info = QLabel(f"github.com/{GITHUB_REPO}")
|
||||
info.setStyleSheet("color: palette(mid);")
|
||||
info.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||
outer.addWidget(info)
|
||||
|
||||
outer.addStretch(1)
|
||||
|
||||
+55
-5
@@ -1,9 +1,12 @@
|
||||
"""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"
|
||||
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
|
||||
@@ -20,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:
|
||||
@@ -50,3 +59,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
|
||||
|
||||
@@ -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()
|
||||
+238
-67
@@ -5,23 +5,39 @@ 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).
|
||||
|
||||
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 re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -44,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")
|
||||
@@ -58,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"}
|
||||
@@ -69,36 +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 -> *.zip 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, exts = ("win",), (".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, exts = ("linux",), (".tar.gz", ".tgz")
|
||||
hint = "linux"
|
||||
else:
|
||||
return None
|
||||
|
||||
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
|
||||
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):
|
||||
@@ -133,9 +229,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 +254,60 @@ 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
|
||||
cleanup_old_staging()
|
||||
staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
|
||||
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)
|
||||
|
||||
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_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 +316,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:
|
||||
@@ -208,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:
|
||||
@@ -256,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()}
|
||||
@@ -269,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()
|
||||
@@ -309,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:
|
||||
|
||||
Reference in New Issue
Block a user