// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9). // Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am // Commander-PC gemessen: // // 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den. // 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) — // VOR dem System, damit eine gepflegte Fassung eine alte Installation // schlägt. // 3. PATH — der „selbst eingetragen"-Fall. // 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall. // 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER // (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den // Programmordner. // // ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern // PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte // Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen // (katalog.py, 28.08.2026). import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' // Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade, // und die CI-Tests laufen auf Linux — dort würde das plattformabhängige // join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226). import { win32 as pfad } from 'node:path' const { delimiter, join } = pfad export interface Werkzeug { titel: string zweck: string programme: readonly string[] orte: readonly string[] /** Teilstring des DisplayName im Uninstall-Zweig der Registry. */ anzeige: string pflicht: boolean } export const WERKZEUGE: Record = { makemkv: { titel: 'MakeMKV', zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.', programme: ['makemkvcon64.exe', 'makemkvcon.exe'], orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'], anzeige: 'MakeMKV', pflicht: true, }, handbrake: { titel: 'HandBrakeCLI', zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.', programme: ['HandBrakeCLI.exe'], orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'], anzeige: 'HandBrake', pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression }, flac: { titel: 'FLAC', zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.', programme: ['flac.exe'], orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'], anzeige: 'FLAC', pflicht: false, // ohne FLAC läuft alles außer Audio-CDs }, } /** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */ export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string { const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.' return join(wurzel, 'Rippy', 'tools') } /** %ProgramFiles%\X → C:\Program Files\X — Groß/Klein wird IGNORIERT. * Bleibt ein % stehen, ist der Kandidat unbrauchbar → ''. */ export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string { let ergebnis = pfad for (const [name, wert] of Object.entries(umgebung)) { if (wert === undefined) continue const marke = `%${name}%`.toUpperCase() for (;;) { const stelle = ergebnis.toUpperCase().indexOf(marke) if (stelle < 0) break ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length) } } return ergebnis.includes('%') ? '' : ergebnis } /** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */ export function kandidaten( name: string, eigenerOrdner: string, umgebung: NodeJS.ProcessEnv, ): string[] { const werkzeug = WERKZEUGE[name] const pfade: string[] = [] // Das MITGELIEFERTE Werkzeug (§ 9) — im installierten Rippy zeigt // RIPPY_VENDOR auf resources\vendor (der Haupt setzt es beim Kern-Start). const vendor = umgebung['RIPPY_VENDOR'] ?? '' if (vendor.length > 0) { for (const programm of werkzeug.programme) { pfade.push(join(vendor, name, programm)) } } for (const programm of werkzeug.programme) { pfade.push(join(eigenerOrdner, name, programm)) pfade.push(join(eigenerOrdner, programm)) } for (const ort of werkzeug.orte) { const entfaltet = entfalten(ort, umgebung) if (entfaltet.length === 0) continue for (const programm of werkzeug.programme) { pfade.push(join(entfaltet, programm)) } } return pfade } function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string { const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? '' for (const ordner of pfadVar.split(delimiter)) { if (ordner.length === 0) continue for (const programm of programme) { const kandidat = join(ordner, programm) if (existsSync(kandidat)) return kandidat } } return '' } /** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist * InstallLocation leer — DisplayIcon zeigt auf eine Datei im * Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit * kein zweiter koffi-Ort entsteht (R1). */ export function ausUninstallRegistry( anzeige: string, programme: readonly string[], ausfuehren: (wurzel: string) => string = regQuery, ): string { const wurzeln = [ 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', ] for (const wurzel of wurzeln) { const ausgabe = ausfuehren(wurzel) // reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT. let passt = false for (const zeile of ausgabe.split(/\r?\n/)) { if (zeile.startsWith('HK')) { passt = false continue } const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile) if (wert === null) continue if (wert[1] === 'DisplayName') { passt = wert[2].includes(anzeige) } else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) { const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '') for (const programm of programme) { const kandidat = join(ordner, programm) if (existsSync(kandidat)) return kandidat } } } } return '' } function regQuery(wurzel: string): string { const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], { encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024, }) return ergebnis.stdout ?? '' } /** Der Pfad zum Werkzeug — oder '' wenn es nicht da ist. Prüf-Funktionen * einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */ export function finden( name: string, eingestellt = '', eigenerOrdner: string = werkzeugOrdner(), umgebung: NodeJS.ProcessEnv = process.env, existiert: (pfad: string) => boolean = existsSync, registry: typeof ausUninstallRegistry = ausUninstallRegistry, ): string { // 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird // NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will // genau den, und ein Tippfehler soll als Fehler sichtbar werden. if (eingestellt.trim().length > 0) { return existiert(eingestellt.trim()) ? eingestellt.trim() : '' } // 2 + 4. Eigener Ordner, dann bekannte Orte. for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) { if (existiert(kandidat)) return kandidat } // 3. PATH. const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung) if (imPfad.length > 0) return imPfad // 5. Registry-Rückfall. return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme) }