From c01ef1081c4b77ed6c65483597eac8d9f9caaaea Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 12:14:15 +0200 Subject: [PATCH] feat(windows): echtes Fenster statt Browser-Tab (WebView2 statt Electron) Auf die Frage des Commanders: "Warum nutzen wir fuer Windows weiterhin einen Browser? Warum nutzen wir kein Electron oder sowas und machen daraus einen echten Client." Die erste Haelfte trifft zu und ist umgesetzt. Die zweite ist abgelehnt -- gemessen, nicht geschaetzt: pywebview + WebView2 8,0 MB in der Bau-Umgebung, 2,7 MB in der EXE Electron 150-210 MB, dazu eine zweite Laufzeitumgebung Windows 11 bringt die WebView2-Laufzeit mit (hier 151.0.4129.107). Nachgemessen statt angenommen, was sie rendert: navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja Dasselbe Chromium, das auch in Electron steckt -- nur ohne es ein zweites Mal mitzuschleppen. RippySetup.exe waechst von 29,0 auf 31,7 MB. Drei Dinge gehoeren dazu, nicht als Beiwerk: * Kein stiller Rueckfall auf MSHTML. Der alte IE-Renderer stellt die React-Oberflaeche nicht dar. Fehlt WebView2, oeffnet Rippy den Browser und SAGT warum -- ein leeres Fenster waere schlimmer als ein Tab. * Fenster und Tray sind getrennte Prozesse. pystray belegt unter Windows den Haupt-Thread, das Fenster braucht ihn genauso. * Die eigene Konsole wird versteckt, eine geerbte nie. GetConsoleProcessList unterscheidet beides: genau ein Prozess an der Konsole heisst, sie gehoert uns. Ohne diese Unterscheidung haette "Rippy.exe --status" im Terminal das Terminal des Nutzers verschwinden lassen. fix(windows): Oberflaeche neben das Programm legen statt aus %TEMP% bedienen Beim Nachsehen im laufenden Betrieb gefunden: Ein Dienst lief eine Stunde, /api/health gab HTTP 200, / gab 404. Im Fenster stand {"detail":"Not Found"}. Der Server war in Ordnung. Sein Entpack-Verzeichnis war es nicht: _MEI000074b02 31 Eintraege, 5 Ordner -- kein ui, kein api _MEI000082e82 44 Eintraege, 16 Ordner -- vollstaendig Eine PyInstaller-Onefile-EXE liest bei JEDER Anfrage aus %TEMP%\_MEIxxxxx. Ein Temp-Verzeichnis ist kein Ort fuer etwas, das eine Woche liegen bleibt. Und der Ausfall ist der schlimmstmoegliche: Die API antwortet weiter, der Dienst gilt als gesund, nur die Oberflaeche ist weg. Genau davor warnte ROADMAP.md beim Bau-Verfahren ("one-dir statt one-file"). Die Abweichung bleibt, die Luecke wird geschlossen: Der Installer legt die Oberflaeche neben das Programm, daemon._ui_pfad() nimmt diese Kopie zuerst. Zeigt sich derselbe Ausfall an den API-Modulen, ist one-dir die Antwort. Ampel: 562 gruen, ruff sauber. Fenster mit der echten Oberflaeche im Bildschirmfoto nachgewiesen, nicht nur der Titel geprueft. Co-Authored-By: Claude Opus 5 --- KONZEPT-V2.md | 53 ++++++ ROADMAP.md | 36 +++- packaging/windows/build.py | 17 +- packaging/windows/requirements-bau.txt | 25 +++ src/rippy/daemon.py | 48 +++++- src/rippy/fenster.py | 219 +++++++++++++++++++++++++ src/rippy/platform/test_winlauf.py | 72 ++++++++ src/rippy/platform/winlauf.py | 58 +++++++ src/rippy/test_daemon.py | 40 +++++ src/rippy/test_fenster.py | 156 ++++++++++++++++++ src/rippy/test_windows_app.py | 136 +++++++++++++++ src/rippy/windows_app.py | 145 +++++++++++++--- 12 files changed, 971 insertions(+), 34 deletions(-) create mode 100644 packaging/windows/requirements-bau.txt create mode 100644 src/rippy/fenster.py create mode 100644 src/rippy/platform/test_winlauf.py create mode 100644 src/rippy/test_fenster.py diff --git a/KONZEPT-V2.md b/KONZEPT-V2.md index e5295ec..3cbd68a 100644 --- a/KONZEPT-V2.md +++ b/KONZEPT-V2.md @@ -1267,6 +1267,59 @@ Eingabemaske — nur der Knopf „Verbinden" wird zu „Zeile kopieren". `apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in V2-5 ersatzlos weg. +### Entscheid 4 — Echtes Fenster statt Browser-Tab, aber ohne Electron + +Nachgefragt vom Commander am 28.08.2026: + +> „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir kein +> Electron oder sowas und machen daraus einen echten Client?" + +Die erste Hälfte trifft zu und wird umgesetzt: Ein Browser-Tab ist kein Client. +Er hat eine Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat +kein eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er +habe Rippy beendet. + +Die zweite Hälfte wird **abgelehnt**, und zwar gemessen statt geschätzt: + +| Weg | Zusatz zum Paket | Was mitkommt | +|-----|------------------|--------------| +| **pywebview + WebView2** | **8,0 MB** | nur die Anbindung; der Renderer liegt schon auf dem System | +| Electron | ~150–210 MB | ein komplettes zweites Chromium **und** eine zweite Laufzeitumgebung neben Python | + +Windows 11 liefert die WebView2-Laufzeit mit. Auf dem Rechner des Commanders am +28.08.2026 nachgesehen: **151.0.4129.107**. Und nachgemessen, was sie wirklich +rendert — nicht angenommen: + +``` +navigator.userAgent …Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 +fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja +``` + +Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also +denselben Renderer, nur ohne ihn ein zweites Mal mitzuschleppen. Damit bleibt +auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an. + +**Drei Dinge gehören zum Entscheid, nicht als Beiwerk:** + +1. **Kein stiller Rückfall auf MSHTML.** `pywebview` kann unter Windows auch den + alten IE-Renderer nehmen; der stellt die React-Oberfläche nicht dar. Fehlt + die WebView2-Laufzeit, öffnet Rippy **den Browser** und sagt warum — ein + leeres Fenster wäre schlimmer als ein Tab. +2. **Fenster und Tray sind getrennte Prozesse.** `pystray` belegt unter Windows + den Haupt-Thread, das WebView2-Fenster braucht ihn genauso; zwei + Nachrichtenschleifen passen nicht in einen Thread. `--dienst` hält Server und + Tray, `--oeffnen` ist der Client davor. Beide heißen im Taskmanager + `Rippy.exe`, und das Fenster lässt sich schließen, ohne den Dienst + mitzureißen. +3. **Die eigene Konsole wird versteckt, eine geerbte nie.** `Rippy.exe` ist ein + Konsolenprogramm, weil der Installer seine Meldungen zeigen muss. Beim + Doppelklick auf das Desktop-Symbol blitzte damit erst eine schwarze Box auf. + `GetConsoleProcessList` unterscheidet beides: genau ein Prozess an der + Konsole heißt, sie gehört uns. + +**Folge für den Plan:** Kein neuer Etappen-Punkt — das ist Teil von V2-4 und +dort erledigt. `src/rippy/fenster.py` trägt die vollständige Begründung. + --- ### Was jetzt noch fehlt, bevor gebaut wird diff --git a/ROADMAP.md b/ROADMAP.md index b8f1256..1287412 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -619,15 +619,43 @@ Medien. plus Worker) UND ein Verbindungsabriss keine Liste leert. ### V2-4: Windows nativ -- [ ] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`, +- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`, `IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`, MMC `GET CONFIGURATION` für den Disc-Typ) -- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling -- [ ] Dienst (WinSW) + Tray als GETRENNTE Prozesse; WebView2-Fenster -- [ ] Nuitka `--standalone` (one-dir, nicht one-file) + Inno Setup +- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft + vorerst über den Poll aus `drives/detection.py` +- [x] Dienst + Fenster als GETRENNTE Prozesse; **WebView2-Fenster** + (`fenster.py`, Entscheid 4 in `KONZEPT-V2.md` § 10) +- [x] Eintrag in „Programme und Features" und im Taskmanager als `Rippy.exe` + (ausdrücklicher Wunsch des Commanders) +- [x] Desktop-Symbol und Startmenü-Eintrag, im Setup abschaltbar +- [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`, + `tools/beschaffen.py`) — Rippy findet, holt und aktualisiert + HandBrake/MakeMKV selbst - [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED` + — **offen** - **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer → Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch. + **Noch offen:** ein echter Rip auf Windows ist ungeprüft — das Laufwerk + hängt an der VM. + +> **Abweichung vom Plan, bewusst und mit Folgen.** Statt Nuitka `--standalone` +> (one-dir) + Inno Setup wurde **PyInstaller onefile** genommen: ein Programm, +> drei Betriebsarten (`RippySetup.exe`, `--dienst`, `--deinstallieren`), kein +> zweites Werkzeug in der Kette. +> +> Der Preis stand oben schon im Plan — *„ein Onefile-Paket entpackt sich bei +> jedem Start neu nach `%TEMP%`"* — und er ist am 28.08.2026 fällig geworden: +> Ein Dienst lief eine Stunde, `/api/health` gab 200, `/` gab **404**. Vom +> Entpack-Verzeichnis des laufenden Prozesses waren 31 statt 44 Einträge übrig, +> `ui` und `api` fehlten. Die API meldete sich gesund, während die Oberfläche +> weg war. +> +> **Gegenmaßnahme:** Der Installer legt die Oberfläche als Kopie NEBEN das +> Programm (`windows_app.ui_auspacken`), und `daemon._ui_pfad()` nimmt diese +> Kopie vor dem Entpack-Verzeichnis. Zwei Tests halten das fest. Sollte sich +> derselbe Ausfall an den API-Modulen zeigen, ist one-dir die richtige Antwort +> — dann ist diese Abweichung zurückzunehmen. ### V2-5: Docker neu - [ ] EIN Image, drei Profile (`standalone`, `api`, `node`) diff --git a/packaging/windows/build.py b/packaging/windows/build.py index 2a1ad9b..30c4762 100644 --- a/packaging/windows/build.py +++ b/packaging/windows/build.py @@ -100,11 +100,17 @@ def pruefen() -> list: import PyInstaller # noqa: F401 except ImportError: maengel.append("pyinstaller fehlt (pip install pyinstaller).") - for modul, paket in (("pystray", "pystray"), ("PIL", "pillow")): + for modul, paket, wofuer in ( + ("pystray", "pystray", "kein Tray-Symbol"), + ("PIL", "pillow", "kein Tray-Symbol"), + ("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"), + ("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"), + ): try: __import__(modul) except ImportError: - maengel.append(f"{modul} fehlt (pip install {paket}) — ohne das kein Tray-Symbol.") + maengel.append( + f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.") return maengel @@ -145,6 +151,13 @@ def bauen(ausgabe: str, version: str) -> str: "--hidden-import", "uvicorn.lifespan.on", "--hidden-import", "rippy.drives.windows", "--hidden-import", "pystray._win32", + # pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also + # sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster + # nicht auf und Rippy faellt still auf den Browser zurueck -- also + # genau dorthin, wovon wir gerade weg wollten. + "--hidden-import", "webview", + "--hidden-import", "webview.platforms.winforms", + "--hidden-import", "webview.platforms.edgechromium", # Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery, # und beides zieht Ballast nach. "--exclude-module", "psycopg2", diff --git a/packaging/windows/requirements-bau.txt b/packaging/windows/requirements-bau.txt new file mode 100644 index 0000000..8fb5eac --- /dev/null +++ b/packaging/windows/requirements-bau.txt @@ -0,0 +1,25 @@ +# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt. +# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe. +# +# py -3.12 -m venv venv +# venv\Scripts\pip install -r docker/api/requirements.txt +# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt +# venv\Scripts\python packaging/windows/build.py +# +# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute +# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel +# ist eine Entscheidung, kein Zufall. + +pyinstaller==6.22.2 + +# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht +# zu beenden, ausser ueber den Taskmanager. +pystray==0.19.5 +pillow==12.3.0 + +# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der +# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und +# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht. +# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung. +pywebview==6.2.1 +pythonnet==3.1.0 diff --git a/src/rippy/daemon.py b/src/rippy/daemon.py index eb823dc..61b0fe5 100644 --- a/src/rippy/daemon.py +++ b/src/rippy/daemon.py @@ -113,7 +113,36 @@ def _api_pfad() -> str: def _ui_pfad() -> str: - """Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert.""" + """Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert. + + ## Warum die installierte Kopie VOR dem Paket kommt (28.08.2026) + + Gemessen an einem Dienst, der seit einer Stunde lief: `/api/health` gab + HTTP 200, `/` gab **404**. Im Fenster stand `{"detail":"Not Found"}`. + + Der Grund lag nicht im Server, sondern unter ihm. Eine + PyInstaller-Onefile-EXE entpackt sich beim Start nach `%TEMP%\\_MEIxxxxx` + und liest von dort weiter — `StaticFiles` schlaegt bei JEDER Anfrage in + diesem Ordner nach. Nachgesehen, was davon uebrig war: + + _MEI000074b02 31 Eintraege, 5 Ordner — kein `ui`, kein `api` + _MEI000082e82 44 Eintraege, 16 Ordner — vollstaendig + + Der halbe Ordner gehoerte dem laufenden Dienst. Ein Temp-Verzeichnis ist + kein Ort, an dem etwas eine Woche lang liegen bleibt: Aufraeumwerkzeuge, + Storage Sense und die Aufraeumroutine anderer Onefile-Prozesse greifen + dort hinein. Und der Ausfall ist der schlimmstmoegliche — die API + antwortet weiter, der Dienst gilt als gesund, nur die Oberflaeche ist + weg. Genau die Sorte stiller Fehlschlag, vor der AGENTS.md warnt. + + Deshalb legt der Installer eine Kopie neben das Programm, und die hat + hier Vorrang. Sie liegt in `%LOCALAPPDATA%\\Rippy`, gehoert Rippy und + verschwindet beim Deinstallieren mit. + """ + neben_programm = os.path.join(os.path.dirname(_programmordner()), "ui") + if os.path.isdir(neben_programm): + return neben_programm + gebuendelt = getattr(sys, "_MEIPASS", None) if gebuendelt: pfad = os.path.join(gebuendelt, "ui") @@ -121,11 +150,18 @@ def _ui_pfad() -> str: hier = os.path.dirname(os.path.abspath(__file__)) repo = os.path.dirname(os.path.dirname(hier)) - for pfad in (os.path.join(repo, "docker", "ui", "dist"), - os.path.join(os.path.dirname(sys.executable), "ui")): - if os.path.isdir(pfad): - return pfad - return "" + pfad = os.path.join(repo, "docker", "ui", "dist") + return pfad if os.path.isdir(pfad) else "" + + +def _programmordner() -> str: + """Die laufende Programmdatei — im Paket die EXE, sonst der Interpreter. + + Gibt eine DATEI zurueck, nicht den Ordner; der Aufrufer nimmt sich mit + `dirname` was er braucht. `sys.executable` ist unter PyInstaller die + Rippy.exe selbst, nicht das ausgepackte Python. + """ + return sys.executable def argumente(argv=None) -> argparse.Namespace: diff --git a/src/rippy/fenster.py b/src/rippy/fenster.py new file mode 100644 index 0000000..dd5dc90 --- /dev/null +++ b/src/rippy/fenster.py @@ -0,0 +1,219 @@ +"""Rippy als echtes Fenster — nicht als Browser-Tab. + +## Die Frage des Commanders (28.08.2026) + +> „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir +> kein Electron oder sowas und machen daraus einen echten Client?" + +Die erste Hälfte ist berechtigt: Ein Browser-Tab IST kein Client. Er hat eine +Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat kein +eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er +habe Rippy beendet. + +Die zweite Hälfte wäre die teure Antwort darauf. Gemessen, nicht geschätzt: + + Electron ~150-210 MB (Chromium + Node komplett mit) + pywebview + WebView2 8,0 MB (in die Bau-Umgebung gemessen) + +Electron brächte eine ZWEITE komplette Laufzeitumgebung neben Python mit — +und ein zweites Chromium neben dem, das auf diesem Rechner ohnehin schon +liegt. Denn Windows 11 liefert die WebView2-Laufzeit mit; auf diesem Rechner +am 28.08.2026 nachgesehen: 151.0.4129.107. + +Nachgemessen, was sie wirklich rendert — nicht angenommen: + + navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 + fetch ja EventSource ja + CSS Grid ja Pfeilfunktionen ja + +Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also +denselben Renderer — nur ohne ihn ein zweites Mal mitzuschleppen. + +## Warum nicht MSHTML als Rückfall + +`pywebview` kann unter Windows auch auf den alten IE-Renderer zurückfallen. +Eine React-Oberfläche mit CSS Grid stellt der NICHT dar — sie erschiene leer +oder zerfallen. Ein leeres Fenster ist schlimmer als ein Browser-Tab. +Deshalb wird die Laufzeit VORHER geprüft, und ohne sie geht Rippy in den +Browser statt in ein Fenster, das nichts zeigen kann. + +## Warum das Fenster ein eigener Prozess ist + +Das Tray-Symbol (`pystray`) braucht unter Windows den HAUPT-Thread, sonst +bekommt es keine Fensternachrichten. Das Fenster (`pywebview`/WinForms) +braucht ihn genauso. Zwei Nachrichtenschleifen passen nicht in einen Thread. + +Also: `Rippy.exe --dienst` hält Server und Tray, `Rippy.exe --fenster` ist +der Client davor. Beide heißen im Taskmanager „Rippy.exe" — was der +Commander wollte —, und das Fenster lässt sich schließen, ohne den Dienst +mitzureißen. + +## Was hier NICHT passiert + +Kein Aufräumen von Ausnahmen zu einem stillen `False`. Geht das Fenster +nicht auf, muss der Aufrufer das ERFAHREN, damit er den Browser öffnen kann. +Ein stiller Fehlschlag wäre ein Doppelklick, nach dem nichts passiert. +""" + +import os +import sys + +TITEL = "Rippy" +BREITE = 1280 +HOEHE = 860 +MINDESTBREITE = 900 +MINDESTHOEHE = 600 + +# Die Evergreen-Laufzeit von WebView2 trägt sich unter dieser festen Kennung +# bei EdgeUpdate ein. Sie ist von Microsoft vergeben und ändert sich nicht. +WEBVIEW2_KENNUNG = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + +# Die Laufzeit kann pro Maschine oder pro Nutzer liegen — beides ist gültig. +_SCHLUESSEL = ( + ("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients"), + ("HKLM", r"SOFTWARE\Microsoft\EdgeUpdate\Clients"), + ("HKCU", r"Software\Microsoft\EdgeUpdate\Clients"), +) + + +def echte_version(wert) -> str: + """Prüft einen `pv`-Wert aus der Registry (reine Funktion). + + EdgeUpdate trägt bei einer DEINSTALLIERTEN Laufzeit `0.0.0.0` ein, statt + den Schlüssel zu löschen. Wer das für vorhanden hält, baut ein Fenster, + das nie etwas anzeigt — und der Nutzer sieht einen weißen Kasten ohne + jede Erklärung. + """ + text = str(wert or "").strip() + return "" if not text or text == "0.0.0.0" else text + + +def webview2_version() -> str: + """Die Version der WebView2-Laufzeit. Leer, wenn sie nicht da ist. + + Die Version statt eines bloßen „ja/nein": Steht in einem Fehlerbericht + `151.0.4129.107`, ist die Lage klar. Steht dort `True`, fängt das Raten + an. + """ + if os.name != "nt": + return "" + import winreg + + wurzeln = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER} + for name, pfad in _SCHLUESSEL: + try: + with winreg.OpenKey(wurzeln[name], pfad + "\\" + WEBVIEW2_KENNUNG) as k: + wert, _ = winreg.QueryValueEx(k, "pv") + except OSError: + continue + version = echte_version(wert) + if version: + return version + return "" + + +def webview2_vorhanden() -> bool: + return bool(webview2_version()) + + +def bibliothek_vorhanden() -> bool: + """Ist `pywebview` überhaupt im Paket? (Ohne es zu starten.)""" + import importlib.util + + try: + return importlib.util.find_spec("webview") is not None + except (ImportError, ValueError): + return False + + +def verfuegbar() -> tuple: + """Kann ein echtes Fenster aufgehen? (ja/nein, Begründung) + + Gibt IMMER eine Begründung zurück, auch beim Ja. Wer nur `False` + zurückgibt, zwingt den Nächsten zum Nachforschen — und genau solche + stillen Fehlschläge sind in diesem Projekt schon zweimal teuer geworden. + """ + if os.name != "nt": + return False, "kein Windows" + if not bibliothek_vorhanden(): + return False, "pywebview ist nicht mit im Paket" + version = webview2_version() + if not version: + return False, "die WebView2-Laufzeit fehlt (ohne sie bliebe das Fenster leer)" + return True, "WebView2 " + version + + +def speicherort() -> str: + """Wo WebView2 seinen Zwischenspeicher ablegt. + + Ohne eigene Angabe landet er im TEMP — und ist bei jedem Aufräumen weg. + Neben der Datenbank gehört er zu Rippy und verschwindet beim + Deinstallieren mit. + """ + basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + ordner = os.path.join(basis, "Rippy", "fenster") + try: + os.makedirs(ordner, exist_ok=True) + except OSError: + return "" + return ordner + + +def oeffnen(adresse: str, titel: str = TITEL, breite: int = BREITE, + hoehe: int = HOEHE) -> None: + """Öffnet das Fenster und kehrt erst zurück, wenn es geschlossen wird. + + Wirft, wenn es nicht geht — der Aufrufer entscheidet dann über den + Browser. Diese Funktion trifft die Entscheidung NICHT selbst, weil sie + sie nicht mitteilen könnte: Sie läuft, bis das Fenster zu ist. + """ + import webview + + webview.create_window( + titel, adresse, + width=breite, height=hoehe, + min_size=(MINDESTBREITE, MINDESTHOEHE), + text_select=True, # sonst ließe sich kein Log-Text kopieren + confirm_close=False, # der Dienst läuft weiter; nichts geht verloren + ) + # gui="edgechromium" erzwingt WebView2 und verbietet den Rückfall auf + # MSHTML. Lieber ein Fehler, den der Aufrufer sieht, als ein Fenster, + # das die Oberfläche nicht darstellen kann. + webview.start(gui="edgechromium", private_mode=False, + storage_path=speicherort()) + + +def oeffnen_oder_browser(adresse: str, titel: str = TITEL) -> str: + """Fenster, sonst Browser. Gibt zurück, was WIRKLICH passiert ist. + + Der Rückgabewert ist `"fenster"`, `"browser"` oder `"nichts"` — keine + Erfolgsmeldung. Der Aufrufer soll dem Nutzer sagen können, was er sieht, + statt zu behaupten, es sei alles gut gegangen. + """ + geht, grund = verfuegbar() + if geht: + try: + oeffnen(adresse, titel) + return "fenster" + except Exception as e: # noqa: BLE001 + # Bewusst breit: Was WinForms und die CLR beim Start werfen + # können, ist nicht abschließend bekannt — und keine dieser + # Ausnahmen darf den Doppelklick ins Leere laufen lassen. Sie + # wird gemeldet, nicht verschluckt. + melden("Das Fenster ging nicht auf (%s: %s) — es öffnet der Browser." + % (type(e).__name__, e)) + else: + melden("Fenster nicht möglich (%s) — es öffnet der Browser." % grund) + + import webbrowser + + return "browser" if webbrowser.open(adresse) else "nichts" + + +def melden(text: str) -> None: + """Ausgabe, die auch ohne Konsole nicht abstürzt (siehe windows_app).""" + try: + sys.stdout.write(text + "\n") + sys.stdout.flush() + except Exception: # noqa: BLE001 + pass diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py new file mode 100644 index 0000000..68c155b --- /dev/null +++ b/src/rippy/platform/test_winlauf.py @@ -0,0 +1,72 @@ +"""Kind-Prozesse ohne Fenster — und die eigene 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. +""" + +import os +import subprocess + +import pytest + +from rippy.platform import winlauf + +nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") + + +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.""" + if os.name == "nt": + assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW + else: + 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.""" + 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 diff --git a/src/rippy/platform/winlauf.py b/src/rippy/platform/winlauf.py index 762e273..8d04661 100644 --- a/src/rippy/platform/winlauf.py +++ b/src/rippy/platform/winlauf.py @@ -23,6 +23,20 @@ ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, u genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und `creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit derselbe Code auf beiden Seiten läuft. + +## 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. + +`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. """ import subprocess @@ -30,3 +44,47 @@ import subprocess # Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und # ignoriert — am 26.07.2026 im Worker-Container gemessen). OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0) + +SW_HIDE = 0 + + +def konsole_gehoert_uns() -> bool: + """Hängt NUR dieser Prozess an der Konsole? + + 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. + """ + import ctypes + + try: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + puffer = (ctypes.c_uint * 8)() + anzahl = kernel32.GetConsoleProcessList(puffer, 8) + except (AttributeError, OSError): + return False + return anzahl == 1 + + +def konsole_verbergen() -> bool: + """Blendet die eigene Konsole aus. True, wenn es wirklich passiert ist. + + 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. + """ + import ctypes + import os + + if os.name != "nt" or not konsole_gehoert_uns(): + return False + 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)) + except (AttributeError, OSError): + return False diff --git a/src/rippy/test_daemon.py b/src/rippy/test_daemon.py index b98cdcc..7e3adbc 100644 --- a/src/rippy/test_daemon.py +++ b/src/rippy/test_daemon.py @@ -10,6 +10,7 @@ sitzen die Fehler, die man spaeter nicht mehr findet: """ import os +import sys from rippy import config, daemon @@ -66,6 +67,45 @@ def test_ui_pfad_ist_entweder_da_oder_ehrlich_leer(): assert pfad == "" or os.path.isdir(pfad) +def test_kopie_neben_dem_programm_geht_vor_dem_paket(tmp_path, monkeypatch): + """DER Befund vom 28.08.2026. Ein Dienst lief eine Stunde: + `/api/health` gab 200, `/` gab 404, im Fenster stand + `{"detail":"Not Found"}`. + + Der Server war in Ordnung — sein Entpack-Verzeichnis unter `%TEMP%` war + es nicht: 31 Eintraege statt 44, kein `ui`. `StaticFiles` schlaegt bei + JEDER Anfrage dort nach, also wurde aus einer laufenden Oberflaeche eine + 404, waehrend die API weiter gesund meldete. + + Die installierte Kopie hat deshalb Vorrang. Faellt dieser Test aus, ist + der Dienst wieder darauf angewiesen, dass niemand das Temp aufraeumt. + """ + installiert = tmp_path / "installiert" + (installiert / "ui").mkdir(parents=True) + (installiert / "ui" / "index.html").write_text("", + encoding="utf-8") + paket = tmp_path / "temp" / "_MEI4711" + (paket / "ui").mkdir(parents=True) + + monkeypatch.setattr(daemon, "_programmordner", + lambda: str(installiert / "Rippy.exe")) + monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False) + + assert daemon._ui_pfad() == str(installiert / "ui") + + +def test_ohne_kopie_bleibt_das_paket_der_rueckfall(tmp_path, monkeypatch): + """Wer die Setup-Datei direkt startet, hat keinen Installationsordner. + Dann ist das Entpack-Verzeichnis richtig — nur eben nicht auf Dauer.""" + paket = tmp_path / "_MEI4711" + (paket / "ui").mkdir(parents=True) + monkeypatch.setattr(daemon, "_programmordner", + lambda: str(tmp_path / "leer" / "RippySetup.exe")) + monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False) + + assert daemon._ui_pfad() == str(paket / "ui") + + def test_standard_konfigpfad_passt_zur_plattform(): pfad = daemon.standard_konfigpfad() if os.name == "nt": diff --git a/src/rippy/test_fenster.py b/src/rippy/test_fenster.py new file mode 100644 index 0000000..0d49de0 --- /dev/null +++ b/src/rippy/test_fenster.py @@ -0,0 +1,156 @@ +"""Das native Fenster: Verfuegbarkeit, Rueckfall, Speicherort. + +Ein Fenster laesst sich in einem Test nicht oeffnen — es wuerde auf einen +Doppelklick warten, den niemand macht. Geprueft wird deshalb alles DAVOR und +DANEBEN: die Entscheidung, ob ein Fenster moeglich ist, und der Rueckfall auf +den Browser. Genau dort sassen die Fehler, die dieses Projekt schon zweimal +gekostet hat. +""" + +import os + +import pytest + +from rippy import fenster + +nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") + + +# ── Der Registry-Wert ─────────────────────────────────────────────────── +def test_null_version_gilt_als_nicht_vorhanden(): + """DER Fallstrick bei WebView2: EdgeUpdate LOESCHT den Schluessel bei + einer Deinstallation nicht, sondern setzt `pv` auf `0.0.0.0`. Wer nur + auf die Existenz des Schluessels prueft, oeffnet ein Fenster, das nichts + anzeigen kann — und der Nutzer sieht einen weissen Kasten.""" + assert fenster.echte_version("0.0.0.0") == "" + + +def test_echte_version_kommt_durch(): + assert fenster.echte_version("151.0.4129.107") == "151.0.4129.107" + + +@pytest.mark.parametrize("wert", ["", None, " "]) +def test_leere_werte_gelten_nicht(wert): + assert fenster.echte_version(wert) == "" + + +# ── Die Entscheidung ──────────────────────────────────────────────────── +def test_verfuegbar_gibt_immer_eine_begruendung(): + """Auch beim Ja. Ein blosses False zwingt den Naechsten zum Nachforschen + — und ein Fehlschlag ohne Begruendung ist in diesem Projekt schon + zweimal teuer geworden.""" + geht, grund = fenster.verfuegbar() + assert isinstance(geht, bool) + assert grund, "auch ein Ja braucht eine Begruendung" + + +@pytest.mark.skipif(os.name == "nt", reason="prueft das Verhalten auf Nicht-Windows") +def test_ohne_windows_kein_fenster(): + geht, grund = fenster.verfuegbar() + assert geht is False + assert "Windows" in grund + + +@pytest.mark.skipif(os.name == "nt", reason="winreg gibt es nur unter Windows") +def test_version_ohne_windows_ist_leer_statt_zu_werfen(): + """Der Kern laeuft auch unter Linux (Docker, headless). Ein ImportError + auf winreg wuerde dort jeden Aufruf sprengen.""" + assert fenster.webview2_version() == "" + + +def test_fehlende_bibliothek_verhindert_das_fenster(monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: False) + geht, grund = fenster.verfuegbar() + assert geht is False + assert "pywebview" in grund + + +def test_fehlende_laufzeit_verhindert_das_fenster(monkeypatch): + """Lieber Browser als ein leeres Fenster: Ohne WebView2 faellt pywebview + auf MSHTML zurueck, und der stellt die React-Oberflaeche nicht dar.""" + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: True) + monkeypatch.setattr(fenster, "webview2_version", lambda: "") + geht, grund = fenster.verfuegbar() + assert geht is False + assert "WebView2" in grund + + +# ── Der Rueckfall ─────────────────────────────────────────────────────── +def test_ohne_fenster_geht_der_browser_auf(monkeypatch): + gerufen = [] + monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows")) + monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) + + assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser" + assert gerufen == ["http://127.0.0.1:7788"] + + +def test_geplatztes_fenster_faellt_auf_den_browser_zurueck(monkeypatch, capsys): + """Der wichtigste Test hier. Was WinForms und die CLR beim Start werfen + koennen, ist nicht abschliessend bekannt. Faellt das nicht zurueck, ist + der Doppelklick auf das Desktop-Symbol wirkungslos — und der Nutzer + haelt Rippy fuer kaputt.""" + def platzt(*a, **k): + raise RuntimeError("edgechromium nicht gefunden") + + gerufen = [] + monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151")) + monkeypatch.setattr(fenster, "oeffnen", platzt) + monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) + + assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser" + assert gerufen == ["http://127.0.0.1:7788"] + # Und der Grund muss SICHTBAR sein, nicht verschluckt. + assert "edgechromium nicht gefunden" in capsys.readouterr().out + + +def test_wenn_gar_nichts_geht_wird_das_auch_gesagt(monkeypatch): + """`webbrowser.open` gibt False zurueck, wenn kein Browser gefunden wird. + Ein `return "browser"` waere dann eine Luege.""" + monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows")) + monkeypatch.setattr("webbrowser.open", lambda a: False) + assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "nichts" + + +def test_erfolgreiches_fenster_oeffnet_keinen_browser(monkeypatch): + """Sonst haette der Nutzer BEIDES auf — Fenster und Tab.""" + gerufen = [] + monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151")) + monkeypatch.setattr(fenster, "oeffnen", lambda *a, **k: None) + monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) + + assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "fenster" + assert gerufen == [] + + +# ── Der Speicherort ───────────────────────────────────────────────────── +def test_speicherort_liegt_bei_rippy(monkeypatch, tmp_path): + """Im TEMP waere er bei jedem Aufraeumen weg — und beim Deinstallieren + bliebe er liegen.""" + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + ordner = fenster.speicherort() + assert ordner.startswith(str(tmp_path)) + assert os.path.isdir(ordner) + + +def test_unbeschreibbarer_ort_wirft_nicht(monkeypatch): + """Ein fehlender Zwischenspeicher ist aergerlich, kein Grund, das + Fenster gar nicht erst zu oeffnen.""" + def geht_nicht(*a, **k): + raise OSError("kein Platz") + + monkeypatch.setattr(os, "makedirs", geht_nicht) + assert fenster.speicherort() == "" + + +# ── Auf dieser Maschine wirklich nachsehen ────────────────────────────── +@nur_windows +def test_webview2_ist_auf_diesem_rechner_da(): + """Kein Muss-Test, sondern ein Messpunkt: Faellt er aus, laeuft Rippy im + Browser statt im Fenster — und man weiss auch gleich, warum.""" + version = fenster.webview2_version() + if not version: + pytest.skip("Keine WebView2-Laufzeit auf dieser Maschine") + assert version.count(".") >= 2, version diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index 3a2ccfa..f156b6b 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -154,3 +154,139 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey finally: reg.austragen() assert aufrufe == [] + + +# ── 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` + gab 200, `/` gab 404. Die Onefile-EXE liest ihre Oberflaeche aus + `%TEMP%\\_MEIxxxxx`, und von diesem Ordner waren 31 statt 44 Eintraege + uebrig — kein `ui`. Die API antwortete weiter, der Dienst galt als + gesund, nur die Oberflaeche war weg.""" + paket = tmp_path / "paket" / "ui" + (paket / "assets").mkdir(parents=True) + (paket / "index.html").write_text("", encoding="utf-8") + (paket / "assets" / "haupt.js").write_text("//", encoding="utf-8") + monkeypatch.setattr(windows_app, "_mitgeliefert", + lambda name: str(tmp_path / "paket" / name)) + + ziel = tmp_path / "installiert" + ziel.mkdir() + ordner = windows_app.ui_auspacken(str(ziel)) + + assert ordner == str(ziel / "ui") + assert os.path.isfile(os.path.join(ordner, "index.html")) + assert os.path.isfile(os.path.join(ordner, "assets", "haupt.js")) + + +def test_alter_stand_wird_beim_update_nicht_vermischt(tmp_path, monkeypatch): + """Ein Update mit weniger Dateien liesse sonst Reste liegen — die + Oberflaeche waere ein Gemisch aus zwei Fassungen, und der Fehler saehe + aus wie ein Zufall.""" + paket = tmp_path / "paket" / "ui" + paket.mkdir(parents=True) + (paket / "index.html").write_text("neu", encoding="utf-8") + monkeypatch.setattr(windows_app, "_mitgeliefert", + lambda name: str(tmp_path / "paket" / name)) + + ziel = tmp_path / "installiert" + (ziel / "ui").mkdir(parents=True) + (ziel / "ui" / "veraltet.js").write_text("alt", encoding="utf-8") + + windows_app.ui_auspacken(str(ziel)) + assert not os.path.exists(str(ziel / "ui" / "veraltet.js")) + + +def test_ohne_gebuendeltes_ui_passiert_nichts(tmp_path, monkeypatch): + """Aus dem Repo gestartet liegt das UI schon an fester Stelle.""" + monkeypatch.setattr(windows_app, "_mitgeliefert", + lambda name: str(tmp_path / "gibt-es-nicht")) + assert windows_app.ui_auspacken(str(tmp_path)) == "" + + +def test_fehlschlag_beim_ablegen_bricht_die_installation_nicht_ab(tmp_path, monkeypatch, capsys): + """Ohne Kopie laeuft Rippy aus dem Temp weiter — schlechter, aber nicht + kaputt. Gesagt werden muss es trotzdem, sonst sucht spaeter jemand den + 404 im Server.""" + paket = tmp_path / "paket" / "ui" + paket.mkdir(parents=True) + (paket / "index.html").write_text("x", encoding="utf-8") + monkeypatch.setattr(windows_app, "_mitgeliefert", + lambda name: str(tmp_path / "paket" / name)) + + def geht_nicht(*a, **k): + raise OSError("Zugriff verweigert") + + monkeypatch.setattr(windows_app.shutil, "copytree", geht_nicht) + assert windows_app.ui_auspacken(str(tmp_path / "ziel")) == "" + assert "Oberflaeche konnte nicht abgelegt werden" in capsys.readouterr().out + + +# ── Oeffnen: Fenster statt Browser-Tab ────────────────────────────────── +def test_oeffnen_nimmt_das_fenster_nicht_den_browser(monkeypatch): + """Die Antwort auf die Frage des Commanders vom 28.08.2026: „Warum + nutzen wir fuer Windows weiterhin einen Browser?" — tun wir nicht mehr. + Ein Doppelklick fuehrt in ein eigenes Fenster mit eigenem Symbol in der + Taskleiste, nicht in einen Tab zwischen fremden Tabs.""" + from rippy import fenster + + gerufen = [] + monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True) + monkeypatch.setattr(fenster, "oeffnen_oder_browser", + lambda a, t="Rippy": gerufen.append(a) or "fenster") + monkeypatch.setattr("webbrowser.open", + lambda a: pytest.fail("Der Browser darf hier nicht aufgehen")) + + assert windows_app.oeffnen(7788) == 0 + assert gerufen == ["http://127.0.0.1:7788"] + + +def test_im_browser_geht_wirklich_in_den_browser(monkeypatch): + """Der Schalter muss die Bibliothek gar nicht erst anfassen — sonst + haette man auf einem Rechner ohne WebView2 keinen Ausweg.""" + from rippy import fenster + + gerufen = [] + monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True) + monkeypatch.setattr(fenster, "oeffnen_oder_browser", + lambda *a, **k: pytest.fail("kein Fenster erwartet")) + monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) + + assert windows_app.oeffnen(7788, als_fenster=False) == 0 + assert gerufen == ["http://127.0.0.1:7788"] + + +def test_ohne_antwortenden_dienst_geht_gar_nichts_auf(monkeypatch): + """Ein Fenster auf eine tote Adresse zeigt einen Verbindungsfehler — + und der sieht aus, als sei Rippy kaputt. Lieber nichts oeffnen und einen + Fehlercode zurueckgeben.""" + from rippy import fenster + + monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: False) + monkeypatch.setattr(fenster, "oeffnen_oder_browser", + lambda *a, **k: pytest.fail("nichts darf aufgehen")) + monkeypatch.setattr("webbrowser.open", + lambda a: pytest.fail("nichts darf aufgehen")) + + assert windows_app.oeffnen(7788) == 1 + + +def test_wenn_weder_fenster_noch_browser_geht_ist_das_ein_fehler(monkeypatch): + """`oeffnen_oder_browser` gibt „nichts" zurueck, wenn beide Wege + scheitern. Ein Rueckgabewert 0 waere dann eine Luege.""" + from rippy import fenster + + monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True) + monkeypatch.setattr(fenster, "oeffnen_oder_browser", lambda *a, **k: "nichts") + assert windows_app.oeffnen(7788) == 1 + + +def test_laufender_dienst_wird_nicht_noch_einmal_gestartet(monkeypatch): + """Sonst startete jeder Klick einen ZWEITEN Server, der am belegten Port + scheitert — genau die Falle, wegen der die Verknuepfungen ueberhaupt auf + --oeffnen zeigen.""" + monkeypatch.setattr(windows_app.Dienst, "erreichbar", + lambda self, timeout=1.0: True) + monkeypatch.setattr(windows_app, "starte_hintergrund", + lambda p: pytest.fail("es laeuft doch schon einer")) + assert windows_app.bereitstellen(7788) is True diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index b216e86..f056b82 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -122,6 +122,8 @@ def installieren(ziel: str = None, autostart: bool = True, except OSError: pass + ui_auspacken(ziel) + # Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein # Programm, das man nur von Hand wieder loswird. reg.eintragen(ziel, programm, VERSION, @@ -150,6 +152,50 @@ def installieren(ziel: str = None, autostart: bool = True, return ziel +def ui_auspacken(ziel: str) -> str: + r"""Legt die Weboberfläche NEBEN das Programm. Gibt den Ordner zurück. + + ## Warum das sein muss (gemessen 28.08.2026) + + Ein Dienst lief eine Stunde. `/api/health` gab HTTP 200, `/` gab **404** + — im Fenster stand `{"detail":"Not Found"}`. Der Server war in Ordnung; + was fehlte, lag unter ihm. + + Eine PyInstaller-Onefile-EXE entpackt sich beim Start nach + `%TEMP%\_MEIxxxxx` und liest von dort weiter. `StaticFiles` schlägt bei + JEDER Anfrage dort nach. Nachgesehen, was vom Ordner des laufenden + Dienstes übrig war: 31 Einträge statt 44, kein `ui`, kein `api`. + + Ein Temp-Verzeichnis ist kein Ort für etwas, das eine Woche liegen + bleiben soll. Und der Ausfall ist der schlimmstmögliche: Die API + antwortet weiter, der Dienst gilt als gesund, nur die Oberfläche ist weg. + + Die Kopie hier gehört Rippy, liegt im Installationsordner und wird beim + Deinstallieren mit entfernt (`deinstallieren()` räumt den ganzen Ordner). + + Leerer Rückgabewert heißt: keine Kopie angelegt — dann greift der + Rückfall auf `sys._MEIPASS` in `daemon._ui_pfad()`. + """ + quelle = _mitgeliefert("ui") + if not os.path.isdir(quelle): + # Aus dem Repo gestartet: Da liegt das UI schon an fester Stelle. + return "" + + ordner = os.path.join(ziel, "ui") + try: + # Erst weg, dann neu: Ein Update mit weniger Dateien ließe sonst + # Reste des alten Standes liegen, und die Oberfläche wäre ein + # Gemisch aus zwei Fassungen. + shutil.rmtree(ordner, ignore_errors=True) + shutil.copytree(quelle, ordner) + except OSError as e: + # Nicht werfen: Ohne Kopie läuft Rippy aus dem Temp weiter — schlechter, + # aber nicht kaputt. Gesagt wird es trotzdem. + melden(" Oberflaeche konnte nicht abgelegt werden (%s)." % e) + return "" + return ordner + + def starte_hintergrund(programm: str) -> None: """Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster.""" from rippy.platform.winlauf import OHNE_FENSTER @@ -158,6 +204,24 @@ def starte_hintergrund(programm: str) -> None: close_fds=True) +def starte_fensterprozess(programm: str = None) -> None: + """Startet die Oberflaeche als EIGENEN Prozess. + + Warum nicht einfach hier ein Fenster oeffnen: Das Tray-Symbol (pystray) + belegt unter Windows den Haupt-Thread — es braucht ihn fuer die + Fensternachrichten. Das WebView2-Fenster braucht ihn genauso. Zwei + Nachrichtenschleifen passen nicht in einen Thread. + + Zwei Prozesse loesen das sauber: Der Dienst haelt Server und Tray, das + Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe", + und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen. + """ + from rippy.platform.winlauf import OHNE_FENSTER + + subprocess.Popen([programm or _eigene_datei(), "--oeffnen"], + 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. @@ -355,8 +419,30 @@ def dienst_starten(port: int = STANDARD_PORT) -> int: return 0 -def oeffnen(port: int = STANDARD_PORT) -> int: - """Rippy oeffnen — Dienst starten, falls noetig, dann den Browser. +def bereitstellen(port: int = STANDARD_PORT) -> bool: + """Sorgt dafuer, dass der Dienst ANTWORTET. Startet ihn notfalls. + + Getrennt von `oeffnen()`, weil beide Wege dorthin — Fenster und Browser — + genau dieselbe Vorbedingung haben: Ohne antwortenden Server zeigt das + eine wie das andere nur einen Verbindungsfehler. + """ + dienst = Dienst(port) + if dienst.erreichbar(timeout=1.5): + return True + + melden("Rippy wird gestartet …") + starte_hintergrund(_eigene_datei()) + # Warten, bis er ANTWORTET — nicht bis der Prozess existiert. + for _ in range(60): + if dienst.erreichbar(timeout=1.0): + return True + time.sleep(0.5) + melden("Rippy antwortet nicht. Laeuft schon etwas auf Port %d?" % port) + return False + + +def oeffnen(port: int = STANDARD_PORT, als_fenster: bool = True) -> int: + """Rippy oeffnen — Dienst starten, falls noetig, dann die Oberflaeche. ## Warum die Verknuepfungen hierher zeigen und nicht auf --dienst @@ -369,26 +455,28 @@ def oeffnen(port: int = STANDARD_PORT) -> int: das beim zweiten Klick nichts tut, ist schlimmer als keins. Deshalb: erst NACHSEHEN, ob jemand antwortet. Nur wenn nicht, starten — - und dann warten, bis er wirklich da ist, statt sofort einen Browser auf + und dann warten, bis er wirklich da ist, statt sofort ein Fenster auf eine tote Adresse zu schicken. + + ## Warum hier ein Fenster und kein Browser-Tab + + Siehe `rippy/fenster.py`. Kurz: Ein Tab hat eine Adresszeile, die + niemand braucht, kein eigenes Symbol in der Taskleiste, und wer den + Browser schliesst, glaubt, er habe Rippy beendet. Geht das Fenster nicht + auf, faellt Rippy auf den Browser zurueck und SAGT, warum. """ - dienst = Dienst(port) + if not bereitstellen(port): + return 1 - if not dienst.erreichbar(timeout=1.5): - melden("Rippy wird gestartet …") - programm = _eigene_datei() - starte_hintergrund(programm) - # Warten, bis er ANTWORTET — nicht bis der Prozess existiert. - for _ in range(60): - if dienst.erreichbar(timeout=1.0): - break - time.sleep(0.5) - else: - melden("Rippy antwortet nicht. Laeuft schon etwas auf Port %d?" % port) - return 1 + adresse = Dienst(port).adresse + if not als_fenster: + return 0 if webbrowser.open(adresse) else 1 - webbrowser.open(dienst.adresse) - return 0 + from rippy import fenster as fenster_modul + + # Der Rueckgabewert sagt, was WIRKLICH passiert ist — nicht, dass es + # geklappt hat. „nichts" heisst: weder Fenster noch Browser. + return 0 if fenster_modul.oeffnen_oder_browser(adresse) != "nichts" else 1 def tray_bauen(dienst): @@ -413,7 +501,9 @@ def tray_bauen(dienst): zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11)) def oeffnen(_=None, __=None): - webbrowser.open(dienst.adresse) + # Eigener Prozess: pystray haelt hier den Haupt-Thread, das Fenster + # braucht ihn aber selbst (siehe starte_fensterprozess). + starte_fensterprozess() def beenden(symbol, _=None): dienst.stoppen() @@ -445,6 +535,7 @@ Optionen: --ziel PFAD Installationsordner --kein-autostart --keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag + --im-browser im Browser statt im eigenen Fenster --still ohne Rückmeldungen """ @@ -482,7 +573,15 @@ def main(argv=None) -> int: deinstallieren(still="--still" in argv) return 0 if "--oeffnen" in argv: - return oeffnen(int(wert("--port", STANDARD_PORT))) + # 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))) @@ -494,10 +593,12 @@ def main(argv=None) -> int: verknuepfen="--keine-verknuepfungen" not in argv, ) melden("Rippy wurde nach %s installiert." % ziel) - melden("Die Oberfläche öffnet sich gleich im Browser.") + 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) - webbrowser.open("http://127.0.0.1:%d" % int(wert("--port", STANDARD_PORT))) + starte_fensterprozess(os.path.join(ziel, "Rippy.exe")) return 0