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>
216 lines
8.5 KiB
Python
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())
|