// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im // öffentlichen Gitea (Entscheid 6). Die vier Regeln: // 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg // die einzige Absicherung; eine http://-Adresse wird ABGELEHNT. // 2. NIE während eines Rips — weder prüfen, laden noch installieren. // 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der // letzten erfolgreichen Prüfung steht im Status. // 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf). // // Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der // Knopf ‚Auf Updates prüfen' und die Meldung beim Start"): Der Kanal // arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und // wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er // zusätzlich die gefundene Version, ihre Änderungsnotizen und den // Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster. import { app } from 'electron' import electronUpdater from 'electron-updater' import { INSTALLER_ARGUMENTE } from './kommandozeile' import { ausserhalbDerLeineStarten } from './leine' import { neuerungenText, updateUrlPruefen } from './update-regeln' export { NEUERUNGEN_HOECHSTENS, STANDARD_UPDATE_URL, neuerungenText, updateUrlPruefen, } from './update-regeln' const { autoUpdater } = electronUpdater // EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle). export type { UpdateStand } from '../gemeinsam/nachrichten' import type { UpdateStand } from '../gemeinsam/nachrichten' const ANFANG: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft', neueVersion: '', neuerungen: '', bereit: false, laeuft: false, fortschritt: -1, } let stand: UpdateStand = { ...ANFANG } let aenderungMelden: () => void = () => { // Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte. } let ripLaeuftJetzt: () => boolean = () => false let scharf = false /** Der geladene Installer — leer, solange nichts bereitliegt. */ let installerPfad = '' /** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich * nicht gegenseitig zwei Installer auf den Hals hetzen. */ let installerGestartet = false let rippyBeenden: () => void = () => app.quit() export function updateStand(): UpdateStand { return { ...stand } } /** Stand ändern UND das Fenster benachrichtigen — nie das eine ohne das * andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher * (der 26.07.-Fund „ein still scheiternder Hintergrund-Prozess"). */ function setzen(teil: Partial): void { stand = { ...stand, ...teil } aenderungMelden() } export function updateEinrichten( eigeneUrl: string, ripLaeuft: () => boolean, melden: (text: string) => void, beiAenderung: () => void = () => { // ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar }, beenden: () => void = () => app.quit(), ): void { aenderungMelden = beiAenderung ripLaeuftJetzt = ripLaeuft rippyBeenden = beenden if (!app.isPackaged) { setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' }) return } const pruefung = updateUrlPruefen(eigeneUrl) if (!pruefung.ok) { setzen({ text: pruefung.grund }) melden(pruefung.grund) return } if (eigeneUrl.trim().length > 0) { autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() }) } autoUpdater.autoDownload = false // WIR installieren, nicht electron-updater (01.09.2026, am lebenden // Objekt bezahlt): Sein eigener Weg startet den Installer per // `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN // Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3) // und starb in genau der Sekunde, in der Rippy sich für ihn beendete. // Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu." autoUpdater.autoInstallOnAppQuit = false scharf = true // Beim Beenden — egal ob über das Tray, den Update-Knopf oder das // Herunterfahren von Windows — bekommt ein bereitliegendes Update noch // seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor // diese Zeile zurückkommt. app.on('will-quit', () => { if (!stand.bereit || ripLaeuftJetzt()) return installerAnwerfen() }) autoUpdater.on('update-available', (info) => { const neuerungen = neuerungenText(info.releaseNotes) if (ripLaeuft()) { // Regel 2: nicht einmal herunterladen, solange gerippt wird — der // tägliche Takt versucht es wieder. setzen({ neueVersion: info.version, neuerungen, laeuft: false, fortschritt: -1, text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`, }) melden(stand.text) return } setzen({ neueVersion: info.version, neuerungen, laeuft: true, fortschritt: 0, text: `Version ${info.version} wird geladen …`, }) // Der Commander wollte die Meldung sehen, nicht suchen müssen. melden(`Update verfügbar: Version ${info.version} wird geladen.`) void autoUpdater .downloadUpdate() .then((pfade: string[]) => { // downloadUpdate liefert die abgelegten Dateien zurück // (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin // ist der Installer, den wir gleich selbst starten. Liegt die Datei // vom letzten Lauf schon im Zwischenspeicher, kommt sie hier // ebenfalls heraus, ohne erneut zu laden. const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? '' if (exe.length > 0) installerPfad = exe }) .catch((fehler: unknown) => { setzen({ laeuft: false, fortschritt: -1, text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`, }) }) }) autoUpdater.on('download-progress', (fortschritt) => { setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) }) }) autoUpdater.on('update-downloaded', (info) => { setzen({ neueVersion: info.version, neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen, bereit: true, laeuft: false, fortschritt: 100, text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`, }) melden(stand.text) }) autoUpdater.on('update-not-available', () => { setzen({ letzteErfolgreichePruefung: new Date().toISOString(), neueVersion: '', neuerungen: '', bereit: false, laeuft: false, fortschritt: -1, text: 'Rippy ist aktuell.', }) }) autoUpdater.on('error', (fehler) => { // Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar. setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` }) }) pruefen() setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.() } function pruefen(): void { if (ripLaeuftJetzt()) return setzen({ laeuft: true, text: 'Suche nach Updates …' }) void autoUpdater.checkForUpdates().catch(() => { // der error-Handler oben trägt den Grund ein }) } /** Der Knopf „Jetzt prüfen" im Fenster. Antwortet IMMER etwas — ein * Knopf, der nichts sagt, ist schlimmer als keiner. */ export function updatePruefenJetzt(): void { if (!scharf) { // Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu // melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen. aenderungMelden() return } if (ripLaeuftJetzt()) { setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' }) return } pruefen() } /** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob * er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */ function installerAnwerfen(): { ok: boolean; grund: string } { if (installerGestartet) return { ok: true, grund: '' } if (installerPfad.length === 0) { return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' } } const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE) if (!urteil.ok) { setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` }) return { ok: false, grund: urteil.fehler } } installerGestartet = true return { ok: true, grund: '' } } /** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn * wirklich etwas bereitliegt — und nie mitten in einem Rip. */ export function updateInstallierenJetzt(): { ok: boolean; grund: string } { if (!scharf || !stand.bereit) { return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' } } if (ripLaeuftJetzt()) { return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' } } // Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy // weg ist, und startet es hinterher wieder (`--force-run`). const urteil = installerAnwerfen() if (!urteil.ok) return urteil rippyBeenden() return { ok: true, grund: '' } }