feat(v5): Etappe W-0 — das Geruest steht
Electron 44 + TypeScript, drei Prozesse (haupt / kern als utilityProcess / fenster mit React), Nachrichten-Schema mit Pruef-Funktionen, node:sqlite als einzige Datenbank-Stelle (kern/speicher/db.ts, § 6.7), Prozess-Leine im Haupt (Job Object aus beweise/leine.js — Begruendung fuer den Ort im Dateikopf), MessagePort direkt Fenster<->Kern, Einzelinstanz-Sperre, Kern-Neustart-Wache, strikte CSP im gebauten Fenster. Wächter-Tests nach § 4.3: koffi nur an zwei benannten Orten (R1), kein leerer catch und kein catch-mit-Leerwert (R2/R4), kein HTTP-Server (§ 4.1), node:sqlite nur in db.ts (§ 6.7). Dazu Datenbank- und Schema-Tests: 18/18 gruen, Typpruefung in drei Kontexten. Der W-0-Beweis (npm run smoke, echtes Programm): SMOKE: OK — fenster=geladen kern=pid:13676,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.0.0-w0 Nachgemessen ausserdem: taskkill /F auf den Haupt-Prozess (ohne /T, der Absturz-Fall aus rc10) — der Kern stirbt mit. Bau-Fallen dokumentiert (BAUEN.md): npm 11 blockt Electrons Install-Skript (§ 3.5), plugin-react 6 verlangt Vite 8 (deshalb 5.2), TypeScript bewusst auf 5.9.3 gepinnt statt tsgo 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
564bd17f84
commit
c539e65cd2
@@ -0,0 +1,37 @@
|
||||
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
|
||||
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
|
||||
// (src/bruecke) und den MessagePort zum Kern.
|
||||
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function fensterErstellen(): BrowserWindow {
|
||||
const fenster = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||
// bevor React lädt.
|
||||
backgroundColor: '#0b1220',
|
||||
title: 'Rippy',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../bruecke/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
||||
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
if (devUrl !== undefined && devUrl.length > 0) {
|
||||
void fenster.loadURL(devUrl)
|
||||
} else {
|
||||
void fenster.loadFile(join(__dirname, '../fenster/index.html'))
|
||||
}
|
||||
return fenster
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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.
|
||||
//
|
||||
// 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, ipcMain, type BrowserWindow } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import { fensterErstellen } from './fenster'
|
||||
import { KernVerwaltung } from './kernstart'
|
||||
import { leineSetzen, type LeineStatus } from './leine'
|
||||
|
||||
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 letzteKernNachricht: KernNachricht | null = null
|
||||
|
||||
const smoke = {
|
||||
fenster: false,
|
||||
kern: false,
|
||||
datenbank: false,
|
||||
leine: leine.ok,
|
||||
pong: false,
|
||||
}
|
||||
|
||||
const kernVerwaltung = new KernVerwaltung(
|
||||
join(app.getPath('userData'), 'rippy.db'),
|
||||
(nachricht) => {
|
||||
letzteKernNachricht = nachricht
|
||||
if (nachricht.art === 'kern-bereit') {
|
||||
smoke.kern = true
|
||||
smoke.datenbank = nachricht.datenbank.ok
|
||||
if (!nachricht.datenbank.ok) {
|
||||
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
||||
}
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
}
|
||||
},
|
||||
() => statusSenden(),
|
||||
)
|
||||
|
||||
function statusSenden(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) return
|
||||
const status: HauptStatus = {
|
||||
version: app.getVersion(),
|
||||
leine,
|
||||
kern: kernVerwaltung.zustand(),
|
||||
}
|
||||
fenster.webContents.send('haupt-status', status)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (fenster !== null && !fenster.isDestroyed()) {
|
||||
if (fenster.isMinimized()) fenster.restore()
|
||||
fenster.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// W-0: Fenster zu = Rippy zu. Erst W-5 bringt das Tray-Symbol — dann lebt
|
||||
// Rippy nach dem Schließen im Hintergrund weiter (§ 4.1, Punkt 3).
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
kernVerwaltung.beenden()
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
kernVerwaltung.starten()
|
||||
fenster = fensterErstellen()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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.
|
||||
await new Promise((fertig) => setTimeout(fertig, 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 kern = letzteKernNachricht
|
||||
const kernInfo =
|
||||
kern !== null && kern.art === 'kern-bereit'
|
||||
? `kern=pid:${kern.pid},node:${kern.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
|
||||
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
|
||||
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
|
||||
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
|
||||
import { MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface KernZustand {
|
||||
laeuft: boolean
|
||||
pid?: number
|
||||
neustarts: number
|
||||
}
|
||||
|
||||
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
|
||||
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
|
||||
const MAX_NEUSTARTS_JE_MINUTE = 3
|
||||
|
||||
export class KernVerwaltung {
|
||||
private kern: UtilityProcess | null = null
|
||||
private beendenGewollt = false
|
||||
private neustarts = 0
|
||||
private neustartZeiten: number[] = []
|
||||
|
||||
constructor(
|
||||
private readonly datenbankPfad: string,
|
||||
private readonly beiNachricht: (nachricht: KernNachricht) => void,
|
||||
private readonly beiZustand: (zustand: KernZustand) => void,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
const kernDatei = join(__dirname, 'kern.js')
|
||||
const kern = utilityProcess.fork(kernDatei, [`--datenbank=${this.datenbankPfad}`], {
|
||||
serviceName: 'Rippy-Kern',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
this.kern = kern
|
||||
|
||||
kern.on('message', (nachricht: unknown) => {
|
||||
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
|
||||
})
|
||||
|
||||
kern.on('spawn', () => {
|
||||
this.beiZustand(this.zustand())
|
||||
})
|
||||
|
||||
kern.on('exit', (code) => {
|
||||
this.kern = null
|
||||
if (this.beendenGewollt) return
|
||||
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
|
||||
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
|
||||
const jetzt = Date.now()
|
||||
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
|
||||
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
|
||||
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
|
||||
this.beiZustand(this.zustand())
|
||||
return
|
||||
}
|
||||
this.neustartZeiten.push(jetzt)
|
||||
this.neustarts += 1
|
||||
this.starten()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
|
||||
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
|
||||
* ins Fenster (§ 4.1).
|
||||
*/
|
||||
fensterVerbinden(webContents: WebContents): void {
|
||||
if (this.kern === null) return
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||
this.kern.postMessage(nachricht, [port1])
|
||||
webContents.postMessage('kern-port', null, [port2])
|
||||
}
|
||||
|
||||
zustand(): KernZustand {
|
||||
return {
|
||||
laeuft: this.kern !== null,
|
||||
pid: this.kern?.pid,
|
||||
neustarts: this.neustarts,
|
||||
}
|
||||
}
|
||||
|
||||
beenden(): void {
|
||||
this.beendenGewollt = true
|
||||
this.kern?.kill()
|
||||
this.kern = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
|
||||
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
|
||||
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
|
||||
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
|
||||
//
|
||||
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
|
||||
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
|
||||
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
|
||||
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
|
||||
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
|
||||
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
|
||||
//
|
||||
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
|
||||
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
|
||||
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
|
||||
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
|
||||
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
|
||||
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||
import koffi from 'koffi'
|
||||
|
||||
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||
// bei Offset 16.
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
|
||||
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
|
||||
|
||||
let gesetzt: LeineStatus | null = null
|
||||
|
||||
/**
|
||||
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
|
||||
* VOR dem Start des Kerns — Kinder erben die Mitgliedschaft nur, wenn sie
|
||||
* nach dem Anlegen gestartet werden.
|
||||
*
|
||||
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
|
||||
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
|
||||
*/
|
||||
export function leineSetzen(): LeineStatus {
|
||||
if (gesetzt !== null) return gesetzt
|
||||
try {
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateJobObjectW = kernel32.func(
|
||||
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
|
||||
)
|
||||
const SetInformationJobObject = kernel32.func(
|
||||
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
|
||||
)
|
||||
const AssignProcessToJobObject = kernel32.func(
|
||||
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
|
||||
)
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
|
||||
const job = CreateJobObjectW(null, null)
|
||||
if (koffi.address(job) === 0n) {
|
||||
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
|
||||
if (
|
||||
!SetInformationJobObject(
|
||||
job,
|
||||
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
|
||||
info,
|
||||
STRUKTUR_GROESSE,
|
||||
)
|
||||
) {
|
||||
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
|
||||
// Prozess-Tod IST der Mechanismus.
|
||||
gesetzt = { ok: true }
|
||||
return gesetzt
|
||||
} catch (fehler) {
|
||||
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
return gesetzt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user