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>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage
|
||
// tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am
|
||
// 31.08.2026 nachgemessen):
|
||
//
|
||
// Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht)
|
||
// Hersteller-Forum HTTP 200, 0,2 s → 1.18.4
|
||
// Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2
|
||
//
|
||
// Daraus folgen die drei Regeln dieser Datei:
|
||
// 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich.
|
||
// Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die
|
||
// HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus,
|
||
// würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste
|
||
// Fassung" verkauft.
|
||
// 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht
|
||
// (Get-AuthenticodeSignature → NotSigned, an der echten Datei
|
||
// gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft
|
||
// inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026
|
||
// an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!).
|
||
// Das ersetzt keine Signatur, fängt aber ab, was wirklich droht:
|
||
// Fehlerseite, halber Download, falsche Version.
|
||
// 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 { 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'
|
||
|
||
export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/'
|
||
export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3'
|
||
// Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das
|
||
// Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py).
|
||
const ARCHIV_STAND = '2025'
|
||
const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe'
|
||
|
||
const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g
|
||
|
||
// Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und
|
||
// begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine
|
||
// Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin.
|
||
const VERSIONS_TIMEOUT_MS = 8_000
|
||
const DOWNLOAD_TIMEOUT_MS = 600_000
|
||
const VERSUCHE = 2
|
||
const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000
|
||
|
||
// Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern
|
||
// eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde.
|
||
const MINDESTGROESSE_BYTES = 1024 * 1024
|
||
|
||
const MAKEMKV_FIRMA = 'GuinpinSoft'
|
||
const MAKEMKV_PRODUKT = 'MakeMKV'
|
||
|
||
export class BeschaffungsFehler extends Error {}
|
||
|
||
/** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH
|
||
* verglichen — als Zeichenkette wäre „1.9.2" größer als „1.18.4". */
|
||
export function hoechsteVersion(text: string): string {
|
||
const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1])
|
||
if (funde.length === 0) return ''
|
||
return funde.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||
}
|
||
|
||
/** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */
|
||
export function istNeuer(neu: string, alt: string): boolean {
|
||
const teile = (v: string): number[] =>
|
||
(v ?? '')
|
||
.split('.')
|
||
.filter((t) => /^\d+$/.test(t))
|
||
.map(Number)
|
||
const a = teile(neu)
|
||
const b = teile(alt)
|
||
if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0
|
||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||
const x = a[i] ?? 0
|
||
const y = b[i] ?? 0
|
||
if (x !== y) return x > y
|
||
}
|
||
return false
|
||
}
|
||
|
||
export interface VersionsQuelle {
|
||
name: string
|
||
url: string
|
||
/** Diese Quelle IST die Wahrheit — wer sie erreicht, sucht nicht weiter.
|
||
* Gilt nur für den Hersteller selbst. */
|
||
massgeblich: boolean
|
||
}
|
||
|
||
export function versionsquellen(): VersionsQuelle[] {
|
||
return [
|
||
{ name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true },
|
||
{ name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false },
|
||
{
|
||
name: 'Archiv der Hersteller-Seite',
|
||
url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`,
|
||
massgeblich: false,
|
||
},
|
||
]
|
||
}
|
||
|
||
async function seiteLaden(url: string, holen: typeof fetch): Promise<string> {
|
||
let letzter: unknown = null
|
||
for (let versuch = 0; versuch < VERSUCHE; versuch++) {
|
||
try {
|
||
const antwort = await holen(url, {
|
||
signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS),
|
||
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||
})
|
||
if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`)
|
||
return await antwort.text()
|
||
} catch (fehler) {
|
||
letzter = fehler
|
||
if (versuch + 1 < VERSUCHE) {
|
||
await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS))
|
||
}
|
||
}
|
||
}
|
||
throw letzter
|
||
}
|
||
|
||
/** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */
|
||
export async function makemkvNeuesteVersion(
|
||
holen: typeof fetch = fetch,
|
||
quellen: VersionsQuelle[] = versionsquellen(),
|
||
): Promise<string> {
|
||
const gefunden: string[] = []
|
||
for (const quelle of quellen) {
|
||
let text: string
|
||
try {
|
||
text = await seiteLaden(quelle.url, holen)
|
||
} catch {
|
||
continue
|
||
}
|
||
const version = hoechsteVersion(text)
|
||
if (version.length === 0) continue
|
||
if (quelle.massgeblich) return version
|
||
gefunden.push(version)
|
||
}
|
||
if (gefunden.length === 0) return ''
|
||
return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||
}
|
||
|
||
/** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis
|
||
* (wer sie einträgt, hat sich etwas gedacht) → Hersteller → Archiv. */
|
||
export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> {
|
||
const herstellerUrl = MAKEMKV_DATEI.replace('%s', version)
|
||
const quellen: Array<[string, string]> = []
|
||
if (basis.trim().length > 0) {
|
||
quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`])
|
||
}
|
||
quellen.push(['Hersteller', herstellerUrl])
|
||
// `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen).
|
||
quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`])
|
||
return quellen
|
||
}
|
||
|
||
export interface Versionsangaben {
|
||
firma: string
|
||
produkt: string
|
||
version: string
|
||
}
|
||
|
||
/** Die Versions-Ressource einer EXE — über PowerShells FileVersionInfo,
|
||
* damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */
|
||
export function versionsangaben(dateiPfad: string): Versionsangaben {
|
||
const leer = { firma: '', produkt: '', version: '' }
|
||
if (process.platform !== 'win32') return leer
|
||
// ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein
|
||
// wörtliches '…'-Literal nicht.
|
||
const skript =
|
||
`$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` +
|
||
'@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress'
|
||
const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], {
|
||
windowsHide: true,
|
||
timeout: 20_000,
|
||
encoding: 'utf8',
|
||
})
|
||
if (lauf.status !== 0) return leer
|
||
try {
|
||
const roh: unknown = JSON.parse(lauf.stdout ?? '')
|
||
if (typeof roh !== 'object' || roh === null) return leer
|
||
const angaben = roh as Record<string, unknown>
|
||
return {
|
||
firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '',
|
||
produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '',
|
||
version: typeof angaben['version'] === 'string' ? angaben['version'] : '',
|
||
}
|
||
} catch {
|
||
return leer
|
||
}
|
||
}
|
||
|
||
/** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */
|
||
export function dateiPruefen(
|
||
dateiPfad: string,
|
||
version: string,
|
||
angaben: (p: string) => Versionsangaben = versionsangaben,
|
||
plattform: NodeJS.Platform = process.platform,
|
||
): { ok: boolean; grund: string } {
|
||
// Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte
|
||
// Fehlerseite tut das nicht.
|
||
const kopf = Buffer.alloc(2)
|
||
try {
|
||
const datei = openSync(dateiPfad, 'r')
|
||
try {
|
||
readSync(datei, kopf, 0, 2, 0)
|
||
} finally {
|
||
closeSync(datei)
|
||
}
|
||
} catch (fehler) {
|
||
return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` }
|
||
}
|
||
if (kopf.toString('latin1') !== 'MZ') {
|
||
return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' }
|
||
}
|
||
if (plattform !== 'win32') {
|
||
// Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen,
|
||
// mehr ist dort nicht zu holen (CI läuft auf Linux).
|
||
return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' }
|
||
}
|
||
const info = angaben(dateiPfad)
|
||
if (!info.firma.includes(MAKEMKV_FIRMA)) {
|
||
return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` }
|
||
}
|
||
if (!info.produkt.includes(MAKEMKV_PRODUKT)) {
|
||
return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` }
|
||
}
|
||
if (version.length > 0 && !info.version.includes(version)) {
|
||
return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` }
|
||
}
|
||
return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` }
|
||
}
|
||
|
||
async function dateiLaden(
|
||
url: string,
|
||
ziel: string,
|
||
melden: (text: string, anteil: number | null) => void,
|
||
holen: typeof fetch,
|
||
): Promise<void> {
|
||
const antwort = await holen(url, {
|
||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||
})
|
||
if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`)
|
||
if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt')
|
||
const erwartet = Number(antwort.headers.get('content-length') ?? '0')
|
||
const schreiber = createWriteStream(ziel)
|
||
const leser = antwort.body.getReader()
|
||
let geladen = 0
|
||
let letzteStufe = -1
|
||
try {
|
||
for (;;) {
|
||
const { done, value } = await leser.read()
|
||
if (done) break
|
||
geladen += value.byteLength
|
||
await new Promise<void>((weiter, kaputt) => {
|
||
schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter()))
|
||
})
|
||
if (erwartet > 0) {
|
||
const anteil = Math.min(1, geladen / erwartet)
|
||
// Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen
|
||
// wären sonst hundert Zeilen.
|
||
const stufe = Math.floor(anteil * 10)
|
||
if (stufe !== letzteStufe) {
|
||
letzteStufe = stufe
|
||
melden(
|
||
`${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`,
|
||
anteil * 0.85,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
await new Promise<void>((weiter) => schreiber.end(weiter))
|
||
}
|
||
const groesse = statSync(ziel).size
|
||
if (groesse < MINDESTGROESSE_BYTES) {
|
||
throw new BeschaffungsFehler(
|
||
`Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`,
|
||
)
|
||
}
|
||
}
|
||
|
||
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
|
||
* Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** — es lädt die
|
||
* offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */
|
||
export async function makemkvInstallerHolen(
|
||
version: string,
|
||
melden: (text: string, anteil: number | null) => void,
|
||
basis = '',
|
||
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`)
|
||
const vorlaeufig = `${ziel}.neu`
|
||
|
||
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)
|
||
const pruefung = pruefen(vorlaeufig, version)
|
||
if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund)
|
||
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||
// Datei nicht ersetzen.
|
||
renameSync(vorlaeufig, ziel)
|
||
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)
|
||
try {
|
||
rmSync(vorlaeufig, { force: true })
|
||
} catch {
|
||
// Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin.
|
||
}
|
||
}
|
||
}
|
||
throw new BeschaffungsFehler(
|
||
`Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` +
|
||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||
)
|
||
}
|