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:
Hitonabi
2026-08-30 16:53:30 +02:00
co-authored by Claude Fable 5
parent 564bd17f84
commit c539e65cd2
27 changed files with 5207 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
// Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
//
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
let seiteBereit = false
const wartendePorts: IpcRendererEvent[] = []
ipcRenderer.on('kern-port', (ereignis) => {
if (seiteBereit) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
} else {
wartendePorts.push(ereignis)
}
})
let letzterStatus: unknown = null
const statusHorcher = new Set<(status: unknown) => void>()
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
letzterStatus = status
for (const horcher of statusHorcher) horcher(status)
})
contextBridge.exposeInMainWorld('rippy', {
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
bereit(): void {
seiteBereit = true
for (const ereignis of wartendePorts.splice(0)) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
}
},
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
aufStatus(horcher: (status: unknown) => void): void {
statusHorcher.add(horcher)
if (letzterStatus !== null) horcher(letzterStatus)
},
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
melden(art: string): void {
ipcRenderer.send('fenster-meldung', String(art))
},
})
+119
View File
@@ -0,0 +1,119 @@
// W-0-Statusbild: zeigt, dass die drei Prozesse stehen und miteinander
// reden. Die echte Oberfläche (Dashboard, Bibliothek, Einstellungen) ist
// Etappe W-5 — hier geht es nur darum, das Gerüst SICHTBAR zu beweisen.
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../gemeinsam/nachrichten'
import { aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return (
typeof wert === 'object' &&
wert !== null &&
typeof (wert as Record<string, unknown>).version === 'string'
)
}
interface KachelProps {
titel: string
zustand: 'ok' | 'fehler' | 'wartet'
zeilen: string[]
}
function Kachel({ titel, zustand, zeilen }: KachelProps) {
const punktFarbe =
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{titel}</h2>
</div>
<div className="mt-2 space-y-0.5">
{zeilen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-slate-400">
{zeile}
</p>
))}
</div>
</div>
)
}
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
const [verbindung, setVerbindung] = useState<Verbindung>({
verbunden: false,
kern: null,
pongLatenzMs: null,
})
useEffect(() => {
window.rippy.aufStatus((status) => {
if (istHauptStatus(status)) setHaupt(status)
})
verbindungStarten()
return aufVerbindung(setVerbindung)
}, [])
const db = verbindung.kern?.datenbank ?? null
return (
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
<header className="border-b border-slate-700/60 px-6 py-4">
<div className="flex items-baseline gap-3">
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
<span className="font-mono text-xs text-slate-400">
v{haupt?.version ?? '…'} · Etappe W-0 Gerüst
</span>
</div>
<p className="mt-1 text-sm text-slate-400">
Drei Prozesse, eine Datenbank, eine Leine das Fundament für Rippy v5.
</p>
</header>
<main className="grid flex-1 content-start gap-4 p-6 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
</main>
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
Rippy v5 · LucyAI · Claude · KrBrZ
</footer>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
export {}
declare global {
interface Window {
rippy: {
bereit(): void
aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './stil.css'
createRoot(document.getElementById('wurzel')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="./haupt.tsx"></script>
</body>
</html>
@@ -0,0 +1,68 @@
// 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 Kern-Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam.
import { istKernNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
}
type Horcher = (stand: Verbindung) => void
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null }
const horcher = new Set<Horcher>()
let gestartet = false
function melden(): void {
for (const h of horcher) h({ ...stand })
}
function portAnnehmen(port: MessagePort): void {
stand.verbunden = true
port.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (!istKernNachricht(nachricht)) return
if (nachricht.art === 'kern-bereit') {
stand.kern = nachricht
} else if (nachricht.art === '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')
}
melden()
}
port.start()
port.postMessage({ art: 'ping', zeit: Date.now() })
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<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({ ...stand })
return () => horcher.delete(h)
}
+7
View File
@@ -0,0 +1,7 @@
@import 'tailwindcss';
/* Grundstimmung wie das heutige Rippy: dunkel, ruhig. Mehr Gestaltung
kommt mit der echten Oberfläche in Etappe W-5. */
:root {
color-scheme: dark;
}
@@ -0,0 +1,69 @@
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = { art: 'ping'; zeit: number }
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
export type HauptNachricht = { art: 'fenster-port' }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
default:
return false
}
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
return istObjekt(wert) && wert.art === 'ping' && typeof wert.zeit === 'number'
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
return istObjekt(wert) && wert.art === 'fenster-port'
}
+37
View File
@@ -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
}
+171
View File
@@ -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)
}
}
+91
View File
@@ -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
}
}
+92
View File
@@ -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
}
}
+89
View File
@@ -0,0 +1,89 @@
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. In W-0 kann der
// Kern: die Datenbank öffnen (node:sqlite, § 3.4), sich beim Haupt melden und
// über einen direkten MessagePort mit dem Fenster reden (Ping → Pong).
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type KernNachricht,
} from '../gemeinsam/nachrichten'
import { Datenbank } from './speicher/db'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
// nicht — er ist ein reiner Node-Prozess).
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
let db: Datenbank | null = null
let dbStatus: DbStatus
const pfad = datenbankPfadAusArgv(process.argv)
if (pfad === null) {
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
} else {
try {
db = new Datenbank(pfad)
// Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis.
db.einstellungSchreiben('letzter_start', new Date().toISOString())
if (db.einstellungLesen('letzter_start') === null) {
dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' }
} else {
dbStatus = { ok: true, pfad }
}
} catch (fehler) {
// R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt.
// Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu
// »es geht nicht« werden (§ 6.2, sinngemäß).
dbStatus = { ok: false, fehler: String(fehler) }
}
}
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
fensterPort?.postMessage(nachricht)
}
process.parentPort.on('message', (ereignis) => {
const nachricht: unknown = ereignis.data
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
// Fenster bringt einen neuen Port — der alte wird geschlossen.
fensterPort?.close()
fensterPort = ereignis.ports[0] ?? null
if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data
if (istFensterNachricht(frage) && frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
}
})
fensterPort.start()
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
anFenster(bereitNachricht())
}
})
function bereitNachricht(): KernNachricht {
return {
art: 'kern-bereit',
pid: process.pid,
nodeVersion: process.versions.node,
datenbank: dbStatus,
}
}
process.parentPort.postMessage(bereitNachricht())
+70
View File
@@ -0,0 +1,70 @@
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
// einer Konfigurationsdatei, die niemand schrieb.
//
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
const SCHEMA_VERSION = 1
export class Datenbank {
readonly pfad: string
private db: DatabaseSync
/**
* Öffnet (oder erzeugt) die Datenbank und legt das Schema an.
* Wirft bei nicht anlegbarem Pfad — der Aufrufer meldet den Fehler
* LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten.
*/
constructor(pfad: string) {
this.pfad = pfad
if (pfad !== ':memory:') {
mkdirSync(dirname(pfad), { recursive: true })
}
this.db = new DatabaseSync(pfad)
this.db.exec(
`CREATE TABLE IF NOT EXISTS meta (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);`,
)
this.db
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
.run(String(SCHEMA_VERSION))
}
/** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */
einstellungLesen(schluessel: string): string | null {
const zeile = this.db
.prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`)
.get(schluessel) as { wert: string } | undefined
return zeile === undefined ? null : zeile.wert
}
einstellungSchreiben(schluessel: string, wert: string): void {
this.db
.prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`)
.run(schluessel, wert)
}
schemaVersion(): number {
const zeile = this.db
.prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`)
.get() as { wert: string } | undefined
return zeile === undefined ? 0 : Number(zeile.wert)
}
schliessen(): void {
this.db.close()
}
}