Ampel / ampel (push) Successful in 1m41s
Commander: "Rippy wird zwar sauber beendet aber startet NICHT neu in der
neuen version."
Ursache, auf drei Beinen belegt:
1. Bibliothek: electron-updater startet den Installer per
spawn(..., { detached: true }) (BaseUpdater.js, an der installierten
Fassung nachgelesen). `detached` setzt unter Windows KEIN Breakaway.
2. Leine: leine.ts setzt KILL_ON_JOB_CLOSE, und Kinder erben die
Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
3. Platte: pending\RippySetup-5.1.3.exe lag geladen da (18:11:47),
installiert war weiter 5.1.2. Der Download klappte, nur die
Installation nicht.
Der Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.
Fix — die Leine bleibt, der Installer klinkt sich aus:
* leine.ts setzt zusaetzlich JOB_OBJECT_LIMIT_BREAKAWAY_OK. Das aendert
fuer sich genommen NICHTS: Kinder erben weiter, ausser sie verlangen
das Ausklinken selbst. Kern, makemkvcon und HandBrakeCLI bleiben
angeleint, die rc10-Waisen-Falle (Paragraph 3.3) ist unveraendert zu.
* Neu: ausserhalbDerLeineStarten() startet EIN Programm per CreateProcessW
mit CREATE_BREAKAWAY_FROM_JOB. Liegt in leine.ts, weil dort die
Arbeitsgruppe verwaltet wird — und weil Waechter R1 koffi nur dort und
in kern/laufwerk/win32.ts erlaubt.
* update.ts installiert jetzt SELBST: autoInstallOnAppQuit ist aus, der
Installerpfad kommt aus downloadUpdate() (laut AppUpdater.d.ts "Paths to
downloaded files"), die Argumente sind die von electron-updater
gemessenen (--updated /S --force-run). Gestartet wird beim Beenden
(will-quit) und ueber den Knopf.
* Neu kommandozeile.ts (pur, ohne koffi/Electron): CreateProcessW nimmt
EINE Zeichenkette. Ohne die Regeln von CommandLineToArgvW zerfiele ein
Pfad wie "C:\Users\Tobi Neu\..." in zwei Argumente.
BEWIESEN, nicht behauptet — neu: beweise/leine-breakaway.js
OHNE Ausklinken (so macht es electron-updater)
lebt nach dem Tod des Elternprozesses: NEIN -> WIE ERWARTET
MIT Ausklinken (so macht es Rippy ab 5.1.4)
lebt nach dem Tod des Elternprozesses: JA -> WIE ERWARTET
Der erste Messlauf zeigte ein falsches Negativ: Der Enkel starb an der
sterbenden KONSOLE des Elternprozesses, nicht an der Arbeitsgruppe. Erst
mit CREATE_NO_WINDOW (eigene Konsole) misst der Versuch, was er messen
soll. Diese Lehre steht als Kommentar im Code und der Flag ist auch im
echten Aufruf gesetzt.
237 Tests gruen (vorher 230), Typpruefung sauber. Version 5.1.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
// 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=<pfad.png> 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, updateInstallierenJetzt, updatePruefenJetzt, 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<KernNachricht, { art: 'kern-bereit' }> | null = null
|
|
let beendenGewollt = smokeModus
|
|
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
|
|
// warten dann.
|
|
const aktiveVorgaenge = new Set<string>()
|
|
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),
|
|
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
|
|
// vorher lag sie still im Haupt und war erst beim nächsten
|
|
// Laden zu sehen (Commander-Fund 01.09.2026).
|
|
() => statusSenden(),
|
|
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
|
|
// der close-Horcher das Fenster nicht bloß versteckt.
|
|
() => beenden(),
|
|
)
|
|
}
|
|
} 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<KernNachricht, { art: 'rip-status' }>['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<void> {
|
|
// 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(),
|
|
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
|
|
// Fenster gerade maximiert ist.
|
|
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
|
|
}
|
|
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()
|
|
})
|
|
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
|
|
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
|
|
// maximiert wird, nicht nur über unseren Knopf.
|
|
fenster.on('maximize', () => statusSenden())
|
|
fenster.on('unmaximize', () => statusSenden())
|
|
// § 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()
|
|
}
|
|
})
|
|
|
|
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
|
|
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
|
|
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
|
|
if (fenster === null || fenster.isDestroyed()) return false
|
|
if (was === 'minimieren') {
|
|
fenster.minimize()
|
|
} else if (was === 'maximieren') {
|
|
if (fenster.isMaximized()) fenster.unmaximize()
|
|
else fenster.maximize()
|
|
} else if (was === 'schliessen') {
|
|
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
|
|
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
|
|
// ein laufender Rip darf davon nichts merken.
|
|
fenster.close()
|
|
} else {
|
|
return false
|
|
}
|
|
return fenster.isDestroyed() ? false : fenster.isMaximized()
|
|
})
|
|
|
|
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
|
|
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
|
|
ipcMain.handle('update-pruefen', () => {
|
|
updatePruefenJetzt()
|
|
return updateStand()
|
|
})
|
|
|
|
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
|
|
// statt still nichts zu tun (R4).
|
|
ipcMain.handle('update-installieren', () => {
|
|
const urteil = updateInstallierenJetzt()
|
|
if (!urteil.ok) statusSenden()
|
|
return urteil
|
|
})
|
|
|
|
// 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<void> {
|
|
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)
|
|
}
|
|
}
|