Files
rippy/src/rippy/windows_app.py
T
HitonabiandClaude Opus 5 35370fd552 fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner
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>
2026-08-28 16:43:41 +02:00

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