feat(setup): Fortschrittsbalken, MakeMKV-Installer mit Rechteabfrage, Updates
Ampel / ampel (push) Successful in 55s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 15:38:51 +02:00
co-authored by Claude Opus 5
parent 95beb91364
commit ea8a043643
6 changed files with 626 additions and 36 deletions
+63
View File
@@ -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.
+205 -18
View File
@@ -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"""<!DOCTYPE html>
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"""<!DOCTYPE html>
<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>
<span>Fehlende Werkzeuge holen<div class="hinweis">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.</div></span></label>
<label class="schalter"><input type="checkbox" id="aktualisieren" checked>
<span>Vorhandene Werkzeuge aktualisieren<div class="hinweis" id="update-hinweis">wird geprüft …</div></span></label>
</section>
<section id="teil-lauf" class="weg">
<h2>Läuft</h2>
<div class="balken-zeile">
<span id="abschnitt">wird vorbereitet …</span>
<span id="prozent">0 %</span>
</div>
<div class="balken-rahmen"><div id="balken"></div></div>
<div id="protokoll"></div>
</section>
</main>
<footer>
<div id="urteil">wird geprüft …</div>
<button id="knopf-admin" class="weg" onclick="alsAdmin()">Als Administrator neu starten</button>
<button id="knopf-abbruch" onclick="pywebview.api.schliessen()">Abbrechen</button>
<button id="knopf-los" class="los" onclick="losgehen()" disabled>Installieren</button>
</footer>
@@ -321,6 +458,43 @@ SEITE = r"""<!DOCTYPE html>
+ '</div></div>').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"""<!DOCTYPE html>
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.'
$('urteil').textContent = ergebnis.ok
? (ergebnis.bereit ? 'Fertig — Rippy ist einsatzbereit.'
: 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).')
: ('Fehlgeschlagen: ' + stand.fehler);
: ('Fehlgeschlagen: ' + ergebnis.fehler);
return;
}
setTimeout(verfolgen, 700);
@@ -381,6 +567,7 @@ SEITE = r"""<!DOCTYPE html>
$('verknuepfungen').checked = v.verknuepfungen;
$('werkzeuge_holen').checked = v.werkzeuge_holen;
pruefen();
updatesNachtragen();
$('port').addEventListener('change', pruefen);
$('ziel').addEventListener('change', pruefen);
});
+168 -4
View File
@@ -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"
+45 -4
View File
@@ -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.
## 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
befehl = [pfad] + (["/S"] if still else [])
try:
subprocess.Popen(befehl, creationflags=OHNE_FENSTER, close_fds=True)
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(
'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 ───────────────────────────────────────────────────────────
+54 -4
View File
@@ -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 050 %, das zweite 50100 %.
"""
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
+85
View File
@@ -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