"""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", # KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde # das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken # zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI # Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit # nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei # Sekunden auf, bis Python so weit ist. # # Was dafuer noetig ist, steht in packaging/windows/einstieg.py: # ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben. "--windowed", "--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())