feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
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:
Hitonabi
2026-08-28 11:00:39 +02:00
co-authored by Claude Opus 5
parent e5254c23f0
commit 059183c651
17 changed files with 1251 additions and 46 deletions
+1
View File
@@ -54,3 +54,4 @@ logs/
# Data # Data
*.db *.db
*.sqlite *.sqlite
dist/
+58 -6
View File
@@ -2,16 +2,66 @@
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste — Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke. kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
Import von `main.py` ein funktionierendes Celery — auch dann, wenn nie ein Rip
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
Start:
File "celery_client.py", ...
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
ModuleNotFoundError: No module named 'celery.fixups'
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
**Was das für den Windows-Betrieb bedeutet — ehrlich gesagt:** Die Oberfläche,
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
""" """
import os import os
from celery import Celery
from celery.utils import worker_direct
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) _client = None
class KeinBroker(RuntimeError):
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
def hole_client():
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
global _client
if _client is None:
try:
from celery import Celery
except ImportError as e:
raise KeinBroker(
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
"Broker ist damit nicht möglich (Umstellung auf die lokale "
"Auftrags-Queue: Etappe V2-5)."
) from e
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
return _client
def __getattr__(name):
"""`celery_client` von außen — baut den Client bei Bedarf.
Damit bleiben die bestehenden `from celery_client import celery_client`
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
"""
if name == "celery_client":
return hole_client()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def transcode_queue(node: str = None): def transcode_queue(node: str = None):
@@ -25,7 +75,9 @@ def transcode_queue(node: str = None):
if not node: if not node:
return "transcode" return "transcode"
try: try:
antworten = celery_client.control.ping(timeout=1.0) or [] from celery.utils import worker_direct
antworten = hole_client().control.ping(timeout=1.0) or []
online = {k for antwort in antworten for k in antwort.keys()} online = {k for antwort in antworten for k in antwort.keys()}
if node in online: if node in online:
return worker_direct(node) return worker_direct(node)
@@ -36,6 +88,6 @@ def transcode_queue(node: str = None):
def start_rip(device_path: str, job_id: str, target_dir: str = None): def start_rip(device_path: str, job_id: str, target_dir: str = None):
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
return celery_client.send_task( return hole_client().send_task(
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir] "worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
) )
+6 -4
View File
@@ -24,7 +24,8 @@ from rippy.bus.waechter import Waechter
import phasen import phasen
import presets as preset_auswahl import presets as preset_auswahl
import rohdaten import rohdaten
from celery_client import celery_client, start_rip import celery_client as celery_anbindung
from celery_client import start_rip
from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN
from config_validation import validate_config, ConfigValidationError from config_validation import validate_config, ConfigValidationError
@@ -1213,7 +1214,8 @@ async def scan_tracks_starten(name: str):
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job") raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}") await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
celery_client.send_task("worker.tasks.scan_tracks", args=[device_path]) celery_anbindung.hole_client().send_task(
"worker.tasks.scan_tracks", args=[device_path])
return {"status": "scanning"} return {"status": "scanning"}
@@ -1271,7 +1273,7 @@ async def retry_transcode(job_id: str):
except ValueError: except ValueError:
meta = {} meta = {}
from celery_client import transcode_queue from celery_client import transcode_queue
celery_client.send_task( celery_anbindung.hole_client().send_task(
"worker.tasks.transcode_files", "worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir], args=[job_id, raw_dir, final_dir],
queue=transcode_queue(meta.get("transcode_node")), queue=transcode_queue(meta.get("transcode_node")),
@@ -1401,7 +1403,7 @@ PING_ALTER_MAX_SEKUNDEN = 30
def _ping_jetzt() -> list: def _ping_jetzt() -> list:
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat.""" """Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
try: try:
antworten = celery_client.control.ping(timeout=1.0) or [] antworten = celery_anbindung.hole_client().control.ping(timeout=1.0) or []
knoten = [k for antwort in antworten for k in antwort.keys()] knoten = [k for antwort in antworten for k in antwort.keys()]
except Exception: except Exception:
knoten = [] knoten = []
+1 -1
View File
@@ -11,7 +11,7 @@ import re
import shutil import shutil
import subprocess import subprocess
from winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
+1 -1
View File
@@ -36,7 +36,7 @@ from rippy.drives.linux import ( # noqa: F401
_auswurf_geglueckt, _auswurf_geglueckt,
) )
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401 from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media") RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
+1 -1
View File
@@ -45,7 +45,7 @@ import re
import subprocess import subprocess
import urllib.request import urllib.request
from winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"` # `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"') DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
+195
View File
@@ -0,0 +1,195 @@
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
## Aufruf
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
* pyinstaller, pystray, pillow
* Das UI muss gebaut sein: cd docker/ui && npm run build
## Warum die Versions-Ressource nicht optional ist
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
und Windows SmartScreen sieht das genauso.
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
daneben aus dieser Ressource.
## Was mit hinein muss
docker/api/ die API-Module (main.py und Nachbarn)
docker/ui/dist/ die gebaute Weboberflaeche
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
"""
import argparse
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
VERSION_VORLAGE = """
VSVersionInfo(
ffi=FixedFileInfo(
filevers=({v0}, {v1}, {v2}, 0),
prodvers=({v0}, {v1}, {v2}, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(u'040704b0', [
StringStruct(u'CompanyName', u'Rippy'),
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
StringStruct(u'FileVersion', u'{version}'),
StringStruct(u'InternalName', u'Rippy'),
StringStruct(u'OriginalFilename', u'Rippy.exe'),
StringStruct(u'ProductName', u'Rippy'),
StringStruct(u'ProductVersion', u'{version}')])
]),
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
# Windows die Angaben je nach Systemsprache gar nicht an.
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
]
)
"""
def versionsdatei(version: str, ordner: str) -> str:
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
pfad = os.path.join(ordner, "version_info.txt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(VERSION_VORLAGE.format(
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
return pfad
def pruefen() -> list:
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
aussieht wie fertig und es nicht ist.
"""
maengel = []
if sys.platform != "win32":
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
maengel.append("docker/api/main.py fehlt.")
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
maengel.append(
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
"Abhilfe: cd docker/ui && npm ci && npm run build")
if not os.path.isfile(os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")):
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
try:
import PyInstaller # noqa: F401
except ImportError:
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
for modul, paket in (("pystray", "pystray"), ("PIL", "pillow")):
try:
__import__(modul)
except ImportError:
maengel.append(f"{modul} fehlt (pip install {paket}) — ohne das kein Tray-Symbol.")
return maengel
def bauen(ausgabe: str, version: str) -> str:
arbeit = os.path.join(ausgabe, "_bau")
os.makedirs(arbeit, exist_ok=True)
trenner = ";" # Windows-Trennzeichen fuer --add-data
befehl = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--onefile",
# --windowed waere schoener (kein Konsolenfenster), macht aber jede
# Ausgabe unsichtbar — auch Fehlermeldungen beim Installieren. Der
# Installer laeuft deshalb MIT Konsole; der Dienst startet spaeter
# ohne Fenster (CREATE_NO_WINDOW in winlauf.py).
"--console",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
"--distpath", ausgabe,
"--workpath", os.path.join(arbeit, "work"),
"--specpath", arbeit,
"--paths", os.path.join(REPO, "src"),
"--paths", os.path.join(REPO, "docker", "api"),
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "rippy.drives.windows",
"--hidden-import", "pystray._win32",
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
# und beides zieht Ballast nach.
"--exclude-module", "psycopg2",
"--exclude-module", "tkinter",
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
]
print("PyInstaller wird aufgerufen …\n")
ergebnis = subprocess.run(befehl, cwd=REPO)
if ergebnis.returncode != 0:
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
exe = os.path.join(ausgabe, "RippySetup.exe")
if not os.path.isfile(exe):
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
return exe
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
print("Rippy — Windows-Bau")
print(" Repo %s" % REPO)
print(" Ausgabe %s" % args.ausgabe)
print(" Version %s" % args.version)
print()
maengel = pruefen()
if maengel:
print("Der Bau kann so nicht laufen:")
for m in maengel:
print(" X %s" % m)
return 1
print("Alle Voraussetzungen erfuellt.")
if args.nur_pruefen:
return 0
os.makedirs(args.ausgabe, exist_ok=True)
exe = bauen(args.ausgabe, args.version)
groesse = os.path.getsize(exe) / 1024**2
print()
print("Fertig: %s (%.1f MB)" % (exe, groesse))
arbeit = os.path.join(args.ausgabe, "_bau")
shutil.rmtree(arbeit, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+24
View File
@@ -0,0 +1,24 @@
"""Einstiegspunkt der gebuendelten RippySetup.exe.
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
"""
import multiprocessing
import sys
def main() -> int:
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
multiprocessing.freeze_support()
from rippy.windows_app import main as rippy_main
return rippy_main()
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -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.
"""
+128
View File
@@ -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) == ""
+217
View File
@@ -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)
+3 -3
View File
@@ -23,12 +23,12 @@ from rippy.queue import lokal
@pytest.fixture @pytest.fixture
def q(tmp_path): def q(tmp_path):
"""Frische SQLite-Datenbank je Test, danach zurückdrehen.""" """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.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
store.init_db() store.init_db()
yield lokal yield lokal
store.engine.dispose() store.engine_holen().dispose()
store.DATABASE_URL, store.engine = vorher_url, vorher_engine store.zustand_wiederherstellen(vorher)
def _lease_zuruecksetzen(auftrag_id, sekunden): def _lease_zuruecksetzen(auftrag_id, sekunden):
+83 -27
View File
@@ -120,7 +120,63 @@ def _engine_bauen(url: str):
return eng 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: 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 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. merkt von dieser Umstellung nichts. Genau darauf achtet rippy/queue/lokal.py.
""" """
global engine, DATABASE_URL global _engine, DATABASE_URL
DATABASE_URL = 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 umschichten), ist Alembic die richtige Antwort — dann aber mit einer
bewussten Stempel-Runde. bewussten Stempel-Runde.
""" """
inspektor = inspect(engine) inspektor = inspect(engine_holen())
vorhandene_tabellen = set(inspektor.get_table_names()) vorhandene_tabellen = set(inspektor.get_table_names())
for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN: for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN:
if tabelle not in vorhandene_tabellen: if tabelle not in vorhandene_tabellen:
@@ -179,7 +235,7 @@ def migrieren() -> None:
spalten = {s["name"] for s in inspektor.get_columns(tabelle)} spalten = {s["name"] for s in inspektor.get_columns(tabelle)}
if spalte in spalten: if spalte in spalten:
continue continue
with engine.begin() as conn: with engine_holen().begin() as conn:
conn.exec_driver_sql(f"ALTER TABLE {tabelle} ADD COLUMN {spalte} {typ}") 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 Wer zuerst startet — API oder Worker —, migriert; der andere findet die
Spalten dann bereits vor. Das war schon vor der Zusammenlegung so. Spalten dann bereits vor. Das war schon vor der Zusammenlegung so.
""" """
metadata.create_all(engine) metadata.create_all(engine_holen())
migrieren() migrieren()
@@ -198,7 +254,7 @@ def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None, job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None, target_dir: str = None, meta: str = None,
) -> None: ) -> None:
with engine.begin() as conn: with engine_holen().begin() as conn:
conn.execute( conn.execute(
jobs.insert().values( jobs.insert().values(
id=job_id, id=job_id,
@@ -215,14 +271,14 @@ def insert_job(
def update_job(job_id: str, **fields) -> None: 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)) conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def get_job(job_id: str) -> dict: def get_job(job_id: str) -> dict:
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die """Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster).""" Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
with engine.connect() as conn: with engine_holen().connect() as conn:
zeile = conn.execute( zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id) select(jobs).where(jobs.c.id == job_id)
).mappings().first() ).mappings().first()
@@ -231,7 +287,7 @@ def get_job(job_id: str) -> dict:
def get_job_status(job_id: str) -> str: def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen.""" """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( zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id) select(jobs.c.status).where(jobs.c.id == job_id)
).first() ).first()
@@ -239,7 +295,7 @@ def get_job_status(job_id: str) -> str:
def list_jobs(limit: int = 100) -> list: def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn: with engine_holen().connect() as conn:
zeilen = conn.execute( zeilen = conn.execute(
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit) select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
).mappings().all() ).mappings().all()
@@ -252,7 +308,7 @@ def list_jobs_mit_status(stati) -> list:
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile. 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( zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at) select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati))) .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: def delete_job(job_id: str) -> None:
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben).""" """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)) conn.execute(jobs.delete().where(jobs.c.id == job_id))
def delete_finished_jobs() -> int: def delete_finished_jobs() -> int:
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben.""" """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( ergebnis = conn.execute(
jobs.delete().where(jobs.c.status.in_(("completed", "failed"))) 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 Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
anlaufen. anlaufen.
""" """
with engine.connect() as conn: with engine_holen().connect() as conn:
zeile = conn.execute( zeile = conn.execute(
select(jobs.c.id) select(jobs.c.id)
.where(jobs.c.status.in_( .where(jobs.c.status.in_(
@@ -295,7 +351,7 @@ def hat_arbeit() -> bool:
def has_active_job(device: str) -> bool: def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz).""" """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( zeile = conn.execute(
select(jobs.c.id) select(jobs.c.id)
.where(jobs.c.device == device) .where(jobs.c.device == device)
@@ -337,14 +393,14 @@ def meta_merken(job_id: str, **felder) -> None:
# ───────────────────────────────────────────────────────────── Logs ──── # ───────────────────────────────────────────────────────────── Logs ────
def add_log(level: str, source: str, message: str) -> None: def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn: with engine_holen().begin() as conn:
conn.execute( conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message) logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
) )
def list_logs(limit: int = 200) -> list: def list_logs(limit: int = 200) -> list:
with engine.connect() as conn: with engine_holen().connect() as conn:
zeilen = conn.execute( zeilen = conn.execute(
select(logs).order_by(logs.c.id.desc()).limit(limit) select(logs).order_by(logs.c.id.desc()).limit(limit)
).mappings().all() ).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. Nachricht über die Welt"). Zu klären in V2-2, wenn der Store-Port steht.
""" """
try: try:
with engine.connect() as conn: with engine_holen().connect() as conn:
zeile = conn.execute( zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key) select(settings_table.c.value).where(settings_table.c.key == key)
).first() ).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 """Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie.""" Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
payload = json.dumps(werte) payload = json.dumps(werte)
with engine.begin() as conn: with engine_holen().begin() as conn:
vorhanden = conn.execute( vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key) select(settings_table.c.key).where(settings_table.c.key == key)
).first() ).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).""" """Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
payload = json.dumps(encoder_liste) payload = json.dumps(encoder_liste)
info_payload = json.dumps(info or {}) info_payload = json.dumps(info or {})
with engine.begin() as conn: with engine_holen().begin() as conn:
vorhanden = conn.execute( vorhanden = conn.execute(
workers.select().where(workers.c.name == name) workers.select().where(workers.c.name == name)
).first() ).first()
@@ -432,7 +488,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
def list_workers() -> list: def list_workers() -> list:
with engine.connect() as conn: with engine_holen().connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all() zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = [] ergebnis = []
for z in zeilen: for z in zeilen:
@@ -453,7 +509,7 @@ def list_workers() -> list:
def delete_worker(name: str) -> None: def delete_worker(name: str) -> None:
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste.""" """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)) 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. unvollständig — dann wird NICHTS als Leiche gewertet.
""" """
grenze = utcnow() - timedelta(seconds=sekunden) grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn: with engine_holen().connect() as conn:
anzahl = conn.execute( anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze) select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar() ).scalar()
@@ -474,12 +530,12 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
# ──────────────────────────────────────────────────── Speicherziele ──── # ──────────────────────────────────────────────────── Speicherziele ────
def list_mounts() -> list: 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()] 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: 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( conn.execute(
storage_mounts.insert().values( storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle, 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: 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)) conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
+3 -3
View File
@@ -18,12 +18,12 @@ from rippy import store
@pytest.fixture @pytest.fixture
def sqlite_store(tmp_path): def sqlite_store(tmp_path):
"""Store auf eine frische SQLite-Datei umstellen und danach zurückdrehen.""" """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.verbinden(f"sqlite:///{(tmp_path / 'rippy.db').as_posix()}")
store.init_db() store.init_db()
yield store yield store
store.engine.dispose() store.engine_holen().dispose()
store.DATABASE_URL, store.engine = vorher_url, vorher_engine store.zustand_wiederherstellen(vorher)
def test_init_db_legt_alle_tabellen_an(sqlite_store): def test_init_db_legt_alle_tabellen_an(sqlite_store):
+91
View File
@@ -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() == ""
+434
View File
@@ -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())