Commander: "Dann haette 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 hatte recht. Ein Doppelklick installierte stillschweigend nach
%LOCALAPPDATA%\Rippy und oeffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts -- auch nicht, ob sein Rechner ueberhaupt kann, was
Rippy braucht.
Jetzt: ein Assistent mit acht Pruefungen, zwei Ordner-Wahlen und drei
Schaltern. Auf diesem Rechner nachgemessen:
OK Betriebssystem Windows 11 (Build 26200)
OK Fenster (WebView2) 151.0.4129.107
OK Optisches Laufwerk \.\G:
OK HandBrakeCLI 1.11.2
! MakeMKV nicht gefunden
-> wird von makemkv.com geholt
OK Speicherplatz 59.1 GB frei
OK Schreibrecht C:\Users\...\AppData\Local\Rippy
OK Netzwerk-Port 7788 ist frei
Drei Entwurfsentscheidungen, die dazugehoeren:
* Nur ein FEHLER blockiert, eine Warnung nicht. Eine Warnung, die den Knopf
sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle.
Kein Laufwerk = Warnung (reine Komprimier-Maschine ist ein vorgesehener
Betriebsfall). Kein Schreibrecht = Fehler.
* Jeder Befund sagt, was zu tun ist. Ein Befund ohne Abhilfe laesst den
Nutzer ratlos zurueck -- ein Test haelt das fuer jede Pruefung fest.
* Die Pruefungen stehen in einrichtung.py, komplett ohne Fenster pruefbar.
Im Fenstermodul steht nur Aufbau und Bruecke.
WebView2 statt Win32-Dialog: Es ist ohnehin da (Entscheid 4), der Assistent
kostet damit NULL zusaetzliche Bytes. Die Seite laedt nichts aus dem Netz --
sie muss auf einem Rechner ohne Internet aufgehen, dort wird sie am
dringendsten gebraucht. Ein Test haelt das fest.
--still und --ohne-assistent gehen weiterhin den stummen Weg. Geht das
Fenster nicht auf, wird mit den Vorgaben installiert und gesagt warum -- ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, waere schlimmer
als eines, das nicht fragt.
fix(tools): %ProgramFiles(x86)% wurde auf echten Rechnern NIE aufgeloest
Beim Bauen der Pruefung aufgefallen, und es ist ein Lehrstueck:
Testumgebung {"ProgramFiles(x86)": ...} -> C:\Program Files (x86)\...
echtes Windows {"PROGRAMFILES(X86)": ...} -> ''
Windows behandelt Umgebungsvariablen ohne Ruecksicht auf die Schreibweise und
legt sie in os.environ GROSS ab. Der Vergleich in _entfalten war exakt, fand
nie eine Uebereinstimmung, und die bekannten Installationsorte fielen aus der
Kandidatenliste. MakeMKV wurde also NUR ueber die Registry-Rueckfallebene
gefunden -- wo die fehlt oder anders aussieht, fand Rippy nichts.
Aufgefallen ist es nur, weil hier zum ersten Mal eine ECHTE Umgebung benutzt
wurde. Der Test spritzte genau die Schreibweise ein, die im Muster stand: Er
war gruener als die Wirklichkeit. Beide Tests nehmen jetzt die echte
Schreibweise, und einer prueft direkt gegen os.environ. Beim Zurueckdrehen
des Fehlers rot gesehen (5 Fehlschlaege).
Ampel lokal: 691 gruen, ruff sauber. Assistent im Bildschirmfoto belegt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""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"""<!DOCTYPE html>
|
||
<html lang="de"><head><meta charset="utf-8">
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; background:#0b0f19; color:#e2e8f0;
|
||
font:14px/1.55 "Segoe UI",system-ui,sans-serif; }
|
||
header { padding:22px 28px 16px; border-bottom:1px solid #1e293b; }
|
||
h1 { margin:0; font-size:19px; font-weight:650; }
|
||
h1 span { color:#f59e0b; }
|
||
.unter { color:#94a3b8; font-size:12.5px; margin-top:4px; }
|
||
main { padding:20px 28px 96px; }
|
||
section { margin-bottom:22px; }
|
||
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.09em;
|
||
color:#94a3b8; margin:0 0 10px; font-weight:600; }
|
||
.zeile { display:flex; gap:10px; padding:7px 0;
|
||
border-bottom:1px solid #131c2e; align-items:flex-start; }
|
||
.zeile:last-child { border-bottom:0; }
|
||
.marke { width:17px; flex:0 0 17px; font-weight:700; text-align:center; }
|
||
.ok .marke { color:#34d399; } .warnung .marke { color:#fbbf24; }
|
||
.fehler .marke { color:#f87171; }
|
||
.inhalt { flex:1; min-width:0; }
|
||
.name { font-weight:600; }
|
||
.wert { color:#cbd5e1; font-family:ui-monospace,Consolas,monospace;
|
||
font-size:12.5px; word-break:break-all; }
|
||
.abhilfe { color:#94a3b8; font-size:12px; margin-top:2px; }
|
||
label.feld { display:block; margin-bottom:14px; }
|
||
label.feld > span { display:block; font-size:12.5px; color:#cbd5e1;
|
||
margin-bottom:5px; }
|
||
.mitknopf { display:flex; gap:8px; }
|
||
input[type=text], input[type=number] {
|
||
flex:1; background:#0f1626; border:1px solid #24324a; border-radius:7px;
|
||
color:#e2e8f0; padding:9px 11px; font-size:13px; font-family:inherit; }
|
||
input:focus { outline:2px solid #f59e0b55; border-color:#f59e0b; }
|
||
button { background:#1c2740; border:1px solid #2e3d5c; color:#e2e8f0;
|
||
border-radius:7px; padding:9px 15px; font-size:13px; cursor:pointer;
|
||
font-family:inherit; }
|
||
button:hover:not(:disabled) { background:#25324f; }
|
||
button.los { background:#f59e0b; border-color:#f59e0b; color:#1a1206;
|
||
font-weight:650; padding:11px 26px; }
|
||
button.los:hover:not(:disabled) { background:#fbbf24; }
|
||
button:disabled { opacity:.45; cursor:not-allowed; }
|
||
.schalter { display:flex; gap:9px; align-items:flex-start; margin-bottom:11px;
|
||
cursor:pointer; }
|
||
.schalter input { margin:3px 0 0; accent-color:#f59e0b; }
|
||
.schalter .hinweis { color:#94a3b8; font-size:12px; }
|
||
footer { position:fixed; left:0; right:0; bottom:0; background:#0b0f19f2;
|
||
border-top:1px solid #1e293b; padding:14px 28px;
|
||
display:flex; align-items:center; gap:16px;
|
||
backdrop-filter:blur(6px); }
|
||
#urteil { flex:1; font-size:12.5px; color:#94a3b8; }
|
||
#protokoll { background:#080c15; border:1px solid #1a2438; border-radius:8px;
|
||
padding:11px 13px; font-family:ui-monospace,Consolas,monospace;
|
||
font-size:12px; color:#94a3b8; max-height:190px; overflow:auto;
|
||
white-space:pre-wrap; }
|
||
.weg { display:none; }
|
||
</style></head><body>
|
||
<header>
|
||
<h1><span>◉</span> Rippy einrichten</h1>
|
||
<div class="unter">Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.</div>
|
||
</header>
|
||
<main>
|
||
<section id="teil-pruefung">
|
||
<h2>Voraussetzungen</h2>
|
||
<div id="pruefungen"><div class="zeile"><div class="inhalt wert">wird geprüft …</div></div></div>
|
||
</section>
|
||
|
||
<section id="teil-orte">
|
||
<h2>Wohin</h2>
|
||
<label class="feld"><span>Rippy selbst (Programmordner)</span>
|
||
<div class="mitknopf">
|
||
<input type="text" id="ziel">
|
||
<button onclick="waehlen('ziel')">Durchsuchen …</button>
|
||
</div>
|
||
</label>
|
||
<label class="feld"><span>Fertige Filme (Ablage) — darf auch ein Netzwerkpfad sein</span>
|
||
<div class="mitknopf">
|
||
<input type="text" id="ablage">
|
||
<button onclick="waehlen('ablage')">Durchsuchen …</button>
|
||
</div>
|
||
</label>
|
||
<label class="feld" style="max-width:190px"><span>Port</span>
|
||
<input type="number" id="port" min="1024" max="65535">
|
||
</label>
|
||
</section>
|
||
|
||
<section id="teil-optionen">
|
||
<h2>Sonst noch</h2>
|
||
<label class="schalter"><input type="checkbox" id="autostart" checked>
|
||
<span>Mit Windows starten<div class="hinweis">Rippy läuft dann im Hintergrund und erkennt eingelegte Discs von allein.</div></span></label>
|
||
<label class="schalter"><input type="checkbox" id="verknuepfungen" checked>
|
||
<span>Symbol auf dem Desktop und im Startmenü<div class="hinweis">Ohne das ist Rippy praktisch unauffindbar.</div></span></label>
|
||
<label class="schalter"><input type="checkbox" id="werkzeuge_holen" checked>
|
||
<span>Fehlende Werkzeuge holen<div class="hinweis">HandBrake liegt bei. MakeMKV wird von makemkv.com geladen und mit dem Installer des Herstellers installiert — ohne MakeMKV kann Rippy keine Disc lesen.</div></span></label>
|
||
</section>
|
||
|
||
<section id="teil-lauf" class="weg">
|
||
<h2>Läuft</h2>
|
||
<div id="protokoll"></div>
|
||
</section>
|
||
</main>
|
||
<footer>
|
||
<div id="urteil">wird geprüft …</div>
|
||
<button id="knopf-abbruch" onclick="pywebview.api.schliessen()">Abbrechen</button>
|
||
<button id="knopf-los" class="los" onclick="losgehen()" disabled>Installieren</button>
|
||
</footer>
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
let laeuft = false;
|
||
|
||
function zeichnePruefungen(bericht) {
|
||
const marken = { ok: '✓', warnung: '!', fehler: '✗' };
|
||
$('pruefungen').innerHTML = bericht.befunde.map(b =>
|
||
'<div class="zeile ' + b.zustand + '">'
|
||
+ '<div class="marke">' + (marken[b.zustand] || '?') + '</div>'
|
||
+ '<div class="inhalt"><div class="name">' + b.name + '</div>'
|
||
+ '<div class="wert">' + b.text + '</div>'
|
||
+ (b.abhilfe ? '<div class="abhilfe">' + b.abhilfe + '</div>' : '')
|
||
+ '</div></div>').join('');
|
||
$('urteil').textContent = bericht.text;
|
||
$('knopf-los').disabled = !bericht.darf || laeuft;
|
||
}
|
||
|
||
async function pruefen() {
|
||
if (laeuft) return;
|
||
zeichnePruefungen(await pywebview.api.pruefen($('ziel').value, Number($('port').value)));
|
||
}
|
||
|
||
async function waehlen(feld) {
|
||
const ordner = await pywebview.api.ordner_waehlen($(feld).value);
|
||
if (ordner) { $(feld).value = ordner; if (feld === 'ziel') pruefen(); }
|
||
}
|
||
|
||
async function losgehen() {
|
||
laeuft = true;
|
||
$('knopf-los').disabled = true;
|
||
$('knopf-los').textContent = 'Läuft …';
|
||
$('teil-lauf').classList.remove('weg');
|
||
$('urteil').textContent = 'Rippy wird eingerichtet …';
|
||
await pywebview.api.installieren({
|
||
ziel: $('ziel').value,
|
||
ablage: $('ablage').value,
|
||
port: Number($('port').value),
|
||
autostart: $('autostart').checked,
|
||
verknuepfungen: $('verknuepfungen').checked,
|
||
werkzeuge_holen: $('werkzeuge_holen').checked,
|
||
});
|
||
verfolgen();
|
||
}
|
||
|
||
async function verfolgen() {
|
||
const neue = await pywebview.api.fortschritt();
|
||
if (neue.length) {
|
||
const p = $('protokoll');
|
||
p.textContent += neue.join('\n') + '\n';
|
||
p.scrollTop = p.scrollHeight;
|
||
}
|
||
const stand = await pywebview.api.stand();
|
||
if (stand && stand.ok !== undefined) {
|
||
$('knopf-abbruch').classList.add('weg');
|
||
$('knopf-los').disabled = false;
|
||
$('knopf-los').textContent = 'Rippy öffnen';
|
||
$('knopf-los').onclick = () => pywebview.api.schliessen();
|
||
$('urteil').textContent = stand.ok
|
||
? (stand.bereit ? 'Fertig — Rippy ist einsatzbereit.'
|
||
: 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).')
|
||
: ('Fehlgeschlagen: ' + stand.fehler);
|
||
return;
|
||
}
|
||
setTimeout(verfolgen, 700);
|
||
}
|
||
|
||
window.addEventListener('pywebviewready', async () => {
|
||
const v = await pywebview.api.vorgaben();
|
||
$('ziel').value = v.ziel;
|
||
$('ablage').value = v.ablage;
|
||
$('port').value = v.port;
|
||
$('autostart').checked = v.autostart;
|
||
$('verknuepfungen').checked = v.verknuepfungen;
|
||
$('werkzeuge_holen').checked = v.werkzeuge_holen;
|
||
pruefen();
|
||
$('port').addEventListener('change', pruefen);
|
||
$('ziel').addEventListener('change', pruefen);
|
||
});
|
||
</script>
|
||
</body></html>
|
||
"""
|
||
|
||
# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests).
|
||
SEITE_LAENGE = len(SEITE)
|
||
|
||
|
||
def seite_enthaelt(text: str) -> bool:
|
||
"""Kleine Hilfe für Tests: Steht etwas in der Seite?"""
|
||
return text in SEITE
|