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