// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: // Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; // React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. // // R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird // jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« // zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. import { istKernNachricht, type AutomatikStand, type BeschaffungStand, type DiscInfoStand, type FensterNachricht, type GeraeteInfo, type KernNachricht, type LaufwerkBericht, type MetaVorschlag, type RipAuftragOptionen, type RipAuftragTitel, type RipStatus, type SpeicherStand, type TitelStand, type VorgaengeStand, type WerkzeugAuskunft, } from '../gemeinsam/nachrichten' export interface Verbindung { verbunden: boolean kern: Extract | null pongLatenzMs: number | null laufwerke: GeraeteInfo[] /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ rips: Record /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ discInfos: Record /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ vorschlaege: Record /** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */ discTitel: Record /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ ripStart: Record /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer * Vorgang beginnt. */ ripProtokoll: Record einstellungen: Record werkzeuge: WerkzeugAuskunft | null schluessel: { quelle: string angenommen: boolean | null weiterBetrieb: boolean gueltigBis: string geholtAm: string text: string version: string } | null /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ beschaffung: BeschaffungStand | null /** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null, * solange der Kern noch nichts geschickt hat. */ vorgaenge: VorgaengeStand | null /** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */ gesundheit: Record /** Freier Platz an Arbeitsordner und Ablage (5.5.0). */ speicher: SpeicherStand | null /** Automatik-Countdown je Laufwerk (5.5.0). */ automatik: Record /** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */ poster: string[] /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ meldungen: string[] } type Horcher = (stand: Verbindung) => void const MAX_MELDUNGEN = 30 export const LEERE_VERBINDUNG: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], rips: {}, discInfos: {}, discTitel: {}, vorschlaege: {}, ripStart: {}, ripProtokoll: {}, einstellungen: {}, werkzeuge: null, schluessel: null, beschaffung: null, vorgaenge: null, gesundheit: {}, speicher: null, automatik: {}, poster: [], meldungen: [], } const stand: Verbindung = { ...LEERE_VERBINDUNG } const horcher = new Set() let gestartet = false let port: MessagePort | null = null function kopie(): Verbindung { return { ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, discInfos: { ...stand.discInfos }, vorschlaege: { ...stand.vorschlaege }, einstellungen: { ...stand.einstellungen }, gesundheit: { ...stand.gesundheit }, automatik: { ...stand.automatik }, poster: [...stand.poster], meldungen: [...stand.meldungen], } } function melden(): void { for (const h of horcher) h(kopie()) } function meldung(text: string): void { stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) } function kernNachricht(nachricht: KernNachricht): void { switch (nachricht.art) { case 'kern-bereit': // 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das // steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren. if (stand.kern !== null && stand.kern.pid !== nachricht.pid) { meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`) } stand.kern = nachricht break case 'pong': stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. window.rippy.melden('kern-pong') break case 'kern-fehler': meldung(`⚠ ${nachricht.text}`) break case 'kern-hinweis': meldung(nachricht.text) break case 'laufwerke': stand.laufwerke = nachricht.liste for (const ereignis of nachricht.ereignisse) { if (ereignis.art === 'disc-eingelegt') { meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) } else if (ereignis.art === 'disc-entnommen') { meldung(`Disc entnommen aus ${ereignis.geraet.name}`) const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik stand.automatik = rest } else if (ereignis.art === 'laufwerk-weg') { meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) } } break case 'auswurf-ergebnis': { const text = nachricht.grund === 'ok' ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) break } case 'disc-info': { const vorher = stand.discInfos[nachricht.info.id] stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } if (nachricht.info.titel.length > 0) { // Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach // dem Info-Lauf schickt den Stand erneut, mit Hinweisen. const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel if (neuErkannt) { meldung( `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, ) } for (const h of nachricht.info.hinweise) { if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`) } } break } case 'disc-titel': stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand } if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`) break case 'metadaten-vorschlaege': stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } break case 'einstellungen': stand.einstellungen = nachricht.werte break case 'werkzeuge': stand.werkzeuge = nachricht.auskunft break case 'schluessel-status': stand.schluessel = nachricht.stand if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) break case 'beschaffung-status': stand.beschaffung = nachricht.stand if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) break case 'vorgaenge': stand.vorgaenge = nachricht.stand break case 'kompression-ergebnis': meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`) break case 'laufwerk-gesundheit': stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht } if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') { meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`) } break case 'speicher': stand.speicher = nachricht.stand break case 'automatik': stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand } break case 'poster-vorschau': stand.poster = nachricht.pfade break case 'rip-status': { stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } } else if (!laeuft) { const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart stand.ripStart = rest } // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks // wiederholen den Text — nur echte Änderungen zählen). const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80), } } if (nachricht.status.phase === 'fertig') { meldung( `Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), ) } else if (nachricht.status.phase === 'fehler') { meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) } else if (nachricht.status.phase === 'abgebrochen') { meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) } break } case 'installer-starten': // Nur fürs Haupt gedacht — im Fenster nichts zu tun. break } melden() } function portAnnehmen(neuerPort: MessagePort): void { // Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte // ist tot und wird geschlossen, damit nichts mehr ins Leere geht. if (port !== null && port !== neuerPort) { try { port.close() } catch { // Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg. } } port = neuerPort stand.verbunden = true neuerPort.onmessage = (ereignis: MessageEvent) => { const nachricht: unknown = ereignis.data if (istKernNachricht(nachricht)) kernNachricht(nachricht) } neuerPort.start() senden({ art: 'ping', zeit: Date.now() }) senden({ art: 'einstellungen-laden' }) senden({ art: 'werkzeuge-laden' }) senden({ art: 'vorgaenge-laden' }) senden({ art: 'speicher-laden' }) melden() } /** Idempotent — der erste Aufruf registriert den Horcher und meldet der * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ export function verbindungStarten(): void { if (gestartet) return gestartet = true window.addEventListener('message', (ereignis: MessageEvent) => { const daten: unknown = ereignis.data if ( typeof daten === 'object' && daten !== null && (daten as Record).art === 'kern-port' && ereignis.ports.length > 0 ) { portAnnehmen(ereignis.ports[0]) } }) window.rippy.bereit() } export function aufVerbindung(h: Horcher): () => void { horcher.add(h) h(kopie()) return () => horcher.delete(h) } function senden(nachricht: FensterNachricht): void { port?.postMessage(nachricht) } /** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */ function anKern(nachricht: FensterNachricht, wasWar: string): void { if (port === null) { meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) melden() return } port.postMessage(nachricht) } export function auswerfen(id: string): void { anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) } /** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */ export function titelLesen(id: string): void { anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`) } /** `auftrag` leer heißt: alles rippen (wie vor 5.2). */ export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void { anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`) } /** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */ export function ripUeberspringen(id: string): void { anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`) } export function ripAbbrechen(id: string): void { anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) } export function rettungStarten(id: string): void { anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`) } export function metadatenSuchen(id: string, text: string): void { senden({ art: 'metadaten-suchen', id, text }) } export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { senden({ art: 'zuordnung-setzen', id, vorschlag }) } export function makemkvUpdate(): void { anKern({ art: 'makemkv-update' }, 'MakeMKV-Update') } export function schluesselPruefen(): void { senden({ art: 'schluessel-pruefen' }) } export function einstellungSetzen(schluessel: string, wert: string): void { anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`) } // ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ── export function vorgaengeLaden(): void { senden({ art: 'vorgaenge-laden' }) } export function vorgangRohLoeschen(vorgangId: string): void { anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen') } export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void { anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren') } export function kompressionAbbrechen(vorgangId: string): void { anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen') } export function rohAufraeumen(): void { anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen') } export function fremdenOrdnerLoeschen(ordner: string): void { anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen') } export function laufwerkPruefen(id: string): void { anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`) } // ── 5.5.0 ─────────────────────────────────────────────────────────────── export function kompressionPause(an: boolean): void { anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter') } export function automatikStopp(id: string): void { senden({ art: 'automatik-stopp', id }) } export function automatikJetzt(id: string): void { anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt') } export function benachrichtigungTesten(): void { anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht') } export function posterLaden(): void { senden({ art: 'poster-laden' }) } export function speicherLaden(): void { senden({ art: 'speicher-laden' }) }