Files
Playtube/playtube/single_instance.py
Fojadrachi 04bc96d33e Setup-Installer, Einzelinstanz-Schutz und Update-System mit einer einzigen Installation
- Inno-Setup-Installer (packaging/playtube.iss): installiert pro Benutzer nach
  %LOCALAPPDATA%\Programs\Playtube, feste AppId -> jede neue Version ueberschreibt die
  vorhandene Installation an Ort und Stelle, raeumt den alten _internal-Ordner auf und
  beendet laufende Playtube-Prozesse vor dem Kopieren
- Updater: installierte Kopie nimmt das kleine Patch (Versionseintrag in "Apps &
  Features" wird nachgezogen), portable Kopie wechselt per stillem Setup in die
  regulaere Installation; Patch nur bei passender PySide6-Version (steht im Dateinamen),
  sonst Setup; Aufraeumen des heruntergeladenen Installers
- Einzelinstanz-Schutz (playtube/single_instance.py): zwei Prozesse auf demselben
  Browser-Profil liessen auf YouTube alle Icons verschwinden; ein zweiter Start holt jetzt
  das laufende Fenster nach vorn (auch .play-Patchdateien werden weitergereicht)
- build.ps1 baut den Installer mit, Release-Pipeline haengt ihn an jedes Release
- README: Installation, Update-Ablauf, Troubleshooting fuer fehlende Icons
2026-09-19 19:25:05 +02:00

87 lines
3.6 KiB
Python

"""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()