From 059183c65113348b2de839f92c1d16ba99df3e31 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 11:00:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20V2-4=20fertig=20=E2=80=94=20Ri?= =?UTF-8?q?ppySetup.exe,=20Taskmanager-Eintrag,=20Deinstallation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 UninstallString "\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 --- .gitignore | 1 + docker/api/celery_client.py | 64 ++- docker/api/main.py | 10 +- docker/worker/caps.py | 2 +- docker/worker/ripping.py | 2 +- docker/worker/schluessel.py | 2 +- packaging/windows/build.py | 195 ++++++++ packaging/windows/einstieg.py | 24 + src/rippy/platform/__init__.py | 5 + src/rippy/platform/test_win_registry.py | 128 ++++++ src/rippy/platform/win_registry.py | 217 +++++++++ .../worker => src/rippy/platform}/winlauf.py | 0 src/rippy/queue/test_lokal.py | 6 +- src/rippy/store/__init__.py | 110 +++-- src/rippy/store/test_sqlite.py | 6 +- src/rippy/test_windows_app.py | 91 ++++ src/rippy/windows_app.py | 434 ++++++++++++++++++ 17 files changed, 1251 insertions(+), 46 deletions(-) create mode 100644 packaging/windows/build.py create mode 100644 packaging/windows/einstieg.py create mode 100644 src/rippy/platform/__init__.py create mode 100644 src/rippy/platform/test_win_registry.py create mode 100644 src/rippy/platform/win_registry.py rename {docker/worker => src/rippy/platform}/winlauf.py (100%) create mode 100644 src/rippy/test_windows_app.py create mode 100644 src/rippy/windows_app.py diff --git a/.gitignore b/.gitignore index 88bcec7..952394e 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ logs/ # Data *.db *.sqlite +dist/ diff --git a/docker/api/celery_client.py b/docker/api/celery_client.py index 601eb77..79497c5 100644 --- a/docker/api/celery_client.py +++ b/docker/api/celery_client.py @@ -2,16 +2,66 @@ 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. + +## 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 -from celery import Celery -from celery.utils import worker_direct - 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): @@ -25,7 +75,9 @@ def transcode_queue(node: str = None): if not node: return "transcode" 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()} if node in online: 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): """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] ) diff --git a/docker/api/main.py b/docker/api/main.py index 647aace..d8ad631 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -24,7 +24,8 @@ from rippy.bus.waechter import Waechter import phasen import presets as preset_auswahl 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 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") 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"} @@ -1271,7 +1273,7 @@ async def retry_transcode(job_id: str): except ValueError: meta = {} from celery_client import transcode_queue - celery_client.send_task( + celery_anbindung.hole_client().send_task( "worker.tasks.transcode_files", args=[job_id, raw_dir, final_dir], queue=transcode_queue(meta.get("transcode_node")), @@ -1401,7 +1403,7 @@ PING_ALTER_MAX_SEKUNDEN = 30 def _ping_jetzt() -> list: """Pingt sofort (blockiert ~1 s) und füllt den Vorrat.""" 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()] except Exception: knoten = [] diff --git a/docker/worker/caps.py b/docker/worker/caps.py index d90de4d..b229bd1 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -11,7 +11,7 @@ import re import shutil import subprocess -from winlauf import OHNE_FENSTER +from rippy.platform.winlauf import OHNE_FENSTER HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 524198f..c6cf9ce 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -36,7 +36,7 @@ from rippy.drives.linux import ( # noqa: F401 _auswurf_geglueckt, ) 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") diff --git a/docker/worker/schluessel.py b/docker/worker/schluessel.py index 792624a..0b80a7e 100644 --- a/docker/worker/schluessel.py +++ b/docker/worker/schluessel.py @@ -45,7 +45,7 @@ import re import subprocess import urllib.request -from winlauf import OHNE_FENSTER +from rippy.platform.winlauf import OHNE_FENSTER # `DRV:,,,,"","",""` DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"') diff --git a/packaging/windows/build.py b/packaging/windows/build.py new file mode 100644 index 0000000..ce5007f --- /dev/null +++ b/packaging/windows/build.py @@ -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()) diff --git a/packaging/windows/einstieg.py b/packaging/windows/einstieg.py new file mode 100644 index 0000000..0e3b6e1 --- /dev/null +++ b/packaging/windows/einstieg.py @@ -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()) diff --git a/src/rippy/platform/__init__.py b/src/rippy/platform/__init__.py new file mode 100644 index 0000000..d16764e --- /dev/null +++ b/src/rippy/platform/__init__.py @@ -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. +""" diff --git a/src/rippy/platform/test_win_registry.py b/src/rippy/platform/test_win_registry.py new file mode 100644 index 0000000..a886894 --- /dev/null +++ b/src/rippy/platform/test_win_registry.py @@ -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) == "" diff --git a/src/rippy/platform/win_registry.py b/src/rippy/platform/win_registry.py new file mode 100644 index 0000000..4ebb43c --- /dev/null +++ b/src/rippy/platform/win_registry.py @@ -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\\ + HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\ + +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) diff --git a/docker/worker/winlauf.py b/src/rippy/platform/winlauf.py similarity index 100% rename from docker/worker/winlauf.py rename to src/rippy/platform/winlauf.py diff --git a/src/rippy/queue/test_lokal.py b/src/rippy/queue/test_lokal.py index 7eb8c29..247f131 100644 --- a/src/rippy/queue/test_lokal.py +++ b/src/rippy/queue/test_lokal.py @@ -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): diff --git a/src/rippy/store/__init__.py b/src/rippy/store/__init__.py index b9bd347..5955318 100644 --- a/src/rippy/store/__init__.py +++ b/src/rippy/store/__init__.py @@ -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 + 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:'), 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)) diff --git a/src/rippy/store/test_sqlite.py b/src/rippy/store/test_sqlite.py index c402556..735da95 100644 --- a/src/rippy/store/test_sqlite.py +++ b/src/rippy/store/test_sqlite.py @@ -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): diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py new file mode 100644 index 0000000..9c1ae60 --- /dev/null +++ b/src/rippy/test_windows_app.py @@ -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() == "" diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py new file mode 100644 index 0000000..1f3352b --- /dev/null +++ b/src/rippy/windows_app.py @@ -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())