diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bb34bf..856f8c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,19 @@ -name: Release +name: Release (Edge) -# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem -# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch -# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue -# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an. +# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup +# und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird. +# +# WICHTIG: +# - Nur Tags mit dem Suffix "-edge" loesen diesen Workflow aus - die normale (Qt-)Playtube +# baut aus dem Branch "main" mit Tags "vX.Y.Z" und bleibt davon unberuehrt. +# - Das Release wird als VORABVERSION veroeffentlicht und nie als "latest" markiert. Die Qt- +# Version fragt "/releases/latest" ab und sieht dieses Release dadurch nie; der Updater der +# Edge-Version sucht seinerseits nur nach Tags "...-edge" (siehe playtube/updater.py). on: push: tags: - - "v*.*.*" + - "v*-edge" workflow_dispatch: {} permissions: @@ -30,14 +35,10 @@ jobs: pip install -r requirements.txt pip install pyinstaller - - name: Playtube.exe bauen + - name: PlaytubeEdge.exe bauen run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build - 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 @@ -48,85 +49,18 @@ jobs: & $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 + - name: Als .zip packen (portabel) + run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip - uses: actions/upload-artifact@v4 with: - name: windows-build - path: Playtube-${{ github.ref_name }}-win64.zip - - - name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates) - # .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-*-win64-pyside*-patch.play - - build-linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren - run: | - sudo apt-get update - sudo apt-get install -y \ - libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \ - libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \ - libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \ - libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \ - libxfixes3 libxi6 libxext6 fonts-liberation - - - name: Abhaengigkeiten installieren - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Playtube-Binary bauen - run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build - - - name: Als .tar.gz packen (volles Paket) - run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube - - - uses: actions/upload-artifact@v4 - with: - name: linux-build - path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz - - - name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates) - run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube - - - uses: actions/upload-artifact@v4 - with: - name: linux-patch - path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz + name: windows-edge + path: | + dist/installer/PlaytubeEdge-Setup-v*.exe + PlaytubeEdge-*-win64.zip publish-release: - needs: [build-windows, build-linux] + needs: [build-windows] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -134,8 +68,31 @@ jobs: path: artifacts merge-multiple: true - - name: GitHub Release veroeffentlichen + - name: GitHub Release veroeffentlichen (Vorabversion) uses: softprops/action-gh-release@v2 with: + name: Playtube Edge ${{ github.ref_name }} files: artifacts/* - generate_release_notes: true + prerelease: true + make_latest: "false" + body: | + ## Playtube Edge - die Version fuer eigene (hochgeladene) Musik + + **Diese Version brauchst du, wenn du selbst hochgeladene Songs in YouTube Music hoeren willst.** + Die normale Playtube-Version kann sie nicht abspielen ("Dieses Videoformat wird nicht + unterstuetzt"), weil ihre eingebaute Browser-Engine (QtWebEngine) kein AAC und kein H.264 + kann. Playtube Edge benutzt stattdessen die Microsoft-Edge-Engine (WebView2) - damit + laufen auch hochgeladene Titel. + + ### Installieren + - Download: **PlaytubeEdge-Setup-vX.Y.Z.exe** (Setup, empfohlen) oder die ZIP-Datei (portabel). + - Voraussetzung: Windows 10/11 mit "WebView2 Runtime" (auf Windows 11 schon vorhanden, + sonst kostenlos bei Microsoft). + - Playtube Edge laeuft **neben** der normalen Playtube (eigener Ordner, eigene Einstellungen). + Beim ersten Start musst du dich in YouTube/YouTube Music einmal neu anmelden. + Lass am besten nur eine der beiden Versionen gleichzeitig spielen. + - Updates: Playtube Edge aktualisiert sich nur ueber diese "Edge"-Releases - nie ueber die + normale Playtube. + + Wer keine eigenen Uploads abspielt, braucht diese Version nicht - die normale Playtube + reicht dann. diff --git a/.gitignore b/.gitignore index 734c424..7c0f77d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ __pycache__/ build/ dist/ run*.log +*_run.log +build_edge.log +.claude/ +.pytest_cache/ test_login*.log diff --git a/README.md b/README.md index 3aa74f5..64481c2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,23 @@ -# Playtube +# Playtube Edge + +> **Das ist die „Edge“-Variante von Playtube (Branch `edge`).** +> **Diese Version brauchst du, wenn du selbst hochgeladene Musik in YouTube Music hören +> willst.** Die normale Playtube (Branch `main`) zeigt bei eigenen Uploads „Dieses +> Videoformat wird nicht unterstützt“, weil ihre Browser-Engine (QtWebEngine) kein AAC und +> kein H.264 abspielen kann. Playtube Edge rendert stattdessen mit der **Microsoft-Edge-Engine +> (WebView2)** – damit laufen auch Uploads. +> +> - Download: [Releases](https://github.com/fojadrachi/Playtube/releases) → Eintrag „Playtube +> Edge vX.Y.Z-edge“ → `PlaytubeEdge-Setup-vX.Y.Z.exe`. Voraussetzung: Windows 10/11 mit +> WebView2-Runtime (Windows 11: vorinstalliert). +> - Läuft **neben** der normalen Playtube (eigener Ordner `%APPDATA%\PlaytubeEdge`, eigenes +> Login – beim ersten Start einmal bei Google anmelden). +> - Updates kommen nur über die `-edge`-Releases (die normale Playtube sieht sie nie). +> - Entwicklung: `pip install -r requirements.txt`, dann `python main.py`. Die +> WebView2-DLLs liegen in `vendor/webview2` (Microsoft, NuGet `Microsoft.Web.WebView2`). +> +> Der Rest dieser README beschreibt die gemeinsame Basis mit der normalen Playtube; wo dort +> „QtWebEngine“ steht, ist in dieser Variante WebView2 gemeint. Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine Erweiterung) mit: diff --git a/main.py b/main.py index fbbcb91..3439c86 100644 --- a/main.py +++ b/main.py @@ -14,10 +14,7 @@ from playtube import __version__, app_id # noqa: E402 from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402 from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # 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.shortcuts import ensure_start_menu_shortcut # noqa: E402 from playtube.updater import cleanup_old_staging # noqa: E402 app_id.set_app_user_model_id() @@ -26,12 +23,6 @@ app_id.configure_webengine_process_path() # High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in # playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login # das eingebettete QtWebEngine nicht als Embedded-WebView blockiert. -os.environ.setdefault( - "QTWEBENGINE_CHROMIUM_FLAGS", - "--disable-features=WinRetrieveSuggestionsOnlyOnDemand " - "--disable-blink-features=AutomationControlled", -) - from PySide6.QtCore import Qt # noqa: E402 from PySide6.QtGui import QIcon # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402 @@ -78,7 +69,7 @@ def main() -> int: # komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird. clear_cache_on_update(__version__) ensure_start_menu_shortcut(APP_NAME) - ensure_play_file_association(APP_NAME) + # Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates). window = MainWindow(config) window.show() diff --git a/packaging/build.ps1 b/packaging/build.ps1 index d46575a..cbff6a0 100644 --- a/packaging/build.ps1 +++ b/packaging/build.ps1 @@ -1,19 +1,11 @@ -# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine- -# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er -# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im -# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht. +# Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und +# danach den Setup-Installer (Inno Setup 6). # # Aufruf (im Projekt-Root, mit aktivierter venv): # powershell -ExecutionPolicy Bypass -File packaging\build.ps1 -# -# Optional: rcedit (https://github.com/electron/rcedit) im PATH oder unter -# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten -# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess" -# 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). + # Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6). [switch]$SkipInstaller ) @@ -21,53 +13,13 @@ $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root -Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan +Write-Host "==> Baue PlaytubeEdge.exe mit PyInstaller ..." -ForegroundColor Cyan pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." } - -$distDir = Join-Path $root "dist\Playtube" -$internalDir = Join-Path $distDir "_internal" -$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir } - -$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 -if (-not $engineProcess) { - Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen." -} else { - $helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe" - Copy-Item $engineProcess.FullName $helperPath -Force - Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan - - $rcedit = Get-Command rcedit -ErrorAction SilentlyContinue - $rceditLocal = Join-Path $PSScriptRoot "rcedit.exe" - if ($rcedit) { - $rceditExe = $rcedit.Source - } elseif (Test-Path $rceditLocal) { - $rceditExe = $rceditLocal - } else { - $rceditExe = $null - } - - if ($rceditExe) { - Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan - & $rceditExe $helperPath --set-icon "$root\assets\icon.ico" - & $rceditExe $helperPath --set-version-string "FileDescription" "Playtube" - & $rceditExe $helperPath --set-version-string "ProductName" "Playtube" - & $rceditExe $helperPath --set-version-string "CompanyName" "Playtube" - & $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe" - } else { - Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow - Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow - Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow - Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow - } -} - -Write-Host "" -Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green +Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.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 +# Ein und derselbe Installer richtet Playtube Edge 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 @@ -86,12 +38,11 @@ if (-not $SkipInstaller) { 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 + Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green } } diff --git a/packaging/playtube.iss b/packaging/playtube.iss index efbde28..32623a7 100644 --- a/packaging/playtube.iss +++ b/packaging/playtube.iss @@ -1,18 +1,19 @@ -; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe). +; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-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 +; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss +; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1). +; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.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. +; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube: +; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener +; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen +; parallel installiert sein; keine ueberschreibt die andere. +; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation +; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py +; uebereinstimmen. +; - Keine ".play"-Dateizuordnung und kein Beenden von Playtube.exe / PlaytubeHelper.exe - das +; gehoert alles der Qt-Version. ; ; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von ; Inno Setup nicht ins Spiel kommt. @@ -21,14 +22,13 @@ #define AppVersion "0.0.0" #endif -#define AppName "Playtube" -#define AppExeName "Playtube.exe" -#define AppHelperName "PlaytubeHelper.exe" +#define AppName "Playtube Edge" +#define AppDirName "PlaytubeEdge" +#define AppExeName "PlaytubeEdge.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} +; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg. +AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28} AppName={#AppName} AppVersion={#AppVersion} AppVerName={#AppName} {#AppVersion} @@ -37,22 +37,21 @@ 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} +DefaultDirName={localappdata}\Programs\{#AppDirName} DisableProgramGroupPage=yes PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible OutputDir=..\dist\installer -OutputBaseFilename=Playtube-Setup-v{#AppVersion} +OutputBaseFilename=PlaytubeEdge-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. +; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des +; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications. CloseApplications=no RestartApplications=no @@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Type: filesandordirs; Name: "{app}\_internal" [Files] -Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\dist\PlaytubeEdge\*"; 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. +; Normale (sichtbare) Installation: Haekchen "Playtube Edge 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. +; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten. Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch [Code] @@ -107,10 +95,8 @@ procedure WaitForOldInstance(); var OldPid, ResultCode: Integer; begin - { Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren - Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat, - statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert - hier nichts. } + { Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID + (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). } OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0); if OldPid > 0 then Exec(PowerShellPath(), @@ -122,16 +108,13 @@ procedure StopLeftoverProcesses(); var ResultCode: Integer; begin - { WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus - Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet - wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat - (so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). } + { WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist + also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur + PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. } Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - { QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem - Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden - (nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). } + { WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen, + wuerden Dateien sperren - gezielt nach Pfad beenden. } if DirExists(ExpandConstant('{app}')) then Exec(PowerShellPath(), '-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' + @@ -161,8 +144,8 @@ 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}') + ')', + '(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then - DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True); + DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True); end; end; diff --git a/packaging/playtube.spec b/packaging/playtube.spec index 032685c..e09edb1 100644 --- a/packaging/playtube.spec +++ b/packaging/playtube.spec @@ -4,22 +4,31 @@ import sys from pathlib import Path +from PyInstaller.utils.hooks import collect_all + block_cipher = None ROOT = Path(SPECPATH).resolve().parent +# Edge-Variante: pythonnet + clr_loader (bringen Python.Runtime.dll/ClrLoader.dll mit) und die +# Microsoft-WebView2-DLLs aus vendor/webview2 (siehe playtube/webview2_runtime.py). +pythonnet_datas, pythonnet_binaries, pythonnet_hidden = collect_all("pythonnet") +clr_datas, clr_binaries, clr_hidden = collect_all("clr_loader") + a = Analysis( [str(ROOT / "main.py")], pathex=[str(ROOT)], - binaries=[], + binaries=pythonnet_binaries + clr_binaries, datas=[ (str(ROOT / "assets"), "assets"), (str(ROOT / "config.json"), "."), - ], - hiddenimports=["pypresence"], + (str(ROOT / "vendor" / "webview2"), "vendor/webview2"), + ] + pythonnet_datas + clr_datas, + hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden, hookspath=[], runtime_hooks=[], - excludes=[], + # Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein. + excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"], noarchive=False, ) @@ -29,7 +38,7 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen. exe_kwargs = dict( exclude_binaries=True, - name="Playtube", + name="PlaytubeEdge", debug=False, bootloader_ignore_signals=False, strip=False, @@ -49,5 +58,5 @@ coll = COLLECT( a.datas, strip=False, upx=False, - name="Playtube", + name="PlaytubeEdge", ) diff --git a/packaging/version_info.txt b/packaging/version_info.txt index eddd93a..4df7038 100644 --- a/packaging/version_info.txt +++ b/packaging/version_info.txt @@ -19,12 +19,12 @@ VSVersionInfo( StringTable( u'040704B0', [StringStruct(u'CompanyName', u'Playtube'), - StringStruct(u'FileDescription', u'Playtube'), + StringStruct(u'FileDescription', u'Playtube Edge'), StringStruct(u'FileVersion', u'1.0.0.0'), - StringStruct(u'InternalName', u'Playtube'), + StringStruct(u'InternalName', u'PlaytubeEdge'), StringStruct(u'LegalCopyright', u''), - StringStruct(u'OriginalFilename', u'Playtube.exe'), - StringStruct(u'ProductName', u'Playtube'), + StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'), + StringStruct(u'ProductName', u'Playtube Edge'), StringStruct(u'ProductVersion', u'1.0.0.0')]) ]), VarFileInfo([VarStruct(u'Translation', [1031, 1200])]) diff --git a/playtube/__init__.py b/playtube/__init__.py index be62def..1b91219 100644 --- a/playtube/__init__.py +++ b/playtube/__init__.py @@ -1,4 +1,4 @@ """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" -__app_name__ = "Playtube" -__version__ = "4.5.1" +__app_name__ = "Playtube Edge" +__version__ = "1.0.0" diff --git a/playtube/audio_routing.py b/playtube/audio_routing.py index c2dfdf0..c842af6 100644 --- a/playtube/audio_routing.py +++ b/playtube/audio_routing.py @@ -30,15 +30,7 @@ from __future__ import annotations import json -from PySide6.QtCore import QUrl from PySide6.QtMultimedia import QMediaDevices -from PySide6.QtWebEngineCore import QWebEnginePermission, QWebEngineProfile - -# Name des eingeschleusten Skripts (pro Seite), um es beim Aendern ersetzen zu koennen. -SCRIPT_NAME = "playtube-audio-output" - -# Herkunft (Origin), fuer die die Berechtigung erteilt wird - nur die beiden Tabs. -YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com") SYSTEM_DEFAULT_LABEL = "Systemstandard" @@ -54,19 +46,13 @@ def list_output_devices() -> list[str]: return names -def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None: - """Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer - YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe - Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der - Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"), - daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt.""" - permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture - for origin in YOUTUBE_ORIGINS: - permission = profile.queryPermission(QUrl(origin), permission_type) - if wanted: - permission.grant() - else: - permission.reset() +def sync_audio_permissions(tabs, wanted: bool) -> None: + """Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die + Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der + Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die + Freigabe selbst (WebView2-Profil, bleibt dort gespeichert).""" + for tab in tabs: + tab.set_device_names_allowed(wanted) # JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt. diff --git a/playtube/browser.py b/playtube/browser.py index 0093709..af03b40 100644 --- a/playtube/browser.py +++ b/playtube/browser.py @@ -1,25 +1,23 @@ -"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login- -Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen -der aktuellen Wiedergabe fuer Discord Rich Presence.""" +"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit +persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich +Presence. + +Edge-Variante von Playtube: statt QtWebEngine (kein AAC/H.264 - hochgeladene Titel liessen sich +nicht abspielen) rendert hier WebView2. Die Schnittstelle von BrowserTab ist dieselbe wie in der +Qt-Variante, damit MainWindow, Fernsteuerung und Discord unveraendert bleiben. +""" from __future__ import annotations +import ctypes import json import os -import sys +from typing import Any, Callable -from PySide6.QtCore import QTimer, Signal, QUrl -from PySide6.QtWebEngineCore import ( - QWebEnginePage, - QWebEngineProfile, - QWebEngineScript, - QWebEngineSettings, - QWebEngineUrlRequestInterceptor, -) -from PySide6.QtWebEngineWidgets import QWebEngineView +from PySide6.QtCore import Qt, QTimer, QUrl, Signal +from PySide6.QtWidgets import QWidget -from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME +from . import webview2_runtime from .audio_routing import build_router_script -from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS from .config import profile_dir from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS @@ -27,214 +25,328 @@ from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PL # Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen # Moment, bis z.B. der neue Titel/Like-Status im DOM steht). _REFRESH_AFTER_COMMAND_MS = 300 +_TASK_POLL_MS = 25 +_MEDIA_POLL_MS = 2000 +_YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com") +# Ohne Nutzergeste starten koennen (Playlist-Start per Stream Dock, Autoplay). +_BROWSER_ARGUMENTS = "--autoplay-policy=no-user-gesture-required" -# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten -# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy- -# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS, -# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome") -# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges -# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist -# unter Umstaenden nicht sicher". -_CHROME_VERSION = CHROME_FULL -_CHROME_MAJOR = CHROME_MAJOR +_user32 = ctypes.windll.user32 if os.name == "nt" else None +if _user32 is not None: + # 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross). + _user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t + _user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int) + _user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t + _user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t) + _user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p) + _user32.MoveWindow.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_bool) + _user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int) -if sys.platform == "win32": - _UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64" - _SEC_CH_UA_PLATFORM = b'"Windows"' - _SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"' -elif sys.platform.startswith("linux"): - _UA_PLATFORM_TOKEN = "X11; Linux x86_64" - _SEC_CH_UA_PLATFORM = b'"Linux"' - _SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"' -else: - _UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7" - _SEC_CH_UA_PLATFORM = b'"macOS"' - _SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"' - -_USER_AGENT = ( - f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 " - f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36" -) - -_SEC_CH_UA = ( - f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", ' - f'"Chromium";v="{_CHROME_MAJOR}"' -).encode("ascii") -_SEC_CH_UA_FULL_VERSION_LIST = ( - f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", ' - f'"Chromium";v="{_CHROME_VERSION}"' -).encode("ascii") +_GWL_STYLE = -16 +_WS_POPUP = 0x80000000 +_WS_CAPTION = 0x00C00000 +_WS_CHILD = 0x40000000 +_WS_VISIBLE = 0x10000000 +_WS_CLIPSIBLINGS = 0x04000000 +_WS_CLIPCHILDREN = 0x02000000 +_SW_SHOW = 5 -class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor): - """Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit - sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise - nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles - Login-Seite ein Embedded-WebView und blockiert die Anmeldung).""" - - def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override) - info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA) - info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST) - info.setHttpHeader(b"sec-ch-ua-mobile", b"?0") - info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM) - info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION) +def _decode_script_result(raw: Any) -> Any: + """ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst + einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert.""" + if raw is None: + return None + try: + value = json.loads(str(raw)) + except (TypeError, ValueError): + return None + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value -_shared_profile: QWebEngineProfile | None = None -_shared_interceptor: _ChromeBrandingInterceptor | None = None +def _in_document(source: str) -> str: + """Umhuellt ein Skript, das document.documentElement braucht, damit es auch beim frueh + (vor dem DOM) ausgefuehrten WebView2-Dokumentskript funktioniert.""" + return ( + "(function(){function go(){" + source + "}" + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',go);}" + "else{go();}})();" + ) -def get_shared_profile() -> QWebEngineProfile: - """Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges - Google-Login fuer YouTube und YouTube Music gleichermassen gilt.""" - global _shared_profile, _shared_interceptor - if _shared_profile is None: - _shared_profile = QWebEngineProfile("playtube-profile") - _shared_profile.setPersistentStoragePath(str(profile_dir() / "storage")) - _shared_profile.setCachePath(str(profile_dir() / "cache")) - _shared_profile.setPersistentCookiesPolicy( - QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies - ) - _shared_profile.setHttpUserAgent(_USER_AGENT) - _shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7") - - # Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein. - _shared_interceptor = _ChromeBrandingInterceptor() - _shared_profile.setUrlRequestInterceptor(_shared_interceptor) - - # JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) - - # muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld. - shim_script = QWebEngineScript() - shim_script.setName("playtube-chrome-shim") - shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) - shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) - shim_script.setRunsOnSubFrames(True) - shim_script.setSourceCode(CHROME_SHIM_JS) - _shared_profile.scripts().insert(shim_script) - return _shared_profile - - -class BrowserTab(QWebEngineView): - """Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence.""" +class BrowserTab(QWidget): + """Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence.""" mediaInfoChanged = Signal(dict) titleUpdated = Signal(str) + loadFinished = Signal(bool) + urlChanged = Signal(QUrl) def __init__(self, home_url: str, parent=None): super().__init__(parent) self._home_url = home_url - self._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output) + self._current_url = QUrl(home_url) + self._audio_output_set = False + self._allow_device_names = False + self._router_script_id: str | None = None + self._core = None + self._pending: list[tuple[Any, Callable[[Any], None] | None]] = [] + self._queued_urls: list[str] = [home_url] + self._queued_router: str | None = None - page = QWebEnginePage(get_shared_profile(), self) - self.setPage(page) + # Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt. + self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) + self.setMinimumSize(200, 150) - settings = page.settings() - settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True) - settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False) - settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True) - settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True) - settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True) - settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True) + runtime = webview2_runtime.load() + self._runtime = runtime + properties = runtime.CreationProperties() + properties.UserDataFolder = str(profile_dir() / "webview2") + properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS + properties.Language = "de-DE" + self._view = runtime.WebView2() + self._view.CreationProperties = properties + self._view.CoreWebView2InitializationCompleted += self._on_core_initialized - page.fullScreenRequested.connect(self._on_full_screen_requested) - - self.load(QUrl(home_url)) + self._task_timer = QTimer(self) + self._task_timer.setInterval(_TASK_POLL_MS) + self._task_timer.timeout.connect(self._drain_tasks) + self._task_timer.start() self._poll_timer = QTimer(self) - self._poll_timer.setInterval(2000) + self._poll_timer.setInterval(_MEDIA_POLL_MS) self._poll_timer.timeout.connect(self._poll_media_state) self._poll_timer.start() - self.titleChanged.connect(self.titleUpdated.emit) + # Steuerelement erst nach dem Aufbau des Fensters erzeugen. + QTimer.singleShot(0, self._create_control) + + # ------------------------------------------------------------ Aufbau + + def _create_control(self) -> None: + self._view.CreateControl() + handle = self._handle() + # Aus dem frei stehenden WinForms-Fenster ein sichtbares Kindfenster des Qt-Widgets machen. + style = _user32.GetWindowLongPtrW(handle, _GWL_STYLE) + style = (style & ~_WS_POPUP & ~_WS_CAPTION) | _WS_CHILD | _WS_VISIBLE | _WS_CLIPSIBLINGS | _WS_CLIPCHILDREN + _user32.SetWindowLongPtrW(handle, _GWL_STYLE, style) + _user32.SetParent(handle, int(self.winId())) + _user32.ShowWindow(handle, _SW_SHOW) + self._fit_to_widget() + self._view.EnsureCoreWebView2Async(None) + + def _handle(self) -> int: + return int(self._view.Handle.ToInt64()) + + def _fit_to_widget(self) -> None: + if _user32 is None or self._view is None or not self._view.IsHandleCreated: + return + ratio = self.devicePixelRatioF() + _user32.MoveWindow(self._handle(), 0, 0, int(self.width() * ratio), int(self.height() * ratio), True) + + def resizeEvent(self, event) -> None: # noqa: N802 (Qt-Override) + super().resizeEvent(event) + self._fit_to_widget() + + def _on_core_initialized(self, _sender, args) -> None: + try: + self._setup_core(args) + except Exception: # pythonnet verschluckt Fehler in Ereignis-Handlern sonst still + import traceback + traceback.print_exc() + + def _setup_core(self, args) -> None: + if not args.IsSuccess: + print(f"[webview2] Initialisierung fehlgeschlagen: {args.InitializationException}", flush=True) + self.loadFinished.emit(False) + return + core = self._view.CoreWebView2 + self._core = core + settings = core.Settings + settings.IsStatusBarEnabled = False + settings.AreDefaultScriptDialogsEnabled = True + core.NavigationCompleted += lambda _s, a: self.loadFinished.emit(bool(a.IsSuccess)) + core.SourceChanged += lambda _s, _a: self._on_source_changed() + core.DocumentTitleChanged += lambda _s, _a: self.titleUpdated.emit(str(core.DocumentTitle or "")) + core.NewWindowRequested += self._on_new_window + core.ContainsFullScreenElementChanged += lambda _s, _a: self._on_fullscreen_changed() + + self._apply_device_name_permission() + if self._queued_router is not None: + self._install_router(self._queued_router) + self._queued_router = None + for url in self._queued_urls: + core.Navigate(url) + self._queued_urls.clear() + + def _on_source_changed(self) -> None: + self._current_url = QUrl(str(self._core.Source)) + self.urlChanged.emit(self._current_url) + + def _on_new_window(self, _sender, args) -> None: + # Links mit target=_blank im selben Tab oeffnen (kein zusaetzliches Fenster). + args.Handled = True + self.setUrl(QUrl(str(args.Uri))) + + def _on_fullscreen_changed(self) -> None: + window = self.window() + if self._core.ContainsFullScreenElement: + window.showFullScreen() + else: + window.showNormal() + self._fit_to_widget() + + # ------------------------------------------------ asynchrone .NET-Aufgaben + + def _await(self, task: Any, callback: Callable[[Any], None] | None = None) -> None: + """Wartet ohne Blockieren auf eine .NET-Task und ruft danach callback(Ergebnis) auf.""" + self._pending.append((task, callback)) + + def _drain_tasks(self) -> None: + if not self._pending: + return + still_pending = [] + for task, callback in self._pending: + if not task.IsCompleted: + still_pending.append((task, callback)) + continue + if callback is None: + continue + try: + callback(None if task.IsFaulted or task.IsCanceled else task.Result) + except Exception: # Fehler im Rueckruf duerfen die Warteschlange nicht blockieren + pass + self._pending = still_pending + + def _run_js(self, script: str, callback: Callable[[Any], None] | None = None) -> None: + if self._core is None: + return + task = self._core.ExecuteScriptAsync(script) + self._await(task, (lambda raw: callback(_decode_script_result(raw))) if callback else None) + + # ------------------------------------------------------------ Navigation + + def url(self) -> QUrl: + return self._current_url + + def setUrl(self, url: QUrl) -> None: # noqa: N802 (Qt-kompatibler Name) + target = url.toString() + self._current_url = url + if self._core is None: + self._queued_urls = [target] + else: + self._core.Navigate(target) def go_home(self) -> None: - self.load(QUrl(self._home_url)) + self.setUrl(QUrl(self._home_url)) + + def back(self) -> None: + if self._core is not None and self._core.CanGoBack: + self._core.GoBack() + + def forward(self) -> None: + if self._core is not None and self._core.CanGoForward: + self._core.GoForward() + + def reload(self) -> None: + if self._core is not None: + self._core.Reload() + + # ------------------------------------------------------ Audio-Ausgabe def set_audio_output(self, device_name: str) -> None: """Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer = Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und - bleibt bei Seitenwechseln/Neuladen erhalten (Skript pro Seite).""" - page = self.page() - scripts = page.scripts() - for old in scripts.find(AUDIO_SCRIPT_NAME): - scripts.remove(old) - + bleibt bei Seitenwechseln erhalten (Skript pro Dokument).""" source = build_router_script(device_name) - if device_name: - script = QWebEngineScript() - script.setName(AUDIO_SCRIPT_NAME) - script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) - script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) - script.setRunsOnSubFrames(False) - script.setSourceCode(source) - scripts.insert(script) - - # Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard, - # falls vorher ein eigenes Geraet gewaehlt war. Ohne jemals gewaehltes Geraet wird die - # Seite gar nicht angefasst. - if device_name or self._audio_output_set: - page.runJavaScript(source) + if self._core is None: + self._queued_router = source if device_name else None + else: + self._install_router(source if device_name else None) + # Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard. + if device_name or self._audio_output_set: + self._run_js(source) self._audio_output_set = bool(device_name) + def _install_router(self, source: str | None) -> None: + old_id, self._router_script_id = self._router_script_id, None + if old_id: + self._core.RemoveScriptToExecuteOnDocumentCreated(old_id) + if source: + task = self._core.AddScriptToExecuteOnDocumentCreatedAsync(_in_document(source)) + self._await(task, self._remember_router_id) + + def _remember_router_id(self, script_id: Any) -> None: + self._router_script_id = str(script_id) if script_id else None + + def set_device_names_allowed(self, allowed: bool) -> None: + """Erlaubt der Seite, die Namen der Ausgabegeraete zu sehen (siehe audio_routing.py).""" + self._allow_device_names = allowed + self._apply_device_name_permission() + + def _apply_device_name_permission(self) -> None: + if self._core is None: + return + state = self._runtime.PermissionState.Allow if self._allow_device_names else self._runtime.PermissionState.Default + try: + for origin in _YOUTUBE_ORIGINS: + self._core.Profile.SetPermissionStateAsync(self._runtime.PermissionKind.Microphone, origin, state) + except Exception as error: # aeltere Runtime ohne diese API: Seite laedt trotzdem + print(f"[webview2] Geraetenamen-Freigabe nicht setzbar: {error}", flush=True) + + # ----------------------------------------------------------- Wiedergabe + def toggle_playback(self) -> None: - self.page().runJavaScript(TOGGLE_PLAYBACK_JS) + self._run_js(TOGGLE_PLAYBACK_JS) def next_track(self) -> None: - self.page().runJavaScript(NEXT_TRACK_JS) + self._run_js(NEXT_TRACK_JS) def previous_track(self) -> None: - self.page().runJavaScript(PREV_TRACK_JS) + self._run_js(PREV_TRACK_JS) def run_media_command(self, command: str, value: float = 0) -> None: """Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand sofort statt erst beim naechsten 2-Sekunden-Poll sieht.""" - self.page().runJavaScript(build_control_js(command, value)) + self._run_js(build_control_js(command, value)) QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state) def list_playlists(self, callback) -> None: """Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf.""" - - def on_result(result) -> None: - try: - callback(json.loads(result) if isinstance(result, str) and result else None) - except json.JSONDecodeError: - callback(None) - - self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result) + if self._core is None: + callback(None) + return + self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None)) def play_playlist(self, playlist_id: str) -> None: """Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet - die Wiedergabe (PlaybackRequiresUserGesture ist aus).""" - self.load(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id))) - - def _on_full_screen_requested(self, request) -> None: - # Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button). - request.accept() - window = self.window() - if request.toggleOn(): - window.showFullScreen() - else: - window.showNormal() + die Wiedergabe.""" + self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id))) def _poll_media_state(self) -> None: - self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result) - - def _on_media_probe_result(self, result) -> None: - # runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig - # als dict (siehe media_probe.py) - das Probe-Skript gibt daher einen - # JSON-String zurueck, der hier geparst wird. - info = None - if isinstance(result, str) and result: - try: - info = json.loads(result) - except json.JSONDecodeError: - info = None - elif isinstance(result, dict): - info = result + self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result) + def _on_media_probe_result(self, info: Any) -> None: if os.environ.get("PLAYTUBE_DEBUG"): - print(f"[media-probe:{self._home_url}] roh={result!r} geparst={info!r}", flush=True) - + print(f"[media-probe:{self._home_url}] geparst={info!r}", flush=True) if isinstance(info, dict): self.mediaInfoChanged.emit(info) + + # ----------------------------------------------------------- Aufraeumen + + def closeEvent(self, event) -> None: # noqa: N802 (Qt-Override) + self.dispose() + super().closeEvent(event) + + def dispose(self) -> None: + self._task_timer.stop() + self._poll_timer.stop() + if self._view is not None: + self._view.Dispose() + self._view = None diff --git a/playtube/config.py b/playtube/config.py index 8c17649..6ec9f98 100644 --- a/playtube/config.py +++ b/playtube/config.py @@ -8,15 +8,18 @@ import sys from pathlib import Path from typing import Any -APP_NAME = "Playtube" -APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID +APP_NAME = "Playtube Edge" +APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID # Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner # ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer # gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass # ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw. # denselben Browser-Profil-Lock geteilt haben). -_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev" +# +# Edge-Variante (WebView2): eigene Ordnernamen ("PlaytubeEdge"/"PlaytubeEdgeDev"), damit sie sich +# nie mit der Qt-Variante ueberschneidet (Profil, Einzelinstanz-Schutz, Fernsteuerungs-Pipe). +_DATA_DIR_NAME = "PlaytubeEdge" if getattr(sys, "frozen", False) else "PlaytubeEdgeDev" DEFAULT_CONFIG: dict[str, Any] = { "app_name": APP_NAME, @@ -28,6 +31,7 @@ DEFAULT_CONFIG: dict[str, Any] = { # Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren. "show_idle_presence": True, }, + # Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py). "updates": { "enabled": True, "check_interval_hours": 6, diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index 7a31e51..5a699f1 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -21,7 +21,7 @@ from PySide6.QtWidgets import ( from . import __version__ as APP_VERSION from .audio_routing import sync_audio_permissions -from .browser import BrowserTab, get_shared_profile +from .browser import BrowserTab from .config import APP_NAME from .discord_rpc import DiscordRPCWorker from .remote_control import build_state as build_remote_state @@ -57,9 +57,9 @@ class MainWindow(QMainWindow): self._tabs.setDocumentMode(True) self.setCentralWidget(self._tabs) - self._sync_audio_permission() self._youtube_tab = BrowserTab(config["home_youtube"], self) self._music_tab = BrowserTab(config["home_music"], self) + self._sync_audio_permission() # Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte # Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach # erteilte schon (siehe audio_routing.py). @@ -254,7 +254,7 @@ class MainWindow(QMainWindow): def _sync_audio_permission(self) -> None: """Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) - nur solange mindestens ein Tab ein eigenes Geraet nutzt.""" - sync_audio_permissions(get_shared_profile(), any(self._audio_outputs())) + sync_audio_permissions((self._youtube_tab, self._music_tab), any(self._audio_outputs())) def _on_tab_load_finished(self, _ok: bool) -> None: if any(self._audio_outputs()): diff --git a/playtube/updater.py b/playtube/updater.py index c8bc627..b0a5238 100644 --- a/playtube/updater.py +++ b/playtube/updater.py @@ -54,16 +54,19 @@ from PySide6.QtCore import QThread, Signal from . import __version__ as CURRENT_VERSION GITHUB_REPO = "fojadrachi/Playtube" -_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" -_USER_AGENT = "Playtube-Updater" +# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die +# Qt-Version sie nie als "neueste Version" sieht) - deshalb die Liste statt /releases/latest. +_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=30" +_EDGE_TAG_SUFFIX = "-edge" +_USER_AGENT = "PlaytubeEdge-Updater" -_LINUX_BINARY_NAME = "Playtube" -_WINDOWS_BINARY_NAME = "Playtube.exe" +_LINUX_BINARY_NAME = "PlaytubeEdge" +_WINDOWS_BINARY_NAME = "PlaytubeEdge.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}" +_INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}" _STAGING_PREFIX = "playtube_update_" @@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None: ) try: with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read().decode("utf-8")) + releases = json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError): return None + if not isinstance(releases, list): + return None + # Neueste Edge-Version = hoechste Versionsnummer unter den "-edge"-Releases (ohne Entwuerfe). + edge_releases = [ + release for release in releases + if isinstance(release, dict) and not release.get("draft") + and str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX) + ] + return max(edge_releases, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None) def is_installed_via_setup() -> bool: @@ -125,6 +137,10 @@ def _asset_kind(name: str) -> str | None: *.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip. Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket.""" name = name.lower() + # Zweite Absicherung neben dem "-edge"-Tag: nur Dateien der Edge-Variante kommen in Frage, + # nie ein Paket der Qt-Version ("Playtube-..."). + if "playtubeedge" not in name: + return None if sys.platform == "win32": if name.endswith(".exe") and "setup" in name: return _KIND_SETUP diff --git a/playtube/webview2_runtime.py b/playtube/webview2_runtime.py new file mode 100644 index 0000000..d8874f4 --- /dev/null +++ b/playtube/webview2_runtime.py @@ -0,0 +1,61 @@ +"""Laedt Microsoft Edge WebView2 (per pythonnet / .NET Framework) fuer den Browser-Tab. + +Die DLLs (Microsoft.Web.WebView2.Core/WinForms, WebView2Loader) liegen im Projektordner +vendor/webview2 (offizielles NuGet-Paket "Microsoft.Web.WebView2"). Die eigentliche +Browser-Engine ist die auf Windows 10/11 vorinstallierte "WebView2 Runtime" (Edge) - sie +bringt AAC/H.264 mit, die in QtWebEngine fehlen. +""" +from __future__ import annotations + +import os +import sys +from functools import lru_cache +from pathlib import Path +from types import SimpleNamespace + + +class WebView2Unavailable(RuntimeError): + """WebView2 konnte nicht geladen werden (DLLs oder Runtime fehlen).""" + + +def _vendor_dir() -> Path: + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)) + return base / "vendor" / "webview2" + + +@lru_cache(maxsize=1) +def load() -> SimpleNamespace: + """Laedt .NET + WebView2 einmalig und gibt die benoetigten Klassen zurueck.""" + vendor = _vendor_dir() + core_dll = vendor / "Microsoft.Web.WebView2.Core.dll" + winforms_dll = vendor / "Microsoft.Web.WebView2.WinForms.dll" + if not core_dll.exists() or not winforms_dll.exists(): + raise WebView2Unavailable(f"WebView2-DLLs fehlen in {vendor}") + + # WebView2Loader.dll (nativ) wird ueber den Suchpfad bzw. runtimes/win-x64/native gefunden. + os.environ["PATH"] = f"{vendor}{os.pathsep}{os.environ.get('PATH', '')}" + try: + from pythonnet import load as load_runtime + + load_runtime("netfx") + import clr + + clr.AddReference("System.Windows.Forms") + clr.AddReference("System.Drawing") + clr.AddReference(str(core_dll)) + clr.AddReference(str(winforms_dll)) + + from Microsoft.Web.WebView2.Core import ( + CoreWebView2PermissionKind, + CoreWebView2PermissionState, + ) + from Microsoft.Web.WebView2.WinForms import CoreWebView2CreationProperties, WebView2 + except Exception as error: # pythonnet wirft je nach Fehler unterschiedliche Typen + raise WebView2Unavailable(f"WebView2 konnte nicht geladen werden: {error}") from error + + return SimpleNamespace( + WebView2=WebView2, + CreationProperties=CoreWebView2CreationProperties, + PermissionKind=CoreWebView2PermissionKind, + PermissionState=CoreWebView2PermissionState, + ) diff --git a/requirements.txt b/requirements.txt index faa020b..6ccfe19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PySide6>=6.10 pypresence>=4.3 +pythonnet>=3.1 diff --git a/vendor/webview2/LICENSE.txt b/vendor/webview2/LICENSE.txt new file mode 100644 index 0000000..45f0297 --- /dev/null +++ b/vendor/webview2/LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (C) Microsoft Corporation. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * The name of Microsoft Corporation, or the names of its contributors +may not be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/vendor/webview2/Microsoft.Web.WebView2.Core.dll b/vendor/webview2/Microsoft.Web.WebView2.Core.dll new file mode 100644 index 0000000..9ed3f0a Binary files /dev/null and b/vendor/webview2/Microsoft.Web.WebView2.Core.dll differ diff --git a/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll b/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll new file mode 100644 index 0000000..4792f9a Binary files /dev/null and b/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll differ diff --git a/vendor/webview2/NOTICE.txt b/vendor/webview2/NOTICE.txt new file mode 100644 index 0000000..bb64ba3 --- /dev/null +++ b/vendor/webview2/NOTICE.txt @@ -0,0 +1,85 @@ +NOTICES AND INFORMATION +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at https://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + +Source Code Compliance Team +Microsoft Corporation +One Microsoft Way +Redmond, WA 98052 +USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. + +---------------------------------------------------------------- + +Antlr3.Runtime 3.5.2-rc1 - BSD 3-Clause + +[The "BSD license"] +Copyright (c) 2011 The ANTLR Project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------- + +--------------------------------------------------------- + +StringTemplate4 4.0.9-rc1 - BSD 3-Clause + +[The "BSD license"] +Copyright (c) 2011 The ANTLR Project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------- diff --git a/vendor/webview2/WebView2Loader.dll b/vendor/webview2/WebView2Loader.dll new file mode 100644 index 0000000..2cc50a9 Binary files /dev/null and b/vendor/webview2/WebView2Loader.dll differ diff --git a/vendor/webview2/runtimes/win-x64/native/WebView2Loader.dll b/vendor/webview2/runtimes/win-x64/native/WebView2Loader.dll new file mode 100644 index 0000000..2cc50a9 Binary files /dev/null and b/vendor/webview2/runtimes/win-x64/native/WebView2Loader.dll differ