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
+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;
}