// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1). // Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster // öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5 // mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird // über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7). // // Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium // („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse, // beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2. // RIPPY_SMOKE_BILD= legt zusätzlich einen Screenshot ab. import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron' import { writeFile } from 'node:fs/promises' import { join, win32 } from 'node:path' import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten' import { werkzeugOrdner } from '../kern/werkzeuge/katalog' import { autostartAktiv, autostartSetzen } from './autostart' import { fensterErstellen } from './fenster' import { KernVerwaltung } from './kernstart' import { leineSetzen, type LeineStatus } from './leine' import { trayErstellen, trayHinweis } from './tray' import { updateEinrichten, updateStand } from './update' const smokeModus = process.argv.includes('--smoke') // Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts. if (smokeModus) { app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke')) } // Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite // Instanz beendet sich; die erste holt ihr Fenster nach vorn. if (!app.requestSingleInstanceLock()) { app.exit(0) } else { start() } function start(): void { // Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der // Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3). const leine: LeineStatus = leineSetzen() if (!leine.ok) { // R4: laut, nicht still — zusätzlich steht es im Fenster-Status. console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`) } let fenster: BrowserWindow | null = null let kernBereit: Extract | null = null let beendenGewollt = smokeModus // § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates // warten dann. const aktiveVorgaenge = new Set() let updateGestartet = false const smoke = { fenster: false, kern: false, datenbank: false, leine: leine.ok, pong: false, } const kernVerwaltung = new KernVerwaltung( join(app.getPath('userData'), 'rippy.db'), (nachricht) => { if (nachricht.art === 'kern-bereit') { kernBereit = nachricht smoke.kern = true smoke.datenbank = nachricht.datenbank.ok if (!nachricht.datenbank.ok) { console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`) } statusSenden() smokePruefen() } else if (nachricht.art === 'rip-status') { ripStatusVerarbeiten(nachricht.status) } else if (nachricht.art === 'einstellungen') { // Einmalig das Selbst-Update einrichten — die Adresse kommt aus // der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8). if (!updateGestartet && !smokeModus) { updateGestartet = true updateEinrichten( nachricht.werte['updateUrl'] ?? '', () => aktiveVorgaenge.size > 0, (text) => toast('Rippy-Update', text), ) } } else if (nachricht.art === 'installer-starten') { void installerStarten(nachricht.pfad) } else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) { // Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch. toast( 'Disc erkannt', `${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` + (nachricht.info.schonGerippt !== null ? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})` : ''), ) } }, () => statusSenden(), smokeModus, ) // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem // Rip-Status — die Information, die man will, ohne das Fenster zu öffnen. function ripStatusVerarbeiten(status: Extract['status']): void { if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') { aktiveVorgaenge.add(status.id) } else { aktiveVorgaenge.delete(status.id) } if (fenster !== null && !fenster.isDestroyed()) { if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) { fenster.setProgressBar(status.prozent / 100) } else if (status.phase === 'fehler') { fenster.setProgressBar(1, { mode: 'error' }) } else { fenster.setProgressBar(-1) } } if (status.phase === 'rippt') { trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`) } else if (status.phase === 'komprimiert') { trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`) } else { trayHinweis('') } if (smokeModus) return if (status.phase === 'fertig') { toast('Rippy ist fertig', status.text) } else if (status.phase === 'fehler') { toast('Rippy: Fehler', status.text) } } // § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird // er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im // Manifest Administratorrechte an — CreateProcess bricht dann mit // ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage // (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über // den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy — // er hängt nicht an der Prozess-Leine und überlebt ein Beenden. async function installerStarten(pfad: string): Promise { // Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus // Rippys eigenem Downloads-Ordner. const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\' if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) { kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` }) return } const fehler = await shell.openPath(pfad) kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null }) } function toast(titel: string, text: string): void { try { if (!Notification.isSupported()) return const meldung = new Notification({ title: titel, body: text.slice(0, 200) }) meldung.on('click', () => fensterZeigen()) meldung.show() } catch (fehler) { console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`) } } function statusSenden(): void { if (fenster === null || fenster.isDestroyed()) return const status: HauptStatus = { version: app.getVersion(), leine, kern: kernVerwaltung.zustand(), autostart: autostartAktiv(), update: updateStand(), } fenster.webContents.send('haupt-status', status) } function fensterVerdrahten(): void { if (fenster === null) return fenster.webContents.on('did-finish-load', () => { smoke.fenster = true // Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port. kernVerwaltung.fensterVerbinden(fenster!.webContents) statusSenden() smokePruefen() }) // § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray. fenster.on('close', (ereignis) => { if (beendenGewollt) return ereignis.preventDefault() fenster?.hide() }) } function fensterZeigen(): void { if (fenster === null || fenster.isDestroyed()) { fenster = fensterErstellen() fensterVerdrahten() return } if (fenster.isMinimized()) fenster.restore() fenster.show() fenster.focus() } function beenden(): void { beendenGewollt = true app.quit() } // Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im // Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort // Fenster ↔ Kern in BEIDE Richtungen trägt. ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => { if (art === 'kern-pong') { smoke.pong = true smokePruefen() } }) // Autostart (§ 8): HKCU\...\Run über Electrons eigene API. ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => { autostartSetzen(an === true) statusSenden() return autostartAktiv() }) // Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine // Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers. ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => { if (fenster === null) return null const ergebnis = await dialog.showOpenDialog(fenster, { title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen', properties: ['openDirectory', 'createDirectory'], }) return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0] }) app.on('second-instance', () => { fensterZeigen() }) // Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft // im Hintergrund weiter. Beendet wird über das Tray-Menü. app.on('window-all-closed', () => { if (beendenGewollt) app.quit() }) app.on('before-quit', () => { beendenGewollt = true kernVerwaltung.beenden() }) void app.whenReady().then(() => { kernVerwaltung.starten() if (!smokeModus) { trayErstellen(fensterZeigen, beenden) } fenster = fensterErstellen() fensterVerdrahten() }) let smokeAbgeschlossen = false function smokePruefen(): void { if (!smokeModus || smokeAbgeschlossen) return const fehlt = Object.entries(smoke) .filter(([, wert]) => !wert) .map(([name]) => name) if (fehlt.length > 0) return smokeAbgeschlossen = true void smokeAbschliessen() } async function smokeAbschliessen(): Promise { const bildPfad = process.env['RIPPY_SMOKE_BILD'] if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) { try { // Der Pong kommt schneller, als der Compositor das erste Bild malt — // ein sofortiges capturePage lieferte hier »UnknownVizError« // (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist; // RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf // späte Kern-Antworten warten. const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750') await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750)) const bild = await fenster.webContents.capturePage() await writeFile(bildPfad, bild.toPNG()) console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`) } catch (fehler) { console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`) } } const kernInfo = kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?' console.log( `SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`, ) // app.exit() überspringt before-quit — den Kern deshalb hier gewollt // beenden, sonst deutet der exit-Wächter das als Absturz und startet neu. kernVerwaltung.beenden() setTimeout(() => app.exit(0), 100) } if (smokeModus) { setTimeout(() => { if (smokeAbgeschlossen) return smokeAbgeschlossen = true const fehlt = Object.entries(smoke) .filter(([, wert]) => !wert) .map(([name]) => name) console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`) kernVerwaltung.beenden() setTimeout(() => app.exit(2), 100) }, 25_000) } }