Files
rippy/packaging/windows/build.py
T
HitonabiandClaude Opus 5 c01ef1081c 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>
2026-08-28 12:14:15 +02:00

216 lines
8.5 KiB
Python

"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
## Aufruf
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
* pyinstaller, pystray, pillow
* Das UI muss gebaut sein: cd docker/ui && npm run build
## Warum die Versions-Ressource nicht optional ist
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
und Windows SmartScreen sieht das genauso.
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
daneben aus dieser Ressource.
## Was mit hinein muss
docker/api/ die API-Module (main.py und Nachbarn)
docker/ui/dist/ die gebaute Weboberflaeche
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
"""
import argparse
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
VERSION_VORLAGE = """
VSVersionInfo(
ffi=FixedFileInfo(
filevers=({v0}, {v1}, {v2}, 0),
prodvers=({v0}, {v1}, {v2}, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(u'040704b0', [
StringStruct(u'CompanyName', u'Rippy'),
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
StringStruct(u'FileVersion', u'{version}'),
StringStruct(u'InternalName', u'Rippy'),
StringStruct(u'OriginalFilename', u'Rippy.exe'),
StringStruct(u'ProductName', u'Rippy'),
StringStruct(u'ProductVersion', u'{version}')])
]),
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
# Windows die Angaben je nach Systemsprache gar nicht an.
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
]
)
"""
def versionsdatei(version: str, ordner: str) -> str:
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
pfad = os.path.join(ordner, "version_info.txt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(VERSION_VORLAGE.format(
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
return pfad
def pruefen() -> list:
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
aussieht wie fertig und es nicht ist.
"""
maengel = []
if sys.platform != "win32":
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
maengel.append("docker/api/main.py fehlt.")
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
"die App nicht rippen.")
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
maengel.append(
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
"Abhilfe: cd docker/ui && npm ci && npm run build")
if not os.path.isfile(os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")):
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
try:
import PyInstaller # noqa: F401
except ImportError:
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
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 {wofuer}.")
return maengel
def bauen(ausgabe: str, version: str) -> str:
arbeit = os.path.join(ausgabe, "_bau")
os.makedirs(arbeit, exist_ok=True)
trenner = ";" # Windows-Trennzeichen fuer --add-data
befehl = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--onefile",
# --windowed waere schoener (kein Konsolenfenster), macht aber jede
# Ausgabe unsichtbar — auch Fehlermeldungen beim Installieren. Der
# Installer laeuft deshalb MIT Konsole; der Dienst startet spaeter
# ohne Fenster (CREATE_NO_WINDOW in winlauf.py).
"--console",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
"--distpath", ausgabe,
"--workpath", os.path.join(arbeit, "work"),
"--specpath", arbeit,
"--paths", os.path.join(REPO, "src"),
"--paths", os.path.join(REPO, "docker", "api"),
"--paths", os.path.join(REPO, "docker", "worker"),
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--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",
"--exclude-module", "tkinter",
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
]
print("PyInstaller wird aufgerufen …\n")
ergebnis = subprocess.run(befehl, cwd=REPO)
if ergebnis.returncode != 0:
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
exe = os.path.join(ausgabe, "RippySetup.exe")
if not os.path.isfile(exe):
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
return exe
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
print("Rippy — Windows-Bau")
print(" Repo %s" % REPO)
print(" Ausgabe %s" % args.ausgabe)
print(" Version %s" % args.version)
print()
maengel = pruefen()
if maengel:
print("Der Bau kann so nicht laufen:")
for m in maengel:
print(" X %s" % m)
return 1
print("Alle Voraussetzungen erfuellt.")
if args.nur_pruefen:
return 0
os.makedirs(args.ausgabe, exist_ok=True)
exe = bauen(args.ausgabe, args.version)
groesse = os.path.getsize(exe) / 1024**2
print()
print("Fertig: %s (%.1f MB)" % (exe, groesse))
arbeit = os.path.join(args.ausgabe, "_bau")
shutil.rmtree(arbeit, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())