fix(v5): MakeMKV-Installer aus dem Internet Archive nur nach Rückfrage, mit Größe und SHA-256

WAS: makemkvInstallerHolen fragt vor der Archiv-Quelle über eine
Freigabe-Funktion; ohne Freigabe wird das Archiv übersprungen und im
Bericht genannt. Der Kern stellt die Frage ans Fenster (beschaffung-status
mit `frage`), die MakeMKV-Karte zeigt sie mit „Archiv-Kopie laden" /
„Lieber nicht" (beschaffung-antwort); ohne Antwort in zehn Minuten gilt
Nein. Die geladene Datei wird mit Größe und SHA-256 gemeldet.
WARUM: Durchsicht 12.09.2026, Fund F16 (Commander-Entscheid: Archiv nur
nach Rückfrage) — eine unsignierte EXE von fremder Hand, die mit
Adminrechten läuft, entscheidet der Nutzer, nicht Rippy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-12 21:43:13 +02:00
co-authored by Claude Opus 5
parent 3dacd7cb4f
commit ae82d8c0fd
7 changed files with 140 additions and 9 deletions
@@ -22,6 +22,7 @@ import {
} from '../bausteine'
import {
benachrichtigungTesten,
beschaffungAntworten,
einstellungSetzen,
fremdenOrdnerLoeschen,
kompressionAbbrechen,
@@ -480,6 +481,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
{b !== null && (
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
{b.frage !== undefined && b.frage.length > 0 && (
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
Archiv-Kopie laden
</button>
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
Lieber nicht
</button>
</div>
</div>
)}
{b.laeuft && b.anteil !== null && (
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
@@ -381,6 +381,11 @@ export function schluesselPruefen(): void {
senden({ art: 'schluessel-pruefen' })
}
/** 5.7.0 (F16): Ja/Nein auf eine Frage der MakeMKV-Beschaffung. */
export function beschaffungAntworten(ja: boolean): void {
anKern({ art: 'beschaffung-antwort', ja }, 'Antwort an die Beschaffung')
}
export function einstellungSetzen(schluessel: string, wert: string): void {
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
}
@@ -257,6 +257,9 @@ export interface BeschaffungStand {
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
/** 5.7.0 (F16): Rippy wartet auf ein Ja/Nein — z. B. „Archiv-Kopie laden?".
* Fehlt: keine Frage offen. */
frage?: string
}
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
@@ -450,6 +453,8 @@ export type FensterNachricht =
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
/** 5.7.0 (F16): Antwort auf eine Frage der MakeMKV-Beschaffung. */
| { art: 'beschaffung-antwort'; ja: boolean }
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
| { art: 'vorgaenge-laden' }
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
@@ -712,6 +717,9 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (wert.art === 'kompression-pause') {
return typeof wert.an === 'boolean'
}
if (wert.art === 'beschaffung-antwort') {
return typeof wert.ja === 'boolean'
}
if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
+56 -5
View File
@@ -15,6 +15,9 @@ export class WerkzeugDienst {
schluesselStand: SchluesselStand | null = null
private beschaffungLaeuft = false
private installerAntwort: ((fehler: string | null) => void) | null = null
/** 5.7.0 (F16): Die offene Ja/Nein-Frage der Beschaffung — null, wenn keine. */
private frageAntwort: ((ja: boolean) => void) | null = null
private letzterBeschaffungsText = ''
constructor(
private readonly kontext: KernKontext,
@@ -115,9 +118,40 @@ export class WerkzeugDienst {
this.installerAntwort = null
}
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false, frage?: string): void {
this.letzterBeschaffungsText = text
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler, ...(frage !== undefined ? { frage } : {}) } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}${frage !== undefined ? ` — FRAGE: ${frage}` : ''}`)
}
/** Antwort des Fensters auf eine offene Frage (beschaffung-antwort). */
beschaffungAntwort(ja: boolean): void {
const antwort = this.frageAntwort
this.frageAntwort = null
if (antwort === null) {
this.kontext.hinweis('Es ist keine Frage der MakeMKV-Beschaffung offen.')
return
}
antwort(ja)
}
/** Eine Ja/Nein-Frage ans Fenster; ohne Antwort in zehn Minuten gilt Nein. */
private frage(text: string): Promise<boolean> {
return new Promise((fertig) => {
this.frageAntwort = (ja) => {
clearTimeout(zeiger)
this.beschaffungMelden(ja ? 'Freigegeben — die Archiv-Kopie wird geladen.' : 'Nicht freigegeben.', null, true)
fertig(ja)
}
const zeiger = setTimeout(() => {
if (this.frageAntwort === null) return
this.frageAntwort = null
this.beschaffungMelden('Keine Antwort — die Archiv-Kopie wird nicht geladen.', null, true)
fertig(false)
}, 10 * 60_000)
zeiger.unref()
this.beschaffungMelden(this.letzterBeschaffungsText, null, true, false, text)
})
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
@@ -168,8 +202,25 @@ export class WerkzeugDienst {
const alteExe = this.makemkvcon()
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const ergebnis = await makemkvInstallerHolen(
neueste,
(text, anteil) => this.beschaffungMelden(text, anteil, true),
'',
undefined,
undefined,
undefined,
// F16: Das Archiv liefert eine unsignierte EXE, die gleich mit
// Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy.
(quelle) =>
this.frage(
`makemkv.com antwortet nicht. Soll Rippy den MakeMKV-Installer ${neueste} aus dem ${quelle} laden? Das ist eine Kopie von fremder Hand, geprüft wird nur, dass sie ein MakeMKV-Programm ist — er läuft danach mit Adminrechten. Wer sicher gehen will, lädt später selbst von makemkv.com.`,
),
)
this.beschaffungMelden(
`Geprüft (Quelle: ${ergebnis.quelle}, ${(ergebnis.bytes / 1024 ** 2).toFixed(1)} MB, SHA-256 ${ergebnis.sha256}) — der Installer wird gestartet …`,
null,
true,
)
const startFehler = await new Promise<string | null>((fertig) => {
this.installerAntwort = fertig
+3
View File
@@ -288,6 +288,9 @@ function fensterNachricht(frage: unknown): void {
case 'schluessel-pruefen':
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
return
case 'beschaffung-antwort':
werkzeuge.beschaffungAntwort(frage.ja)
return
case 'vorgaenge-laden':
vorgaenge.neuMessen()
return
+30 -2
View File
@@ -22,7 +22,8 @@
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
// `.neu`, geprüft, erst dann getauscht.
import { spawnSync } from 'node:child_process'
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { win32 as pfad } from 'node:path'
import { werkzeugOrdner } from './katalog'
@@ -285,6 +286,22 @@ export interface BeschaffungErgebnis {
pfad: string
quelle: string
version: string
/** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei damit man sie
* gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */
bytes: number
sha256: string
}
/** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */
export function pruefsumme(dateiPfad: string): string {
return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex')
}
/** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener
* Basis. Das Internet Archive liefert eine unsignierte EXE, die mit
* Adminrechten läuft das entscheidet der Nutzer, nicht Rippy (F16). */
export function brauchtFreigabe(quellenName: string): boolean {
return quellenName === 'Internet Archive'
}
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
@@ -297,6 +314,9 @@ export async function makemkvInstallerHolen(
holen: typeof fetch = fetch,
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
pruefen: typeof dateiPruefen = dateiPruefen,
/** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne
* diese Funktion wird sie übersprungen nie still geladen. */
freigabe?: (quellenName: string, url: string) => Promise<boolean>,
): Promise<BeschaffungErgebnis> {
mkdirSync(ordner, { recursive: true })
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
@@ -304,6 +324,14 @@ export async function makemkvInstallerHolen(
const fehlschlaege: string[] = []
for (const [name, url] of makemkvQuellen(version, basis)) {
if (brauchtFreigabe(name)) {
const erlaubt = freigabe !== undefined && (await freigabe(name, url))
if (!erlaubt) {
fehlschlaege.push(`${name}: nicht freigegeben`)
melden(`${name} wurde nicht freigegeben.`, null)
continue
}
}
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
try {
await dateiLaden(url, vorlaeufig, melden, holen)
@@ -312,7 +340,7 @@ export async function makemkvInstallerHolen(
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
// Datei nicht ersetzen.
renameSync(vorlaeufig, ziel)
return { pfad: ziel, quelle: name, version }
return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) }
} catch (fehler) {
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
melden(`${name} hat nicht geliefert.`, null)
+24 -2
View File
@@ -136,10 +136,11 @@ describe('dateiPruefen', () => {
describe('makemkvInstallerHolen', () => {
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt (mit Freigabe)', async () => {
const ordner = tempOrdner()
const holen = (async (url: string | URL | Request) =>
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
const gefragt: string[] = []
const ergebnis = await makemkvInstallerHolen(
'1.18.4',
() => {},
@@ -147,16 +148,35 @@ describe('makemkvInstallerHolen', () => {
holen,
ordner,
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
async (quelle) => {
gefragt.push(quelle)
return true
},
)
expect(gefragt).toEqual(['Internet Archive'])
expect(ergebnis.quelle).toBe('Internet Archive')
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
expect(ergebnis.bytes).toBe(GROSS.length)
expect(ergebnis.sha256).toMatch(/^[0-9a-f]{64}$/)
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
})
// 5.7.0 (Durchsicht F16): Ohne Freigabe wird das Archiv NIE benutzt — es
// liefert eine unsignierte EXE, die mit Adminrechten läuft.
it('ohne Freigabe bleibt das Archiv aus — und der Bericht sagt es', async () => {
const ordner = tempOrdner()
const holen = (async (url: string | URL | Request) =>
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(/Internet Archive: nicht freigegeben/)
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => false)).rejects.toThrow(
/Internet Archive: nicht freigegeben/,
)
})
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
const ordner = tempOrdner()
const holen = (async () => antwort('weg', 404)) as typeof fetch
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => true)).rejects.toThrow(
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
)
})
@@ -166,6 +186,8 @@ describe('Nachrichten-Schema der Update-Strecke', () => {
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
expect(istFensterNachricht({ art: 'beschaffung-antwort', ja: true })).toBe(true)
expect(istFensterNachricht({ art: 'beschaffung-antwort' })).toBe(false)
})
it('Haupt → Kern: installer-ergebnis', () => {
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)