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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 12:14:15 +02:00
co-authored by Claude Opus 5
parent 069fc46f44
commit c01ef1081c
12 changed files with 971 additions and 34 deletions
+53
View File
@@ -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 | ~150210 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
+32 -4
View File
@@ -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`)
+15 -2
View File
@@ -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",
+25
View File
@@ -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
+42 -6
View File
@@ -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:
+219
View File
@@ -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
+72
View File
@@ -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
+58
View File
@@ -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
+40
View File
@@ -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("<!DOCTYPE html>",
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":
+156
View File
@@ -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
+136
View File
@@ -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("<!DOCTYPE html>", 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
+123 -22
View File
@@ -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