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