Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1577a4f47 | ||
|
|
c01ef1081c |
@@ -1267,6 +1267,59 @@ Eingabemaske — nur der Knopf „Verbinden" wird zu „Zeile kopieren".
|
|||||||
`apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in
|
`apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in
|
||||||
V2-5 ersatzlos weg.
|
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
|
### Was jetzt noch fehlt, bevor gebaut wird
|
||||||
|
|||||||
+32
-4
@@ -619,15 +619,43 @@ Medien.
|
|||||||
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
||||||
|
|
||||||
### V2-4: Windows nativ
|
### 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`,
|
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
|
||||||
MMC `GET CONFIGURATION` für den Disc-Typ)
|
MMC `GET CONFIGURATION` für den Disc-Typ)
|
||||||
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling
|
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
|
||||||
- [ ] Dienst (WinSW) + Tray als GETRENNTE Prozesse; WebView2-Fenster
|
vorerst über den Poll aus `drives/detection.py`
|
||||||
- [ ] Nuitka `--standalone` (one-dir, nicht one-file) + Inno Setup
|
- [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`
|
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
|
||||||
|
— **offen**
|
||||||
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
||||||
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
|
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
|
### V2-5: Docker neu
|
||||||
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
||||||
|
|||||||
+108
-1
@@ -1,6 +1,113 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v4.0-beta — V2-0 bis V2-3 stehen, Polling ist weg (28.08.2026)
|
## Aktueller Stand: v4.0-rc — Windows ist ein echtes Programm (28.08.2026)
|
||||||
|
|
||||||
|
> **Rippy läuft auf Windows ohne Docker, in einem eigenen Fenster, findet und
|
||||||
|
> aktualisiert seine Werkzeuge selbst — und meldet sich bei Windows an wie
|
||||||
|
> jedes andere Programm.**
|
||||||
|
|
||||||
|
### ZUSTAND, gemessen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Repo | `c01ef10` auf `main` |
|
||||||
|
| Tests | **562 grün** + 18 übersprungen (Sitzungsbeginn: 378) |
|
||||||
|
| `RippySetup.exe` | **31,7 MB**, liegt auf dem Desktop |
|
||||||
|
| Fenster | WebView2 **151.0.4129.107**, 1280×860, Oberfläche im Bild belegt |
|
||||||
|
| Werkzeuge auf diesem PC | 5 Encoder gefunden, darunter **AMD VCE** |
|
||||||
|
| VM (Arcane) | **noch auf `05ab655`** — alles ab `b723fee` ist NICHT deployt |
|
||||||
|
| Echter Rip auf Windows | **ungeprüft** — das Laufwerk hängt an der VM |
|
||||||
|
|
||||||
|
### Was jetzt geht
|
||||||
|
|
||||||
|
Doppelklick RippySetup.exe -> installiert nach %LOCALAPPDATA%\Rippy
|
||||||
|
Desktop-Symbol + Startmenue-Eintrag
|
||||||
|
Eintrag in "Programme und Features"
|
||||||
|
Autostart (HKCU\...\Run)
|
||||||
|
Doppelklick Desktop-Symbol -> eigenes Fenster, keine Adresszeile,
|
||||||
|
kein Browser, kein Konsolenblitzer
|
||||||
|
Taskmanager -> "Rippy.exe" mit Beschreibung
|
||||||
|
Programme und Features -> Rippy 2.0.0, Deinstallieren raeumt auf
|
||||||
|
|
||||||
|
### Die Entscheidung dieser Sitzung: WebView2, nicht Electron
|
||||||
|
|
||||||
|
Der Commander fragte: *„Warum nutzen wir für Windows weiterhin einen Browser?
|
||||||
|
Warum nutzen wir kein Electron oder sowas und machen daraus einen echten
|
||||||
|
Client."*
|
||||||
|
|
||||||
|
Erste Hälfte: berechtigt, umgesetzt. Zweite Hälfte: abgelehnt, **gemessen**:
|
||||||
|
|
||||||
|
| | Zusatz | Was mitkommt |
|
||||||
|
|---|---|---|
|
||||||
|
| pywebview + WebView2 | **2,7 MB** in der EXE | nur die Anbindung |
|
||||||
|
| Electron | ~150–210 MB | zweites Chromium **und** zweite Laufzeit neben Python |
|
||||||
|
|
||||||
|
Windows 11 liefert die Laufzeit mit. Nachgemessen, was sie rendert:
|
||||||
|
`Chrome/151.0.0.0 … Edg/151.0.0.0`, fetch ✓, EventSource ✓, CSS Grid ✓.
|
||||||
|
Dasselbe Chromium wie in Electron — nur ohne es zweimal mitzuschleppen.
|
||||||
|
Vollständige Begründung: `src/rippy/fenster.py`, `KONZEPT-V2.md` § 10
|
||||||
|
Entscheid 4.
|
||||||
|
|
||||||
|
### DER FUND: ein Dienst, der gesund meldete und keine Oberfläche mehr hatte
|
||||||
|
|
||||||
|
Beim Nachsehen im laufenden Betrieb — nicht in einem Test:
|
||||||
|
|
||||||
|
/api/health HTTP 200
|
||||||
|
/ HTTP 404 im Fenster: {"detail":"Not Found"}
|
||||||
|
|
||||||
|
Der Server war in Ordnung. Sein Entpack-Verzeichnis war es nicht:
|
||||||
|
|
||||||
|
_MEI000074b02 31 Eintraege, 5 Ordner <- der laufende Dienst, kein ui
|
||||||
|
_MEI000082e82 44 Eintraege, 16 Ordner <- vollstaendig
|
||||||
|
|
||||||
|
Eine PyInstaller-Onefile-EXE liest bei **jeder** Anfrage aus `%TEMP%\_MEIxxxxx`.
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Genau davor warnte `ROADMAP.md` beim Bau-Verfahren** („one-dir statt
|
||||||
|
one-file"). Die Abweichung bleibt, die Lücke ist geschlossen: Der Installer
|
||||||
|
legt die Oberfläche neben das Programm, `daemon._ui_pfad()` nimmt diese Kopie
|
||||||
|
zuerst. Zwei Tests halten es fest. Zeigt sich derselbe Ausfall an den
|
||||||
|
API-Modulen, ist one-dir die richtige Antwort.
|
||||||
|
|
||||||
|
### Der Vorfall, der den PC des Commanders getroffen hat
|
||||||
|
|
||||||
|
Ein Test rief `installieren()` auf. Seit dem Verknüpfungs-Feature legt das
|
||||||
|
Verknüpfungen auf dem **echten** Desktop an — die kennen kein `tmp_path`. Der
|
||||||
|
Test überschrieb damit das funktionierende Desktop-Symbol durch eines, das auf
|
||||||
|
eine 2-KB-Attrappe in `…\Temp\pytest-of-…` zeigte. Windows meldete beim Klick:
|
||||||
|
*„Diese App kann auf dem PC nicht ausgeführt werden."*
|
||||||
|
|
||||||
|
Aufgeräumt hatte der Test nur Registry und Autostart. **Ein Test, der Spuren
|
||||||
|
außerhalb von `tmp_path` hinterlässt, ist kein Test, sondern ein Eingriff.**
|
||||||
|
Jetzt zwei Sicherungen statt einer: `verknuepfen=False` **und** die
|
||||||
|
Anlege-Funktion ist ersetzt. Dazu ein eigener Wächter-Test.
|
||||||
|
|
||||||
|
### Gebaut in dieser Sitzung (11 Commits)
|
||||||
|
|
||||||
|
- **V2-4 Windows-Treiber** (`b723fee`, `3d7f3b1`, `9d95c95`) — Win32 per
|
||||||
|
ctypes, an einer echten Disc bewiesen
|
||||||
|
- **Daemon unter Windows** (`e5254c2`) — `rippyd` ohne Docker
|
||||||
|
- **RippySetup.exe** (`059183c`) — ein Programm, drei Betriebsarten
|
||||||
|
- **Werkzeug-Kette** (`288f9ee`, `f4a8d77`) — finden, holen, aktuell halten;
|
||||||
|
Rippy ist **standalone**, wie der Commander es verlangt hat
|
||||||
|
- **Verknüpfungen + `--oeffnen`** (`99c0586`)
|
||||||
|
- **Test-Vorfall behoben** (`069fc46`)
|
||||||
|
- **Echtes Fenster + UI-Kopie** (`c01ef10`)
|
||||||
|
|
||||||
|
### WAS ALS NÄCHSTES ANSTEHT
|
||||||
|
|
||||||
|
- [ ] **Laufwerk an den PC** — ein echter Rip unter Windows ist noch nie
|
||||||
|
gelaufen. Alles davor ist bewiesen, das nicht.
|
||||||
|
- [ ] **Deploy auf Arcane** — die VM steht auf `05ab655`, elf Commits zurück.
|
||||||
|
- [ ] **V2-5 Docker neu**: ein Image, drei Profile, Host-Mounts.
|
||||||
|
- [ ] **V2-6 Headless** — zurückgestellt, aber nicht gestrichen
|
||||||
|
(ausdrücklich: *„soll aber nicht vernachlässigt werden"*).
|
||||||
|
- [ ] **V2-7**: neue Funktionen inkl. Schlüsselkette in drei Stufen.
|
||||||
|
- [ ] Offen aus V2-4: `WM_DEVICECHANGE` statt Poll, `SetThreadExecutionState`.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-beta — V2-0 bis V2-3 stehen, Polling ist weg (28.08.2026)
|
||||||
|
|
||||||
> **Vier Etappen gebaut, alle gruen, alles deployt.** Und ein Vorfall, den es
|
> **Vier Etappen gebaut, alle gruen, alles deployt.** Und ein Vorfall, den es
|
||||||
> ohne den Deploy nie gegeben haette — der aber eine echte Altlast aufgedeckt hat.
|
> ohne den Deploy nie gegeben haette — der aber eine echte Altlast aufgedeckt hat.
|
||||||
|
|||||||
@@ -100,11 +100,17 @@ def pruefen() -> list:
|
|||||||
import PyInstaller # noqa: F401
|
import PyInstaller # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
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:
|
try:
|
||||||
__import__(modul)
|
__import__(modul)
|
||||||
except ImportError:
|
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
|
return maengel
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +151,13 @@ def bauen(ausgabe: str, version: str) -> str:
|
|||||||
"--hidden-import", "uvicorn.lifespan.on",
|
"--hidden-import", "uvicorn.lifespan.on",
|
||||||
"--hidden-import", "rippy.drives.windows",
|
"--hidden-import", "rippy.drives.windows",
|
||||||
"--hidden-import", "pystray._win32",
|
"--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,
|
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
||||||
# und beides zieht Ballast nach.
|
# und beides zieht Ballast nach.
|
||||||
"--exclude-module", "psycopg2",
|
"--exclude-module", "psycopg2",
|
||||||
|
|||||||
@@ -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
@@ -113,7 +113,36 @@ def _api_pfad() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _ui_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)
|
gebuendelt = getattr(sys, "_MEIPASS", None)
|
||||||
if gebuendelt:
|
if gebuendelt:
|
||||||
pfad = os.path.join(gebuendelt, "ui")
|
pfad = os.path.join(gebuendelt, "ui")
|
||||||
@@ -121,11 +150,18 @@ def _ui_pfad() -> str:
|
|||||||
|
|
||||||
hier = os.path.dirname(os.path.abspath(__file__))
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
repo = os.path.dirname(os.path.dirname(hier))
|
repo = os.path.dirname(os.path.dirname(hier))
|
||||||
for pfad in (os.path.join(repo, "docker", "ui", "dist"),
|
pfad = os.path.join(repo, "docker", "ui", "dist")
|
||||||
os.path.join(os.path.dirname(sys.executable), "ui")):
|
return pfad if os.path.isdir(pfad) else ""
|
||||||
if os.path.isdir(pfad):
|
|
||||||
return pfad
|
|
||||||
return ""
|
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:
|
def argumente(argv=None) -> argparse.Namespace:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
|
||||||
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
||||||
derselbe Code auf beiden Seiten läuft.
|
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
|
import subprocess
|
||||||
@@ -30,3 +44,47 @@ import subprocess
|
|||||||
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
||||||
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
||||||
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ sitzen die Fehler, die man spaeter nicht mehr findet:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
from rippy import config, daemon
|
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)
|
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():
|
def test_standard_konfigpfad_passt_zur_plattform():
|
||||||
pfad = daemon.standard_konfigpfad()
|
pfad = daemon.standard_konfigpfad()
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -154,3 +154,139 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey
|
|||||||
finally:
|
finally:
|
||||||
reg.austragen()
|
reg.austragen()
|
||||||
assert aufrufe == []
|
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
@@ -122,6 +122,8 @@ def installieren(ziel: str = None, autostart: bool = True,
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
ui_auspacken(ziel)
|
||||||
|
|
||||||
# Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein
|
# Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein
|
||||||
# Programm, das man nur von Hand wieder loswird.
|
# Programm, das man nur von Hand wieder loswird.
|
||||||
reg.eintragen(ziel, programm, VERSION,
|
reg.eintragen(ziel, programm, VERSION,
|
||||||
@@ -150,6 +152,50 @@ def installieren(ziel: str = None, autostart: bool = True,
|
|||||||
return ziel
|
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:
|
def starte_hintergrund(programm: str) -> None:
|
||||||
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
@@ -158,6 +204,24 @@ def starte_hintergrund(programm: str) -> None:
|
|||||||
close_fds=True)
|
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:
|
def deinstallieren(still: bool = False) -> None:
|
||||||
"""Nimmt alle Spuren zurück: Autostart, Registry-Eintrag, Dateien."""
|
"""Nimmt alle Spuren zurück: Autostart, Registry-Eintrag, Dateien."""
|
||||||
# WOHIN wurde installiert? Das steht in der Registry, nicht im Code.
|
# 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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def oeffnen(port: int = STANDARD_PORT) -> int:
|
def bereitstellen(port: int = STANDARD_PORT) -> bool:
|
||||||
"""Rippy oeffnen — Dienst starten, falls noetig, dann den Browser.
|
"""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
|
## 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.
|
das beim zweiten Klick nichts tut, ist schlimmer als keins.
|
||||||
|
|
||||||
Deshalb: erst NACHSEHEN, ob jemand antwortet. Nur wenn nicht, starten —
|
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.
|
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):
|
adresse = Dienst(port).adresse
|
||||||
melden("Rippy wird gestartet …")
|
if not als_fenster:
|
||||||
programm = _eigene_datei()
|
return 0 if webbrowser.open(adresse) else 1
|
||||||
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
|
|
||||||
|
|
||||||
webbrowser.open(dienst.adresse)
|
from rippy import fenster as fenster_modul
|
||||||
return 0
|
|
||||||
|
# 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):
|
def tray_bauen(dienst):
|
||||||
@@ -413,7 +501,9 @@ def tray_bauen(dienst):
|
|||||||
zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11))
|
zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11))
|
||||||
|
|
||||||
def oeffnen(_=None, __=None):
|
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):
|
def beenden(symbol, _=None):
|
||||||
dienst.stoppen()
|
dienst.stoppen()
|
||||||
@@ -445,6 +535,7 @@ Optionen:
|
|||||||
--ziel PFAD Installationsordner
|
--ziel PFAD Installationsordner
|
||||||
--kein-autostart
|
--kein-autostart
|
||||||
--keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag
|
--keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag
|
||||||
|
--im-browser im Browser statt im eigenen Fenster
|
||||||
--still ohne Rückmeldungen
|
--still ohne Rückmeldungen
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -482,7 +573,15 @@ def main(argv=None) -> int:
|
|||||||
deinstallieren(still="--still" in argv)
|
deinstallieren(still="--still" in argv)
|
||||||
return 0
|
return 0
|
||||||
if "--oeffnen" in argv:
|
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:
|
if "--dienst" in argv:
|
||||||
return dienst_starten(int(wert("--port", STANDARD_PORT)))
|
return dienst_starten(int(wert("--port", STANDARD_PORT)))
|
||||||
|
|
||||||
@@ -494,10 +593,12 @@ def main(argv=None) -> int:
|
|||||||
verknuepfen="--keine-verknuepfungen" not in argv,
|
verknuepfen="--keine-verknuepfungen" not in argv,
|
||||||
)
|
)
|
||||||
melden("Rippy wurde nach %s installiert." % ziel)
|
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:
|
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)
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user