diff --git a/src/rippy/einrichtung.py b/src/rippy/einrichtung.py new file mode 100644 index 0000000..fa086df --- /dev/null +++ b/src/rippy/einrichtung.py @@ -0,0 +1,260 @@ +"""Der Einrichtungs-Assistent: prüfen, fragen, installieren. + +## Der Befund des Commanders (28.08.2026) + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend +nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts +gefragt und erfuhr nichts — auch nicht, ob sein Rechner überhaupt kann, was +Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht, +seit wann. + +## Was hier passiert und was NICHT + +Dieses Modul enthält die **Prüfungen und die Entscheidungen** — keine +Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne +WebView2, ohne Fenster und ohne Installation prüfbar bleibt. + +## Die drei Sorten Befund + + ok geht + warnung geht, aber der Nutzer sollte es wissen + fehler geht NICHT — die Installation würde nichts nützen + +Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine +Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für +jede Sorte: + +* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine + reine Komprimier-Maschine — ein vorgesehener Betriebsfall (`install.sh` sagt + das seit Langem: „läuft trotzdem durch"). +* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann. +* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden. + +## Warum jede Prüfung sagt, was zu tun ist + +Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder +Eintrag ein `abhilfe`-Feld — leer nur dann, wenn wirklich nichts zu tun ist. +""" + +import os +import shutil +import socket +import sys + +OK = "ok" +WARNUNG = "warnung" +FEHLER = "fehler" + +# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist +# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der +# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft. +MINDESTPLATZ_MB = 500 + +STANDARD_PORT = 7788 + + +def _eintrag(name, zustand, text, abhilfe=""): + return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe} + + +# ── Die einzelnen Prüfungen ───────────────────────────────────────────── +def pruefe_windows(version=None) -> dict: + """Windows 10 oder neuer. Darunter gibt es kein WebView2.""" + if os.name != "nt": + return _eintrag("Betriebssystem", FEHLER, "Kein Windows", + "Für Linux gibt es die Docker- und die Headless-Fassung.") + bau = version if version is not None else sys.getwindowsversion().build + if bau >= 22000: + return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau) + if bau >= 10240: + return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau) + return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau, + "Rippy braucht mindestens Windows 10.") + + +def pruefe_webview(version=None) -> dict: + """Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem. + + Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser + funktioniert, er ist nur weniger schön. + """ + if version is None: + from rippy import fenster + + version = fenster.webview2_version() + if version: + return _eintrag("Fenster (WebView2)", OK, version) + return _eintrag( + "Fenster (WebView2)", WARNUNG, "nicht gefunden", + 'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos ' + 'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").') + + +def pruefe_platz(ziel: str, frei_mb=None) -> dict: + """Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner.""" + from rippy import betrieb + + if frei_mb is None: + pfad = betrieb.naechster_vorhandener(ziel) + if not pfad: + return _eintrag("Speicherplatz", FEHLER, + "Der Ort ist nicht erreichbar: %s" % ziel, + "Einen anderen Zielordner wählen.") + try: + frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024) + except OSError as e: + return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e, + "Einen anderen Zielordner wählen.") + if frei_mb >= MINDESTPLATZ_MB: + return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024)) + return _eintrag("Speicherplatz", FEHLER, + "nur %d MB frei, gebraucht werden %d MB" + % (frei_mb, MINDESTPLATZ_MB), + "Platz schaffen oder einen anderen Zielordner wählen.") + + +def pruefe_schreibrecht(ziel: str, probe=None) -> dict: + """Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten. + + Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage + nach dem Schreibrecht anhand des Nur-Lesen-Flags, nicht anhand der + Zugriffsrechte. Also wird eine Datei angelegt und wieder gelöscht. + """ + if probe is not None: + geklappt, grund = probe + else: + geklappt, grund = _schreibprobe(ziel) + if geklappt: + return _eintrag("Schreibrecht", OK, ziel) + return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), + 'Einen Ordner unterhalb von „Benutzer" wählen — dort ' + 'braucht Rippy keine Administratorrechte.') + + +def _schreibprobe(ziel: str): + import tempfile + + try: + os.makedirs(ziel, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe", + delete=True): + pass + return True, "" + except OSError as e: + return False, e.strerror or str(e) + + +def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict: + """Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen.""" + if belegt is None: + belegt = _port_belegt(port) + if not belegt: + return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port) + # Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist + # eine Neuinstallation genau richtig. + return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port, + "Läuft Rippy schon? Sonst im Setup einen anderen Port " + "wählen.") + + +def _port_belegt(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + try: + s.bind(("127.0.0.1", port)) + return False + except OSError: + return True + + +def pruefe_laufwerk(laufwerke=None) -> dict: + """Ein optisches Laufwerk. WARNUNG, kein Fehler. + + Rippy ohne Laufwerk ist eine reine Komprimier-Maschine — ein vorgesehener + Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt. + """ + if laufwerke is None: + try: + from rippy import drives + + laufwerke = drives.treiber().list_optical_devices() + except Exception: # noqa: BLE001 + laufwerke = [] + if laufwerke: + return _eintrag("Optisches Laufwerk", OK, + ", ".join(str(x) for x in laufwerke)) + return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden", + "Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. " + "Ein USB-Brenner reicht.") + + +def pruefe_werkzeuge(bestand=None) -> list: + """MakeMKV und HandBrake — je ein eigener Eintrag. + + MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre + es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`). + """ + from rippy.tools import katalog + + bestand = katalog.bestand() if bestand is None else bestand + ergebnis = [] + for name, eintrag in sorted(bestand.items()): + titel = eintrag.get("titel", name) + if eintrag.get("da"): + ergebnis.append(_eintrag(titel, OK, + eintrag.get("version") or "gefunden")) + elif name == "handbrake": + ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert")) + else: + ergebnis.append(_eintrag( + titel, WARNUNG, "nicht gefunden", + "Das Setup lädt es von makemkv.com und startet den Installer " + "des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen.")) + return ergebnis + + +# ── Alles zusammen ────────────────────────────────────────────────────── +def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list: + """Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge.""" + return ([pruefe_windows(), pruefe_webview(), pruefe_laufwerk()] + + pruefe_werkzeuge() + + [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)]) + + +def darf_installieren(pruefungen: list) -> bool: + """Blockiert NUR bei einem echten Fehler. (reine Funktion) + + Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, + der nur warnt, ist eine Falle. + """ + return not any(p["zustand"] == FEHLER for p in pruefungen) + + +def zusammenfassung(pruefungen: list) -> str: + """Ein Satz über den Gesamtzustand. (reine Funktion)""" + fehler = [p for p in pruefungen if p["zustand"] == FEHLER] + warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG] + if fehler: + return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler) + if warnungen: + return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen) + return "Alles bereit." + + +def vorgaben() -> dict: + """Womit die Maske vorbelegt wird.""" + from rippy import betrieb + from rippy.platform import win_registry as reg + + return { + "ziel": reg.standard_installationsordner(), + "ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"), + "port": STANDARD_PORT, + "autostart": True, + "verknuepfungen": True, + "werkzeuge_holen": True, + } diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py new file mode 100644 index 0000000..0e55b87 --- /dev/null +++ b/src/rippy/setup_fenster.py @@ -0,0 +1,389 @@ +"""Das Fenster des Einrichtungs-Assistenten. + +## Warum es das gibt + +Commander am 28.08.2026: + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +## Warum WebView2 und nicht ein Windows-Dialog + +Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit +(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null +zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen +ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau +ausdrücklich ausgeschlossen. + +## Warum die Prüfungen NICHT hier stehen + +Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. +Was hier steht, lässt sich nicht testen — also steht hier so wenig wie +möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. + +## Was passiert, wenn das Fenster nicht aufgeht + +Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein +Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer +als eines, das nicht fragt. +""" + +import threading + +from rippy import einrichtung + +TITEL = "Rippy einrichten" +BREITE = 860 +HOEHE = 760 + + +class Bruecke: + """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. + + Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel + wie überall sonst in diesem Projekt. + """ + + def __init__(self): + self.ergebnis = None + self._fenster = None + self._fortschritt = [] + + # ── Auskunft ──────────────────────────────────────────────────────── + def vorgaben(self) -> dict: + return einrichtung.vorgaben() + + def pruefen(self, ziel: str = "", port: int = 0) -> dict: + """Der Voraussetzungs-Bericht für die angegebenen Werte.""" + vorgabe = einrichtung.vorgaben() + befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], + int(port or vorgabe["port"])) + return { + "befunde": befunde, + "darf": einrichtung.darf_installieren(befunde), + "text": einrichtung.zusammenfassung(befunde), + } + + def ordner_waehlen(self, vorschlag: str = "") -> str: + """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" + import webview + + try: + gewaehlt = self._fenster.create_file_dialog( + webview.FOLDER_DIALOG, directory=vorschlag or "") + except Exception: # noqa: BLE001 + return "" + if not gewaehlt: + return "" + return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) + + def fortschritt(self) -> list: + """Was seit dem letzten Abruf passiert ist. Leert sich dabei.""" + zeilen, self._fortschritt = self._fortschritt, [] + return zeilen + + # ── Handeln ───────────────────────────────────────────────────────── + def installieren(self, optionen: dict) -> dict: + """Installiert im Hintergrund-Thread und gibt sofort zurück. + + Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter + Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster + sieht aus wie ein Absturz. + """ + threading.Thread(target=self._installieren_wirklich, + args=(optionen or {},), daemon=True).start() + return {"gestartet": True} + + def _installieren_wirklich(self, optionen: dict) -> None: + from rippy import windows_app + + def melden(text, anteil=None): + self._fortschritt.append(str(text)) + + try: + melden("Dateien werden abgelegt …") + ziel = windows_app.installieren( + ziel=optionen.get("ziel") or None, + autostart=bool(optionen.get("autostart", True)), + starten_danach=False, + verknuepfen=bool(optionen.get("verknuepfungen", True)), + ) + if optionen.get("ablage"): + melden("Ablage wird gespeichert …") + _ablage_speichern(optionen["ablage"]) + if optionen.get("werkzeuge_holen", True): + melden("Werkzeuge werden geprüft …") + from rippy.tools import einrichten as werkzeug_einrichtung + + bericht = werkzeug_einrichtung.sicherstellen(fortschritt=melden) + melden(werkzeug_einrichtung.bericht(bericht)) + bereit = bericht["bereit"] + else: + bereit = True + self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} + melden("Fertig.") + except Exception as ex: # noqa: BLE001 + # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig + # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr + # passiert. + self.ergebnis = {"ok": False, + "fehler": "%s: %s" % (type(ex).__name__, ex)} + melden("Fehlgeschlagen: %s" % ex) + + def stand(self) -> dict: + """None, solange es läuft. Danach das Ergebnis.""" + return self.ergebnis or {} + + def schliessen(self) -> None: + try: + self._fenster.destroy() + except Exception: # noqa: BLE001 + pass + + +def _ablage_speichern(pfad: str) -> None: + """Die gewählte Ablage in die Einstellungen schreiben. + + Über die laufende Datenbank, nicht über eine Konfigurationsdatei: Das UI + liest `outputDir` aus den Einstellungen, und genau dort muss der Wert + landen, damit die Wahl im Assistenten überhaupt etwas bewirkt. + """ + from rippy import config, daemon, store + + werte = config.laden() + store.verbinden(config.datenbank_url(werte)) + import sys + + sys.path.insert(0, daemon._api_pfad()) + import db as api_db # noqa: E402 + + api_db.init_db() + aktuell = api_db.get_settings() or {} + aktuell["outputDir"] = pfad + api_db.save_settings(aktuell) + + +def starten() -> dict: + """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. + + `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, + ob er ohne Assistent installiert. + """ + import webview + + bruecke = Bruecke() + fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, + width=BREITE, height=HOEHE, + min_size=(720, 560), text_select=True) + bruecke._fenster = fenster + webview.start(gui="edgechromium", private_mode=False) + return bruecke.ergebnis or {} + + +def moeglich() -> tuple: + """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" + from rippy import fenster as fenster_modul + + return fenster_modul.verfuegbar() + + +# ── Die Seite ─────────────────────────────────────────────────────────── +# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine +# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne +# Internet aufgehen — dort wird er am dringendsten gebraucht. +SEITE = r""" +
+ +