From ea8a04364358125c0984efc46ecf81d982fad451 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 15:38:51 +0200 Subject: [PATCH] feat(setup): Fortschrittsbalken, MakeMKV-Installer mit Rechteabfrage, Updates Drei Punkte des Commanders vom 28.08.2026. ## 1. "bei der installation eine progress bar waere gut" Die Daten dafuer waren schon da -- der Fortschritts-Rueckruf traegt seit jeher einen Anteil, die Bruecke warf ihn nur weg. Jetzt rechnet `Fortschritt` Abschnitt + Teil-Anteil in einen Gesamtstand um. Die Gewichte sind gemessen: Dateien ablegen 0-8 % (rund drei Sekunden), Ablage 8-10 %, Werkzeuge 10-97 % (sechs Sekunden bis mehrere Minuten). Ein Balken, der bei 90 % minutenlang steht, ist schlimmer als gar keiner. Zwei Regeln, beide mit Test: * Er laeuft NIE zurueck. Der Werkzeug-Abschnitt kann mehrere Downloads enthalten, jeder mit eigenem 0-bis-1 -- ohne die Regel spraenge der Balken bei jedem neuen Werkzeug an den Abschnittsanfang. Zusaetzlich rechnet `sicherstellen` den Anteil eines Werkzeugs auf die ganze Strecke um. * Eine Textmeldung ohne Zahl bewegt ihn nicht. Sie ist keine Aussage ueber den Fortschritt. Am laufenden Fenster nachgewiesen: "Werkzeuge werden geprueft -- 36 %", Balken bernsteinfarben, am Ende gruen bei 100 %. ## 2. "make MKV laesst sich nicht installieren weil der vorgang erhoehte ## rechte benoetigt" Der Kern, und der Fehler lag bei uns: `subprocess.Popen` benutzt `CreateProcess`, und das ZEIGT KEINE UAC-Abfrage -- es bricht mit ERROR_ELEVATION_REQUIRED (740) ab. MakeMKV installiert nach Program Files und fordert im Manifest Administratorrechte an. `ShellExecuteW` ist der dokumentierte Weg, der die Abfrage ausloest. Damit braucht NUR dieser eine Aufruf die Erhoehung. Und deshalb laeuft das Setup NICHT dauerhaft erhoeht (dazu die Antwort im Chat): Ein erhoehter Prozess sieht die eingebundenen Netzlaufwerke nicht -- auf diesem Rechner gemessen, X:, Y:, Z: ohne Erhoehung sichtbar, EnableLinkedConnections nicht gesetzt. Genau das hatte der Commander vorher selbst als Fehler gemeldet. Wer trotzdem erhoeht starten will, bekommt einen Knopf -- aber nur, wenn das Schreibrecht am gewaehlten Ziel wirklich fehlt. ## 3. "wenn MakeMKV oder Handbrake schon installiert sind ein update ## verfuegbar ist. Das kann ja direkt im setup abgehandelt werden." `veraltete()` vergleicht nach ZAHLEN (ein Zeichenvergleich hielte 1.9.2 fuer neuer als 1.11.2 -- und HandBrake ist genau dort). `sicherstellen` holt sie auf Wunsch mit, aber IMMER nach den fehlenden: Ein Update ist Komfort, ein fehlendes Pflichtwerkzeug ist ein Hindernis. Der Assistent zeigt es als eigenen Schalter mit Klartext ("MakeMKV 1.18.2 -> 1.18.4"). Der Update-Check laeuft als GETRENNTER Aufruf nach der Voraussetzungs-Pruefung: Er kostet Netz, und makemkv.com braucht dafuer im schlimmsten Fall gut zwanzig Sekunden -- in der Pruefung bliebe das Fenster so lange leer. Sind die Quellen nicht erreichbar, schaltet sich der Haken ab und sagt warum; ein Haken, der nichts tun kann, ist eine Falle. Ampel lokal: 758 gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- src/rippy/platform/winlauf.py | 63 ++++++++ src/rippy/setup_fenster.py | 225 ++++++++++++++++++++++++++--- src/rippy/test_setup_fenster.py | 172 +++++++++++++++++++++- src/rippy/tools/beschaffen.py | 59 ++++++-- src/rippy/tools/einrichten.py | 58 +++++++- src/rippy/tools/test_einrichten.py | 85 +++++++++++ 6 files changed, 626 insertions(+), 36 deletions(-) diff --git a/src/rippy/platform/winlauf.py b/src/rippy/platform/winlauf.py index 2da137b..a6b0db3 100644 --- a/src/rippy/platform/winlauf.py +++ b/src/rippy/platform/winlauf.py @@ -133,6 +133,69 @@ def ausgabe_umleiten(pfad: str = None) -> str: return pfad +def mit_rechteabfrage_starten(programm: str, argumente: str = "") -> bool: + r"""Startet ein Programm über die Shell — mit UAC-Abfrage, falls nötig. + + Der Unterschied zu `subprocess.Popen` ist der ganze Punkt: `Popen` benutzt + `CreateProcess`, und das **zeigt keine UAC-Abfrage**. Verlangt das Ziel im + Manifest Administratorrechte, bricht es mit `ERROR_ELEVATION_REQUIRED` + (740) ab. Genau daran ist am 28.08.2026 der MakeMKV-Installer gescheitert. + + Ohne Verb: Windows entscheidet selbst, ob eine Abfrage nötig ist. Ein + festes „runas" würde auch dort erhöhen, wo es nicht gebraucht wird. + """ + if os.name != "nt": + return False + import ctypes + + SW_SHOWNORMAL = 1 + try: + ergebnis = ctypes.WinDLL("shell32").ShellExecuteW( + None, None, programm, argumente or None, None, SW_SHOWNORMAL) + except (AttributeError, OSError): + return False + # Über 32 heißt Erfolg (dokumentierter Rückgabewert von ShellExecute). + # 5 = ACCESS_DENIED: Der Nutzer hat die UAC-Abfrage weggeklickt. + return int(ergebnis) > 32 + + +def als_admin_neu_starten(programm: str, argumente: str = "") -> bool: + r"""Startet ein Programm mit Administratorrechten neu (löst UAC aus). + + True heißt: Windows hat den Start angenommen. False heißt: Der Nutzer hat + die Abfrage abgebrochen oder es ging nicht — dann läuft der aufrufende + Prozess einfach weiter, statt sich zu beenden. + + ## Warum das NICHT der Normalfall ist (gemessen 28.08.2026) + + Ein erhöhter Prozess bekommt ein anderes Zugriffstoken, und eingebundene + Netzlaufwerke hängen am Token der SITZUNG. Auf diesem Rechner gemessen: + + ohne Erhöhung X:, Y:, Z: sichtbar + EnableLinkedConnections nicht gesetzt (Windows-Standard) + + Erhöht wären die drei also weg — genau der Befund, den der Commander + gemeldet hat („als Admin kann man kein netzlaufwerk wählen"). Deshalb ist + das hier ein Knopf und keine Voreinstellung: Wer nach `Programme` + installieren will, braucht ihn; wer eine Freigabe als Ablage will, darf + ihn NICHT drücken. + """ + if os.name != "nt": + return False + import ctypes + + SW_SHOWNORMAL = 1 + try: + # ShellExecuteW mit dem Verb „runas" ist der dokumentierte Weg. Ein + # Rückgabewert über 32 heißt Erfolg; 5 (ACCESS_DENIED) heißt: Der + # Nutzer hat die UAC-Abfrage weggeklickt. + ergebnis = ctypes.WinDLL("shell32").ShellExecuteW( + None, "runas", programm, argumente or None, None, SW_SHOWNORMAL) + except (AttributeError, OSError): + return False + return int(ergebnis) > 32 + + def meldung_zeigen(text: str, titel: str = "Rippy", fehler: bool = False) -> bool: """Ein Meldungsfenster. True, wenn es wirklich gezeigt wurde. diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py index d0ac816..1695fe5 100644 --- a/src/rippy/setup_fenster.py +++ b/src/rippy/setup_fenster.py @@ -38,6 +38,66 @@ BREITE = 860 HOEHE = 760 +# Die Abschnitte der Installation und was sie am Balken belegen. +# +# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert +# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen +# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der +# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei +# 90 % minutenlang steht, ist schlimmer als gar keiner. +ABSCHNITTE = { + "dateien": (0.00, 0.08, "Dateien werden abgelegt"), + "ablage": (0.08, 0.10, "Ablage wird gespeichert"), + "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), + "fertig": (0.97, 1.00, "Fertig"), +} + + +class Fortschritt: + """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. + + Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein + Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als + keiner. + + Zwei Regeln: + + * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads + enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der + Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. + * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine + Aussage über den Fortschritt. + """ + + def __init__(self): + self.prozent = 0 + self.abschnitt = "" + self.zeilen = [] + + def beginne(self, name: str) -> None: + von, _bis, titel = ABSCHNITTE[name] + self.abschnitt = titel + self._von, self._bis, _ = ABSCHNITTE[name] + self.setze(von) + + def melde(self, text, anteil=None) -> None: + if text: + self.zeilen.append(str(text)) + if anteil is None: + return + anteil = min(1.0, max(0.0, float(anteil))) + self.setze(self._von + (self._bis - self._von) * anteil) + + def setze(self, anteil: float) -> None: + self.prozent = max(self.prozent, int(round(anteil * 100))) + + def abholen(self) -> dict: + """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" + zeilen, self.zeilen = self.zeilen, [] + return {"zeilen": zeilen, "prozent": self.prozent, + "abschnitt": self.abschnitt} + + class Bruecke: """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. @@ -48,7 +108,7 @@ class Bruecke: def __init__(self): self.ergebnis = None self._fenster = None - self._fortschritt = [] + self._lauf = Fortschritt() # ── Auskunft ──────────────────────────────────────────────────────── def vorgaben(self) -> dict: @@ -65,6 +125,38 @@ class Bruecke: "text": einrichtung.zusammenfassung(befunde), } + def updates(self) -> dict: + """Gibt es für die installierten Werkzeuge etwas Neueres? + + ## Warum das ein EIGENER Aufruf ist (28.08.2026) + + Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon + installiert sind ein update verfügbar ist."* + + Die Auskunft kostet Netz — und makemkv.com braucht dafür im + schlimmsten Fall gut zwanzig Sekunden (gemessen). In der + Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. + Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und + diese Auskunft trägt die Update-Hinweise nach. + + Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine + Nichtauskunft ist keine Aussage. + """ + from rippy.tools import beschaffen, einrichten as werkzeuge, katalog + + try: + bestand = katalog.bestand() + neueste = beschaffen.neueste_versionen() + except Exception: # noqa: BLE001 + return {"erreichbar": False, "offen": []} + + offen = [{"name": name, + "titel": eintrag.get("titel", name), + "installiert": eintrag.get("version", ""), + "neueste": neu} + for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] + return {"erreichbar": any(neueste.values()), "offen": offen} + def ordner_waehlen(self, vorschlag: str = "") -> str: """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" import webview @@ -78,10 +170,14 @@ class Bruecke: 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 + def fortschritt(self) -> dict: + """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. + + Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, + und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text + und Stand auseinanderlaufen. + """ + return self._lauf.abholen() # ── Handeln ───────────────────────────────────────────────────────── def installieren(self, optionen: dict) -> dict: @@ -98,10 +194,10 @@ class Bruecke: def _installieren_wirklich(self, optionen: dict) -> None: from rippy import windows_app - def melden(text, anteil=None): - self._fortschritt.append(str(text)) + melden = self._lauf.melde try: + self._lauf.beginne("dateien") melden("Dateien werden abgelegt …") ziel = windows_app.installieren( ziel=optionen.get("ziel") or None, @@ -110,19 +206,24 @@ class Bruecke: verknuepfen=bool(optionen.get("verknuepfungen", True)), ) if optionen.get("ablage"): + self._lauf.beginne("ablage") melden("Ablage wird gespeichert …") _ablage_speichern(optionen["ablage"]) + self._lauf.beginne("werkzeuge") 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) + bericht = werkzeug_einrichtung.sicherstellen( + fortschritt=melden, + aktualisieren=bool(optionen.get("aktualisieren", False))) melden(werkzeug_einrichtung.bericht(bericht)) bereit = bericht["bereit"] else: bereit = True + self._lauf.beginne("fertig") self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} - melden("Fertig.") + melden("Fertig.", 1.0) 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 @@ -135,6 +236,26 @@ class Bruecke: """None, solange es läuft. Danach das Ergebnis.""" return self.ergebnis or {} + def als_admin(self) -> dict: + """Startet das Setup mit Administratorrechten neu. + + Nur auf Knopfdruck, nie von selbst. Der Grund steht in + `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen + Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das + genau der falsche Weg. + + Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der + Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt + das Fenster einfach offen. + """ + from rippy import windows_app + from rippy.platform import winlauf + + geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) + if geklappt: + self.schliessen() + return {"gestartet": geklappt} + def schliessen(self) -> None: try: self._fenster.destroy() @@ -251,6 +372,14 @@ SEITE = r""" display:flex; align-items:center; gap:16px; backdrop-filter:blur(6px); } #urteil { flex:1; font-size:12.5px; color:#94a3b8; } + .balken-rahmen { height:9px; background:#111a2b; border-radius:99px; + overflow:hidden; margin:2px 0 10px; } + #balken { height:100%; width:0%; border-radius:99px; background:#f59e0b; + transition:width .35s ease; } + .balken-zeile { display:flex; justify-content:space-between; + font-size:12.5px; color:#cbd5e1; margin-bottom:4px; } + #prozent { font-family:ui-monospace,Consolas,monospace; color:#f59e0b; + font-variant-numeric:tabular-nums; } #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; @@ -293,16 +422,24 @@ SEITE = r""" + Fehlende Werkzeuge holen
HandBrake liegt bei. MakeMKV wird von makemkv.com geladen und mit dem Installer des Herstellers installiert — dafür fragt Windows einmal nach Administratorrechten. Ohne MakeMKV kann Rippy keine Disc lesen.
+

Läuft

+
+ wird vorbereitet … + 0 % +
+
@@ -321,6 +458,43 @@ SEITE = r""" + '').join(''); $('urteil').textContent = bericht.text; $('knopf-los').disabled = !bericht.darf || laeuft; + + /* Der Knopf für Administratorrechte erscheint NUR, wenn das Schreibrecht + am gewählten Ziel fehlt. Nicht dauerhaft: Erhöht sieht Rippy die + eingebundenen Netzlaufwerke nicht mehr — wer eine Freigabe als Ablage + will, darf ihn gerade NICHT drücken. */ + const rechteProblem = bericht.befunde.some( + b => b.name === 'Schreibrecht' && b.zustand === 'fehler'); + $('knopf-admin').classList.toggle('weg', !rechteProblem || laeuft); + } + + async function updatesNachtragen() { + /* Getrennt vom Voraussetzungs-Bericht: Die Auskunft kostet Netz, und + makemkv.com braucht dafuer im schlimmsten Fall gut zwanzig Sekunden. + Solange steht "wird geprueft" da, statt das Fenster leer zu lassen. */ + const stand = await pywebview.api.updates(); + const hinweis = $('update-hinweis'); + if (!stand.erreichbar) { + hinweis.textContent = 'Update-Quellen nicht erreichbar — es wird nichts aktualisiert.'; + $('aktualisieren').checked = false; + return; + } + if (!stand.offen.length) { + hinweis.textContent = 'Alles auf dem neuesten Stand.'; + return; + } + hinweis.textContent = stand.offen + .map(o => o.titel + ' ' + o.installiert + ' → ' + o.neueste) + .join(' · '); + } + + async function alsAdmin() { + $('knopf-admin').disabled = true; + const antwort = await pywebview.api.als_admin(); + if (!antwort.gestartet) { + $('knopf-admin').disabled = false; + $('urteil').textContent = 'Der Neustart als Administrator wurde abgebrochen.'; + } } async function pruefen() { @@ -346,27 +520,39 @@ SEITE = r""" autostart: $('autostart').checked, verknuepfungen: $('verknuepfungen').checked, werkzeuge_holen: $('werkzeuge_holen').checked, + aktualisieren: $('aktualisieren').checked, }); verfolgen(); } async function verfolgen() { - const neue = await pywebview.api.fortschritt(); - if (neue.length) { + const takt = await pywebview.api.fortschritt(); + if (takt.zeilen && takt.zeilen.length) { const p = $('protokoll'); - p.textContent += neue.join('\n') + '\n'; + p.textContent += takt.zeilen.join('\n') + '\n'; p.scrollTop = p.scrollHeight; } - const stand = await pywebview.api.stand(); - if (stand && stand.ok !== undefined) { + if (typeof takt.prozent === 'number') { + $('balken').style.width = takt.prozent + '%'; + $('prozent').textContent = takt.prozent + ' %'; + } + if (takt.abschnitt) $('abschnitt').textContent = takt.abschnitt; + + const ergebnis = await pywebview.api.stand(); + if (ergebnis && ergebnis.ok !== undefined) { + // Am Ende ist der Balken voll — und die Farbe sagt, wie es ausging. + $('balken').style.width = '100%'; + $('prozent').textContent = '100 %'; + $('balken').style.background = ergebnis.ok ? '#34d399' : '#f87171'; + $('abschnitt').textContent = ergebnis.ok ? 'Fertig' : 'Abgebrochen'; $('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); + $('urteil').textContent = ergebnis.ok + ? (ergebnis.bereit ? 'Fertig — Rippy ist einsatzbereit.' + : 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).') + : ('Fehlgeschlagen: ' + ergebnis.fehler); return; } setTimeout(verfolgen, 700); @@ -381,6 +567,7 @@ SEITE = r""" $('verknuepfungen').checked = v.verknuepfungen; $('werkzeuge_holen').checked = v.werkzeuge_holen; pruefen(); + updatesNachtragen(); $('port').addEventListener('change', pruefen); $('ziel').addEventListener('change', pruefen); }); diff --git a/src/rippy/test_setup_fenster.py b/src/rippy/test_setup_fenster.py index 7896cc3..c80ff76 100644 --- a/src/rippy/test_setup_fenster.py +++ b/src/rippy/test_setup_fenster.py @@ -67,9 +67,10 @@ def test_gewaehlter_ordner_kommt_als_text(): def test_fortschritt_leert_sich_beim_abholen(): """Sonst waechst das Protokoll bei jedem Abruf um alles Bisherige.""" b = setup_fenster.Bruecke() - b._fortschritt = ["eins", "zwei"] - assert b.fortschritt() == ["eins", "zwei"] - assert b.fortschritt() == [] + b._lauf.melde("eins") + b._lauf.melde("zwei") + assert b.fortschritt()["zeilen"] == ["eins", "zwei"] + assert b.fortschritt()["zeilen"] == [] def test_stand_ist_leer_solange_nichts_fertig_ist(): @@ -90,7 +91,7 @@ def test_ein_fehlschlag_beim_installieren_wird_SICHTBAR(monkeypatch): assert b.ergebnis["ok"] is False assert "Zugriff verweigert" in b.ergebnis["fehler"] - assert any("Fehlgeschlagen" in z for z in b._fortschritt) + assert any("Fehlgeschlagen" in z for z in b._lauf.zeilen) def test_erfolgreiche_installation_meldet_den_ordner(monkeypatch): @@ -120,3 +121,166 @@ def test_werkzeuge_koennen_uebersprungen_werden(monkeypatch): def test_schliessen_ohne_fenster_wirft_nicht(): setup_fenster.Bruecke().schliessen() + + +# -- Der Fortschrittsbalken --------------------------------------------- +def test_der_balken_faengt_bei_null_an(): + f = setup_fenster.Fortschritt() + assert f.abholen() == {"zeilen": [], "prozent": 0, "abschnitt": ""} + + +def test_ein_abschnitt_setzt_den_anfang(): + f = setup_fenster.Fortschritt() + f.beginne("werkzeuge") + stand = f.abholen() + assert stand["prozent"] == 10 + assert "Werkzeuge" in stand["abschnitt"] + + +def test_der_anteil_wird_in_den_abschnitt_gerechnet(): + """Werkzeuge belegen 10 bis 97 Prozent. Halb fertig heisst also rund 54.""" + f = setup_fenster.Fortschritt() + f.beginne("werkzeuge") + f.melde("laeuft", 0.5) + assert 50 <= f.prozent <= 58, f.prozent + + +def test_der_balken_laeuft_NIE_zurueck(): + """Der Werkzeug-Abschnitt kann mehrere Downloads enthalten, jeder mit + eigenem 0-bis-1. Ohne diese Regel spraenge der Balken bei jedem neuen + Werkzeug zurueck an den Abschnittsanfang.""" + f = setup_fenster.Fortschritt() + f.beginne("werkzeuge") + f.melde("erstes Werkzeug fertig", 0.9) + hoch = f.prozent + f.melde("zweites Werkzeug faengt an", 0.0) + assert f.prozent == hoch, "der Balken ist zurueckgesprungen" + + +def test_eine_meldung_ohne_zahl_bewegt_nichts(): + """Eine Textmeldung ist keine Aussage ueber den Fortschritt.""" + f = setup_fenster.Fortschritt() + f.beginne("dateien") + f.melde("irgendwas passiert") + assert f.prozent == 0 + + +def test_zeilen_leeren_sich_beim_abholen_der_stand_nicht(): + f = setup_fenster.Fortschritt() + f.beginne("werkzeuge") + f.melde("eins", 0.5) + erst = f.abholen() + zweit = f.abholen() + assert erst["zeilen"] == ["eins"] + assert zweit["zeilen"] == [] + assert zweit["prozent"] == erst["prozent"], "der Stand darf nicht vergessen werden" + + +def test_ein_verrutschter_anteil_sprengt_nichts(): + f = setup_fenster.Fortschritt() + f.beginne("dateien") + f.melde("x", 5.0) + assert f.prozent <= 100 + f.melde("y", -1.0) + assert f.prozent <= 100 + + +def test_die_abschnitte_ueberlappen_nicht_und_enden_bei_hundert(): + grenzen = sorted(setup_fenster.ABSCHNITTE.values()) + assert grenzen[0][0] == 0.0 + assert grenzen[-1][1] == 1.0 + for (_, ende_a, _), (von_b, _, _) in zip(grenzen, grenzen[1:]): + assert abs(ende_a - von_b) < 1e-9, (ende_a, von_b) + + +def test_die_seite_zeigt_einen_balken(): + assert setup_fenster.seite_enthaelt('id="balken"') + assert setup_fenster.seite_enthaelt("prozent") + + +# -- Administratorrechte auf Abruf -------------------------------------- +def test_der_admin_knopf_ist_da_aber_nicht_vorgewaehlt(): + """Commander: "lass das setup sonst immer mit erhoehten rechten starten". + + Immer erhoeht waere genau der Fehler, den er selbst gemeldet hat: Ein + erhoehter Prozess sieht die eingebundenen Netzlaufwerke NICHT (auf diesem + Rechner gemessen: X:, Y:, Z: ohne Erhoehung sichtbar, + EnableLinkedConnections nicht gesetzt). Deshalb ein Knopf statt einer + Voreinstellung -- und der ist anfangs verborgen.""" + assert setup_fenster.seite_enthaelt('id="knopf-admin"') + assert setup_fenster.seite_enthaelt('class="weg"') + assert setup_fenster.seite_enthaelt("pywebview.api.als_admin") + + +def test_der_admin_knopf_erscheint_nur_beim_rechteproblem(): + """Sonst laedt er dazu ein, sich die Netzlaufwerke wegzunehmen.""" + assert setup_fenster.seite_enthaelt("b.name === 'Schreibrecht'") + assert setup_fenster.seite_enthaelt("b.zustand === 'fehler'") + + +def test_ein_abgebrochener_uac_dialog_schliesst_das_fenster_nicht(monkeypatch): + """Wer die UAC-Abfrage wegklickt, will weitermachen -- nicht von vorn + anfangen.""" + from rippy.platform import winlauf + + monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: False) + b = setup_fenster.Bruecke() + b._fenster = LeeresFenster() + assert b.als_admin() == {"gestartet": False} + assert b._fenster.zerstoert is False + + +def test_ein_angenommener_neustart_schliesst_das_fenster(monkeypatch): + from rippy.platform import winlauf + + monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: True) + b = setup_fenster.Bruecke() + b._fenster = LeeresFenster() + assert b.als_admin() == {"gestartet": True} + assert b._fenster.zerstoert is True + + +# -- Updates im Setup ---------------------------------------------------- +def test_die_seite_bietet_updates_an(): + """Commander: "bitte baue ein, das wenn MakeMKV oder Handbrake schon + installiert sind ein update verfuegbar ist. Das kann ja theoretisch direkt + im setup abgehandelt werden.\"""" + assert setup_fenster.seite_enthaelt('id="aktualisieren"') + assert setup_fenster.seite_enthaelt("pywebview.api.updates") + assert setup_fenster.seite_enthaelt("aktualisieren: $('aktualisieren').checked") + + +def test_nicht_erreichbare_quellen_schalten_das_update_AB(): + """Eine Nichtauskunft ist keine Aussage -- und ein Haken, der nichts tun + kann, ist eine Falle.""" + assert setup_fenster.seite_enthaelt("stand.erreichbar") + assert setup_fenster.seite_enthaelt("$('aktualisieren').checked = false") + + +def test_updates_ohne_netz_melden_nicht_erreichbar(monkeypatch): + from rippy.tools import beschaffen + + def platzt(): + raise OSError("kein Netz") + + monkeypatch.setattr(beschaffen, "neueste_versionen", platzt) + stand = setup_fenster.Bruecke().updates() + assert stand == {"erreichbar": False, "offen": []} + + +def test_updates_melden_was_offen_ist(monkeypatch): + from rippy.tools import beschaffen, katalog + + monkeypatch.setattr(katalog, "bestand", lambda *a, **k: { + "makemkv": {"titel": "MakeMKV", "da": True, "version": "1.18.2", + "pflicht": True}, + "handbrake": {"titel": "HandBrakeCLI", "da": True, "version": "1.11.2", + "pflicht": False}}) + monkeypatch.setattr(beschaffen, "neueste_versionen", + lambda: {"makemkv": "1.18.4", "handbrake": "1.11.2"}) + + stand = setup_fenster.Bruecke().updates() + assert stand["erreichbar"] is True + assert [o["titel"] for o in stand["offen"]] == ["MakeMKV"] + assert stand["offen"][0]["installiert"] == "1.18.2" + assert stand["offen"][0]["neueste"] == "1.18.4" diff --git a/src/rippy/tools/beschaffen.py b/src/rippy/tools/beschaffen.py index a71cf78..e4771ac 100644 --- a/src/rippy/tools/beschaffen.py +++ b/src/rippy/tools/beschaffen.py @@ -505,21 +505,62 @@ def makemkv_holen(version: str = "", basis: str = None, fortschritt=None, "Rippy findet es danach von allein.") -def starte_installer(pfad: str, still: bool = True) -> None: - """Startet eine Installationsdatei — auf Wunsch ohne Rückfragen. +def starte_installer(pfad: str, still: bool = True, starter=None) -> None: + r"""Startet eine Installationsdatei — auf Wunsch ohne Rückfragen. `/S` ist der stille Modus von NSIS, mit dem MakeMKVs Windows-Installer gebaut ist. Ohne Kennzeichen erscheint der gewohnte Dialog; das ist der ehrlichere Vorgabewert, wenn jemand zusieht. - """ - from rippy.platform.winlauf import OHNE_FENSTER - befehl = [pfad] + (["/S"] if still else []) - try: - subprocess.Popen(befehl, creationflags=OHNE_FENSTER, close_fds=True) - except OSError as e: + ## Warum ShellExecute und NICHT subprocess.Popen (28.08.2026) + + Der Commander: *„Das Problem ist, das make MKV sich nicht installieren + lässt weil der vorgang erhöhte rechte benötigt."* + + Er hat den Kern getroffen, und der Fehler lag hier. MakeMKV installiert + nach `C:\Program Files (x86)` — sein Installer fordert im Manifest + Administratorrechte an. `subprocess.Popen` benutzt `CreateProcess`, und + das **zeigt keine UAC-Abfrage**: Es bricht mit `ERROR_ELEVATION_REQUIRED` + (740) ab. `ShellExecuteW` ist der dokumentierte Weg, der die Abfrage + auslöst. + + ## Warum trotzdem nicht das ganze Setup erhöht läuft + + Weil ein erhöhter Prozess die eingebundenen Netzlaufwerke NICHT sieht — + auf diesem Rechner gemessen: X:, Y:, Z: ohne Erhöhung sichtbar, + `EnableLinkedConnections` nicht gesetzt. Der Commander hat genau das + vorher als Fehler gemeldet. Erhöht wird deshalb nur DIESER eine Aufruf: + eine UAC-Abfrage für MakeMKVs Installer, und Rippy behält seine + Laufwerke. + + `starter` ist einspritzbar, damit der Weg prüfbar ist, ohne etwas zu + installieren. + """ + if starter is not None: + if not starter(pfad, "/S" if still else ""): + raise BeschaffungsFehler( + "Der Installer wurde nicht gestartet (Abfrage abgebrochen?).") + return + + if os.name != "nt": + from rippy.platform.winlauf import OHNE_FENSTER + + try: + subprocess.Popen([pfad] + (["/S"] if still else []), + creationflags=OHNE_FENSTER, close_fds=True) + except OSError as e: + raise BeschaffungsFehler( + f"Der Installer ließ sich nicht starten: {e}") from e + return + + from rippy.platform import winlauf + + if not winlauf.mit_rechteabfrage_starten(pfad, "/S" if still else ""): raise BeschaffungsFehler( - f"Der Installer ließ sich nicht starten: {e}") from e + 'Der Installer von MakeMKV wurde nicht gestartet. Er braucht ' + 'Administratorrechte (er installiert nach „Programme") — die ' + 'Abfrage von Windows muss bestätigt werden. Rippy selbst braucht ' + 'sie nicht.') # ── Gemeinsam ─────────────────────────────────────────────────────────── diff --git a/src/rippy/tools/einrichten.py b/src/rippy/tools/einrichten.py index e438c0c..cea7195 100644 --- a/src/rippy/tools/einrichten.py +++ b/src/rippy/tools/einrichten.py @@ -56,9 +56,31 @@ def einsatzbereit(bestand: dict) -> bool: return all(e.get("da") for e in bestand.values() if e.get("pflicht")) +def veraltete(bestand: dict, neueste: dict) -> list: + """Welche Werkzeuge sind DA, aber überholt? (reine Funktion) + + Commander am 28.08.2026: *„bitte baue ein, das wenn MakeMKV oder Handbrake + schon installiert sind ein update verfügbar ist. Das kann ja theoretisch + direkt im setup abgehandelt werden."* + + Verglichen wird über `beschaffen.ist_neuer` — also nach ZAHLEN. Ein + Zeichenvergleich hielte „1.9.2" für neuer als „1.18.4", und HandBrake ist + genau dort. + """ + offen = [] + for name, eintrag in sorted((bestand or {}).items()): + if not eintrag.get("da"): + continue + neu = (neueste or {}).get(name, "") + if beschaffen.ist_neuer(neu, eintrag.get("version", "")): + offen.append((name, eintrag, neu)) + return offen + + def sicherstellen(fortschritt=None, eingestellt: dict = None, handbrake_holen=None, makemkv_holen=None, - bestand_lesen=None) -> dict: + bestand_lesen=None, aktualisieren: bool = False, + neueste_lesen=None) -> dict: """Holt, was fehlt. Gibt zurück, was DANACH wirklich da ist. Die drei Funktionen sind einspritzbar — so ist der ganze Ablauf prüfbar, @@ -82,18 +104,46 @@ def sicherstellen(fortschritt=None, eingestellt: dict = None, vorher = bestand_lesen() geholt, fehler = [], {} - for name, eintrag in fehlende(vorher): + offen = fehlende(vorher) + + # Auf Wunsch auch das, was DA ist, aber überholt. Absichtlich hinter den + # fehlenden: Ein Update ist Komfort, ein fehlendes Pflichtwerkzeug ist ein + # Hindernis — bei wackliger Verbindung soll zuerst ankommen, was zählt. + if aktualisieren: + neueste_lesen = neueste_lesen or beschaffen.neueste_versionen + try: + neueste = neueste_lesen() + except Exception: # noqa: BLE001 + # Ein Update-Check ist Komfort; er darf nichts blockieren. + neueste = {} + for name, eintrag, neu in veraltete(vorher, neueste): + melden("%s %s -> %s wird aktualisiert …" + % (eintrag.get("titel", name), eintrag.get("version", "?"), neu)) + offen.append((name, eintrag)) + for nummer, (name, eintrag) in enumerate(offen): titel = eintrag.get("titel", name) + + def teil_melden(text, anteil=None, _n=nummer, _gesamt=len(offen)): + """Den Anteil EINES Werkzeugs auf die ganze Strecke umrechnen. + + Ohne das meldet jeder Download 0 bis 1, und ein Fortschrittsbalken + spränge bei jedem neuen Werkzeug zurück an den Anfang. Zwei + Werkzeuge: das erste füllt 0–50 %, das zweite 50–100 %. + """ + if fortschritt: + fortschritt(text, None if anteil is None + else (_n + anteil) / max(1, _gesamt)) + melden("%s fehlt — wird geholt …" % titel) try: if name == "handbrake": - handbrake_holen(fortschritt=fortschritt) + handbrake_holen(fortschritt=teil_melden) elif name == "makemkv": # still=False: Der Nutzer SOLL den Installer von MakeMKV # sehen. Ein fremdes Programm still im Hintergrund zu # installieren, ist ein Ubergriff — und wenn es hakt, sieht # er wenigstens, woran. - makemkv_holen(fortschritt=fortschritt, still=False) + makemkv_holen(fortschritt=teil_melden, still=False) else: continue except Exception as e: # noqa: BLE001 diff --git a/src/rippy/tools/test_einrichten.py b/src/rippy/tools/test_einrichten.py index b819626..fe509ed 100644 --- a/src/rippy/tools/test_einrichten.py +++ b/src/rippy/tools/test_einrichten.py @@ -184,3 +184,88 @@ def test_eine_neuere_fassung_wird_nicht_zurueckgedreht(tmp_path): def test_ohne_mitgeliefertes_passiert_nichts(tmp_path): assert einrichten.mitgelieferte_werkzeuge( str(tmp_path / "gibt-es-nicht"), str(tmp_path)) == [] + + +# ── Updates schon vorhandener Werkzeuge ───────────────────────────────── +# +# Commander am 28.08.2026: "bitte baue ein, das wenn MakeMKV oder Handbrake +# schon installiert sind ein update verfuegbar ist. Das kann ja theoretisch +# direkt im setup abgehandelt werden." +def test_ueberholtes_werkzeug_wird_erkannt(): + veraltet = einrichten.veraltete(_bestand(), + {"makemkv": "1.19.0", "handbrake": "1.11.2"}) + assert [n for n, _, _ in veraltet] == ["makemkv"] + assert veraltet[0][2] == "1.19.0" + + +def test_zweistellige_versionen_kippen_nicht(): + """DER Klassiker: Als Zeichenkette waere 1.9.2 groesser als 1.11.2 -- + und HandBrake ist genau dort.""" + bestand = {"handbrake": {"titel": "HandBrakeCLI", "da": True, + "version": "1.11.2", "pflicht": False}} + assert einrichten.veraltete(bestand, {"handbrake": "1.9.2"}) == [] + assert len(einrichten.veraltete(bestand, {"handbrake": "1.11.3"})) == 1 + + +def test_ohne_auskunft_gibt_es_kein_update(): + """Antwortet die Quelle nicht, darf NICHT 'Update verfuegbar' dastehen. + Eine Nichtauskunft ist keine Aussage.""" + assert einrichten.veraltete(_bestand(), {}) == [] + assert einrichten.veraltete(_bestand(), {"makemkv": ""}) == [] + + +def test_fehlende_werkzeuge_zaehlen_hier_nicht_mit(): + """Die holt `fehlende()` -- sonst stuende ein fehlendes Werkzeug zweimal + in der Liste und wuerde zweimal geholt.""" + assert einrichten.veraltete(_bestand(makemkv=False), + {"makemkv": "1.19.0"}) == [] + + +def test_ohne_flag_wird_NICHT_aktualisiert(): + """Eine unbeaufsichtigte Installation soll nicht ploetzlich Downloads + anstossen.""" + gerufen = [] + einrichten.sicherstellen( + bestand_lesen=lambda: _bestand(), + neueste_lesen=lambda: {"makemkv": "1.19.0"}, + handbrake_holen=lambda **k: gerufen.append("hb"), + makemkv_holen=lambda **k: gerufen.append("mkv")) + assert gerufen == [] + + +def test_mit_flag_wird_aktualisiert(): + gerufen = [] + einrichten.sicherstellen( + aktualisieren=True, + bestand_lesen=lambda: _bestand(), + neueste_lesen=lambda: {"makemkv": "1.19.0", "handbrake": "1.11.2"}, + handbrake_holen=lambda **k: gerufen.append("hb"), + makemkv_holen=lambda **k: gerufen.append("mkv")) + assert gerufen == ["mkv"] + + +def test_fehlendes_kommt_VOR_dem_update(): + """Bei wackliger Verbindung soll zuerst ankommen, was zaehlt: Ein + fehlendes Pflichtwerkzeug ist ein Hindernis, ein Update ist Komfort.""" + reihenfolge = [] + einrichten.sicherstellen( + aktualisieren=True, + bestand_lesen=lambda: _bestand(makemkv=False), + neueste_lesen=lambda: {"handbrake": "1.99.0"}, + handbrake_holen=lambda **k: reihenfolge.append("handbrake"), + makemkv_holen=lambda **k: reihenfolge.append("makemkv")) + assert reihenfolge == ["makemkv", "handbrake"] + + +def test_ein_kaputter_update_check_bricht_nichts_ab(): + """Ein Update-Check ist Komfort; er darf nichts blockieren.""" + def platzt(): + raise OSError("kein Netz") + + ergebnis = einrichten.sicherstellen( + aktualisieren=True, + bestand_lesen=lambda: _bestand(), + neueste_lesen=platzt, + handbrake_holen=lambda **k: None, + makemkv_holen=lambda **k: None) + assert ergebnis["bereit"] is True