diff --git a/packaging/windows/build.py b/packaging/windows/build.py index 30c4762..455e61a 100644 --- a/packaging/windows/build.py +++ b/packaging/windows/build.py @@ -123,11 +123,16 @@ def bauen(ausgabe: str, version: str) -> str: 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", + # KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde + # das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken + # zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI + # Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit + # nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei + # Sekunden auf, bis Python so weit ist. + # + # Was dafuer noetig ist, steht in packaging/windows/einstieg.py: + # ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben. + "--windowed", "--name", "RippySetup", "--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"), "--version-file", versionsdatei(version, arbeit), diff --git a/packaging/windows/einstieg.py b/packaging/windows/einstieg.py index 0e3b6e1..dbe48a2 100644 --- a/packaging/windows/einstieg.py +++ b/packaging/windows/einstieg.py @@ -3,18 +3,55 @@ 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. + +## Warum hier trotzdem etwas zur Ausgabe steht + +Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start +KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026: +„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet." + +Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr` +schreibt — uvicorn tut das —, wuerde mit +`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und +zwar mitten im Start, ohne dass irgendwo etwas stuende. + +Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`: + + 1. In einem Terminal gestartet? -> dort hineinschreiben + 2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log """ import multiprocessing import sys +def _ausgabe_richten() -> None: + """Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei.""" + from rippy.platform import winlauf + + if not winlauf.ohne_konsole(): + return # aus dem Quellcode gestartet + if winlauf.an_elternkonsole_haengen(): + # Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt + # sys.stdout trotz Konsole None. + try: + sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", + buffering=1) + sys.stderr = sys.stdout + return + except OSError: + pass + winlauf.ausgabe_umleiten() + + 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() + _ausgabe_richten() + from rippy.windows_app import main as rippy_main return rippy_main() diff --git a/src/rippy/bus/test_waechter.py b/src/rippy/bus/test_waechter.py index 008d21b..4548e60 100644 --- a/src/rippy/bus/test_waechter.py +++ b/src/rippy/bus/test_waechter.py @@ -218,6 +218,50 @@ def test_haengendes_laufwerk_haelt_den_waechter_nicht_an(): assert bus.typen == ["job.progress"] +class FrischGestartet: + """Eine Uhr, die gerade erst angefangen hat zu zählen. + + `time.monotonic()` zählt ab einem beliebigen Nullpunkt — unter Linux ab + dem Start der Maschine. Auf einem Rechner, der seit Wochen läuft, ist der + Wert riesig; auf einem frisch gestarteten (oder in einem Container) ist er + klein. Genau dieser Unterschied hat die Ampel rot gemacht. + """ + + def __init__(self, wert=0.5): + self.wert = wert + + def monotonic(self): + return self.wert + + +def test_erste_abfrage_faellt_nicht_aus_wenn_die_uhr_bei_null_steht(monkeypatch): + """DER Fehler aus den Ampel-Läufen 181–184 (28.08.2026). + + `_laufwerke_geprueft` stand auf `0.0` und sollte „noch nie geprüft" + heißen. Verglichen wurde es aber mit `time.monotonic()`. Auf einer frisch + gestarteten Maschine ist `jetzt - 0.0` KLEIN — also sah der Wächter aus, + als hätte er die Laufwerke gerade eben schon gelesen, und die erste + Abfrage fiel ersatzlos aus. + + Unter Windows fiel das nie auf, weil der Zähler dort schon beim ersten + Start groß ist. Dieser Test stellt den kleinen Zähler nach und läuft + deshalb auf JEDER Plattform. + """ + from rippy.bus import waechter as modul + + monkeypatch.setattr(modul, "time", FrischGestartet(0.5)) + + aufrufe = [] + w = Waechter(FakeStore(), FakeBus(), + laufwerke_lesen=lambda: aufrufe.append(1) or [_lw("/dev/sr0")], + laufwerks_takt=999) + w.einmal() + assert len(aufrufe) == 1, ( + "Die erste Laufwerks-Abfrage ist ausgefallen: 0.0 wurde als Zeitpunkt " + "gelesen statt als 'noch nie geprueft'." + ) + + def test_laufwerke_werden_seltener_abgefragt_als_jobs(): """Ein ioctl kostet mehr als ein SELECT. Ohne den eigenen Takt liefe jede Sekunde eine Laufwerksabfrage — auf einem klemmenden Laufwerk waere diff --git a/src/rippy/bus/waechter.py b/src/rippy/bus/waechter.py index d20a296..fc5df79 100644 --- a/src/rippy/bus/waechter.py +++ b/src/rippy/bus/waechter.py @@ -181,11 +181,20 @@ class Waechter: self._laufwerks_takt = laufwerks_takt self._jobs: dict = {} self._laufwerke: dict = {} - self._laufwerke_geprueft = 0.0 + # None heißt „noch nie geprüft" — NICHT 0.0. `time.monotonic()` zählt + # ab einem beliebigen Nullpunkt; unter Linux ist das der Start der + # Maschine. Auf einem frisch gestarteten System ist `jetzt - 0.0` + # deshalb KLEIN, und die erste Abfrage fiel schlicht aus. + # + # Auf dem Linux-Runner der Ampel genau so passiert (28.08.2026): null + # Laufwerks-Abfragen statt einer. Unter Windows fällt es nie auf, weil + # der Zähler dort schon beim ersten Start groß ist — ein Fehler, den + # nur die andere Plattform zeigt. + self._laufwerke_geprueft = None self._system_lesen = system_lesen self._system_takt = system_takt self._system = None - self._system_geprueft = 0.0 + self._system_geprueft = None self._letzte_log_id = None self._letzter_lauf = 0.0 self._fehler_in_folge = 0 @@ -264,7 +273,8 @@ class Waechter: if self._system_lesen is None: return 0 jetzt = time.monotonic() - if jetzt - self._system_geprueft < self._system_takt: + if (self._system_geprueft is not None + and jetzt - self._system_geprueft < self._system_takt): return 0 self._system_geprueft = jetzt try: @@ -289,7 +299,8 @@ class Waechter: if self._laufwerke_lesen is None: return 0 jetzt = time.monotonic() - if jetzt - self._laufwerke_geprueft < self._laufwerks_takt: + if (self._laufwerke_geprueft is not None + and jetzt - self._laufwerke_geprueft < self._laufwerks_takt): return 0 self._laufwerke_geprueft = jetzt try: diff --git a/src/rippy/drives/linux.py b/src/rippy/drives/linux.py index ce222ed..7ee5602 100644 --- a/src/rippy/drives/linux.py +++ b/src/rippy/drives/linux.py @@ -54,6 +54,37 @@ from rippy.drives.cdrom import ( # noqa: F401 CDS_TRAY_OPEN, ) +# Was `detection.py` beisteuert — auf Anfrage, nicht beim Import. +# +# ## Warum das hier stehen MUSS (Ampel rot seit 28.08.2026, Lauf 181–184) +# +# `docker/api/main.py` holt sich beim Import `device_discovery.drive_status`. +# Der Windows-Treiber hat die Funktion; dieses Modul hatte sie nicht mehr, +# seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert +# wird. Folge: Unter Windows lief alles, auf Linux starb main.py beim Import +# mit `AttributeError: module 'rippy.drives.linux' has no attribute +# 'drive_status'` — **der API-Container wäre gar nicht hochgekommen**. +# +# Lokal fiel es nicht auf, weil hier Windows läuft und `treiber()` dann gar +# nicht zu diesem Modul greift. Genau dafür ist die Ampel da — und genau +# deshalb ist Regel A („grün, bevor irgendetwas fertig heißt") keine Formalie. +# +# PEP 562: `__getattr__` wird nur gefragt, wenn der Name nicht schon als +# Modul-Variable existiert. Damit bleibt `import rippy.drives.linux` unter +# Windows möglich (kein `fcntl` nötig), und wer die Funktionen wirklich +# BENUTZT, bekommt sie — oder einen lauten ImportError. +_AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes", + "detect_disc_type", "classify") + + +def __getattr__(name): + if name in _AUS_DETECTION: + from rippy.drives import detection + + return getattr(detection, name) + raise AttributeError("module %r has no attribute %r" % (__name__, name)) + + # Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein # bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges. AUSWURF_WARTEN_SEKUNDEN = 5 diff --git a/src/rippy/drives/test_treiberwahl.py b/src/rippy/drives/test_treiberwahl.py index 701399d..5b41e21 100644 --- a/src/rippy/drives/test_treiberwahl.py +++ b/src/rippy/drives/test_treiberwahl.py @@ -71,4 +71,58 @@ def test_windows_treiber_kann_alles_pflichtgemaesse(name): reason="der Linux-Treiber braucht fcntl") @pytest.mark.parametrize("name", PFLICHT) def test_linux_treiber_kann_alles_pflichtgemaesse(name): - assert callable(getattr(drives.treiber("linux"), name, None)) + assert callable(getattr(drives.treiber("linux"), name, None)), ( + f"Dem Linux-Treiber fehlt {name}(). Das faellt sonst erst im Betrieb " + "als AttributeError auf — auf der anderen Plattform." + ) + + +# Was `linux.py` nicht selbst hat, sondern aus `detection.py` durchreicht. +# Getrennt gefuehrt, weil genau diese Trennung der Fehler war. +AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes", + "detect_disc_type", "classify") + + +@pytest.mark.parametrize("name", AUS_DETECTION) +def test_linux_treiber_reicht_die_detection_funktionen_durch(name, monkeypatch): + """DER Fehler, der die Ampel vier Commits lang rot hielt (28.08.2026). + + `docker/api/main.py` holt sich beim Import `device_discovery.drive_status`. + Der Windows-Treiber hat die Funktion. `linux.py` hatte sie NICHT mehr, + seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert + wird — sonst waere `main.py` unter Windows nicht ladbar. + + Folge: Unter Windows lief alles, auf Linux starb `main.py` beim Import + mit `AttributeError: module 'rippy.drives.linux' has no attribute + 'drive_status'`. **Der API-Container waere gar nicht hochgekommen.** + + Der Test oben faellt unter Windows aus (er braucht `fcntl`) — dieser + hier NICHT: Er schiebt eine Attrappe von `detection` unter und prueft + nur die Durchreiche. Damit haette der Fehler auch hier auffallen muessen, + nicht erst auf dem Linux-Runner. + """ + import types + + from rippy.drives import linux + + attrappe = types.ModuleType("rippy.drives.detection") + for wie_es_heisst in AUS_DETECTION: + setattr(attrappe, wie_es_heisst, + lambda *a, _n=wie_es_heisst, **k: _n) + monkeypatch.setitem(sys.modules, "rippy.drives.detection", attrappe) + + durchgereicht = getattr(linux, name, None) + assert callable(durchgereicht), ( + f"linux.py reicht {name}() nicht durch — genau daran ist die API " + "auf Linux beim Import gestorben." + ) + assert durchgereicht() == name + + +def test_unbekannte_namen_werfen_weiter(monkeypatch): + """Die Durchreiche darf kein Sammelbecken werden: Ein Tippfehler muss + ein AttributeError bleiben, sonst verschwindet er still.""" + from rippy.drives import linux + + with pytest.raises(AttributeError): + linux.gibt_es_nicht diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py index 68c155b..3f0cb8b 100644 --- a/src/rippy/platform/test_winlauf.py +++ b/src/rippy/platform/test_winlauf.py @@ -1,13 +1,22 @@ -"""Kind-Prozesse ohne Fenster — und die eigene Konsole. +"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole. -Ein Konsolenfenster laesst sich im Test nicht ansehen. Geprueft wird deshalb -die ENTSCHEIDUNG: Gehoert die Konsole uns, oder haben wir sie geerbt? Genau -daran haengt, ob `konsole_verbergen()` ein Fremdkoerper ausblendet oder dem -Nutzer sein Terminal wegnimmt. +Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird +deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG, +wohin die Ausgabe geht, wenn es keine Konsole gibt. + +## Warum das nicht Nebensache ist + +Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim +Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`. +Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit +`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten +im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem +AGENTS.md warnt. """ import os import subprocess +import sys import pytest @@ -16,6 +25,7 @@ from rippy.platform import winlauf nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") +# ── Kind-Prozesse ─────────────────────────────────────────────────────── def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): """Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten.""" @@ -25,48 +35,89 @@ def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): assert winlauf.OHNE_FENSTER == 0 -def test_ohne_windows_wird_nichts_verborgen(): - """Der Kern laeuft auch unter Linux. Ein ctypes.WinDLL waere dort ein - harter Fehler mitten im Start.""" +# ── Gibt es ueberhaupt eine Ausgabe? ──────────────────────────────────── +def test_fehlende_standardausgabe_wird_erkannt(monkeypatch): + """Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None.""" + monkeypatch.setattr(sys, "stdout", None) + assert winlauf.ohne_konsole() is True + + +def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch): + """pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein + `fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte, + was jemand gebrauchen kann.""" + class Puffer: + write = staticmethod(lambda _: None) + + monkeypatch.setattr(sys, "stdout", Puffer()) + assert winlauf.ohne_konsole() is True + + +def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path): + datei = tmp_path / "echt.txt" + with open(datei, "w", encoding="utf-8") as f: + monkeypatch.setattr(sys, "stdout", f) + assert winlauf.ohne_konsole() is False + + +# ── Die Protokolldatei ────────────────────────────────────────────────── +def test_protokoll_liegt_bei_rippy(): + """Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und + verschwindet beim Deinstallieren mit.""" + pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis")) + assert pfad.endswith(os.path.join("Rippy", "rippy.log")) + assert pfad.startswith(os.path.join("C:" + os.sep, "Basis")) + + +def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch): + ziel = str(tmp_path / "unterordner" / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + assert winlauf.ausgabe_umleiten(ziel) == ziel + print("eine Zeile mit Umlaut: ä") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read() + + +def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path): + """Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg + — also genau das, was man dann sucht.""" + ziel = str(tmp_path / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten(ziel) + print("erster Start") + sys.stdout.flush() + winlauf.ausgabe_umleiten(ziel) + print("zweiter Start") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + inhalt = open(ziel, encoding="utf-8").read() + assert "erster Start" in inhalt and "zweiter Start" in inhalt + + +def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch): + """Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das + wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines + ohne Protokoll.""" + def geht_nicht(*a, **k): + raise OSError("kein Platz") + + monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht) + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log") + print("das darf nicht werfen") + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + + +# ── Das Meldungsfenster ───────────────────────────────────────────────── +def test_ohne_windows_kein_meldungsfenster(): if os.name == "nt": pytest.skip("prueft das Verhalten auf Nicht-Windows") - assert winlauf.konsole_verbergen() is False - assert winlauf.konsole_gehoert_uns() is False - - -@nur_windows -def test_geerbte_konsole_wird_in_ruhe_gelassen(): - """DER Fallstrick. `Rippy.exe --status` in einem Terminal wuerde bei - einer naiven Umsetzung das Terminal des Nutzers verschwinden lassen. - - Dieser Test laeuft SELBST in einem Terminal (pytest wurde von einer Shell - gestartet) — an dieser Konsole haengt also mehr als ein Prozess. Genau - der Fall, in dem nichts passieren darf. - """ - if winlauf.konsole_gehoert_uns(): - pytest.skip("Dieser Lauf hat eine eigene Konsole — kein Erbfall") - assert winlauf.konsole_verbergen() is False - - -@nur_windows -def test_eigene_konsole_wird_erkannt(): - """Ein frisch gestarteter Prozess MIT eigener Konsole muss sich als - Eigentuemer erkennen — sonst waere die Konsole nie zu verbergen und die - schwarze Box bliebe bei jedem Doppelklick stehen.""" - programm = ( - "import sys; sys.path.insert(0, %r);" - "from rippy.platform import winlauf;" - "print(winlauf.konsole_gehoert_uns())" - % os.path.join(os.path.dirname(os.path.dirname( - os.path.dirname(os.path.abspath(__file__))))) - ) - import sys - - ergebnis = subprocess.run( - [sys.executable, "-c", programm], - capture_output=True, text=True, timeout=60, - # NEUE Konsole: Damit hat der Kindprozess eine eigene, an der sonst - # niemand haengt. Ohne dieses Flag erbte er unsere. - creationflags=subprocess.CREATE_NEW_CONSOLE, - ) - assert ergebnis.stdout.strip() == "True", ergebnis.stderr + assert winlauf.meldung_zeigen("egal") is False + assert winlauf.an_elternkonsole_haengen() is False diff --git a/src/rippy/platform/verknuepfungen.py b/src/rippy/platform/verknuepfungen.py index e069831..1ed2e18 100644 --- a/src/rippy/platform/verknuepfungen.py +++ b/src/rippy/platform/verknuepfungen.py @@ -33,6 +33,7 @@ der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das beim zweiten Klick einen zweiten Server startet, wäre eine Falle. """ +import ntpath import os import subprocess @@ -83,8 +84,13 @@ def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "", zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon)) if beschreibung: zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung)) + # ntpath, nicht os.path: Eine .lnk zeigt IMMER auf einen + # Windows-Pfad — auch wenn dieser Code gerade auf dem Linux-Runner + # der Ampel laeuft. `os.path.dirname` gab dort fuer + # C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der + # Test schlug nur dort fehl. zeilen.append("$s.WorkingDirectory = '%s'" - % _ps_text(arbeitsordner or os.path.dirname(ziel))) + % _ps_text(arbeitsordner or ntpath.dirname(ziel))) zeilen.append("$s.Save()") return "; ".join(zeilen) diff --git a/src/rippy/platform/winlauf.py b/src/rippy/platform/winlauf.py index 8d04661..2da137b 100644 --- a/src/rippy/platform/winlauf.py +++ b/src/rippy/platform/winlauf.py @@ -26,65 +26,127 @@ derselbe Code auf beiden Seiten läuft. ## Die eigene Konsole (28.08.2026) -Dieselbe Frage noch einmal, nur andersherum: Rippy.exe ist als -Konsolenprogramm gebaut — der Installer MUSS seine Meldungen zeigen können. -Ein Doppelklick auf das Desktop-Symbol öffnet damit aber erst eine schwarze -Box und dann das Fenster. Für ein Programm, das ein „echter Client" sein -soll, ist das ein Fremdkörper. +Commander: *„Unterbinde das CMD Fenster was sich beim Öffnen mitöffnet."* -`konsole_verbergen()` blendet sie aus — aber nur die EIGENE. Wer -`Rippy.exe --oeffnen` in einem Terminal eintippt, dessen Konsole gehört ihm, -nicht Rippy; sie zu verstecken würde ihm sein Fenster wegnehmen. Woran man -das unterscheidet: `GetConsoleProcessList` sagt, wie viele Prozesse an dieser -Konsole hängen. Genau einer — dann ist es die eigene. +Der erste Anlauf wollte das Fenster VERSTECKEN: `GetConsoleProcessList` +fragen, und bei genau einem angehängten Prozess `ShowWindow(SW_HIDE)`. Zwei +Gründe, warum das nicht reicht: + +1. **Eine Onefile-EXE ist ZWEI Prozesse** — ein Starter und die eigentliche + Anwendung. An der Konsole hängen also immer zwei. Die Prüfung war damit + nie wahr, und das Fenster blieb einfach stehen. (Gemessen: PID 33172 mit + 8 MB, PID 33580 mit 103 MB — beide `Rippy.exe`.) +2. Selbst wenn sie ginge, wäre das Fenster erst nach ein bis zwei Sekunden + weg — so lange braucht Python bis zur ersten eigenen Zeile. Ein + Aufblitzen ist kein „unterbunden". + +**Die Konsole wird deshalb gar nicht erst erzeugt.** Die EXE ist als +Fenster-Programm gebaut (`--windowed`), nicht als Konsolen-Programm. Windows +legt dann für keinen Start eine Konsole an. + +Der Preis: Es gibt keine Standardausgabe mehr — `sys.stdout` ist `None`. +Deshalb die drei Funktionen hier unten: + + an_elternkonsole_haengen() in einem Terminal gestartet? Dann DORT + hineinschreiben (fuer --status, --hilfe) + ausgabe_umleiten() sonst: in eine Protokolldatei + meldung_zeigen() und was der Nutzer sehen MUSS, kommt in + ein Fenster statt in ein schwarzes Nichts + +Ein Programm ohne Konsole, das seine Meldungen ins Leere schreibt, wäre +genau der stille Fehlschlag, vor dem AGENTS.md warnt. """ +import os import subprocess +import sys # Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und # ignoriert — am 26.07.2026 im Worker-Container gemessen). OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0) -SW_HIDE = 0 +ATTACH_PARENT_PROCESS = -1 +MB_OK = 0x0 +MB_ICONINFORMATION = 0x40 +MB_ICONERROR = 0x10 -def konsole_gehoert_uns() -> bool: - """Hängt NUR dieser Prozess an der Konsole? +def ohne_konsole() -> bool: + """Läuft dieser Prozess ohne Standardausgabe? - False heißt: Wir haben sie geerbt (Terminal, `cmd`, PowerShell). Dann ist - sie fremdes Eigentum — Hände weg. Ohne diese Unterscheidung würde ein - `Rippy.exe --status` im Terminal das Terminal des Nutzers verschwinden - lassen. + Das ist der Normalfall in einer `--windowed` gebauten EXE. `print()` würde + dann werfen oder ins Leere gehen — beides schlecht, deshalb wird es + abgefragt statt angenommen. """ + return sys.stdout is None or getattr(sys.stdout, "fileno", None) is None + + +def an_elternkonsole_haengen() -> bool: + """An die Konsole des Aufrufers andocken, falls es eine gibt. + + Wer `Rippy.exe --status` in PowerShell eintippt, soll die Antwort DORT + sehen. Ein Fenster-Programm hat zwar keine eigene Konsole, darf sich aber + an die des Aufrufers hängen — genau dafür gibt es + `ATTACH_PARENT_PROCESS`. Beim Doppelklick gibt es keine, dann ist das + Ergebnis schlicht False und der Aufrufer leitet um. + """ + if os.name != "nt": + return False import ctypes try: - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - puffer = (ctypes.c_uint * 8)() - anzahl = kernel32.GetConsoleProcessList(puffer, 8) + return bool(ctypes.WinDLL("kernel32", use_last_error=True) + .AttachConsole(ATTACH_PARENT_PROCESS)) except (AttributeError, OSError): return False - return anzahl == 1 -def konsole_verbergen() -> bool: - """Blendet die eigene Konsole aus. True, wenn es wirklich passiert ist. +def protokolldatei(basis: str = None) -> str: + """Wohin die Ausgabe geht, wenn niemand zusieht. (reine Funktion) - Kein `FreeConsole()`: Das schließt auch die Standardausgabe, und ein - späteres `print` würde dann werfen. Verstecken reicht — das Fenster ist - weg, die Ströme bleiben heil. + Neben die Datenbank, nicht ins TEMP: Was Rippy über sich selbst + aufschreibt, gehört zu Rippy und wird beim Deinstallieren mit entfernt. """ - import ctypes - import os + wurzel = basis or os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + return os.path.join(wurzel, "Rippy", "rippy.log") - if os.name != "nt" or not konsole_gehoert_uns(): - return False + +def ausgabe_umleiten(pfad: str = None) -> str: + """Standardausgabe in die Protokolldatei. Gibt den Pfad zurück, sonst "". + + OHNE das würde jede Bibliothek, die auf `sys.stderr` schreibt, in einer + `--windowed` EXE mit `AttributeError: 'NoneType' object has no attribute + 'write'` sterben — mitten im Start, ohne dass irgendwo etwas stünde. + """ + pfad = pfad or protokolldatei() try: - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - fenster = kernel32.GetConsoleWindow() - if not fenster: - return False - return bool(ctypes.WinDLL("user32", use_last_error=True) - .ShowWindow(fenster, SW_HIDE)) + os.makedirs(os.path.dirname(pfad), exist_ok=True) + strom = open(pfad, "a", encoding="utf-8", errors="replace", buffering=1) + except OSError: + # Selbst das ging nicht. Dann lieber ins Nichts als beim Start sterben. + try: + strom = open(os.devnull, "w") + except OSError: + return "" + sys.stdout = strom + sys.stderr = strom + return pfad + + +def meldung_zeigen(text: str, titel: str = "Rippy", fehler: bool = False) -> bool: + """Ein Meldungsfenster. True, wenn es wirklich gezeigt wurde. + + Für alles, was der Nutzer sehen MUSS und ohne Konsole sonst nie sähe — + vor allem das Ergebnis der Installation. + """ + if os.name != "nt": + return False + import ctypes + + symbol = MB_ICONERROR if fehler else MB_ICONINFORMATION + try: + ctypes.WinDLL("user32", use_last_error=True).MessageBoxW( + None, str(text), str(titel), MB_OK | symbol) + return True except (AttributeError, OSError): return False diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index f156b6b..186f505 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -156,6 +156,46 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey assert aufrufe == [] +# ── Rueckmeldung ohne Konsole ─────────────────────────────────────────── +def test_ohne_konsole_kommt_ein_fenster(monkeypatch): + """Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am + 28.08.2026: „Unterbinde das CMD Fenster"), geht `melden()` beim + Doppelklick in eine Protokolldatei — die niemand aufschlaegt. Ein + gescheiterter Installer saehe damit exakt aus wie einer, der nichts tut.""" + from rippy.platform import winlauf + + gezeigt = [] + monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True) + monkeypatch.setattr(winlauf, "meldung_zeigen", + lambda text, titel="Rippy", fehler=False: + gezeigt.append((text, fehler)) or True) + + assert windows_app.abschluss_melden("Fertig: C:\\Rippy") is True + assert gezeigt == [("Fertig: C:\\Rippy", False)] + + +def test_mit_konsole_kommt_KEIN_fenster(monkeypatch): + """Wer das Setup in PowerShell aufruft, will keinen Klick wegdruecken, + sondern die Zeile lesen.""" + from rippy.platform import winlauf + + monkeypatch.setattr(winlauf, "ohne_konsole", lambda: False) + monkeypatch.setattr(winlauf, "meldung_zeigen", + lambda *a, **k: pytest.fail("kein Fenster erwartet")) + assert windows_app.abschluss_melden("Fertig") is False + + +def test_still_heisst_still(monkeypatch): + """`--still` ist fuer unbeaufsichtigte Installationen. Ein Meldungsfenster + wuerde dort ewig auf einen Klick warten, den niemand macht.""" + from rippy.platform import winlauf + + monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True) + monkeypatch.setattr(winlauf, "meldung_zeigen", + lambda *a, **k: pytest.fail("kein Fenster bei --still")) + assert windows_app.abschluss_melden("Fertig", still=True) is False + + # ── Die Oberflaeche neben dem Programm ────────────────────────────────── def test_ui_wird_neben_das_programm_gelegt(tmp_path, monkeypatch): """DER Befund vom 28.08.2026: Ein Dienst lief eine Stunde, `/api/health` diff --git a/src/rippy/tools/katalog.py b/src/rippy/tools/katalog.py index 7141f00..2988b80 100644 --- a/src/rippy/tools/katalog.py +++ b/src/rippy/tools/katalog.py @@ -38,7 +38,9 @@ Uninstall-Zweig der Registry (`DisplayVersion`). Das ist kein Notbehelf, sondern die einzige Stelle, an der sie unter Windows überhaupt steht. """ +import ntpath import os +import posixpath import shutil import subprocess @@ -84,6 +86,40 @@ def werkzeug_ordner(basis: str = None) -> str: return os.path.join(os.path.expanduser("~"), ".local", "share", "rippy", "tools") +def verbinden(basis: str, *teile) -> str: + r"""Pfadteile mit dem Trenner der BASIS verbinden, nicht dem des Systems. + + ## Warum nicht `os.path.join` (Ampel rot 28.08.2026, Läufe 181–184) + + `os.path.join` nimmt immer den Trenner der laufenden Maschine. Auf dem + Linux-Runner der Ampel wurde aus `C:\Program Files (x86)\MakeMKV` und + `makemkvcon64.exe` dann: + + C:\Program Files (x86)\MakeMKV/makemkvcon64.exe + + Ein Pfad, den es auf keiner Maschine gibt. Fünf Tests sind daran + gescheitert — und zwar NUR auf Linux, weshalb es hier lange niemand sah. + + Ein Werkzeug-Pfad beschreibt einen Ort auf der Maschine, die ihn + NENNT — nicht auf der, die gerade rechnet. Deshalb entscheidet die + Basis: Laufwerksbuchstabe oder Backslash heißt Windows. + """ + modul = ntpath if _sieht_nach_windows_aus(basis) else posixpath + return modul.join(basis, *teile) + + +def _sieht_nach_windows_aus(pfad: str) -> bool: + """`C:\\…` oder irgendein Backslash — dann ist es ein Windows-Pfad.""" + pfad = pfad or "" + return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":") + + +def ordner_von(datei: str) -> str: + """Der Ordner einer Datei — nach dem Trenner der DATEI (siehe `verbinden`).""" + modul = ntpath if _sieht_nach_windows_aus(datei) else posixpath + return modul.dirname(datei) + + def _entfalten(pfad: str, umgebung: dict) -> str: """%ProgramFiles%\\X -> C:\\Program Files\\X (mit eigener Umgebung testbar).""" ergebnis = pfad @@ -107,15 +143,15 @@ def kandidaten(name: str, eigener_ordner: str = None, umgebung: dict = None) -> # 2. Rippys eigener Ordner — VOR dem System, damit eine selbst gepflegte # Fassung eine alte Systeminstallation schlägt. for programm in werkzeug["programme"]: - pfade.append(os.path.join(eigener_ordner, name, programm)) - pfade.append(os.path.join(eigener_ordner, programm)) + pfade.append(verbinden(eigener_ordner, name, programm)) + pfade.append(verbinden(eigener_ordner, programm)) # 4. Bekannte Installationsorte. for ort in werkzeug["orte"]: entfaltet = _entfalten(ort, umgebung) if not entfaltet: continue for programm in werkzeug["programme"]: - pfade.append(os.path.join(entfaltet, programm)) + pfade.append(verbinden(entfaltet, programm)) return pfade @@ -151,7 +187,7 @@ def finden(name: str, eingestellt: str = "", eigener_ordner: str = None, ordner = aus_registry(WERKZEUGE[name]["anzeige"]).get("ordner", "") if ordner: for programm in WERKZEUGE[name]["programme"]: - pfad = os.path.join(ordner, programm) + pfad = verbinden(ordner, programm) if existiert(pfad): return pfad return "" @@ -179,7 +215,7 @@ def ordner_aus_eintrag(werte: dict) -> str: for schluessel in ("DisplayIcon", "UninstallString"): datei = programmdatei_aus(werte.get(schluessel) or "") if datei: - return os.path.dirname(datei) + return ordner_von(datei) return "" diff --git a/src/rippy/tools/test_katalog.py b/src/rippy/tools/test_katalog.py index 22662d4..24e5bb5 100644 --- a/src/rippy/tools/test_katalog.py +++ b/src/rippy/tools/test_katalog.py @@ -14,6 +14,8 @@ laeuft auf jeder Plattform. from rippy.tools import katalog +BACKSLASH = chr(92) + # Eine Umgebung ohne echte Systemvariablen, damit die Pfade vorhersagbar sind. UMGEBUNG = { "ProgramFiles": r"C:\Program Files", @@ -35,6 +37,52 @@ def _keine_registry(_): return {} +# ── Pfade zusammensetzen ──────────────────────────────────────────────── +# +# Diese fuenf Tests gibt es wegen der Ampel-Laeufe 181–184 (28.08.2026): Sie +# waren rot, und zwar NUR auf Linux. `os.path.join` nimmt immer den Trenner +# der laufenden Maschine — auf dem Linux-Runner wurde aus +# `C:\Program Files (x86)\MakeMKV` + `makemkvcon64.exe` dann +# `C:\Program Files (x86)\MakeMKV/makemkvcon64.exe`, ein Pfad, den es auf +# keiner Maschine gibt. +# +# Sie pruefen BEIDE Zweige, laufen also ueberall und faenden den Fehler auch +# unter Windows. Genau das war vorher nicht so. +def test_windows_pfad_bekommt_backslashes(): + assert katalog.verbinden(r"C:\Program Files (x86)\MakeMKV", + "makemkvcon64.exe") == \ + r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe" + + +def test_linux_pfad_bekommt_schraegstriche(): + assert katalog.verbinden("/usr/local/bin", "makemkvcon") == \ + "/usr/local/bin/makemkvcon" + + +def test_laufwerksbuchstabe_allein_reicht_als_hinweis(): + """`D:` ohne Backslash ist trotzdem Windows — und wird auch so verbunden. + + Der erste Anlauf dieses Tests erwartete `D:\\Rippy\\tools` und war damit + falsch: `D:` OHNE Backslash bedeutet unter Windows „der aktuelle Ordner + auf Laufwerk D", nicht dessen Wurzel. `ntpath.join` setzt deshalb + absichtlich keinen Trenner davor. Geprueft wird hier also, dass die + Windows-Regeln gelten — nicht, dass eine bequeme Erwartung erfuellt wird. + """ + assert katalog.verbinden("D:", "Rippy", "tools") == r"D:Rippy\tools" + assert katalog.verbinden("D:" + BACKSLASH, "Rippy") == r"D:\Rippy" + + +def test_ordner_von_folgt_dem_pfad_nicht_der_maschine(): + assert katalog.ordner_von(r"C:\Program Files (x86)\MakeMKV\uninstall.exe") \ + == r"C:\Program Files (x86)\MakeMKV" + assert katalog.ordner_von("/opt/makemkv/bin/makemkvcon") == "/opt/makemkv/bin" + + +def test_leerer_pfad_gilt_nicht_als_windows(): + assert katalog._sieht_nach_windows_aus("") is False + assert katalog._sieht_nach_windows_aus(None) is False + + # ── Suchreihenfolge ───────────────────────────────────────────────────── def test_eingestellter_pfad_schlaegt_alles(): """Wer einen Pfad eintraegt, will genau den — sonst haette er nichts diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index f056b82..9e45f3a 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -89,6 +89,30 @@ def melden(text: str) -> None: pass +def abschluss_melden(text: str, still: bool = False, fehler: bool = False) -> bool: + """Das Ergebnis so melden, dass der Nutzer es WIRKLICH sieht. + + Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am + 28.08.2026: „Unterbinde das CMD Fenster"), geht `melden()` beim + Doppelklick in eine Protokolldatei — die niemand aufschlägt. Ein + gescheiterter Installer sähe damit exakt aus wie einer, der nichts tut. + + Deshalb hier zusätzlich ein Meldungsfenster, und zwar NUR wenn keine + Konsole da ist: Wer das Setup in PowerShell aufruft, will keinen Klick + wegdrücken, sondern die Zeile lesen. + + Gibt zurück, ob ein Fenster gezeigt wurde. + """ + melden(text.replace("\n\n", "\n")) + if still: + return False + from rippy.platform import winlauf + + if not winlauf.ohne_konsole(): + return False + return winlauf.meldung_zeigen(text, fehler=fehler) + + # ═══════════════════════════════════════════════════ Installation ═══════ def installieren(ziel: str = None, autostart: bool = True, starten_danach: bool = True, verknuepfen: bool = True) -> str: @@ -573,32 +597,37 @@ def main(argv=None) -> int: deinstallieren(still="--still" in argv) return 0 if "--oeffnen" in argv: - # Rippy.exe ist ein Konsolenprogramm (der Installer MUSS seine - # Meldungen zeigen koennen). Beim Doppelklick auf das Desktop-Symbol - # blitzte damit erst eine schwarze Box auf und dann das Fenster -- - # ein Fremdkoerper in einem Programm, das ein echter Client sein soll. - # Verborgen wird nur die EIGENE Konsole, nie eine geerbte. - from rippy.platform.winlauf import konsole_verbergen - konsole_verbergen() return oeffnen(int(wert("--port", STANDARD_PORT)), als_fenster="--im-browser" not in argv) if "--dienst" in argv: return dienst_starten(int(wert("--port", STANDARD_PORT))) # Ohne Argument: installieren. Das ist der Doppelklick-Fall. - ziel = installieren( - ziel=wert("--ziel"), - autostart="--kein-autostart" not in argv, - starten_danach="--nicht-starten" not in argv, - verknuepfen="--keine-verknuepfungen" not in argv, - ) + try: + ziel = installieren( + ziel=wert("--ziel"), + autostart="--kein-autostart" not in argv, + starten_danach="--nicht-starten" not in argv, + verknuepfen="--keine-verknuepfungen" not in argv, + ) + except Exception as e: # noqa: BLE001 + # Ohne Konsole waere ein Absturz hier VOLLKOMMEN unsichtbar: Der + # Doppelklick tut nichts, und niemand erfaehrt warum. Erst zeigen, + # dann weiterwerfen — der Rueckgabewert bleibt ein Fehler. + abschluss_melden("Die Installation ist gescheitert:\n\n%s: %s" + % (type(e).__name__, e), still="--still" in argv, + fehler=True) + raise + melden("Rippy wurde nach %s installiert." % ziel) melden("Die Oberfläche öffnet sich gleich in einem eigenen Fenster.") if "--nicht-starten" not in argv: # Als eigener Prozess und aus dem INSTALLIERTEN Programm heraus: # Das Setup soll sich beenden koennen, ohne das Fenster mitzunehmen. - time.sleep(3) starte_fensterprozess(os.path.join(ziel, "Rippy.exe")) + else: + abschluss_melden("Rippy wurde installiert:\n\n%s" % ziel, + still="--still" in argv) return 0