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,4 @@
|
|||||||
|
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Rippy v5 bauen — Anleitung
|
||||||
|
|
||||||
|
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||||
|
> die Messungen dazu liegen in `beweise/`.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Werkzeug | Version | Anmerkung |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||||
|
| npm | 11 | kommt mit Node 24 |
|
||||||
|
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||||
|
|
||||||
|
## Einrichten — zwei Schritte, nicht einer
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||||
|
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||||
|
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||||
|
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||||
|
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||||
|
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||||
|
|
||||||
|
## Die Läufe
|
||||||
|
|
||||||
|
| Befehl | Was er tut |
|
||||||
|
|--------|------------|
|
||||||
|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||||
|
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||||
|
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||||
|
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||||
|
|
||||||
|
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||||
|
fasst die echten Einstellungen nicht an.
|
||||||
|
|
||||||
|
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||||
|
|
||||||
|
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||||
|
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||||
|
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||||
|
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||||
|
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||||
|
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||||
|
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||||
|
|
||||||
|
## Festgelegte Versionen und warum
|
||||||
|
|
||||||
|
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||||
|
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||||
|
Release prüfen, ob eine neue Linie ansteht.
|
||||||
|
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||||
|
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||||
|
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||||
|
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||||
|
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||||
|
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||||
|
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||||
|
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||||
|
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||||
|
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||||
|
// haupt → out/haupt/index.js (Electron Main)
|
||||||
|
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||||
|
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||||
|
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||||
|
// fenster → out/fenster/ (React)
|
||||||
|
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||||
|
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||||
|
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||||
|
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||||
|
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||||
|
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||||
|
function cspNurImBau(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'csp-nur-im-bau',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<head>',
|
||||||
|
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'" />`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/haupt',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||||
|
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/bruecke',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: 'src/fenster',
|
||||||
|
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/fenster',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+3818
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "rippy",
|
||||||
|
"productName": "Rippy",
|
||||||
|
"version": "5.0.0-w0",
|
||||||
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
|
"main": "out/haupt/index.js",
|
||||||
|
"author": "KrBrZ",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"smoke": "electron-vite build && electron . --smoke",
|
||||||
|
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"koffi": "^3.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"electron": "44.0.0",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "^7.3.6",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Tests für kern/speicher/db.ts — laufen mit dem System-Node (node:sqlite
|
||||||
|
// gibt es dort wie in Electrons Node, KONZEPT-WINDOWS.md § 3.4).
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { Datenbank } from '../src/kern/speicher/db'
|
||||||
|
|
||||||
|
let aufraeumen: Array<() => void> = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// LIFO — zuletzt Registriertes zuerst: die Datenbank muss ZU sein, bevor
|
||||||
|
// ihr Ordner gelöscht wird (Windows verweigert das Löschen offener Dateien).
|
||||||
|
for (const weg of aufraeumen.splice(0).reverse()) weg()
|
||||||
|
})
|
||||||
|
|
||||||
|
function frischerPfad(): string {
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-db-test-'))
|
||||||
|
aufraeumen.push(() => rmSync(ordner, { recursive: true, force: true }))
|
||||||
|
return join(ordner, 'unterordner', 'rippy.db')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Datenbank', () => {
|
||||||
|
it('legt Datei samt fehlender Ordner an und meldet Schema-Version', () => {
|
||||||
|
const pfad = frischerPfad()
|
||||||
|
const db = new Datenbank(pfad)
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
expect(existsSync(pfad)).toBe(true)
|
||||||
|
expect(db.schemaVersion()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt und liest eine Einstellung', () => {
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
db.einstellungSchreiben('ablage', 'E:\\Rippy')
|
||||||
|
expect(db.einstellungLesen('ablage')).toBe('E:\\Rippy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('überschreibt eine vorhandene Einstellung', () => {
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
db.einstellungSchreiben('ablage', 'C:\\alt')
|
||||||
|
db.einstellungSchreiben('ablage', 'E:\\neu')
|
||||||
|
expect(db.einstellungLesen('ablage')).toBe('E:\\neu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unbekannter Schlüssel ist null, kein Fehler und kein Leerwert', () => {
|
||||||
|
// R2: „nicht gefunden" ist eine Aussage (null) — kein erfundener
|
||||||
|
// Leerstring, der als Wert gelesen würde.
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
expect(db.einstellungLesen('gibt-es-nicht')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bleibt über Schließen und Neuöffnen erhalten', () => {
|
||||||
|
const pfad = frischerPfad()
|
||||||
|
const erste = new Datenbank(pfad)
|
||||||
|
erste.einstellungSchreiben('letzter_start', '2026-08-30T12:00:00Z')
|
||||||
|
erste.schliessen()
|
||||||
|
const zweite = new Datenbank(pfad)
|
||||||
|
aufraeumen.push(() => zweite.schliessen())
|
||||||
|
expect(zweite.einstellungLesen('letzter_start')).toBe('2026-08-30T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wirft bei nicht anlegbarem Pfad, statt still weiterzumachen', () => {
|
||||||
|
// R4: Der Aufrufer (kern/index.ts) fängt das und MELDET es — aber die
|
||||||
|
// Datenbank selbst darf so einen Zustand nie verschlucken.
|
||||||
|
// NUL ist unter Windows als Ordnername unzulässig; unter Linux (CI)
|
||||||
|
// scheitert das Anlegen unterhalb einer Datei genauso zuverlässig.
|
||||||
|
const kaputt = join(process.platform === 'win32' ? 'C:\\NUL<>:' : '/dev/null/x', 'rippy.db')
|
||||||
|
expect(() => new Datenbank(kaputt)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Tests für das Nachrichten-Schema (gemeinsam/nachrichten.ts). Die
|
||||||
|
// Prüf-Funktionen sind die Eingangstür beider Prozesse — was hier
|
||||||
|
// durchkommt, wird drüben ungeprüft verwendet.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
istDbStatus,
|
||||||
|
istFensterNachricht,
|
||||||
|
istHauptNachricht,
|
||||||
|
istKernNachricht,
|
||||||
|
} from '../src/gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
describe('istKernNachricht', () => {
|
||||||
|
it('akzeptiert kern-bereit mit vollständigen Feldern', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'kern-bereit',
|
||||||
|
pid: 1234,
|
||||||
|
nodeVersion: '24.18.1',
|
||||||
|
datenbank: { ok: true, pfad: 'C:\\x\\rippy.db' },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('akzeptiert kern-bereit auch mit Datenbank-Fehler', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'kern-bereit',
|
||||||
|
pid: 1,
|
||||||
|
nodeVersion: '24.18.1',
|
||||||
|
datenbank: { ok: false, fehler: 'Platte voll' },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('akzeptiert pong und kern-fehler', () => {
|
||||||
|
expect(istKernNachricht({ art: 'pong', zeit: 17 })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'kern-fehler', text: 'kaputt' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lehnt Unvollständiges und Fremdes ab', () => {
|
||||||
|
expect(istKernNachricht(null)).toBe(false)
|
||||||
|
expect(istKernNachricht('kern-bereit')).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'kern-bereit', pid: 1 })).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'pong', zeit: 'gleich' })).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'unbekannt' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istDbStatus', () => {
|
||||||
|
it('verlangt zum ok-Wert den Pfad, zum Fehler den Text', () => {
|
||||||
|
expect(istDbStatus({ ok: true, pfad: 'x' })).toBe(true)
|
||||||
|
expect(istDbStatus({ ok: false, fehler: 'y' })).toBe(true)
|
||||||
|
expect(istDbStatus({ ok: true })).toBe(false)
|
||||||
|
expect(istDbStatus({ ok: false })).toBe(false)
|
||||||
|
expect(istDbStatus({ ok: 'ja' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istFensterNachricht', () => {
|
||||||
|
it('akzeptiert nur ping mit Zeit', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'ping', zeit: 1 })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'ping' })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'pong', zeit: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istHauptNachricht', () => {
|
||||||
|
it('akzeptiert nur fenster-port', () => {
|
||||||
|
expect(istHauptNachricht({ art: 'fenster-port' })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'port' })).toBe(false)
|
||||||
|
expect(istHauptNachricht(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
|
||||||
|
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
|
||||||
|
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join, relative, sep } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const WURZEL = join(__dirname, '..')
|
||||||
|
const SRC = join(WURZEL, 'src')
|
||||||
|
|
||||||
|
function quelldateien(ordner: string): string[] {
|
||||||
|
const ergebnis: string[] = []
|
||||||
|
for (const name of readdirSync(ordner)) {
|
||||||
|
const pfad = join(ordner, name)
|
||||||
|
if (statSync(pfad).isDirectory()) {
|
||||||
|
ergebnis.push(...quelldateien(pfad))
|
||||||
|
} else if (/\.(ts|tsx)$/.test(name)) {
|
||||||
|
ergebnis.push(pfad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativPosix(pfad: string): string {
|
||||||
|
return relative(WURZEL, pfad).split(sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateien = quelldateien(SRC).map((pfad) => ({
|
||||||
|
pfad: relativPosix(pfad),
|
||||||
|
inhalt: readFileSync(pfad, 'utf8'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
||||||
|
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
|
||||||
|
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
|
||||||
|
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
|
||||||
|
const erlaubt = new Set(['src/haupt/leine.ts', 'src/kern/laufwerk/win32.ts'])
|
||||||
|
|
||||||
|
it('koffi wird nirgendwo sonst angefasst', () => {
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => !erlaubt.has(d.pfad))
|
||||||
|
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
|
||||||
|
it('kein leerer catch-Block', () => {
|
||||||
|
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
|
||||||
|
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
|
||||||
|
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
|
||||||
|
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
|
||||||
|
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
|
||||||
|
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
|
||||||
|
it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
|
||||||
|
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
|
||||||
|
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
|
||||||
|
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
|
||||||
|
// reinen Variablennamen ist erlaubt.
|
||||||
|
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
|
||||||
|
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
||||||
|
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
||||||
|
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
||||||
|
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
|
||||||
|
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": [],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/bruecke/**/*.ts", "src/gemeinsam/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.bruecke.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node", "electron"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/haupt/**/*.ts",
|
||||||
|
"src/kern/**/*.ts",
|
||||||
|
"src/gemeinsam/**/*.ts",
|
||||||
|
"electron.vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"test/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/fenster/**/*.ts", "src/fenster/**/*.tsx", "src/gemeinsam/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Testlauf (Vitest) — läuft mit dem System-Node, NICHT in Electron.
|
||||||
|
// Deshalb dürfen Tests nur Module anfassen, die ohne Electron und ohne
|
||||||
|
// Windows laufen: gemeinsam/, kern/speicher/ (node:sqlite gibt es in
|
||||||
|
// Node 24 wie in Electrons Node — KONZEPT-WINDOWS.md § 3.4) und die
|
||||||
|
// Wächter, die Quelltext nur LESEN. Alles mit koffi/Win32 wird hier
|
||||||
|
// weder importiert noch ausgeführt — das beweist der Smoke-Lauf (BAUEN.md).
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user