Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.
## 1. „Was ist mit dem Cover auf Windows Rippy?"
Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:
if movie.get("title", "").lower() == kandidat.lower():
An seiner Disc gemessen:
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
'Evangelion' 20 Treffer irgendein Evangelion
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster — und wurde verworfen. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:
Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text
## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
## Arbeitsverzeichnis zu aendern? momentan geht das nicht."
Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:
* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.
Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.
## 3. „Failed to remove temporary directory: …_MEI0000b0882"
GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.
Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.
794 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
917 lines
36 KiB
Python
917 lines
36 KiB
Python
"""Rippy für Windows: Installer, Hintergrunddienst und Deinstallation in EINER Datei.
|
|
|
|
## Warum eine Datei und nicht drei
|
|
|
|
Die naheliegende Aufteilung wäre `RippySetup.exe`, `Rippy.exe` und
|
|
`RippyUninstall.exe`. Drei PyInstaller-Bauten, drei Signaturen, drei
|
|
Versionsstände, die auseinanderlaufen können — und ein Deinstaller, der sich
|
|
selbst löschen müsste.
|
|
|
|
Stattdessen: **ein Programm, drei Betriebsarten.**
|
|
|
|
RippySetup.exe installiert (Doppelklick)
|
|
Rippy.exe --dienst laeuft im Hintergrund
|
|
Rippy.exe --deinstallieren raeumt sich weg
|
|
|
|
Bei der Installation kopiert sich die Datei nach
|
|
`%LOCALAPPDATA%\\Rippy\\Rippy.exe`. Der Name ist Absicht: **Im Taskmanager
|
|
steht dann „Rippy.exe"** — was der Commander ausdrücklich wollte. Ein Prozess,
|
|
den man nicht wiederfindet, kann man auch nicht beenden.
|
|
|
|
## Warum kein Windows-Dienst
|
|
|
|
Ein echter Dienst startet vor der Anmeldung — klingt besser, kostet aber bei
|
|
JEDER Installation und jedem Update eine Administrator-Abfrage, und ein Dienst
|
|
kann kein Tray-Symbol zeigen (er hat keine Benutzersitzung). Rippy nimmt den
|
|
Autostart-Eintrag unter `HKCU\\...\\Run`: kein UAC, Tray funktioniert, und mit
|
|
einem Registry-Eintrag wieder weg.
|
|
|
|
## Warum das Tray-Symbol keine Nebensache ist
|
|
|
|
Ein Hintergrundprozess ohne sichtbaren Zugang ist eine Zumutung: Man weiß
|
|
nicht, ob er läuft, kommt nicht an die Oberfläche und kann ihn nicht beenden,
|
|
ohne den Taskmanager zu bemühen. Das Symbol beantwortet alle drei Fragen.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
|
|
from rippy.platform import verknuepfungen
|
|
from rippy.platform import win_registry as reg
|
|
|
|
VERSION = os.environ.get("RIPPY_VERSION", "2.0.0")
|
|
STANDARD_PORT = 7788
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════ Hilfen ═════
|
|
def _eigene_datei() -> str:
|
|
"""Der Pfad DIESER ausführbaren Datei (auch im PyInstaller-Paket)."""
|
|
if getattr(sys, "frozen", False):
|
|
return sys.executable
|
|
return os.path.abspath(sys.argv[0])
|
|
|
|
|
|
def _mitgeliefert(name: str) -> str:
|
|
"""Eine mitgepackte Datei (Icon, UI, API) im Paket oder im Repo finden."""
|
|
gebuendelt = getattr(sys, "_MEIPASS", None)
|
|
if gebuendelt:
|
|
return os.path.join(gebuendelt, name)
|
|
hier = os.path.dirname(os.path.abspath(__file__))
|
|
repo = os.path.dirname(os.path.dirname(hier))
|
|
return os.path.join(repo, name)
|
|
|
|
|
|
def _icon_pfad() -> str:
|
|
for kandidat in (_mitgeliefert("rippy.ico"),
|
|
_mitgeliefert(os.path.join("deploy", "worker-windows", "rippy.ico"))):
|
|
if os.path.isfile(kandidat):
|
|
return kandidat
|
|
return ""
|
|
|
|
|
|
def melden(text: str) -> None:
|
|
"""Ausgabe, die auch ohne Konsole nicht abstürzt.
|
|
|
|
Eine als `--windowed` gebaute EXE hat keine Standardausgabe; `print()`
|
|
schreibt dann ins Leere oder wirft. Und unter Windows kann eine
|
|
Konsolen-Ausgabe an einem Umlaut sterben (cp1252) — genau daran ist der
|
|
erste Standalone-Start gescheitert. Deshalb hier: nie werfen.
|
|
"""
|
|
try:
|
|
sys.stdout.write(text + "\n")
|
|
sys.stdout.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def abschluss_melden(text: str, still: bool = False, fehler: bool = False) -> bool:
|
|
"""Das Ergebnis so melden, dass der Nutzer es WIRKLICH sieht.
|
|
|
|
Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am
|
|
28.08.2026: „Unterbinde das CMD Fenster"), geht `melden()` beim
|
|
Doppelklick in eine Protokolldatei — die niemand aufschlägt. Ein
|
|
gescheiterter Installer sähe damit exakt aus wie einer, der nichts tut.
|
|
|
|
Deshalb hier zusätzlich ein Meldungsfenster, und zwar NUR wenn keine
|
|
Konsole da ist: Wer das Setup in PowerShell aufruft, will keinen Klick
|
|
wegdrücken, sondern die Zeile lesen.
|
|
|
|
Gibt zurück, ob ein Fenster gezeigt wurde.
|
|
"""
|
|
melden(text.replace("\n\n", "\n"))
|
|
if still:
|
|
return False
|
|
from rippy.platform import winlauf
|
|
|
|
if not winlauf.ohne_konsole():
|
|
return False
|
|
return winlauf.meldung_zeigen(text, fehler=fehler)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════ Installation ═══════
|
|
def installieren(ziel: str = None, autostart: bool = True,
|
|
starten_danach: bool = True, verknuepfen: bool = True) -> str:
|
|
"""Kopiert Rippy an seinen Platz und meldet es bei Windows an.
|
|
|
|
Gibt den Installationsordner zurück. Wirft bei echten Fehlern — eine
|
|
halb geglückte Installation soll NICHT wie ein Erfolg aussehen.
|
|
"""
|
|
ziel = ziel or reg.standard_installationsordner()
|
|
os.makedirs(ziel, exist_ok=True)
|
|
|
|
quelle = _eigene_datei()
|
|
programm = os.path.join(ziel, "Rippy.exe")
|
|
|
|
# Sich selbst kopieren. Läuft gerade eine ältere Fassung, ist die Datei
|
|
# gesperrt — dann daneben legen und beim nächsten Start tauschen.
|
|
if os.path.abspath(quelle).lower() != os.path.abspath(programm).lower():
|
|
try:
|
|
shutil.copy2(quelle, programm)
|
|
except PermissionError:
|
|
ersatz = programm + ".neu"
|
|
shutil.copy2(quelle, ersatz)
|
|
melden("Rippy läuft gerade — die neue Fassung wird beim nächsten "
|
|
"Start übernommen.")
|
|
programm = ersatz
|
|
|
|
icon = _icon_pfad()
|
|
if icon and os.path.isfile(icon):
|
|
try:
|
|
shutil.copy2(icon, os.path.join(ziel, "rippy.ico"))
|
|
except OSError:
|
|
pass
|
|
|
|
ui_auspacken(ziel)
|
|
datenbank_umziehen()
|
|
|
|
# Mitgelieferte Werkzeuge (HandBrakeCLI) neben das Programm legen. Dort
|
|
# sucht katalog.kandidaten() VOR allen Systempfaden.
|
|
from rippy.tools import einrichten
|
|
|
|
for name in einrichten.mitgelieferte_werkzeuge(_mitgeliefert("tools"), ziel):
|
|
melden(" Werkzeug bereitgestellt: %s" % name)
|
|
|
|
# Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein
|
|
# Programm, das man nur von Hand wieder loswird.
|
|
reg.eintragen(ziel, programm, VERSION,
|
|
icon=os.path.join(ziel, "rippy.ico") if icon else "")
|
|
|
|
if autostart:
|
|
reg.autostart_setzen('"%s" --dienst' % programm)
|
|
|
|
if verknuepfen:
|
|
# Ohne Symbol auf dem Desktop und ohne Startmenue-Eintrag ist ein
|
|
# Hintergrundprogramm praktisch unauffindbar: Es laeuft, aber der
|
|
# Nutzer hat keinen Weg hinein ausser der Adresse im Kopf.
|
|
angelegt = verknuepfungen.alle_anlegen(
|
|
programm, icon=os.path.join(ziel, "rippy.ico"))
|
|
for ort in ("desktop", "startmenue"):
|
|
if ort in angelegt:
|
|
melden(" Verknuepfung angelegt: %s" % angelegt[ort])
|
|
else:
|
|
# Sagen, was NICHT geklappt hat — sonst sucht der Nutzer ein
|
|
# Symbol, das es nie gab.
|
|
melden(" Verknuepfung (%s) konnte nicht angelegt werden." % ort)
|
|
|
|
if starten_danach:
|
|
starte_hintergrund(programm)
|
|
|
|
return ziel
|
|
|
|
|
|
def beenden_skript(ordner: str, eigene_pids: list) -> str:
|
|
"""Das PowerShell-Skript, das die ANDEREN Rippy-Prozesse beendet.
|
|
|
|
Reine Funktion, damit der Filter prüfbar ist, ohne etwas zu beenden.
|
|
|
|
## Warum das nötig ist (gemessen 28.08.2026)
|
|
|
|
Beim Deinstallieren im laufenden Betrieb blieben liegen:
|
|
|
|
rippy.db, rippy.db-shm, rippy.db-wal
|
|
(von einem anderen Prozess verwendet)
|
|
|
|
Der Dienst lief weiter und hielt die Datenbank offen. Damit wäre der
|
|
Fehler von vorhin durch eine andere Tür zurückgekommen: Die nächste
|
|
Installation hätte wieder den alten Zustand geerbt.
|
|
|
|
## Warum über den ORDNER gefiltert wird
|
|
|
|
`Rippy.exe` heißt auch die Setup-Datei, die den Auftrag gerade ausführt —
|
|
und die darf sich nicht selbst abschießen. Beendet wird nur, was aus dem
|
|
Installationsordner läuft, und die eigenen Prozesskennungen sind
|
|
ausgenommen.
|
|
|
|
## Hinweis für spätere Sitzungen
|
|
|
|
In der Entwicklungsumgebung schlägt der Vergleich zuverlässig fehl, und
|
|
das ist KEIN Fehler im Code: Die Werkzeuge laufen dort in einem
|
|
App-Container, der Schreibzugriffe auf `%LOCALAPPDATA%` nach
|
|
|
|
…\AppData\Local\Packages\<app>\LocalCache\Local\Rippy
|
|
|
|
umleitet. Der Registry-Eintrag `InstallLocation` zeigt dagegen auf den
|
|
ECHTEN Pfad — Prozess und Ordner passen dann nie zusammen. Auf einer
|
|
normalen Installation liegen beide am selben Ort. Die Mechanik wurde am
|
|
28.08.2026 mit dem tatsächlichen Ordner nachgewiesen: Prozess beendet,
|
|
Datenbankdateien danach löschbar.
|
|
"""
|
|
ort = (ordner or "").replace("'", "''").rstrip("\\/")
|
|
pids = ",".join(str(int(p)) for p in eigene_pids if p)
|
|
return (
|
|
"Get-CimInstance Win32_Process -Filter \"Name='Rippy.exe'\" "
|
|
"| Where-Object { $_.ExecutablePath -like '%s\\*' "
|
|
"-and $_.ProcessId -notin @(%s) } "
|
|
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
|
|
"-ErrorAction SilentlyContinue }" % (ort, pids or "0")
|
|
)
|
|
|
|
|
|
def dienst_beenden(ordner: str) -> bool:
|
|
"""Beendet die anderen Rippy-Prozesse aus diesem Ordner. Wirft nicht."""
|
|
if os.name != "nt" or not ordner:
|
|
return False
|
|
from rippy.platform.winlauf import OHNE_FENSTER
|
|
|
|
eigene = [os.getpid()]
|
|
try:
|
|
eigene.append(os.getppid()) # der PyInstaller-Starter
|
|
except OSError:
|
|
pass
|
|
try:
|
|
ergebnis = subprocess.run(
|
|
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
beenden_skript(ordner, eigene)],
|
|
capture_output=True, text=True, timeout=60,
|
|
creationflags=OHNE_FENSTER)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
# Kurz Luft lassen: Windows gibt die Dateihandles nicht im selben
|
|
# Augenblick frei, in dem der Prozess endet.
|
|
time.sleep(2)
|
|
return ergebnis.returncode == 0
|
|
|
|
|
|
def alte_orte() -> list:
|
|
"""Ordner ausserhalb des Installationsordners, die Rippy einmal angelegt hat.
|
|
|
|
Reine Aufzaehlung, damit `deinstallieren()` pruefbar bleibt und niemand
|
|
versehentlich einen fremden Ordner in die Liste schreibt.
|
|
"""
|
|
from rippy import config
|
|
|
|
alt = config.alter_datenbankpfad()
|
|
if not alt:
|
|
return []
|
|
ordner = os.path.dirname(alt)
|
|
# Nur wenn er wirklich Rippy gehoert. Ein `%ProgramData%` ohne unseren
|
|
# Namen darin waere ein fremder Ordner, und den fasst niemand an.
|
|
if os.path.basename(ordner).lower() != "rippy" or not os.path.isdir(ordner):
|
|
return []
|
|
return [ordner]
|
|
|
|
|
|
def datenbank_umziehen() -> str:
|
|
r"""Holt eine Datenbank vom alten Ort ab. Gibt den Zielpfad zurück, sonst "".
|
|
|
|
Bis zum 28.08.2026 lag sie unter `%ProgramData%\Rippy\rippy.db` — also
|
|
AUSSERHALB des Installationsordners. Die Deinstallation räumte sie damit
|
|
nicht weg, und eine „frische" Installation erbte den alten Zustand samt
|
|
`setup.done = true`. Genau deshalb fehlte dem Commander der
|
|
Ersteinrichtungs-Assistent.
|
|
|
|
Wer Rippy schon benutzt hat, soll seine Jobs und Einstellungen behalten:
|
|
Die Datei wird verschoben, nicht ignoriert und nicht gelöscht. Gibt es am
|
|
neuen Ort schon eine, bleibt sie unangetastet — die neuere gewinnt nicht
|
|
automatisch, aber die BENUTZTE.
|
|
"""
|
|
from rippy import config
|
|
|
|
alt = config.alter_datenbankpfad()
|
|
neu = config.standard_datenbankpfad()
|
|
if not alt or not os.path.isfile(alt) or os.path.isfile(neu):
|
|
return ""
|
|
try:
|
|
os.makedirs(os.path.dirname(neu), exist_ok=True)
|
|
shutil.move(alt, neu)
|
|
except OSError as e:
|
|
melden(" Alte Datenbank konnte nicht umgezogen werden (%s)." % e)
|
|
return ""
|
|
melden(" Datenbank uebernommen: %s -> %s" % (alt, neu))
|
|
return neu
|
|
|
|
|
|
def ui_auspacken(ziel: str) -> str:
|
|
r"""Legt die Weboberfläche NEBEN das Programm. Gibt den Ordner zurück.
|
|
|
|
## Warum das sein muss (gemessen 28.08.2026)
|
|
|
|
Ein Dienst lief eine Stunde. `/api/health` gab HTTP 200, `/` gab **404**
|
|
— im Fenster stand `{"detail":"Not Found"}`. Der Server war in Ordnung;
|
|
was fehlte, lag unter ihm.
|
|
|
|
Eine PyInstaller-Onefile-EXE entpackt sich beim Start nach
|
|
`%TEMP%\_MEIxxxxx` und liest von dort weiter. `StaticFiles` schlägt bei
|
|
JEDER Anfrage dort nach. Nachgesehen, was vom Ordner des laufenden
|
|
Dienstes übrig war: 31 Einträge statt 44, kein `ui`, kein `api`.
|
|
|
|
Ein Temp-Verzeichnis ist kein Ort für etwas, das eine Woche liegen
|
|
bleiben soll. Und der Ausfall ist der schlimmstmögliche: Die API
|
|
antwortet weiter, der Dienst gilt als gesund, nur die Oberfläche ist weg.
|
|
|
|
Die Kopie hier gehört Rippy, liegt im Installationsordner und wird beim
|
|
Deinstallieren mit entfernt (`deinstallieren()` räumt den ganzen Ordner).
|
|
|
|
Leerer Rückgabewert heißt: keine Kopie angelegt — dann greift der
|
|
Rückfall auf `sys._MEIPASS` in `daemon._ui_pfad()`.
|
|
"""
|
|
quelle = _mitgeliefert("ui")
|
|
if not os.path.isdir(quelle):
|
|
# Aus dem Repo gestartet: Da liegt das UI schon an fester Stelle.
|
|
return ""
|
|
|
|
ordner = os.path.join(ziel, "ui")
|
|
try:
|
|
# Erst weg, dann neu: Ein Update mit weniger Dateien ließe sonst
|
|
# Reste des alten Standes liegen, und die Oberfläche wäre ein
|
|
# Gemisch aus zwei Fassungen.
|
|
shutil.rmtree(ordner, ignore_errors=True)
|
|
shutil.copytree(quelle, ordner)
|
|
except OSError as e:
|
|
# Nicht werfen: Ohne Kopie läuft Rippy aus dem Temp weiter — schlechter,
|
|
# aber nicht kaputt. Gesagt wird es trotzdem.
|
|
melden(" Oberflaeche konnte nicht abgelegt werden (%s)." % e)
|
|
return ""
|
|
return ordner
|
|
|
|
|
|
def starte_hintergrund(programm: str) -> None:
|
|
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
|
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
|
|
|
# `env=` ist hier kein Beiwerk: Ohne die Bereinigung liefe dieses Kind im
|
|
# Auspack-Ordner des Elternprozesses. Begruendung in winlauf.py.
|
|
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
|
|
close_fds=True, env=umgebung_ohne_bundle())
|
|
|
|
|
|
def starte_fensterprozess(programm: str = None) -> None:
|
|
"""Startet die Oberflaeche als EIGENEN Prozess.
|
|
|
|
Warum nicht einfach hier ein Fenster oeffnen: Das Tray-Symbol (pystray)
|
|
belegt unter Windows den Haupt-Thread — es braucht ihn fuer die
|
|
Fensternachrichten. Das WebView2-Fenster braucht ihn genauso. Zwei
|
|
Nachrichtenschleifen passen nicht in einen Thread.
|
|
|
|
Zwei Prozesse loesen das sauber: Der Dienst haelt Server und Tray, das
|
|
Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe",
|
|
und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen.
|
|
"""
|
|
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
|
|
|
subprocess.Popen([programm or _eigene_datei(), "--oeffnen"],
|
|
creationflags=OHNE_FENSTER, close_fds=True,
|
|
env=umgebung_ohne_bundle())
|
|
|
|
|
|
def deinstallieren(still: bool = False) -> None:
|
|
"""Nimmt alle Spuren zurück: Autostart, Registry-Eintrag, Dateien."""
|
|
# WOHIN wurde installiert? Das steht in der Registry, nicht im Code.
|
|
# Vorher stand hier `reg.standard_installationsordner()` — wer nach
|
|
# `--ziel D:\Rippy` installiert hatte, dessen Dateien waeren geblieben,
|
|
# waehrend ein fremder Ordner unter %LOCALAPPDATA% angefasst worden waere.
|
|
# InstallLocation wird genau dafuer beim Eintragen mitgeschrieben.
|
|
ordner = (reg.gelesene_werte().get("InstallLocation")
|
|
or reg.standard_installationsordner())
|
|
|
|
for lnk in verknuepfungen.alle_entfernen():
|
|
if not still:
|
|
melden(" Verknuepfung entfernt: %s" % lnk)
|
|
|
|
reg.autostart_entfernen()
|
|
reg.austragen()
|
|
|
|
# ⚠️ ZUERST die WebView2-Helfer beenden (Befund 28.08.2026).
|
|
#
|
|
# Beim ersten echten Deinstallieren blieben 148 Dateien liegen, alle
|
|
# unter `fenster\EBWebView\`. WebView2 startet eigene Prozesse
|
|
# (msedgewebview2.exe: Renderer, GPU, Netz, Crashpad); stirbt Rippy,
|
|
# laufen sie als Waisen weiter und halten den Zwischenspeicher offen.
|
|
# Danach ging `shutil.rmtree` nicht mehr — und der Fehler wurde
|
|
# verschluckt, waehrend „Rippy wurde entfernt" gemeldet wurde.
|
|
from rippy import fenster as fenster_modul
|
|
|
|
fenster_modul.helfer_beenden(os.path.join(ordner, "fenster"))
|
|
|
|
# ⚠️ Und den DIENST selbst (Befund 28.08.2026). Lief er weiter, blieben
|
|
# rippy.db, rippy.db-shm und rippy.db-wal liegen — „von einem anderen
|
|
# Prozess verwendet". Die naechste Installation haette damit wieder den
|
|
# alten Zustand geerbt, also genau den Fehler von vorhin durch eine
|
|
# andere Tuer.
|
|
dienst_beenden(ordner)
|
|
|
|
# Der ALTE Datenbank-Ort (bis 28.08.2026 %ProgramData%\Rippy) liegt
|
|
# ausserhalb des Installationsordners. Blieb er stehen, erbte die naechste
|
|
# Installation den alten Zustand — genau daran fehlte dem Commander der
|
|
# Ersteinrichtungs-Assistent. Ein Deinstallieren, das Zustand stehen
|
|
# laesst, ist kein Deinstallieren.
|
|
for rest in alte_orte():
|
|
try:
|
|
shutil.rmtree(rest)
|
|
if not still:
|
|
melden(" Alter Datenordner entfernt: %s" % rest)
|
|
except OSError:
|
|
pass
|
|
|
|
laeuft_von_hier = os.path.abspath(_eigene_datei()).lower().startswith(
|
|
os.path.abspath(ordner).lower())
|
|
|
|
# Die eigene Datei kann sich nicht selbst loeschen, solange sie laeuft.
|
|
# Also: alles andere jetzt, die EXE per Aufraeum-Befehl danach.
|
|
geblieben = []
|
|
for name in os.listdir(ordner) if os.path.isdir(ordner) else []:
|
|
pfad = os.path.join(ordner, name)
|
|
if laeuft_von_hier and os.path.abspath(pfad).lower() == os.path.abspath(_eigene_datei()).lower():
|
|
continue
|
|
try:
|
|
shutil.rmtree(pfad) if os.path.isdir(pfad) else os.remove(pfad)
|
|
except OSError as e:
|
|
# NICHT verschlucken. Ein „Rippy wurde entfernt" ueber einem
|
|
# halb geleerten Ordner ist eine Falschaussage.
|
|
geblieben.append("%s (%s)" % (name, e.strerror or e))
|
|
|
|
if laeuft_von_hier:
|
|
selbst_loeschen(_eigene_datei(), ordner)
|
|
|
|
if not still:
|
|
if geblieben:
|
|
melden("Rippy wurde entfernt — diese Reste waren gesperrt und "
|
|
"werden gleich nachgeholt:")
|
|
for eintrag in geblieben:
|
|
melden(" " + eintrag)
|
|
else:
|
|
melden("Rippy wurde entfernt.")
|
|
|
|
|
|
AUFRAEUM_SKRIPT = """@echo off
|
|
rem Erzeugt von Rippy beim Deinstallieren. Loescht sich selbst am Ende.
|
|
for /l %%n in (1,1,15) do (
|
|
ping 127.0.0.1 -n 3 >nul
|
|
del /q "{exe}" >nul 2>&1
|
|
if not exist "{exe}" goto weg
|
|
)
|
|
goto ende
|
|
:weg
|
|
rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
|
|
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
|
|
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
|
|
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
|
|
rmdir /s /q "{ordner}" >nul 2>&1
|
|
:ende
|
|
del /q "%~f0" >nul 2>&1
|
|
"""
|
|
|
|
|
|
def aufraeum_skript(exe: str, ordner: str) -> str:
|
|
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
|
|
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
|
|
|
|
|
|
def selbst_loeschen(exe: str, ordner: str) -> str:
|
|
r"""Loescht die eigene EXE und den Ordner — nachdem dieser Prozess weg ist.
|
|
|
|
Gibt den Pfad des Aufraeum-Skripts zurueck (fuer Tests und Diagnose).
|
|
|
|
## Warum ein Skript und kein cmd-Einzeiler (gemessen 28.08.2026)
|
|
|
|
Der erste Versuch uebergab den ganzen Befehl als EIN Argument an
|
|
`cmd /c`. Python maskiert dabei die inneren Anfuehrungszeichen — bei cmd
|
|
kam an:
|
|
|
|
del /q \"C:\Users\...\Rippy.exe\"
|
|
|
|
Mit den Backslashes davor ist das ein anderer Dateiname, also loeschte
|
|
`del` nichts und meldete auch nichts (die Ausgabe war nach >nul
|
|
umgeleitet). Ergebnis: Registry-Eintrag und Autostart waren weg, die
|
|
28-MB-Datei blieb liegen — und der Fehlschlag war unsichtbar. Genau die
|
|
Sorte still scheiternder Hintergrundprozess, vor der AGENTS.md warnt.
|
|
|
|
Ein Skript hat das Problem nicht: Sein Inhalt geht durch keine
|
|
Argument-Zerlegung.
|
|
|
|
## Warum es fuenfzehnmal versucht
|
|
|
|
Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse — ein Starter und
|
|
die eigentliche Anwendung. Der Starter raeumt nach dem Ende noch sein
|
|
Entpack-Verzeichnis auf und haelt die Datei so lange offen. Zwei Sekunden
|
|
sind dafuer mal genug und mal nicht.
|
|
|
|
Klappt es in einer halben Minute nicht, bleibt eine Datei liegen. Das ist
|
|
unschoen, aber harmlos: Registry-Eintrag und Autostart sind da schon weg,
|
|
Rippy startet also nicht mehr mit.
|
|
"""
|
|
import tempfile
|
|
|
|
from rippy.platform.winlauf import OHNE_FENSTER
|
|
|
|
skript = os.path.join(tempfile.gettempdir(), "rippy-aufraeumen.bat")
|
|
# ascii + replace: Der Pfad kann Umlaute enthalten, und cmd liest .bat
|
|
# in der Konsolen-Codepage. Ein Fragezeichen im Pfad ist besser als ein
|
|
# Skript, das cmd gar nicht erst annimmt.
|
|
with open(skript, "w", encoding="ascii", errors="replace", newline="\r\n") as f:
|
|
f.write(aufraeum_skript(exe, ordner))
|
|
|
|
subprocess.Popen(["cmd", "/c", skript], creationflags=OHNE_FENSTER,
|
|
close_fds=True)
|
|
return skript
|
|
|
|
|
|
# ═══════════════════════════════════════════════ Der Hintergrunddienst ══
|
|
class Dienst:
|
|
"""Rippy im Hintergrund: der Server in einem Thread, das Tray-Symbol davor."""
|
|
|
|
def __init__(self, port: int = STANDARD_PORT):
|
|
self.port = port
|
|
self._server = None
|
|
self._laeuft = False
|
|
|
|
# ── Server ──────────────────────────────────────────────────────────
|
|
def server_starten(self) -> None:
|
|
from rippy import config, daemon, store
|
|
|
|
werte = config.laden(flags={"server": {"port": self.port}})
|
|
store.verbinden(config.datenbank_url(werte))
|
|
app, _ = daemon.anwendung_bauen(werte)
|
|
|
|
# Ohne diesen Schritt koennte Rippy alles anzeigen und nichts tun.
|
|
from rippy import standalone
|
|
from rippy.bus.memory import bus as ereignis_bus
|
|
|
|
standalone.starten(bus=ereignis_bus, store=store,
|
|
slots=werte["queue"].get("rip_slots", 1))
|
|
|
|
import uvicorn
|
|
|
|
self._server = uvicorn.Server(uvicorn.Config(
|
|
app, host="127.0.0.1", port=self.port, log_level="warning"))
|
|
self._laeuft = True
|
|
self._server.run()
|
|
|
|
def stoppen(self) -> None:
|
|
self._laeuft = False
|
|
if self._server is not None:
|
|
self._server.should_exit = True
|
|
|
|
@property
|
|
def adresse(self) -> str:
|
|
return "http://127.0.0.1:%d" % self.port
|
|
|
|
def erreichbar(self, timeout: float = 1.0) -> bool:
|
|
"""Antwortet der Server WIRKLICH? Nicht: läuft der Thread noch.
|
|
|
|
Ein lebender Thread ist kein Beweis dafür, dass jemand bedient wird —
|
|
dieselbe Unterscheidung wie überall sonst in diesem Projekt.
|
|
"""
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
try:
|
|
with urllib.request.urlopen(self.adresse + "/api/health", timeout=timeout):
|
|
return True
|
|
except (urllib.error.URLError, OSError):
|
|
return False
|
|
|
|
|
|
def _reste_wegraeumen() -> None:
|
|
"""Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren."""
|
|
try:
|
|
from rippy.platform.winlauf import reste_aufraeumen
|
|
|
|
anzahl = reste_aufraeumen()
|
|
if anzahl:
|
|
melden(" %d zurueckgelassene Auspack-Ordner entfernt." % anzahl)
|
|
except Exception: # noqa: BLE001
|
|
pass # Aufraeumen ist Kuer. Es darf den Dienst nicht kosten.
|
|
|
|
|
|
def dienst_starten(port: int = STANDARD_PORT) -> int:
|
|
"""Server im Hintergrund-Thread, Tray-Symbol im Vordergrund.
|
|
|
|
Die Reihenfolge ist unter Windows Pflicht: `pystray` muss im HAUPT-Thread
|
|
laufen, sonst bekommt es keine Fensternachrichten und das Symbol
|
|
erscheint nie.
|
|
"""
|
|
# Reste frueherer Laeufe wegraeumen — nebenher, damit der Start nicht
|
|
# darauf wartet. Beim Commander lagen 20 solcher Ordner mit 1,1 GB
|
|
# herum (Befund 28.08.2026); die Ursache ist mit `umgebung_ohne_bundle`
|
|
# behoben, das Liegengebliebene raeumt niemand sonst weg.
|
|
threading.Thread(target=_reste_wegraeumen, daemon=True,
|
|
name="rippy-aufraeumen").start()
|
|
|
|
dienst = Dienst(port)
|
|
threading.Thread(target=dienst.server_starten, daemon=True,
|
|
name="rippy-server").start()
|
|
|
|
# Kurz warten und NACHSEHEN, ob der Server wirklich antwortet.
|
|
for _ in range(30):
|
|
if dienst.erreichbar(timeout=0.5):
|
|
break
|
|
time.sleep(0.5)
|
|
|
|
symbol = tray_bauen(dienst)
|
|
if symbol is None:
|
|
# Kein Tray möglich (fehlende Bibliothek): Dann eben ohne — der
|
|
# Dienst ist wichtiger als sein Symbol. Aber es wird gesagt.
|
|
melden("Tray-Symbol nicht verfügbar — Rippy läuft trotzdem auf "
|
|
+ dienst.adresse)
|
|
try:
|
|
while True:
|
|
time.sleep(3600)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
symbol.run()
|
|
dienst.stoppen()
|
|
return 0
|
|
|
|
|
|
def bereitstellen(port: int = STANDARD_PORT) -> bool:
|
|
"""Sorgt dafuer, dass der Dienst ANTWORTET. Startet ihn notfalls.
|
|
|
|
Getrennt von `oeffnen()`, weil beide Wege dorthin — Fenster und Browser —
|
|
genau dieselbe Vorbedingung haben: Ohne antwortenden Server zeigt das
|
|
eine wie das andere nur einen Verbindungsfehler.
|
|
"""
|
|
dienst = Dienst(port)
|
|
if dienst.erreichbar(timeout=1.5):
|
|
return True
|
|
|
|
melden("Rippy wird gestartet …")
|
|
starte_hintergrund(_eigene_datei())
|
|
# Warten, bis er ANTWORTET — nicht bis der Prozess existiert.
|
|
for _ in range(60):
|
|
if dienst.erreichbar(timeout=1.0):
|
|
return True
|
|
time.sleep(0.5)
|
|
melden("Rippy antwortet nicht. Laeuft schon etwas auf Port %d?" % port)
|
|
return False
|
|
|
|
|
|
def oeffnen(port: int = STANDARD_PORT, als_fenster: bool = True) -> int:
|
|
"""Rippy oeffnen — Dienst starten, falls noetig, dann die Oberflaeche.
|
|
|
|
## Warum die Verknuepfungen hierher zeigen und nicht auf --dienst
|
|
|
|
Ein Doppelklick auf ein Desktop-Symbol soll Rippy OEFFNEN. Ob der Dienst
|
|
schon laeuft, ist dem Nutzer egal — und er kann es auch nicht wissen.
|
|
|
|
Zeigte die Verknuepfung auf `--dienst`, startete jeder Klick einen
|
|
ZWEITEN Server. Der zweite scheiterte am belegten Port, das Fenster
|
|
blitzte auf und verschwand, und im Browser passierte nichts. Ein Symbol,
|
|
das beim zweiten Klick nichts tut, ist schlimmer als keins.
|
|
|
|
Deshalb: erst NACHSEHEN, ob jemand antwortet. Nur wenn nicht, starten —
|
|
und dann warten, bis er wirklich da ist, statt sofort ein Fenster auf
|
|
eine tote Adresse zu schicken.
|
|
|
|
## Warum hier ein Fenster und kein Browser-Tab
|
|
|
|
Siehe `rippy/fenster.py`. Kurz: Ein Tab hat eine Adresszeile, die
|
|
niemand braucht, kein eigenes Symbol in der Taskleiste, und wer den
|
|
Browser schliesst, glaubt, er habe Rippy beendet. Geht das Fenster nicht
|
|
auf, faellt Rippy auf den Browser zurueck und SAGT, warum.
|
|
"""
|
|
if not bereitstellen(port):
|
|
return 1
|
|
|
|
adresse = Dienst(port).adresse
|
|
if not als_fenster:
|
|
return 0 if webbrowser.open(adresse) else 1
|
|
|
|
from rippy import fenster as fenster_modul
|
|
|
|
# Der Rueckgabewert sagt, was WIRKLICH passiert ist — nicht, dass es
|
|
# geklappt hat. „nichts" heisst: weder Fenster noch Browser.
|
|
return 0 if fenster_modul.oeffnen_oder_browser(adresse) != "nichts" else 1
|
|
|
|
|
|
def tray_bauen(dienst):
|
|
"""Das Tray-Symbol. None, wenn pystray/Pillow fehlen."""
|
|
try:
|
|
import pystray
|
|
from PIL import Image
|
|
except ImportError:
|
|
return None
|
|
|
|
icon = _icon_pfad()
|
|
try:
|
|
bild = Image.open(icon) if icon and os.path.isfile(icon) else None
|
|
except OSError:
|
|
bild = None
|
|
if bild is None:
|
|
from PIL import ImageDraw
|
|
|
|
bild = Image.new("RGB", (64, 64), (16, 20, 32))
|
|
zeichner = ImageDraw.Draw(bild)
|
|
zeichner.ellipse((8, 8, 56, 56), outline=(245, 158, 11), width=6)
|
|
zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11))
|
|
|
|
def oeffnen(_=None, __=None):
|
|
# Eigener Prozess: pystray haelt hier den Haupt-Thread, das Fenster
|
|
# braucht ihn aber selbst (siehe starte_fensterprozess).
|
|
starte_fensterprozess()
|
|
|
|
def beenden(symbol, _=None):
|
|
dienst.stoppen()
|
|
symbol.stop()
|
|
|
|
def zustand(_=None):
|
|
return "Läuft — " + dienst.adresse if dienst.erreichbar() else "Startet …"
|
|
|
|
menue = pystray.Menu(
|
|
pystray.MenuItem("Rippy öffnen", oeffnen, default=True),
|
|
pystray.MenuItem(zustand, None, enabled=False),
|
|
pystray.Menu.SEPARATOR,
|
|
pystray.MenuItem("Beenden", beenden),
|
|
)
|
|
return pystray.Icon("Rippy", bild, "Rippy", menue)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════ Einstieg ═══════
|
|
HILFE = """Rippy für Windows
|
|
|
|
RippySetup.exe installieren (Doppelklick)
|
|
Rippy.exe --oeffnen oeffnen (startet den Dienst, falls noetig)
|
|
Rippy.exe --dienst im Hintergrund laufen lassen
|
|
Rippy.exe --deinstallieren entfernen
|
|
Rippy.exe --status nachsehen, was installiert ist
|
|
|
|
Optionen:
|
|
--port N Port (Vorgabe: 7788)
|
|
--ziel PFAD Installationsordner
|
|
--kein-autostart
|
|
--keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag
|
|
--ohne-werkzeuge MakeMKV/HandBrake NICHT pruefen und nicht holen
|
|
--ohne-assistent ohne Einrichtungs-Fenster, mit den Vorgaben
|
|
--im-browser im Browser statt im eigenen Fenster
|
|
--still ohne Rückmeldungen
|
|
"""
|
|
|
|
|
|
def status() -> int:
|
|
melden("Rippy %s" % VERSION)
|
|
melden(" Diese Datei %s" % _eigene_datei())
|
|
melden(" Installationsordner %s" % reg.standard_installationsordner())
|
|
if os.name != "nt":
|
|
melden(" (kein Windows — Registry wird nicht gelesen)")
|
|
return 0
|
|
melden(" In Programme+Features %s" % ("ja" if reg.ist_eingetragen() else "nein"))
|
|
melden(" Autostart %s" % (reg.autostart_wert() or "nein"))
|
|
for name, pfad in (("Desktop-Symbol", verknuepfungen.desktop_pfad()),
|
|
("Startmenue", verknuepfungen.startmenue_pfad())):
|
|
melden(" %-21s %s" % (name, pfad if pfad and os.path.isfile(pfad)
|
|
else "nein"))
|
|
for name, wert in sorted(reg.gelesene_werte().items()):
|
|
melden(" %-18s %s" % (name, wert))
|
|
return 0
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
|
|
def wert(flag, standard=None):
|
|
return argv[argv.index(flag) + 1] if flag in argv else standard
|
|
|
|
if "--hilfe" in argv or "--help" in argv or "-h" in argv:
|
|
melden(HILFE)
|
|
return 0
|
|
if "--status" in argv:
|
|
return status()
|
|
if "--deinstallieren" in argv:
|
|
deinstallieren(still="--still" in argv)
|
|
return 0
|
|
if "--oeffnen" in argv:
|
|
return oeffnen(int(wert("--port", STANDARD_PORT)),
|
|
als_fenster="--im-browser" not in argv)
|
|
if "--dienst" in argv:
|
|
return dienst_starten(int(wert("--port", STANDARD_PORT)))
|
|
|
|
# Ohne Argument: der Doppelklick-Fall — und der gehoert in den
|
|
# Einrichtungs-Assistenten.
|
|
#
|
|
# Commander am 28.08.2026: "Dann haette ich beim 'Setup' auch erwartet das
|
|
# nen echtes setup passiert - wo will ich das hinspeichern, nen pre
|
|
# requirement check usw. Da kommt garnichts Rippy geht einfach auf."
|
|
#
|
|
# `--still` und `--ohne-assistent` gehen weiterhin den stummen Weg: fuer
|
|
# unbeaufsichtigte Installationen und fuer die eigenen Tests.
|
|
if "--still" not in argv and "--ohne-assistent" not in argv:
|
|
ergebnis = assistent_starten()
|
|
if ergebnis.get("ok"):
|
|
# ⚠️ Und jetzt Rippy auch WIRKLICH starten.
|
|
#
|
|
# Der Knopf am Ende des Assistenten heisst „Rippy oeffnen". Bis
|
|
# hierher schloss er nur das Fenster, und danach passierte nichts
|
|
# mehr: Der Dienst lief nicht, kein Fenster ging auf. Ein Knopf,
|
|
# der etwas anderes tut als sein Beschriftung sagt, ist schlimmer
|
|
# als keiner.
|
|
programm = os.path.join(ergebnis.get("ziel") or
|
|
reg.standard_installationsordner(),
|
|
"Rippy.exe")
|
|
starte_fensterprozess(programm)
|
|
return 0
|
|
if ergebnis.get("abgebrochen"):
|
|
melden("Abgebrochen — es wurde nichts veraendert.")
|
|
return 1
|
|
# Weder erledigt noch abgebrochen: Der Assistent ging gar nicht auf.
|
|
# Dann wird ohne ihn installiert, statt gar nichts zu tun.
|
|
melden("Der Einrichtungs-Assistent ging nicht auf (%s) — es wird mit "
|
|
"den Vorgaben installiert." % (ergebnis.get("grund") or "?"))
|
|
|
|
try:
|
|
ziel = installieren(
|
|
ziel=wert("--ziel"),
|
|
autostart="--kein-autostart" not in argv,
|
|
starten_danach="--nicht-starten" not in argv,
|
|
verknuepfen="--keine-verknuepfungen" not in argv,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
# Ohne Konsole waere ein Absturz hier VOLLKOMMEN unsichtbar: Der
|
|
# Doppelklick tut nichts, und niemand erfaehrt warum. Erst zeigen,
|
|
# dann weiterwerfen — der Rueckgabewert bleibt ein Fehler.
|
|
abschluss_melden("Die Installation ist gescheitert:\n\n%s: %s"
|
|
% (type(e).__name__, e), still="--still" in argv,
|
|
fehler=True)
|
|
raise
|
|
|
|
melden("Rippy wurde nach %s installiert." % ziel)
|
|
|
|
# Ohne MakeMKV kann Rippy keine Disc lesen — dann ist es ein
|
|
# Anzeigeprogramm. Der Commander am 28.08.2026: „Ohne das ist das tool
|
|
# NICHT einsatzfaehig." Also gehoert die Beschaffung ins Setup und nicht
|
|
# in eine Meldung im UI, die niemand befolgen kann.
|
|
text = "Rippy wurde installiert:\n\n%s" % ziel
|
|
if "--ohne-werkzeuge" not in argv:
|
|
text = werkzeuge_einrichten()
|
|
|
|
if "--nicht-starten" not in argv:
|
|
# Als eigener Prozess und aus dem INSTALLIERTEN Programm heraus:
|
|
# Das Setup soll sich beenden koennen, ohne das Fenster mitzunehmen.
|
|
starte_fensterprozess(os.path.join(ziel, "Rippy.exe"))
|
|
abschluss_melden(text, still="--still" in argv)
|
|
return 0
|
|
|
|
|
|
def assistent_starten() -> dict:
|
|
"""Oeffnet den Einrichtungs-Assistenten. Sagt, was daraus wurde.
|
|
|
|
Rueckgabe:
|
|
{"ok": True, ...} installiert
|
|
{"abgebrochen": True} Fenster zugemacht, nichts veraendert
|
|
{"grund": "..."} ging nicht auf — der Aufrufer entscheidet
|
|
|
|
Wirft nicht. Ein Setup, das gar nichts tut, weil sein Fenster nicht
|
|
aufging, waere schlimmer als eines, das nicht fragt.
|
|
"""
|
|
from rippy import setup_fenster
|
|
|
|
geht, grund = setup_fenster.moeglich()
|
|
if not geht:
|
|
return {"grund": grund}
|
|
try:
|
|
ergebnis = setup_fenster.starten()
|
|
except Exception as e: # noqa: BLE001
|
|
return {"grund": "%s: %s" % (type(e).__name__, e)}
|
|
if not ergebnis:
|
|
return {"abgebrochen": True}
|
|
return ergebnis
|
|
|
|
|
|
def werkzeuge_einrichten() -> str:
|
|
"""Holt fehlende Werkzeuge und gibt den Bericht als Text zurück.
|
|
|
|
Wirft nicht: Ein nicht erreichbarer Download-Server ist kein Grund, eine
|
|
sonst geglückte Installation scheitern zu lassen. Was fehlt, steht dann
|
|
im Bericht — mitsamt dem Weg, es von Hand zu beschaffen.
|
|
"""
|
|
from rippy.tools import einrichten
|
|
|
|
try:
|
|
ergebnis = einrichten.sicherstellen(
|
|
fortschritt=lambda t, anteil=None: melden(" " + t))
|
|
return einrichten.bericht(ergebnis)
|
|
except Exception as e: # noqa: BLE001
|
|
return ('Rippy ist installiert. Die Werkzeug-Prüfung ist gescheitert '
|
|
'(%s: %s) — sieh in den Einstellungen unter „System" nach.'
|
|
% (type(e).__name__, e))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|