feat(v5): Etappe W-1 — Laufwerk: Wache, Typ, Auswerfen mit Nachsehen
Ampel / ampel (push) Successful in 1m21s
Ampel / ampel (push) Successful in 1m21s
kern/laufwerk in drei Schichten: codes.ts (Steuercodes HERGELEITET wie win_ioctl.py, gegen die Doku-Zahlen getestet), win32.ts (der einzige koffi-Ort im Kern, Muster aus beweise/laufwerk.js), disc.ts (Logik gegen die LaufwerkApi-Schnittstelle — classify mit den cdrom.py-Schwellen, Geraete-Info mit ready/empty/unknown + Klartext-Grund, Auswurf als entriegeln→auswerfen→NACHSEHEN mit injizierbarem Warten). Dazu wache.ts im 3-Sekunden-Takt (§ 5 Plan A): Uebergangslogik pur, gescheiterte Runde behaelt den Stand (R2) und meldet laut (R4). Fenster zeigt Laufwerke live (Modell, Typ, Groesse, Grund) mit Auswerfen-Knopf; Ereigniszeilen fuer Disc rein/raus und Fehler. Bündel-Falle gefunden und behoben: Ein woertliches require überlebt Rollup nicht — win32 wird per dynamischem import() als eigener Chunk gebaut; der Fehler war dank R4 im Fenster sichtbar statt still. Gemessen am echten BU40N (30.08.2026, RIPPY_MESSUNG=1): Laufwerk G: status=ready typ=bluray groesse=33759690752 modell=HL-DT-ST BD-RE BU40N 1.03 serial=0025114C0149 Der Auswurf-Beweis (messung.auswurf) folgt am Ende der Sitzung — der BU40N kann die Schublade nicht selbst einziehen. 52 Tests gruen (Steuercodes, classify, Auswurf-Ablauf, Geraete-Info, Wache, Smoke weiterhin gruen mit Laufwerks-Kachel im Bild). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6ed8035dcc
commit
a45069af5e
@@ -1,9 +1,9 @@
|
||||
// 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.
|
||||
// Statusbild W-0/W-1: die drei Prozesse plus die echten Laufwerke aus der
|
||||
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
|
||||
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { HauptStatus } from '../gemeinsam/nachrichten'
|
||||
import { aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
|
||||
import type { GeraeteInfo, HauptStatus } from '../gemeinsam/nachrichten'
|
||||
import { aufVerbindung, auswerfen, verbindungStarten, type Verbindung } from './kernverbindung'
|
||||
|
||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
return (
|
||||
@@ -13,6 +13,18 @@ function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
)
|
||||
}
|
||||
|
||||
function gb(bytes: number): string {
|
||||
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
|
||||
}
|
||||
|
||||
const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
||||
cd: 'Audio-CD',
|
||||
dvd: 'DVD',
|
||||
bluray: 'Blu-ray',
|
||||
uhd: '4K-UHD',
|
||||
unknown: 'unbekannt',
|
||||
}
|
||||
|
||||
interface KachelProps {
|
||||
titel: string
|
||||
zustand: 'ok' | 'fehler' | 'wartet'
|
||||
@@ -39,12 +51,52 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe =
|
||||
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-slate-500'
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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">{geraet.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => auswerfen(geraet.id)}
|
||||
disabled={geraet.status !== 'ready'}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||
>
|
||||
Auswerfen
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
|
||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
||||
{geraet.status === 'ready' && (
|
||||
<p className="text-slate-200">
|
||||
{TYP_NAMEN[geraet.typ]}
|
||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
<p className="text-amber-300">{geraet.grund}</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,
|
||||
laufwerke: [],
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,52 +115,91 @@ export default function App() {
|
||||
<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
|
||||
v{haupt?.version ?? '…'} · Etappe W-1 — Laufwerk
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Drei Prozesse, eine Datenbank, eine Leine — das Fundament für Rippy v5.
|
||||
Drei Prozesse, eine Datenbank, eine Leine — und die Disc-Wache läuft.
|
||||
</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 className="flex-1 space-y-6 p-6">
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Laufwerke
|
||||
</h2>
|
||||
{verbindung.laufwerke.length === 0 ? (
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel key={geraet.id} geraet={geraet} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Gerüst
|
||||
</h2>
|
||||
<div className="grid content-start gap-4 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 …']
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{verbindung.meldungen.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Ereignisse
|
||||
</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
{verbindung.meldungen.map((zeile, i) => (
|
||||
<p key={i} className="font-mono text-xs text-slate-400">
|
||||
{zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
|
||||
|
||||
@@ -2,43 +2,84 @@
|
||||
// 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
|
||||
// R2 gilt auch hier: Der letzte bekannte 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'
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import { istKernNachricht, type GeraeteInfo, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null }
|
||||
const MAX_MELDUNGEN = 8
|
||||
|
||||
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], meldungen: [] }
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) h({ ...stand })
|
||||
for (const h of horcher) h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
|
||||
}
|
||||
|
||||
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') {
|
||||
function meldung(text: string): void {
|
||||
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||
}
|
||||
|
||||
function kernNachricht(nachricht: KernNachricht): void {
|
||||
switch (nachricht.art) {
|
||||
case 'kern-bereit':
|
||||
stand.kern = nachricht
|
||||
} else if (nachricht.art === 'pong') {
|
||||
break
|
||||
case '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')
|
||||
break
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'auswurf-ergebnis': {
|
||||
const text =
|
||||
nachricht.grund === 'ok'
|
||||
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
melden()
|
||||
}
|
||||
port.start()
|
||||
port.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
melden()
|
||||
}
|
||||
|
||||
function portAnnehmen(neuerPort: MessagePort): void {
|
||||
port = neuerPort
|
||||
stand.verbunden = true
|
||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
melden()
|
||||
}
|
||||
|
||||
@@ -63,6 +104,15 @@ export function verbindungStarten(): void {
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({ ...stand })
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Auswerfen ${id}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'auswerfen', id })
|
||||
}
|
||||
|
||||
@@ -11,14 +11,43 @@
|
||||
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||||
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||||
|
||||
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||||
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||||
|
||||
export interface GeraeteInfo {
|
||||
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||||
id: string
|
||||
name: string
|
||||
typ: DiscTyp
|
||||
pfad: string
|
||||
status: LaufwerkStatus
|
||||
modell: string
|
||||
seriennummer: string
|
||||
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||||
groesseBytes: number
|
||||
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export type WacheEreignis =
|
||||
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||||
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-weg'; id: string }
|
||||
|
||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||
|
||||
/** 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 }
|
||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht = { art: 'ping'; zeit: number }
|
||||
export type FensterNachricht = { art: 'ping'; zeit: number } | { art: 'auswerfen'; id: string }
|
||||
|
||||
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
|
||||
export type HauptNachricht = { art: 'fenster-port' }
|
||||
@@ -48,6 +77,14 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
return typeof wert.zeit === 'number'
|
||||
case 'kern-fehler':
|
||||
return typeof wert.text === 'string'
|
||||
case 'laufwerke':
|
||||
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||||
case 'auswurf-ergebnis':
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.grund === 'string' &&
|
||||
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -61,7 +98,10 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
}
|
||||
|
||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||
return istObjekt(wert) && wert.art === 'ping' && typeof wert.zeit === 'number'
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||
if (wert.art === 'auswerfen') return typeof wert.id === 'string' && wert.id.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||
|
||||
@@ -39,7 +39,7 @@ function start(): void {
|
||||
}
|
||||
|
||||
let fenster: BrowserWindow | null = null
|
||||
let letzteKernNachricht: KernNachricht | null = null
|
||||
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
|
||||
|
||||
const smoke = {
|
||||
fenster: false,
|
||||
@@ -52,8 +52,8 @@ function start(): void {
|
||||
const kernVerwaltung = new KernVerwaltung(
|
||||
join(app.getPath('userData'), 'rippy.db'),
|
||||
(nachricht) => {
|
||||
letzteKernNachricht = nachricht
|
||||
if (nachricht.art === 'kern-bereit') {
|
||||
kernBereit = nachricht
|
||||
smoke.kern = true
|
||||
smoke.datenbank = nachricht.datenbank.ok
|
||||
if (!nachricht.datenbank.ok) {
|
||||
@@ -142,11 +142,8 @@ function start(): void {
|
||||
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=?'
|
||||
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
|
||||
console.log(
|
||||
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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).
|
||||
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
|
||||
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
||||
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
||||
//
|
||||
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
||||
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
type DbStatus,
|
||||
type KernNachricht,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { Datenbank } from './speicher/db'
|
||||
|
||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
@@ -57,6 +60,66 @@ function anFenster(nachricht: KernNachricht): void {
|
||||
fensterPort?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
function anHauptUndFenster(nachricht: KernNachricht): void {
|
||||
process.parentPort.postMessage(nachricht)
|
||||
anFenster(nachricht)
|
||||
}
|
||||
|
||||
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||
let laufwerkApi: LaufwerkApi | null = null
|
||||
let laufwerkFehler: string | null = null
|
||||
let wache: DiscWache | null = null
|
||||
|
||||
const laufwerkeBereit: Promise<void> = (async () => {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||
}
|
||||
const { win32Api } = await import('./laufwerk/win32')
|
||||
laufwerkApi = win32Api()
|
||||
wache = new DiscWache(
|
||||
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||
(meldung) => {
|
||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
},
|
||||
(fehler) => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
|
||||
},
|
||||
)
|
||||
wache.starten()
|
||||
} catch (fehler) {
|
||||
laufwerkFehler = String(fehler)
|
||||
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
|
||||
}
|
||||
})()
|
||||
|
||||
async function auswerfen(id: string): Promise<void> {
|
||||
await laufwerkeBereit
|
||||
if (laufwerkApi === null) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
|
||||
return
|
||||
}
|
||||
const geraete = listOptischeLaufwerke(laufwerkApi)
|
||||
const geraet = geraete.find((g) => kennung(g) === id)
|
||||
if (geraet === undefined) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||
return
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||
wache?.runde()
|
||||
}
|
||||
|
||||
process.parentPort.on('message', (ereignis) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||
@@ -67,13 +130,22 @@ process.parentPort.on('message', (ereignis) => {
|
||||
if (fensterPort === null) return
|
||||
fensterPort.on('message', (portEreignis) => {
|
||||
const frage: unknown = portEreignis.data
|
||||
if (istFensterNachricht(frage) && frage.art === 'ping') {
|
||||
if (!istFensterNachricht(frage)) return
|
||||
if (frage.art === 'ping') {
|
||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
} else if (frage.art === 'auswerfen') {
|
||||
void auswerfen(frage.id)
|
||||
}
|
||||
})
|
||||
fensterPort.start()
|
||||
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
|
||||
anFenster(bereitNachricht())
|
||||
void laufwerkeBereit.then(() => {
|
||||
if (laufwerkFehler !== null) {
|
||||
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
|
||||
}
|
||||
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
|
||||
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
|
||||
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
|
||||
// allein in win32.ts (Wächter R1).
|
||||
//
|
||||
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
|
||||
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
|
||||
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
|
||||
// Microsoft-Dokumentation genannten Zahlen gegen.
|
||||
//
|
||||
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
|
||||
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
|
||||
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
|
||||
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
|
||||
// dort an echter Hardware bewiesen).
|
||||
|
||||
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
|
||||
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
|
||||
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
|
||||
}
|
||||
|
||||
const FILE_DEVICE_CD_ROM = 0x02
|
||||
const FILE_DEVICE_DISK = 0x07
|
||||
const FILE_DEVICE_MASS_STORAGE = 0x2d
|
||||
|
||||
const METHOD_BUFFERED = 0
|
||||
const METHOD_OUT_DIRECT = 2
|
||||
|
||||
const FILE_ANY_ACCESS = 0
|
||||
const FILE_READ_ACCESS = 0x0001
|
||||
|
||||
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
|
||||
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
|
||||
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
|
||||
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
|
||||
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
|
||||
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
|
||||
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
|
||||
export const CDROM_TOC_BYTES = 4 + 100 * 8
|
||||
|
||||
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
|
||||
export const SDD_VENDOR_ID_OFFSET = 12
|
||||
export const SDD_PRODUCT_ID_OFFSET = 16
|
||||
export const SDD_PRODUCT_REVISION_OFFSET = 20
|
||||
export const SDD_SERIAL_NUMBER_OFFSET = 24
|
||||
|
||||
export const CDROM_DISK_AUDIO_TRACK = 0x01
|
||||
export const CDROM_DISK_DATA_TRACK = 0x02
|
||||
|
||||
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
|
||||
export const ERROR_INVALID_FUNCTION = 1
|
||||
export const ERROR_FILE_NOT_FOUND = 2
|
||||
export const ERROR_ACCESS_DENIED = 5
|
||||
export const ERROR_NOT_READY = 21
|
||||
export const ERROR_NOT_SUPPORTED = 50
|
||||
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
|
||||
|
||||
export const DRIVE_CDROM = 5
|
||||
|
||||
export const GENERIC_READ = 0x8000_0000
|
||||
export const FILE_SHARE_READ = 0x01
|
||||
export const FILE_SHARE_WRITE = 0x02
|
||||
export const OPEN_EXISTING = 3
|
||||
|
||||
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
|
||||
export class Win32Fehler extends Error {
|
||||
constructor(
|
||||
was: string,
|
||||
readonly code: number,
|
||||
) {
|
||||
super(`${was} scheiterte (Win32-Fehler ${code})`)
|
||||
this.name = 'Win32Fehler'
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
|
||||
export interface LaufwerkApi {
|
||||
laufwerksbuchstaben(): string[]
|
||||
laufwerkstyp(buchstabe: string): number
|
||||
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
||||
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
||||
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
||||
oeffnen(geraetepfad: string, zugriff: number): unknown
|
||||
schliessen(handle: unknown): void
|
||||
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
|
||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
|
||||
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
|
||||
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
|
||||
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
|
||||
// (windows.py, an echter Hardware gemessen 28.08.2026):
|
||||
//
|
||||
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
|
||||
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
|
||||
// Auswurf mit Erfolg und tut nichts (R3).
|
||||
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
|
||||
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
|
||||
import {
|
||||
CDROM_DISK_AUDIO_TRACK,
|
||||
CDROM_DISK_DATA_TRACK,
|
||||
ERROR_ACCESS_DENIED,
|
||||
ERROR_FILE_NOT_FOUND,
|
||||
ERROR_INVALID_FUNCTION,
|
||||
ERROR_NOT_READY,
|
||||
ERROR_NOT_SUPPORTED,
|
||||
ERROR_NO_MEDIA_IN_DRIVE,
|
||||
DRIVE_CDROM,
|
||||
GENERIC_READ,
|
||||
IOCTL_CDROM_DISK_TYPE,
|
||||
IOCTL_DISK_GET_LENGTH_INFO,
|
||||
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||
IOCTL_STORAGE_EJECT_MEDIA,
|
||||
IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
SDD_PRODUCT_ID_OFFSET,
|
||||
SDD_PRODUCT_REVISION_OFFSET,
|
||||
SDD_SERIAL_NUMBER_OFFSET,
|
||||
SDD_VENDOR_ID_OFFSET,
|
||||
Win32Fehler,
|
||||
type LaufwerkApi,
|
||||
} from './codes'
|
||||
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
|
||||
|
||||
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
|
||||
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
|
||||
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
|
||||
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
|
||||
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
|
||||
export const UHD_MIN_BYTES = 55 * 1024 ** 3
|
||||
|
||||
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
|
||||
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
|
||||
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
|
||||
if (spurFlags & CDROM_DISK_DATA_TRACK) {
|
||||
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
|
||||
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** „D:" oder „D" oder „D:\" → \\.\D: — der Gerätepfad hat KEINEN
|
||||
* abschließenden Backslash. Mit einem öffnet CreateFileW das
|
||||
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
|
||||
* ERROR_INVALID_FUNCTION — das sieht aus wie „kann das Laufwerk nicht". */
|
||||
export function geraetepfad(buchstabe: string): string {
|
||||
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
|
||||
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
|
||||
return '\\\\.\\' + kern[0].toUpperCase() + ':'
|
||||
}
|
||||
|
||||
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
|
||||
export function kennung(geraet: string): string {
|
||||
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||
}
|
||||
|
||||
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
|
||||
return api
|
||||
.laufwerksbuchstaben()
|
||||
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
|
||||
.map(geraetepfad)
|
||||
.sort()
|
||||
}
|
||||
|
||||
// ── Zustand ─────────────────────────────────────────────────────────────
|
||||
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
|
||||
return true
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
|
||||
return false
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
|
||||
const handle = api.oeffnen(geraet, zugriff)
|
||||
try {
|
||||
return tun(handle)
|
||||
} finally {
|
||||
api.schliessen(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
|
||||
export function spurFlags(api: LaufwerkApi, geraet: string): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
|
||||
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
|
||||
})
|
||||
}
|
||||
|
||||
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
|
||||
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
|
||||
})
|
||||
}
|
||||
|
||||
// ── Verriegeln ──────────────────────────────────────────────────────────
|
||||
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
|
||||
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
|
||||
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
|
||||
mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auswerfen ───────────────────────────────────────────────────────────
|
||||
export type { AuswurfGrund }
|
||||
|
||||
const AUSWURF_NACHSEHEN_RUNDEN = 5
|
||||
|
||||
/** Entriegeln → auswerfen → NACHSEHEN (R3). Wirft NIE; gibt den Grund
|
||||
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
|
||||
* prüfbar ist. */
|
||||
export async function auswerfenMitGrund(
|
||||
api: LaufwerkApi,
|
||||
geraet: string,
|
||||
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
|
||||
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
|
||||
let handle: unknown
|
||||
try {
|
||||
handle = api.oeffnen(geraet, GENERIC_READ)
|
||||
} catch (fehler) {
|
||||
return { grund: 'kein-zugriff', fehler: String(fehler) }
|
||||
}
|
||||
try {
|
||||
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
|
||||
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
|
||||
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
|
||||
let entriegelnFehler: string | null = null
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
|
||||
} catch (fehler) {
|
||||
entriegelnFehler = String(fehler)
|
||||
}
|
||||
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
|
||||
} catch (fehler) {
|
||||
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||
}
|
||||
|
||||
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
|
||||
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
|
||||
try {
|
||||
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
|
||||
} catch (fehler) {
|
||||
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||
}
|
||||
await warten(1000)
|
||||
}
|
||||
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
|
||||
} finally {
|
||||
try {
|
||||
api.schliessen(handle)
|
||||
} catch {
|
||||
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
|
||||
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
|
||||
const ZUGRIFFS_GRUENDE: Record<number, string> = {
|
||||
[ERROR_INVALID_FUNCTION]:
|
||||
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
|
||||
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
|
||||
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
|
||||
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
|
||||
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
|
||||
}
|
||||
|
||||
export function zugriffsGrund(fehler: unknown): string {
|
||||
const code = fehler instanceof Win32Fehler ? fehler.code : null
|
||||
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
|
||||
if (code !== null) {
|
||||
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
|
||||
}
|
||||
return 'Das Laufwerk antwortet nicht.'
|
||||
}
|
||||
|
||||
// ── Geräteangaben ───────────────────────────────────────────────────────
|
||||
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
|
||||
* Offset 0 heißt „gibt es nicht" (dokumentiertes Verhalten des
|
||||
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
|
||||
export function textBei(puffer: Buffer, offsetPos: number): string {
|
||||
if (puffer.length < offsetPos + 4) return ''
|
||||
const offset = puffer.readUInt32LE(offsetPos)
|
||||
if (offset === 0 || offset >= puffer.length) return ''
|
||||
let ende = puffer.indexOf(0, offset)
|
||||
if (ende < 0) ende = puffer.length
|
||||
return puffer.toString('latin1', offset, ende).trim()
|
||||
}
|
||||
|
||||
export interface Geraeteangaben {
|
||||
hersteller: string
|
||||
modell: string
|
||||
seriennummer: string
|
||||
}
|
||||
|
||||
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht — ein Laufwerk
|
||||
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
|
||||
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
|
||||
* wenn das Medium gestört ist (rc11-Befund). */
|
||||
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
|
||||
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
|
||||
try {
|
||||
return mitHandle(api, geraet, 0, (h) => {
|
||||
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
|
||||
return {
|
||||
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
|
||||
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
|
||||
.filter((t) => t.length > 0)
|
||||
.join(' '),
|
||||
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return leer
|
||||
}
|
||||
}
|
||||
|
||||
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
|
||||
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
|
||||
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
|
||||
// rippt (Muster aus windows.py).
|
||||
const angabenSpeicher = new Map<string, Geraeteangaben>()
|
||||
|
||||
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||
let angaben = angabenSpeicher.get(geraet)
|
||||
if (angaben === undefined) {
|
||||
angaben = geraeteangaben(api, geraet)
|
||||
angabenSpeicher.set(geraet, angaben)
|
||||
}
|
||||
return angaben
|
||||
}
|
||||
|
||||
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
|
||||
export function angabenSpeicherLeeren(): void {
|
||||
angabenSpeicher.clear()
|
||||
}
|
||||
|
||||
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
|
||||
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
|
||||
let status: LaufwerkStatus = 'unknown'
|
||||
let typ: DiscTyp = 'unknown'
|
||||
let groesse = 0
|
||||
let grund = ''
|
||||
|
||||
try {
|
||||
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
|
||||
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
|
||||
if (da) {
|
||||
status = 'ready'
|
||||
try {
|
||||
groesse = discGroesseBytes(api, geraet)
|
||||
typ = classify(spurFlags(api, geraet), groesse)
|
||||
} catch (fehler) {
|
||||
typ = 'unknown'
|
||||
grund = zugriffsGrund(fehler)
|
||||
}
|
||||
} else {
|
||||
status = 'empty'
|
||||
}
|
||||
} catch (fehler) {
|
||||
status = 'unknown'
|
||||
grund = zugriffsGrund(fehler)
|
||||
}
|
||||
|
||||
const buchstabe = kennung(geraet)
|
||||
const angaben = angabenGemerkt(api, geraet)
|
||||
return {
|
||||
id: buchstabe,
|
||||
name: `Laufwerk ${buchstabe}:`,
|
||||
typ,
|
||||
pfad: geraet,
|
||||
status,
|
||||
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
|
||||
seriennummer: angaben.seriennummer,
|
||||
groesseBytes: groesse,
|
||||
grund,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
|
||||
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
|
||||
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
|
||||
// bleibt dieser Takt als Rückfallebene bestehen.
|
||||
//
|
||||
// Regeln, die hier verdrahtet sind:
|
||||
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
|
||||
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
|
||||
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
|
||||
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export type { WacheEreignis }
|
||||
|
||||
/** Pure Funktion: Zustandsvergleich → Ereignisse (Muster aus
|
||||
* bus/waechter.py `laufwerks_unterschiede`). */
|
||||
export function laufwerksUnterschiede(
|
||||
vorher: ReadonlyMap<string, GeraeteInfo>,
|
||||
jetzt: ReadonlyMap<string, GeraeteInfo>,
|
||||
): WacheEreignis[] {
|
||||
const ereignisse: WacheEreignis[] = []
|
||||
for (const [id, stand] of jetzt) {
|
||||
const alt = vorher.get(id)
|
||||
if (alt === undefined) {
|
||||
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
|
||||
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||
continue
|
||||
}
|
||||
const vorherBereit = alt.status === 'ready'
|
||||
const jetztBereit = stand.status === 'ready'
|
||||
if (!vorherBereit && jetztBereit) {
|
||||
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
|
||||
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
|
||||
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
|
||||
// Disc (R2); der Grund steht im Geräte-Eintrag.
|
||||
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
|
||||
}
|
||||
}
|
||||
for (const id of vorher.keys()) {
|
||||
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
|
||||
}
|
||||
return ereignisse
|
||||
}
|
||||
|
||||
export const WACHE_TAKT_MS = 3000
|
||||
|
||||
export interface WacheMeldung {
|
||||
laufwerke: GeraeteInfo[]
|
||||
ereignisse: WacheEreignis[]
|
||||
}
|
||||
|
||||
export class DiscWache {
|
||||
private stand = new Map<string, GeraeteInfo>()
|
||||
private zeiger: NodeJS.Timeout | null = null
|
||||
private laeuft = false
|
||||
|
||||
constructor(
|
||||
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
|
||||
private readonly lesen: () => GeraeteInfo[],
|
||||
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
|
||||
private readonly melden: (meldung: WacheMeldung) => void,
|
||||
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
|
||||
private readonly fehlerMelden: (fehler: unknown) => void,
|
||||
private readonly taktMs: number = WACHE_TAKT_MS,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
if (this.laeuft) return
|
||||
this.laeuft = true
|
||||
this.runde()
|
||||
}
|
||||
|
||||
stoppen(): void {
|
||||
this.laeuft = false
|
||||
if (this.zeiger !== null) {
|
||||
clearTimeout(this.zeiger)
|
||||
this.zeiger = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
|
||||
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||
runde(): void {
|
||||
if (!this.laeuft) return
|
||||
try {
|
||||
const liste = this.lesen()
|
||||
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
|
||||
const geaendert =
|
||||
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
|
||||
this.stand = jetzt
|
||||
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
|
||||
} catch (fehler) {
|
||||
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
|
||||
this.fehlerMelden(fehler)
|
||||
}
|
||||
if (this.laeuft) {
|
||||
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
|
||||
// Der Takt darf einen Programm-Exit nicht aufhalten.
|
||||
this.zeiger.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
aktuellerStand(): GeraeteInfo[] {
|
||||
return [...this.stand.values()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
|
||||
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
|
||||
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
|
||||
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
|
||||
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
|
||||
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
|
||||
// beweise/laufwerk.js, 30.08.2026).
|
||||
import koffi from 'koffi'
|
||||
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
|
||||
|
||||
export type { LaufwerkApi }
|
||||
|
||||
let echteApi: LaufwerkApi | null = null
|
||||
|
||||
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
|
||||
export function win32Api(): LaufwerkApi {
|
||||
if (echteApi !== null) return echteApi
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()')
|
||||
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)')
|
||||
const CreateFileW = kernel32.func(
|
||||
'void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)',
|
||||
)
|
||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
const DeviceIoControl = kernel32.func(
|
||||
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
||||
)
|
||||
|
||||
echteApi = {
|
||||
laufwerksbuchstaben(): string[] {
|
||||
const maske = GetLogicalDrives() as number
|
||||
const buchstaben: string[] = []
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
|
||||
}
|
||||
return buchstaben
|
||||
},
|
||||
|
||||
laufwerkstyp(buchstabe: string): number {
|
||||
return GetDriveTypeW(`${buchstabe}:\\`) as number
|
||||
},
|
||||
|
||||
oeffnen(geraetepfad: string, zugriff: number): unknown {
|
||||
const handle = CreateFileW(
|
||||
geraetepfad,
|
||||
zugriff,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
null,
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
null,
|
||||
)
|
||||
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
|
||||
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
|
||||
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
|
||||
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
|
||||
throw new Win32Fehler('CreateFileW', GetLastError() as number)
|
||||
}
|
||||
return handle
|
||||
},
|
||||
|
||||
schliessen(handle: unknown): void {
|
||||
CloseHandle(handle)
|
||||
},
|
||||
|
||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
||||
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
||||
const zurueck = [0]
|
||||
const erfolg = DeviceIoControl(
|
||||
handle,
|
||||
code,
|
||||
eingabe ?? null,
|
||||
eingabe?.length ?? 0,
|
||||
aus,
|
||||
ausgabeBytes,
|
||||
zurueck,
|
||||
null,
|
||||
)
|
||||
if (!erfolg) {
|
||||
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
|
||||
}
|
||||
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
|
||||
},
|
||||
}
|
||||
return echteApi
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Tests der Laufwerks-Schicht — komplett ohne Windows und ohne koffi:
|
||||
// disc.ts arbeitet gegen die LaufwerkApi-Schnittstelle, hier nachgebildet.
|
||||
// Die Testfälle wandern sinngemäß aus dem bewiesenen Docker-Zweig mit
|
||||
// (test_win_ioctl.py, test_windows.py, test_cdrom.py) — ein Treiber, der
|
||||
// dieselben Fälle besteht, kennt dieselben Fallen (KONZEPT § 10).
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
CDROM_DISK_AUDIO_TRACK,
|
||||
CDROM_DISK_DATA_TRACK,
|
||||
ERROR_ACCESS_DENIED,
|
||||
ERROR_INVALID_FUNCTION,
|
||||
ERROR_NOT_READY,
|
||||
IOCTL_CDROM_DISK_TYPE,
|
||||
IOCTL_DISK_GET_LENGTH_INFO,
|
||||
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||
IOCTL_STORAGE_EJECT_MEDIA,
|
||||
IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
DRIVE_CDROM,
|
||||
Win32Fehler,
|
||||
type LaufwerkApi,
|
||||
} from '../src/kern/laufwerk/codes'
|
||||
import {
|
||||
angabenSpeicherLeeren,
|
||||
auswerfenMitGrund,
|
||||
classify,
|
||||
geraeteInfo,
|
||||
geraetepfad,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
textBei,
|
||||
BLURAY_MIN_BYTES,
|
||||
UHD_MIN_BYTES,
|
||||
} from '../src/kern/laufwerk/disc'
|
||||
|
||||
describe('Steuercodes — gegen die Microsoft-Dokumentation gerechnet', () => {
|
||||
// Sollwerte aus der Doku (dieselbe Gegenprobe wie test_win_ioctl.py):
|
||||
// ein Zahlendreher sähe sonst aus wie „das Laufwerk kann das nicht".
|
||||
it.each([
|
||||
['IOCTL_STORAGE_CHECK_VERIFY2', IOCTL_STORAGE_CHECK_VERIFY2, 0x002d0800],
|
||||
['IOCTL_STORAGE_MEDIA_REMOVAL', IOCTL_STORAGE_MEDIA_REMOVAL, 0x002d4804],
|
||||
['IOCTL_STORAGE_EJECT_MEDIA', IOCTL_STORAGE_EJECT_MEDIA, 0x002d4808],
|
||||
['IOCTL_STORAGE_QUERY_PROPERTY', IOCTL_STORAGE_QUERY_PROPERTY, 0x002d1400],
|
||||
['IOCTL_CDROM_DISK_TYPE', IOCTL_CDROM_DISK_TYPE, 0x00020040],
|
||||
['IOCTL_DISK_GET_LENGTH_INFO', IOCTL_DISK_GET_LENGTH_INFO, 0x0007405c],
|
||||
])('%s stimmt mit der Doku überein', (_name, wert, erwartet) => {
|
||||
expect(wert).toBe(erwartet)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classify — Typ aus Spur-Flags und Größe', () => {
|
||||
it('Audio-Spur ist eine CD, egal wie groß', () => {
|
||||
expect(classify(CDROM_DISK_AUDIO_TRACK, 700 * 1e6)).toBe('cd')
|
||||
expect(classify(CDROM_DISK_AUDIO_TRACK | CDROM_DISK_DATA_TRACK, 0)).toBe('cd')
|
||||
})
|
||||
it('Datenspur wird nach Größe eingeordnet', () => {
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, 4.7e9)).toBe('dvd')
|
||||
// Der gemessene Fall vom 28.08.2026: BD-50 mit 48.149.364.736 Bytes
|
||||
// (44,84 GiB) liegt unter der 55-GiB-Schwelle und ist „bluray".
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, 48_149_364_736)).toBe('bluray')
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, 66 * 1024 ** 3)).toBe('uhd')
|
||||
})
|
||||
it('die Schwellen selbst', () => {
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES)).toBe('bluray')
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES - 1)).toBe('dvd')
|
||||
expect(classify(CDROM_DISK_DATA_TRACK, UHD_MIN_BYTES)).toBe('uhd')
|
||||
})
|
||||
it('keine Spur-Info heißt unknown, nicht dvd', () => {
|
||||
expect(classify(0, 33e9)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('geraetepfad und kennung — hin und zurück', () => {
|
||||
it('baut den Gerätepfad OHNE abschließenden Backslash', () => {
|
||||
// Mit Backslash öffnet CreateFileW das Wurzelverzeichnis statt des
|
||||
// Geräts — die Steuercodes scheitern dann mit ERROR_INVALID_FUNCTION.
|
||||
expect(geraetepfad('G')).toBe('\\\\.\\G:')
|
||||
expect(geraetepfad('g:')).toBe('\\\\.\\G:')
|
||||
expect(geraetepfad('G:\\')).toBe('\\\\.\\G:')
|
||||
})
|
||||
it('wirft bei leerer Angabe', () => {
|
||||
expect(() => geraetepfad('')).toThrow()
|
||||
})
|
||||
it('kennung löst den Pfad wieder auf (der /dev/G-404-Fund)', () => {
|
||||
expect(kennung('\\\\.\\G:')).toBe('G')
|
||||
expect(kennung(geraetepfad('e'))).toBe('E')
|
||||
})
|
||||
})
|
||||
|
||||
describe('textBei — STORAGE_DEVICE_DESCRIPTOR auswerten', () => {
|
||||
it('liest die Zeichenkette am Offset', () => {
|
||||
const puffer = Buffer.alloc(40)
|
||||
puffer.writeUInt32LE(32, 12) // Offset an Position 12 zeigt auf Byte 32
|
||||
puffer.write('HL-DT-ST\x00', 32, 'latin1')
|
||||
expect(textBei(puffer, 12)).toBe('HL-DT-ST')
|
||||
})
|
||||
it('Offset 0 heißt „gibt es nicht"', () => {
|
||||
expect(textBei(Buffer.alloc(40), 12)).toBe('')
|
||||
})
|
||||
it('Offset außerhalb des Puffers ist leer, kein Absturz', () => {
|
||||
const puffer = Buffer.alloc(16)
|
||||
puffer.writeUInt32LE(999, 12)
|
||||
expect(textBei(puffer, 12)).toBe('')
|
||||
expect(textBei(Buffer.alloc(2), 12)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Die Win32-Nachbildung (Muster aus test_windows.py) ──────────────────
|
||||
interface FakeVerhalten {
|
||||
buchstaben?: string[]
|
||||
typen?: Record<string, number>
|
||||
oeffnenFehler?: number
|
||||
antworten?: Map<number, Buffer | number> // Buffer = Antwort, number = Win32-Fehlercode
|
||||
}
|
||||
|
||||
function fakeApi(verhalten: FakeVerhalten): LaufwerkApi & { protokoll: string[] } {
|
||||
const protokoll: string[] = []
|
||||
return {
|
||||
protokoll,
|
||||
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
||||
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
||||
oeffnen(pfad, zugriff) {
|
||||
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
||||
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
||||
return { pfad }
|
||||
},
|
||||
schliessen() {
|
||||
protokoll.push('schliessen')
|
||||
},
|
||||
steuern(_h, code, eingabe) {
|
||||
protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
|
||||
const antwort = verhalten.antworten?.get(code)
|
||||
if (antwort === undefined) return Buffer.alloc(0)
|
||||
if (typeof antwort === 'number') throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, antwort)
|
||||
return antwort
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function u32(wert: number): Buffer {
|
||||
const b = Buffer.alloc(4)
|
||||
b.writeUInt32LE(wert)
|
||||
return b
|
||||
}
|
||||
|
||||
function u64(wert: bigint): Buffer {
|
||||
const b = Buffer.alloc(8)
|
||||
b.writeBigUInt64LE(wert)
|
||||
return b
|
||||
}
|
||||
|
||||
const sofort = async (): Promise<void> => {}
|
||||
|
||||
beforeEach(() => angabenSpeicherLeeren())
|
||||
|
||||
describe('listOptischeLaufwerke', () => {
|
||||
it('nimmt nur DRIVE_CDROM und baut Gerätepfade', () => {
|
||||
const api = fakeApi({ buchstaben: ['C', 'E', 'G'], typen: { C: 3, E: DRIVE_CDROM, G: DRIVE_CDROM } })
|
||||
expect(listOptischeLaufwerke(api)).toEqual(['\\\\.\\E:', '\\\\.\\G:'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('auswerfenMitGrund — entriegeln, auswerfen, NACHSEHEN (R3)', () => {
|
||||
it('meldet ok erst, wenn das Medium wirklich weg ist', async () => {
|
||||
let verifyRunde = 0
|
||||
const api = fakeApi({})
|
||||
api.steuern = (_h, code, eingabe) => {
|
||||
api.protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
|
||||
if (code === IOCTL_STORAGE_CHECK_VERIFY2) {
|
||||
verifyRunde += 1
|
||||
// Erst beim zweiten Nachsehen ist die Disc draußen.
|
||||
if (verifyRunde >= 2) throw new Win32Fehler('DeviceIoControl', ERROR_NOT_READY)
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||
expect(grund).toBe('ok')
|
||||
expect(fehler).toBeNull()
|
||||
// Reihenfolge: erst entriegeln (MEDIA_REMOVAL mit 0), dann auswerfen.
|
||||
const reihenfolge = api.protokoll.filter((z) => z.startsWith('steuern'))
|
||||
expect(reihenfolge[0]).toContain(IOCTL_STORAGE_MEDIA_REMOVAL.toString(16))
|
||||
expect(reihenfolge[0]).toContain('ein=00')
|
||||
expect(reihenfolge[1]).toContain(IOCTL_STORAGE_EJECT_MEDIA.toString(16))
|
||||
})
|
||||
|
||||
it('Erfolg des Auswurf-Befehls allein ist KEIN ok — bleibt-drin', async () => {
|
||||
// Der v1-Fehler: CDROMEJECT quittiert Erfolg auf verriegeltem Laufwerk
|
||||
// und tut nichts. CHECK_VERIFY2 sagt hier weiter „Medium da".
|
||||
const api = fakeApi({ antworten: new Map() })
|
||||
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||
expect(grund).toBe('bleibt-drin')
|
||||
})
|
||||
|
||||
it('kein Zugriff aufs Gerät heißt kein-zugriff, wirft nicht', async () => {
|
||||
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
|
||||
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||
expect(grund).toBe('kein-zugriff')
|
||||
expect(fehler).toContain('Win32-Fehler 5')
|
||||
})
|
||||
|
||||
it('abgelehnter Auswurf-Befehl heißt abgelehnt', async () => {
|
||||
const api = fakeApi({ antworten: new Map([[IOCTL_STORAGE_EJECT_MEDIA, ERROR_INVALID_FUNCTION]]) })
|
||||
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||
expect(grund).toBe('abgelehnt')
|
||||
})
|
||||
|
||||
it('gescheitertes Entriegeln verhindert den Auswurf-Versuch nicht', async () => {
|
||||
const api = fakeApi({
|
||||
antworten: new Map<number, Buffer | number>([
|
||||
[IOCTL_STORAGE_MEDIA_REMOVAL, ERROR_INVALID_FUNCTION],
|
||||
[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY],
|
||||
]),
|
||||
})
|
||||
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||
expect(grund).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('geraeteInfo — ready/empty/unknown, nie eine falsche Aussage', () => {
|
||||
const beschreibung = (() => {
|
||||
// STORAGE_DEVICE_DESCRIPTOR: Offsets bei 12/16/20/24 → Strings ab 40.
|
||||
const b = Buffer.alloc(96)
|
||||
b.writeUInt32LE(40, 12)
|
||||
b.write('HL-DT-ST\x00', 40, 'latin1')
|
||||
b.writeUInt32LE(56, 16)
|
||||
b.write('BD-RE BU40N\x00', 56, 'latin1')
|
||||
b.writeUInt32LE(72, 20)
|
||||
b.write('1.03\x00', 72, 'latin1')
|
||||
b.writeUInt32LE(80, 24)
|
||||
b.write('0025114C0149\x00', 80, 'latin1')
|
||||
return b
|
||||
})()
|
||||
|
||||
it('Disc drin: ready mit Typ, Größe, Modell, Seriennummer', () => {
|
||||
const api = fakeApi({
|
||||
antworten: new Map<number, Buffer | number>([
|
||||
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
|
||||
[IOCTL_CDROM_DISK_TYPE, u32(CDROM_DISK_DATA_TRACK)],
|
||||
[IOCTL_DISK_GET_LENGTH_INFO, u64(33_759_690_752n)],
|
||||
[IOCTL_STORAGE_QUERY_PROPERTY, beschreibung],
|
||||
]),
|
||||
})
|
||||
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||
expect(info).toMatchObject({
|
||||
id: 'G',
|
||||
status: 'ready',
|
||||
typ: 'bluray',
|
||||
groesseBytes: 33_759_690_752,
|
||||
modell: 'HL-DT-ST BD-RE BU40N 1.03',
|
||||
seriennummer: '0025114C0149',
|
||||
grund: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('leeres Laufwerk: empty, kein Fehler', () => {
|
||||
const api = fakeApi({
|
||||
antworten: new Map<number, Buffer | number>([[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY]]),
|
||||
})
|
||||
expect(geraeteInfo(api, '\\\\.\\G:').status).toBe('empty')
|
||||
})
|
||||
|
||||
it('Gerät nicht ansprechbar: unknown MIT Grund — nicht empty (R2)', () => {
|
||||
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
|
||||
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||
expect(info.status).toBe('unknown')
|
||||
expect(info.grund).toContain('hält das Laufwerk fest')
|
||||
})
|
||||
|
||||
it('der 30.08.-Fall: Medium da, aber Typ-Abfrage bricht — ready mit Grund', () => {
|
||||
const api = fakeApi({
|
||||
antworten: new Map<number, Buffer | number>([
|
||||
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
|
||||
[IOCTL_DISK_GET_LENGTH_INFO, ERROR_INVALID_FUNCTION],
|
||||
]),
|
||||
})
|
||||
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||
expect(info.status).toBe('ready')
|
||||
expect(info.typ).toBe('unknown')
|
||||
expect(info.grund).toContain('beantwortet keine Medien-Abfragen mehr')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// AUSWURF-MESSUNG an echter Hardware — der W-1-Beweis „Auswerfen wirkt
|
||||
// (nachgemessen, nicht geglaubt)". Läuft NUR auf ausdrückliches Verlangen:
|
||||
//
|
||||
// RIPPY_MESSUNG_AUSWURF=1 npx vitest run test/messung.auswurf.test.ts
|
||||
//
|
||||
// ⚠ Wirft die Disc im ersten optischen Laufwerk WIRKLICH aus. Der BU40N
|
||||
// kann die Schublade nicht selbst einziehen (LOAD_MEDIA →
|
||||
// ERROR_INVALID_FUNCTION, windows.py 28.08.2026) — nach dem Lauf bleibt
|
||||
// der Tray offen. Deshalb getrennt von messung.laufwerk (nur lesend).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG_AUSWURF === '1' && process.platform === 'win32'
|
||||
|
||||
describe.skipIf(!messen)('Auswurf am echten Laufwerk — Zustand davor und danach', () => {
|
||||
it('entriegelt, wirft aus und SIEHT NACH', async () => {
|
||||
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||
const { auswerfenMitGrund, geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||
|
||||
const api = win32Api()
|
||||
const geraete = listOptischeLaufwerke(api)
|
||||
expect(geraete.length).toBeGreaterThan(0)
|
||||
const geraet = geraete[0]
|
||||
|
||||
const davor = geraeteInfo(api, geraet)
|
||||
console.log(`MESSUNG davor: status=${davor.status} typ=${davor.typ}`)
|
||||
expect(davor.status).toBe('ready') // ohne Disc misst dieser Lauf nichts
|
||||
|
||||
const start = Date.now()
|
||||
const { grund, fehler } = await auswerfenMitGrund(api, geraet)
|
||||
const dauerMs = Date.now() - start
|
||||
console.log(`MESSUNG auswurf: grund=${grund} fehler=${fehler ?? '-'} dauer=${dauerMs}ms`)
|
||||
|
||||
const danach = geraeteInfo(api, geraet)
|
||||
console.log(`MESSUNG danach: status=${danach.status}`)
|
||||
|
||||
// Der eigentliche Beweis ist der ZUSTAND davor/danach — nicht der
|
||||
// Rückgabewert des Steuercodes (windows.py: 4 → 1 am echten Gerät).
|
||||
expect(grund).toBe('ok')
|
||||
expect(danach.status).toBe('empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Auswurf-Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG_AUSWURF=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
// MESSUNG an echter Hardware — läuft nur auf Verlangen:
|
||||
//
|
||||
// RIPPY_MESSUNG=1 npx vitest run test/messung.laufwerk.test.ts
|
||||
//
|
||||
// In der Ampel (Linux, kein Laufwerk) wird sie übersprungen; die Ergebnisse
|
||||
// werden im SAVEPOINT.md mit Ausgabe dokumentiert (beweisen statt
|
||||
// behaupten). NUR LESEND — kein Auswerfen, kein Verriegeln; der
|
||||
// Auswurf-Beweis ist ein eigener, ausdrücklicher Lauf (messung.auswurf).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||
|
||||
describe.skipIf(!messen)('Messung am echten Laufwerk', () => {
|
||||
it('listet Laufwerke und liest ihren Zustand', async () => {
|
||||
// koffi erst hier laden — beim Einsammeln der Tests bleibt alles rein.
|
||||
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||
const { geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||
|
||||
const api = win32Api()
|
||||
const geraete = listOptischeLaufwerke(api)
|
||||
console.log(`MESSUNG optische Laufwerke: ${JSON.stringify(geraete)}`)
|
||||
expect(Array.isArray(geraete)).toBe(true)
|
||||
|
||||
for (const geraet of geraete) {
|
||||
const info = geraeteInfo(api, geraet)
|
||||
console.log(
|
||||
`MESSUNG ${info.name} status=${info.status} typ=${info.typ} ` +
|
||||
`groesse=${info.groesseBytes} modell="${info.modell}" ` +
|
||||
`serial=${info.seriennummer}${info.grund.length > 0 ? ` grund="${info.grund}"` : ''}`,
|
||||
)
|
||||
expect(info.id.length).toBeGreaterThan(0)
|
||||
expect(['ready', 'empty', 'unknown']).toContain(info.status)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Ohne Messung ist die Datei trotzdem ein gültiges Test-Modul.
|
||||
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// Tests der Disc-Wache — die Übergangs-Logik pur, und der Fehlerpfad:
|
||||
// Eine gescheiterte Runde darf den Zustand NICHT löschen (R2) und muss
|
||||
// gemeldet werden (R4).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GeraeteInfo } from '../src/gemeinsam/nachrichten'
|
||||
import { DiscWache, laufwerksUnterschiede } from '../src/kern/laufwerk/wache'
|
||||
|
||||
function geraet(id: string, status: GeraeteInfo['status'], typ: GeraeteInfo['typ'] = 'unknown'): GeraeteInfo {
|
||||
return {
|
||||
id,
|
||||
name: `Laufwerk ${id}:`,
|
||||
typ,
|
||||
pfad: `\\\\.\\${id}:`,
|
||||
status,
|
||||
modell: '',
|
||||
seriennummer: '',
|
||||
groesseBytes: 0,
|
||||
grund: '',
|
||||
}
|
||||
}
|
||||
|
||||
function alsMap(...geraete: GeraeteInfo[]): Map<string, GeraeteInfo> {
|
||||
return new Map(geraete.map((g) => [g.id, g]))
|
||||
}
|
||||
|
||||
describe('laufwerksUnterschiede', () => {
|
||||
it('empty → ready ist eine eingelegte Disc', () => {
|
||||
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap(geraet('G', 'ready', 'bluray')))
|
||||
expect(ereignisse).toEqual([{ art: 'disc-eingelegt', geraet: expect.objectContaining({ id: 'G' }) }])
|
||||
})
|
||||
|
||||
it('ready → empty ist eine entnommene Disc', () => {
|
||||
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'empty')))
|
||||
expect(ereignisse).toEqual([{ art: 'disc-entnommen', geraet: expect.objectContaining({ id: 'G' }) }])
|
||||
})
|
||||
|
||||
it('ready → unknown ist KEINE Entnahme — „konnte nicht nachsehen" (R2)', () => {
|
||||
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'unknown')))
|
||||
expect(ereignisse).toEqual([])
|
||||
})
|
||||
|
||||
it('neues Laufwerk mit Disc meldet beides', () => {
|
||||
const ereignisse = laufwerksUnterschiede(alsMap(), alsMap(geraet('G', 'ready', 'dvd')))
|
||||
expect(ereignisse.map((e) => e.art)).toEqual(['laufwerk-neu', 'disc-eingelegt'])
|
||||
})
|
||||
|
||||
it('verschwundenes Laufwerk wird gemeldet', () => {
|
||||
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap())
|
||||
expect(ereignisse).toEqual([{ art: 'laufwerk-weg', id: 'G' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiscWache', () => {
|
||||
it('meldet Änderungen und behält bei einem Rundenfehler den Stand (R2/R4)', () => {
|
||||
let stand: GeraeteInfo[] = [geraet('G', 'empty')]
|
||||
let lesenBricht = false
|
||||
const meldungen: string[] = []
|
||||
const fehler: string[] = []
|
||||
|
||||
const wache = new DiscWache(
|
||||
() => {
|
||||
if (lesenBricht) throw new Error('Win32 kaputt')
|
||||
return stand
|
||||
},
|
||||
(meldung) => meldungen.push(meldung.ereignisse.map((e) => e.art).join(',') || 'stand'),
|
||||
(f) => fehler.push(String(f)),
|
||||
999_999,
|
||||
)
|
||||
|
||||
wache.starten() // Runde 1: erster Stand
|
||||
expect(wache.aktuellerStand()).toHaveLength(1)
|
||||
expect(meldungen).toEqual(['laufwerk-neu'])
|
||||
|
||||
stand = [geraet('G', 'ready', 'bluray')]
|
||||
wache.runde() // Runde 2: Disc rein
|
||||
expect(meldungen).toEqual(['laufwerk-neu', 'disc-eingelegt'])
|
||||
|
||||
lesenBricht = true
|
||||
wache.runde() // Runde 3: Lesen bricht — Stand bleibt, Fehler gemeldet
|
||||
expect(fehler).toHaveLength(1)
|
||||
expect(wache.aktuellerStand()).toHaveLength(1)
|
||||
expect(wache.aktuellerStand()[0].status).toBe('ready')
|
||||
|
||||
lesenBricht = false
|
||||
wache.runde() // Runde 4: nichts geändert — keine neue Meldung
|
||||
expect(meldungen).toHaveLength(2)
|
||||
|
||||
wache.stoppen()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user