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