feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s
Ampel / ampel (push) Failing after 46s
WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.
DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:
1. EINTRAG IM TASKMANAGER
ProcessName Rippy
Beschreibung Rippy — automatisches Ripping
Produkt Rippy
Version 2.0.0
Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.
2. EINTRAG IN PROGRAMME UND FEATURES
Aus der Registry zurueckgelesen, nicht behauptet:
DisplayName Rippy
DisplayVersion 2.0.0
Publisher Rippy
InstallLocation <Zielordner>
UninstallString "<pfad>\Rippy.exe" --deinstallieren
EstimatedSize 29241 (KiB)
NoModify/NoRepair 1
HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
Installation laeuft aber ohne UAC-Abfrage durch.
VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.
VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:
1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
store.verbinden() kaeme immer zu spaet.
2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
`from celery_client import celery_client` loeste es sogar dann aus, wenn
nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
Ansage statt eines Importfehlers.
3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
Prozesse; der Starter haelt die Datei nach dem Ende noch offen).
4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
steht in der Registry (InstallLocation) und wird jetzt dort gelesen.
WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.
GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e5254c23f0
commit
059183c651
@@ -0,0 +1,5 @@
|
||||
"""Plattform-Anbindung: Windows-Dienst/Tray/Registry, systemd, Prozessstart.
|
||||
|
||||
Alles, was NUR auf einer Plattform gilt und nichts mit dem Fachkern zu tun
|
||||
hat. Der Kern kennt diese Module nicht — er kennt die Ports.
|
||||
"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Die Werte für „Programme und Features" — geprüft, bevor sie geschrieben werden.
|
||||
|
||||
Die reinen Funktionen laufen auf jeder Plattform. Die Tests, die wirklich in
|
||||
die Registry schreiben, laufen nur unter Windows und räumen hinter sich auf —
|
||||
sie benutzen einen eigenen Schlüsselnamen, damit eine echte Installation
|
||||
niemals angefasst wird.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from rippy.platform import win_registry as reg
|
||||
|
||||
TEST_SCHLUESSEL = "RippyTestEintrag" # NIE der echte
|
||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="Registry gibt es nur unter Windows")
|
||||
|
||||
|
||||
# ── Die Werte selbst ────────────────────────────────────────────────────
|
||||
def test_pflichtwerte_sind_gesetzt():
|
||||
"""Windows zeigt einen Eintrag OHNE DisplayName gar nicht an, und ohne
|
||||
UninstallString ist der Deinstallieren-Knopf tot."""
|
||||
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\RippySetup.exe", "2.0.0")
|
||||
assert werte["DisplayName"]
|
||||
assert werte["UninstallString"]
|
||||
|
||||
|
||||
def test_uninstallstring_ist_gequotet():
|
||||
"""DER klassische Fehler. Ohne Anfuehrungszeichen liest Windows
|
||||
„C:\\Program Files\\Rippy\\x.exe" als „C:\\Program" mit Argumenten —
|
||||
beim Deinstallieren passiert dann nichts oder etwas Falsches.
|
||||
"""
|
||||
werte = reg.eintrag_werte(
|
||||
r"C:\Program Files\Rippy", r"C:\Program Files\Rippy\RippySetup.exe", "2.0.0")
|
||||
assert werte["UninstallString"].startswith('"C:\\Program Files\\Rippy\\RippySetup.exe"')
|
||||
assert werte["UninstallString"].endswith("--deinstallieren")
|
||||
|
||||
|
||||
def test_stille_deinstallation_ist_vorgesehen():
|
||||
"""Windows benutzt QuietUninstallString bei automatisierten Entfernungen.
|
||||
Fehlt er, faellt Windows auf den lauten zurueck und ein Dialog blockiert
|
||||
eine unbeaufsichtigte Abmeldung."""
|
||||
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
|
||||
assert "--still" in werte["QuietUninstallString"]
|
||||
|
||||
|
||||
def test_reparieren_und_aendern_sind_ausgeblendet():
|
||||
"""Rippy kann beides nicht. Knoepfe, die ins Leere fuehren, sind
|
||||
dieselbe Sorte Placebo wie ein Fortschrittsbalken ohne Fortschritt."""
|
||||
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
|
||||
assert werte["NoModify"] == 1
|
||||
assert werte["NoRepair"] == 1
|
||||
|
||||
|
||||
def test_groesse_wird_in_kib_erwartet():
|
||||
"""EstimatedSize ist in KiB. Wer Bytes eintraegt, meldet in „Programme und
|
||||
Features" das Tausendfache — aus 90 MB werden 90 GB."""
|
||||
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0", groesse_kib=92_160)
|
||||
assert werte["EstimatedSize"] == 92_160 # = 90 MiB
|
||||
|
||||
|
||||
def test_ohne_icon_kein_leerer_eintrag():
|
||||
"""Ein leerer DisplayIcon laesst Windows das Standard-Symbol zeigen —
|
||||
aber ein Wert, der da ist und ins Leere zeigt, macht ein kaputtes Bild."""
|
||||
assert "DisplayIcon" not in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0")
|
||||
assert "DisplayIcon" in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0", icon=r"C:\R\r.ico")
|
||||
|
||||
|
||||
def test_uninstall_pfad_liegt_unter_dem_richtigen_schluessel():
|
||||
pfad = reg.uninstall_pfad("Rippy")
|
||||
assert pfad.startswith(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
|
||||
assert pfad.endswith(r"\Rippy")
|
||||
|
||||
|
||||
def test_installationsordner_braucht_keine_adminrechte():
|
||||
"""Program Files waere gewohnter, verlangt aber bei JEDER Installation
|
||||
und jedem Update eine UAC-Abfrage."""
|
||||
ordner = reg.standard_installationsordner()
|
||||
assert "Rippy" in ordner
|
||||
if os.name == "nt":
|
||||
assert "Program Files" not in ordner
|
||||
|
||||
|
||||
def test_ordnergroesse_zaehlt_wirklich(tmp_path):
|
||||
(tmp_path / "a.bin").write_bytes(b"x" * 4096)
|
||||
unter = tmp_path / "unter"
|
||||
unter.mkdir()
|
||||
(unter / "b.bin").write_bytes(b"y" * 4096)
|
||||
assert reg.ordnergroesse_kib(str(tmp_path)) == 8
|
||||
|
||||
|
||||
# ── Wirklich schreiben (nur Windows) ────────────────────────────────────
|
||||
@nur_windows
|
||||
def test_eintrag_landet_wirklich_in_der_registry(tmp_path):
|
||||
"""Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
|
||||
kein Beweis. Genau diese Lehre steht in AGENTS.md."""
|
||||
ordner = str(tmp_path)
|
||||
(tmp_path / "RippySetup.exe").write_bytes(b"x" * 1024)
|
||||
deinstaller = str(tmp_path / "RippySetup.exe")
|
||||
try:
|
||||
reg.eintragen(ordner, deinstaller, "2.0.0-test", schluessel=TEST_SCHLUESSEL)
|
||||
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is True
|
||||
|
||||
gelesen = reg.gelesene_werte(TEST_SCHLUESSEL)
|
||||
assert gelesen["DisplayName"] == "Rippy"
|
||||
assert gelesen["DisplayVersion"] == "2.0.0-test"
|
||||
assert gelesen["UninstallString"].startswith('"' + deinstaller + '"')
|
||||
assert gelesen["NoModify"] == 1
|
||||
finally:
|
||||
reg.austragen(TEST_SCHLUESSEL)
|
||||
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is False
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_austragen_auf_nichts_ist_kein_fehler():
|
||||
assert reg.austragen("RippyGibtEsNicht") is False
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_autostart_setzen_lesen_entfernen(tmp_path):
|
||||
name = "RippyTestAutostart"
|
||||
befehl = '"' + str(tmp_path / "Rippy.exe") + '"'
|
||||
try:
|
||||
reg.autostart_setzen(befehl, name=name)
|
||||
assert reg.autostart_wert(name) == befehl
|
||||
finally:
|
||||
reg.autostart_entfernen(name)
|
||||
assert reg.autostart_wert(name) == ""
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Windows-Registrierung: „Programme und Features" und Autostart.
|
||||
|
||||
## Wie ein Programm in „Programme und Features" landet
|
||||
|
||||
Nicht durch das Kopieren von Dateien. Windows liest diese Liste ausschließlich
|
||||
aus der Registry, unter:
|
||||
|
||||
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
|
||||
HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
|
||||
|
||||
HKCU ist die Installation für DIESEN Benutzer, HKLM die für alle — letztere
|
||||
braucht Administratorrechte. Rippy nimmt HKCU: Der Eintrag erscheint genauso in
|
||||
„Apps & Features", aber die Installation läuft ohne UAC-Abfrage durch.
|
||||
|
||||
Pflicht sind genau zwei Werte:
|
||||
|
||||
DisplayName was in der Liste steht
|
||||
UninstallString was Windows startet, wenn man „Deinstallieren" klickt
|
||||
|
||||
Alles andere ist Kür, aber es macht den Unterschied zwischen einem
|
||||
gepflegten Eintrag und einem, der aussieht wie Schadsoftware:
|
||||
`DisplayVersion`, `Publisher`, `InstallLocation`, `DisplayIcon`,
|
||||
`EstimatedSize` (in KiB!), `NoModify`, `NoRepair`.
|
||||
|
||||
## Autostart
|
||||
|
||||
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run
|
||||
|
||||
Bewusst HKCU und nicht der `Startup`-Ordner: Ein Eintrag in Run ist mit einem
|
||||
Registry-Zugriff wieder weg, eine Verknüpfung im Startup-Ordner muss man
|
||||
suchen. Und bewusst NICHT als Windows-Dienst — der bräuchte Administrator
|
||||
rechte bei jeder Installation und jedem Update.
|
||||
|
||||
## Warum die Werte hier getrennt gebaut werden
|
||||
|
||||
`eintrag_werte()` und `uninstall_pfad()` sind reine Funktionen. Damit lässt
|
||||
sich prüfen, dass die Pflichtwerte gesetzt sind und der Deinstallations-Befehl
|
||||
richtig gequotet ist — ohne in die Registry zu schreiben. Ein falsch
|
||||
gequoteter `UninstallString` ist ein klassischer Fehler: Steht der Pfad mit
|
||||
Leerzeichen ohne Anführungszeichen drin, startet Windows beim Deinstallieren
|
||||
das falsche Programm oder gar nichts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Der Schlüsselname unter Uninstall. Muss über Versionen hinweg GLEICH bleiben —
|
||||
# ändert er sich, entsteht bei einem Update ein zweiter Eintrag, und der alte
|
||||
# bleibt als Leiche stehen.
|
||||
SCHLUESSEL = "Rippy"
|
||||
|
||||
UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
RUN_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
|
||||
ANZEIGENAME = "Rippy"
|
||||
HERAUSGEBER = "Rippy"
|
||||
|
||||
|
||||
def uninstall_pfad(schluessel: str = SCHLUESSEL) -> str:
|
||||
return UNINSTALL_PFAD + "\\" + schluessel
|
||||
|
||||
|
||||
def _quoten(pfad: str) -> str:
|
||||
"""Pfad für die Kommandozeile in Anführungszeichen — immer.
|
||||
|
||||
„C:\\Program Files\\Rippy\\…" ohne Anführungszeichen liest Windows als
|
||||
„C:\\Program" mit den Argumenten „Files\\Rippy\\…". Beim Deinstallieren
|
||||
passiert dann entweder nichts oder etwas Falsches.
|
||||
"""
|
||||
return '"' + pfad.strip('"') + '"'
|
||||
|
||||
|
||||
def eintrag_werte(installationsordner: str, deinstaller: str, version: str,
|
||||
icon: str = "", groesse_kib: int = 0) -> dict:
|
||||
"""Die Werte für „Programme und Features" (reine Funktion).
|
||||
|
||||
`groesse_kib` ist in KiB — Windows zeigt sie als „Größe" an. Wer Bytes
|
||||
einträgt, meldet 1000-mal zu viel; das ist ein häufiger Fehler.
|
||||
"""
|
||||
werte = {
|
||||
"DisplayName": ANZEIGENAME,
|
||||
"DisplayVersion": version,
|
||||
"Publisher": HERAUSGEBER,
|
||||
"InstallLocation": installationsordner,
|
||||
"UninstallString": _quoten(deinstaller) + " --deinstallieren",
|
||||
"QuietUninstallString": _quoten(deinstaller) + " --deinstallieren --still",
|
||||
# Rippy kann sich nicht selbst reparieren oder teilweise installieren —
|
||||
# die Knöpfe dafür gehören also ausgeblendet, statt ins Leere zu führen.
|
||||
"NoModify": 1,
|
||||
"NoRepair": 1,
|
||||
}
|
||||
if icon:
|
||||
werte["DisplayIcon"] = icon
|
||||
if groesse_kib:
|
||||
werte["EstimatedSize"] = int(groesse_kib)
|
||||
return werte
|
||||
|
||||
|
||||
def ordnergroesse_kib(ordner: str) -> int:
|
||||
"""Größe eines Ordners in KiB — für den EstimatedSize-Wert."""
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(ordner):
|
||||
for name in dateien:
|
||||
try:
|
||||
summe += os.path.getsize(os.path.join(wurzel, name))
|
||||
except OSError:
|
||||
pass
|
||||
return summe // 1024
|
||||
|
||||
|
||||
# ── Schreiben und Entfernen (nur unter Windows) ─────────────────────────
|
||||
def _winreg():
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("Registry-Zugriff gibt es nur unter Windows.")
|
||||
import winreg
|
||||
|
||||
return winreg
|
||||
|
||||
|
||||
def eintragen(installationsordner: str, deinstaller: str, version: str,
|
||||
icon: str = "", schluessel: str = SCHLUESSEL) -> None:
|
||||
"""Schreibt den Eintrag für „Programme und Features"."""
|
||||
winreg = _winreg()
|
||||
werte = eintrag_werte(
|
||||
installationsordner, deinstaller, version, icon,
|
||||
ordnergroesse_kib(installationsordner),
|
||||
)
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
|
||||
for name, wert in werte.items():
|
||||
if isinstance(wert, int):
|
||||
winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, wert)
|
||||
else:
|
||||
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert))
|
||||
|
||||
|
||||
def austragen(schluessel: str = SCHLUESSEL) -> bool:
|
||||
"""Entfernt den Eintrag. False, wenn es ihn gar nicht gab."""
|
||||
winreg = _winreg()
|
||||
try:
|
||||
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel))
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def ist_eingetragen(schluessel: str = SCHLUESSEL) -> bool:
|
||||
winreg = _winreg()
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def gelesene_werte(schluessel: str = SCHLUESSEL) -> dict:
|
||||
"""Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests.
|
||||
|
||||
Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
|
||||
kein Beweis dafür, dass der Eintrag angekommen ist.
|
||||
"""
|
||||
winreg = _winreg()
|
||||
ergebnis = {}
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
name, wert, _ = winreg.EnumValue(k, i)
|
||||
except OSError:
|
||||
break
|
||||
ergebnis[name] = wert
|
||||
i += 1
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return ergebnis
|
||||
|
||||
|
||||
# ── Autostart ───────────────────────────────────────────────────────────
|
||||
def autostart_setzen(befehl: str, name: str = SCHLUESSEL) -> None:
|
||||
winreg = _winreg()
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
|
||||
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, befehl)
|
||||
|
||||
|
||||
def autostart_entfernen(name: str = SCHLUESSEL) -> bool:
|
||||
winreg = _winreg()
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0,
|
||||
winreg.KEY_ALL_ACCESS) as k:
|
||||
winreg.DeleteValue(k, name)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def autostart_wert(name: str = SCHLUESSEL) -> str:
|
||||
winreg = _winreg()
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
|
||||
return winreg.QueryValueEx(k, name)[0]
|
||||
except (FileNotFoundError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def standard_installationsordner() -> str:
|
||||
"""%LOCALAPPDATA%\\Rippy — dorthin darf ohne Administratorrechte geschrieben werden.
|
||||
|
||||
`Program Files` wäre der gewohnte Ort, verlangt aber bei JEDER Installation
|
||||
und jedem Update eine UAC-Abfrage. Für ein Programm, das sich selbst
|
||||
aktualisieren soll, ist das der falsche Handel.
|
||||
"""
|
||||
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
return os.path.join(basis, "Rippy")
|
||||
|
||||
|
||||
def laeuft_als_gebuendelte_exe() -> bool:
|
||||
return getattr(sys, "frozen", False)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
|
||||
|
||||
## Der Befund, der das nötig gemacht hat (26.07.2026)
|
||||
|
||||
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
|
||||
recht — es war kein Geist, sondern der Herzschlag des Workers.
|
||||
|
||||
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
|
||||
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
|
||||
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
|
||||
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
|
||||
kein Fenster.
|
||||
|
||||
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
|
||||
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
|
||||
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
|
||||
Schüben — genau das „alle paar Sekunden".
|
||||
|
||||
## Warum eine eigene Datei
|
||||
|
||||
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
|
||||
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
|
||||
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
|
||||
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
||||
derselbe Code auf beiden Seiten läuft.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
||||
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
||||
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
@@ -23,12 +23,12 @@ from rippy.queue import lokal
|
||||
@pytest.fixture
|
||||
def q(tmp_path):
|
||||
"""Frische SQLite-Datenbank je Test, danach zurückdrehen."""
|
||||
vorher_url, vorher_engine = store.DATABASE_URL, store.engine
|
||||
vorher = store.zustand_sichern()
|
||||
store.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
|
||||
store.init_db()
|
||||
yield lokal
|
||||
store.engine.dispose()
|
||||
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
|
||||
store.engine_holen().dispose()
|
||||
store.zustand_wiederherstellen(vorher)
|
||||
|
||||
|
||||
def _lease_zuruecksetzen(auftrag_id, sekunden):
|
||||
|
||||
+83
-27
@@ -120,7 +120,63 @@ def _engine_bauen(url: str):
|
||||
return eng
|
||||
|
||||
|
||||
engine = _engine_bauen(DATABASE_URL)
|
||||
_engine = None
|
||||
|
||||
|
||||
def engine_holen():
|
||||
"""Die Engine — beim ERSTEN Zugriff gebaut, nicht beim Import.
|
||||
|
||||
## Warum das wichtig ist (Vorfall 28.08.2026)
|
||||
|
||||
Hier stand `engine = _engine_bauen(DATABASE_URL)` auf Modulebene. Die
|
||||
Vorgabe von `DATABASE_URL` ist PostgreSQL — also versuchte SQLAlchemy schon
|
||||
beim IMPORT dieses Moduls, den psycopg2-Dialekt zu laden. Im
|
||||
Windows-Paket gibt es psycopg2 bewusst nicht (der Standalone-Betrieb
|
||||
benutzt SQLite), und die fertige EXE starb sofort:
|
||||
|
||||
File "rippy/store/__init__.py", line 123, in <module>
|
||||
ModuleNotFoundError: No module named 'psycopg2'
|
||||
|
||||
Das Tueckische: Im Repo und in beiden Containern faellt es nie auf, weil
|
||||
psycopg2 dort installiert ist. Erst das gebaute Paket zeigt es.
|
||||
|
||||
Und der Fehler ist grundsaetzlicher als der fehlende Treiber: Ein Modul,
|
||||
das beim Import schon eine Datenbankverbindung aufbaut, laesst sich nicht
|
||||
mehr umstellen — `verbinden()` kaeme immer zu spaet.
|
||||
"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = _engine_bauen(DATABASE_URL)
|
||||
return _engine
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""`store.engine` von aussen — baut die Engine bei Bedarf.
|
||||
|
||||
PEP 562: Wird nur gefragt, wenn der Name NICHT schon als Modul-Variable
|
||||
existiert. Innerhalb dieses Moduls wird deshalb `engine_holen()` benutzt.
|
||||
"""
|
||||
if name == "engine":
|
||||
return engine_holen()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def zustand_sichern():
|
||||
"""(URL, Engine) merken — OHNE eine Engine zu bauen.
|
||||
|
||||
Fuer Tests und Werkzeuge, die den Store voruebergehend umstellen. Der
|
||||
naheliegende Weg `vorher = store.engine` ist FALSCH: Er baut die Engine
|
||||
erst, obwohl vielleicht nie eine gebraucht wurde — und beim
|
||||
Zurueckschreiben mit `store.engine = vorher` entsteht eine echte
|
||||
Modul-Variable, die `__getattr__` von da an dauerhaft ueberdeckt.
|
||||
Genau daran sind am 28.08.2026 zwanzig Tests gescheitert.
|
||||
"""
|
||||
return DATABASE_URL, _engine
|
||||
|
||||
|
||||
def zustand_wiederherstellen(zustand) -> None:
|
||||
global DATABASE_URL, _engine
|
||||
DATABASE_URL, _engine = zustand
|
||||
|
||||
|
||||
def verbinden(url: str) -> None:
|
||||
@@ -134,9 +190,9 @@ def verbinden(url: str) -> None:
|
||||
lesen — ein `from rippy.store import engine` bindet den alten Wert fest und
|
||||
merkt von dieser Umstellung nichts. Genau darauf achtet rippy/queue/lokal.py.
|
||||
"""
|
||||
global engine, DATABASE_URL
|
||||
global _engine, DATABASE_URL
|
||||
DATABASE_URL = url
|
||||
engine = _engine_bauen(url)
|
||||
_engine = _engine_bauen(url)
|
||||
|
||||
|
||||
|
||||
@@ -171,7 +227,7 @@ def migrieren() -> None:
|
||||
umschichten), ist Alembic die richtige Antwort — dann aber mit einer
|
||||
bewussten Stempel-Runde.
|
||||
"""
|
||||
inspektor = inspect(engine)
|
||||
inspektor = inspect(engine_holen())
|
||||
vorhandene_tabellen = set(inspektor.get_table_names())
|
||||
for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN:
|
||||
if tabelle not in vorhandene_tabellen:
|
||||
@@ -179,7 +235,7 @@ def migrieren() -> None:
|
||||
spalten = {s["name"] for s in inspektor.get_columns(tabelle)}
|
||||
if spalte in spalten:
|
||||
continue
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {tabelle} ADD COLUMN {spalte} {typ}")
|
||||
|
||||
|
||||
@@ -189,7 +245,7 @@ def init_db() -> None:
|
||||
Wer zuerst startet — API oder Worker —, migriert; der andere findet die
|
||||
Spalten dann bereits vor. Das war schon vor der Zusammenlegung so.
|
||||
"""
|
||||
metadata.create_all(engine)
|
||||
metadata.create_all(engine_holen())
|
||||
migrieren()
|
||||
|
||||
|
||||
@@ -198,7 +254,7 @@ def insert_job(
|
||||
job_id: str, device: str, disc_type: str = None, title: str = None,
|
||||
target_dir: str = None, meta: str = None,
|
||||
) -> None:
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(
|
||||
jobs.insert().values(
|
||||
id=job_id,
|
||||
@@ -215,14 +271,14 @@ def insert_job(
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields) -> None:
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict:
|
||||
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
||||
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs).where(jobs.c.id == job_id)
|
||||
).mappings().first()
|
||||
@@ -231,7 +287,7 @@ def get_job(job_id: str) -> dict:
|
||||
|
||||
def get_job_status(job_id: str) -> str:
|
||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.status).where(jobs.c.id == job_id)
|
||||
).first()
|
||||
@@ -239,7 +295,7 @@ def get_job_status(job_id: str) -> str:
|
||||
|
||||
|
||||
def list_jobs(limit: int = 100) -> list:
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
@@ -252,7 +308,7 @@ def list_jobs_mit_status(stati) -> list:
|
||||
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
||||
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
||||
.where(jobs.c.status.in_(list(stati)))
|
||||
@@ -262,13 +318,13 @@ def list_jobs_mit_status(stati) -> list:
|
||||
|
||||
def delete_job(job_id: str) -> None:
|
||||
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(jobs.delete().where(jobs.c.id == job_id))
|
||||
|
||||
|
||||
def delete_finished_jobs() -> int:
|
||||
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
ergebnis = conn.execute(
|
||||
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
|
||||
)
|
||||
@@ -283,7 +339,7 @@ def hat_arbeit() -> bool:
|
||||
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
|
||||
anlaufen.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.id)
|
||||
.where(jobs.c.status.in_(
|
||||
@@ -295,7 +351,7 @@ def hat_arbeit() -> bool:
|
||||
|
||||
def has_active_job(device: str) -> bool:
|
||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.id)
|
||||
.where(jobs.c.device == device)
|
||||
@@ -337,14 +393,14 @@ def meta_merken(job_id: str, **felder) -> None:
|
||||
|
||||
# ───────────────────────────────────────────────────────────── Logs ────
|
||||
def add_log(level: str, source: str, message: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(
|
||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||
)
|
||||
|
||||
|
||||
def list_logs(limit: int = 200) -> list:
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(logs).order_by(logs.c.id.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
@@ -374,7 +430,7 @@ def get_settings(key: str = "ui", bei_fehler_leer: bool = False) -> dict:
|
||||
Nachricht über die Welt"). Zu klären in V2-2, wenn der Store-Port steht.
|
||||
"""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||
).first()
|
||||
@@ -394,7 +450,7 @@ def save_settings(werte: dict, key: str = "ui") -> None:
|
||||
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||
payload = json.dumps(werte)
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
vorhanden = conn.execute(
|
||||
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||
).first()
|
||||
@@ -413,7 +469,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
||||
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
||||
payload = json.dumps(encoder_liste)
|
||||
info_payload = json.dumps(info or {})
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
vorhanden = conn.execute(
|
||||
workers.select().where(workers.c.name == name)
|
||||
).first()
|
||||
@@ -432,7 +488,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
||||
|
||||
|
||||
def list_workers() -> list:
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
zeilen = conn.execute(select(workers)).mappings().all()
|
||||
ergebnis = []
|
||||
for z in zeilen:
|
||||
@@ -453,7 +509,7 @@ def list_workers() -> list:
|
||||
|
||||
def delete_worker(name: str) -> None:
|
||||
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(workers.delete().where(workers.c.name == name))
|
||||
|
||||
|
||||
@@ -465,7 +521,7 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||
unvollständig — dann wird NICHTS als Leiche gewertet.
|
||||
"""
|
||||
grenze = utcnow() - timedelta(seconds=sekunden)
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
anzahl = conn.execute(
|
||||
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
||||
).scalar()
|
||||
@@ -474,12 +530,12 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||
|
||||
# ──────────────────────────────────────────────────── Speicherziele ────
|
||||
def list_mounts() -> list:
|
||||
with engine.connect() as conn:
|
||||
with engine_holen().connect() as conn:
|
||||
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
|
||||
|
||||
|
||||
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(
|
||||
storage_mounts.insert().values(
|
||||
name=name, typ=typ, quelle=quelle,
|
||||
@@ -489,5 +545,5 @@ def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, p
|
||||
|
||||
|
||||
def delete_mount(name: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
with engine_holen().begin() as conn:
|
||||
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
|
||||
|
||||
@@ -18,12 +18,12 @@ from rippy import store
|
||||
@pytest.fixture
|
||||
def sqlite_store(tmp_path):
|
||||
"""Store auf eine frische SQLite-Datei umstellen und danach zurückdrehen."""
|
||||
vorher_url, vorher_engine = store.DATABASE_URL, store.engine
|
||||
vorher = store.zustand_sichern()
|
||||
store.verbinden(f"sqlite:///{(tmp_path / 'rippy.db').as_posix()}")
|
||||
store.init_db()
|
||||
yield store
|
||||
store.engine.dispose()
|
||||
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
|
||||
store.engine_holen().dispose()
|
||||
store.zustand_wiederherstellen(vorher)
|
||||
|
||||
|
||||
def test_init_db_legt_alle_tabellen_an(sqlite_store):
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Die Windows-App: Installation, Deinstallation, Aufraeum-Skript.
|
||||
|
||||
Die reinen Funktionen laufen ueberall. Was wirklich installiert, laeuft nur
|
||||
unter Windows und in einem Testordner — die echte Installation wird nie
|
||||
angefasst.
|
||||
|
||||
Hinweis zu den Backslashes: Windows-Pfade stehen hier durchweg in ROHEN
|
||||
Zeichenketten. In einer normalen waere ein Pfad wie C:\\Program die
|
||||
ungueltige Escape-Sequenz Backslash-P — heute eine DeprecationWarning, in
|
||||
einer kuenftigen Python-Version ein harter Fehler.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from rippy import windows_app
|
||||
|
||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||
|
||||
BACKSLASH = chr(92)
|
||||
|
||||
|
||||
# ── Das Aufraeum-Skript ─────────────────────────────────────────────────
|
||||
def test_pfade_stehen_in_anfuehrungszeichen():
|
||||
"""DER Fehler vom 28.08.2026.
|
||||
|
||||
Der Loeschbefehl ging als EIN Argument an cmd. Python maskiert dabei die
|
||||
inneren Anfuehrungszeichen, bei cmd kam ein Dateiname MIT vorangestellten
|
||||
Backslashes an. `del` loeschte nichts und meldete nichts (die Ausgabe war
|
||||
nach nul umgeleitet) — Registry-Eintrag und Autostart waren weg, die
|
||||
28-MB-Datei blieb liegen. Ein Skript hat das Problem nicht: Sein Inhalt
|
||||
geht durch keine Argument-Zerlegung.
|
||||
"""
|
||||
s = windows_app.aufraeum_skript(
|
||||
r"C:\Program Files\Rippy\Rippy.exe", r"C:\Program Files\Rippy")
|
||||
assert '"' + r"C:\Program Files\Rippy\Rippy.exe" + '"' in s
|
||||
assert BACKSLASH + '"' not in s, "Anfuehrungszeichen duerfen NICHT maskiert sein"
|
||||
|
||||
|
||||
def test_skript_versucht_es_mehrfach():
|
||||
"""Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse; der Starter
|
||||
haelt die Datei nach dem Ende noch offen. Ein einziger Versuch schlaegt
|
||||
dann manchmal fehl — und zwar unsichtbar."""
|
||||
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
|
||||
assert "for /l" in s
|
||||
assert "(1,1,15)" in s
|
||||
|
||||
|
||||
def test_skript_loescht_sich_selbst():
|
||||
"""Sonst bleibt nach jeder Deinstallation eine .bat im Temp liegen."""
|
||||
assert "%~f0" in windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
|
||||
|
||||
|
||||
def test_ordner_ohne_abschliessenden_trenner():
|
||||
"""rmdir will den Ordner OHNE Schluss-Backslash — mit scheitert es."""
|
||||
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", "C:" + BACKSLASH + "R" + BACKSLASH)
|
||||
assert 'rmdir "' + r"C:\R" + '"' in s
|
||||
|
||||
|
||||
# ── Installation (echt, in einem Testordner) ────────────────────────────
|
||||
@nur_windows
|
||||
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
|
||||
"""Der ganze Kreislauf. Der Registry-Schluessel ist der ECHTE — deshalb
|
||||
wird am Ende immer aufgeraeumt, auch wenn der Test scheitert."""
|
||||
from rippy.platform import win_registry as reg
|
||||
|
||||
# Nicht die eigene Datei kopieren (das waere python.exe), sondern eine
|
||||
# Attrappe: geprueft wird die Verdrahtung, nicht shutil.copy2.
|
||||
attrappe = tmp_path / "quelle" / "RippySetup.exe"
|
||||
attrappe.parent.mkdir()
|
||||
attrappe.write_bytes(b"x" * 2048)
|
||||
monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe))
|
||||
|
||||
ziel = str(tmp_path / "installiert")
|
||||
try:
|
||||
ordner = windows_app.installieren(ziel=ziel, autostart=True,
|
||||
starten_danach=False)
|
||||
assert os.path.isfile(os.path.join(ordner, "Rippy.exe"))
|
||||
assert reg.ist_eingetragen() is True
|
||||
|
||||
werte = reg.gelesene_werte()
|
||||
assert werte["InstallLocation"] == ziel
|
||||
assert werte["DisplayName"] == "Rippy"
|
||||
assert reg.autostart_wert().endswith("--dienst")
|
||||
finally:
|
||||
reg.autostart_entfernen()
|
||||
reg.austragen()
|
||||
|
||||
assert reg.ist_eingetragen() is False
|
||||
assert reg.autostart_wert() == ""
|
||||
@@ -0,0 +1,434 @@
|
||||
"""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 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
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════ Installation ═══════
|
||||
def installieren(ziel: str = None, autostart: bool = True,
|
||||
starten_danach: 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
|
||||
|
||||
# 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 starten_danach:
|
||||
starte_hintergrund(programm)
|
||||
|
||||
return ziel
|
||||
|
||||
|
||||
def starte_hintergrund(programm: str) -> None:
|
||||
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
|
||||
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
|
||||
close_fds=True)
|
||||
|
||||
|
||||
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())
|
||||
|
||||
reg.autostart_entfernen()
|
||||
reg.austragen()
|
||||
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.
|
||||
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:
|
||||
pass
|
||||
|
||||
if laeuft_von_hier:
|
||||
selbst_loeschen(_eigene_datei(), ordner)
|
||||
|
||||
if not still:
|
||||
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
|
||||
rmdir "{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)
|
||||
|
||||
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 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.
|
||||
"""
|
||||
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 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):
|
||||
webbrowser.open(dienst.adresse)
|
||||
|
||||
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 --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
|
||||
--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, 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 "--dienst" in argv:
|
||||
return dienst_starten(int(wert("--port", STANDARD_PORT)))
|
||||
|
||||
# Ohne Argument: installieren. Das ist der Doppelklick-Fall.
|
||||
ziel = installieren(
|
||||
ziel=wert("--ziel"),
|
||||
autostart="--kein-autostart" not in argv,
|
||||
starten_danach="--nicht-starten" not in argv,
|
||||
)
|
||||
melden("Rippy wurde nach %s installiert." % ziel)
|
||||
melden("Die Oberfläche öffnet sich gleich im Browser.")
|
||||
if "--nicht-starten" not in argv:
|
||||
time.sleep(3)
|
||||
webbrowser.open("http://127.0.0.1:%d" % int(wert("--port", STANDARD_PORT)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user