// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt, // Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit // aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des // Klick-Beweises. // // 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). import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten' import type { KernKontext } from '../kontext' import type { LaufwerkApi } from '../laufwerk/codes' import { audioSpurLesen, audioToc, auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke, } from '../laufwerk/disc' import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse' import { DiscWache } from '../laufwerk/wache' import type { Spur } from '../rip/audiocd' const GESUNDHEIT_STUNDEN = 24 export interface LaufwerkHorcher { discEingelegt(geraet: GeraeteInfo): void discEntnommen(geraet: GeraeteInfo): void } export class LaufwerkDienst { private api: LaufwerkApi | null = null private wache: DiscWache | null = null fehler: string | null = null /** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein * Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann * nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts). * Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */ readonly fake: string readonly bereit: Promise constructor( private readonly kontext: KernKontext, private readonly horcher: LaufwerkHorcher, /** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */ private readonly ripLaeuft: (id: string) => boolean, umgebung: NodeJS.ProcessEnv = process.env, ) { this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase() this.bereit = this.starten() } private fakeGeraet(): GeraeteInfo { return { id: this.fake, name: `Laufwerk ${this.fake}:`, typ: 'bluray', pfad: `\\\\.\\${this.fake}:`, status: 'ready', modell: 'NACHGEBAUT (KLICK-BEWEIS)', seriennummer: 'KLICK', groesseBytes: 33_759_100_928, grund: '', } } private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void { this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse }) for (const ereignis of meldung.ereignisse) { if (ereignis.art === 'disc-eingelegt') { this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`) this.horcher.discEingelegt(ereignis.geraet) } else if (ereignis.art === 'disc-entnommen') { this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`) this.horcher.discEntnommen(ereignis.geraet) } else if (ereignis.art === 'laufwerk-weg') { this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`) } } } private async starten(): Promise { const wacheFehler = (fehler: unknown): void => { // R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte // Stand im Fenster bleibt stehen (R2), die Wache läuft weiter. this.kontext.fehler(`Disc-Wache: ${String(fehler)}`) } try { if (this.fake.length > 0) { this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler) this.wache.starten() return } if (process.platform !== 'win32') { throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).') } const { win32Api } = await import('../laufwerk/win32') this.api = win32Api() this.wache = new DiscWache( () => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)), (m) => this.wacheMeldung(m), wacheFehler, ) this.wache.starten() } catch (fehler) { this.fehler = String(fehler) this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`) console.error(`[kern] Laufwerke: ${this.fehler}`) } } aktuellerStand(): GeraeteInfo[] { return this.wache?.aktuellerStand() ?? [] } /** Der Geräte-Name zu einer Kennung — oder null. */ geraetVon(id: string): string | null { if (this.api === null) return null return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null } /** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */ geraetInfoVon(id: string): GeraeteInfo | null { if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null const geraet = this.geraetVon(id) if (geraet === null || this.api === null) return null return geraeteInfo(this.api, geraet) } audioToc(id: string): Spur[] { const geraet = this.geraetVon(id) if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) return audioToc(this.api, geraet) } audioSpurLesen( id: string, start: number, sektoren: number, schreiben: (daten: Buffer) => void, fortschritt?: (anteil: number) => void, ): number { const geraet = this.geraetVon(id) if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt) } async auswerfen(id: string): Promise { await this.bereit if (this.ripLaeuft(id)) { this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'abgelehnt', fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`, }) return } if (this.api === null) { this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler }) return } const geraet = this.geraetVon(id) if (geraet === null) { this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` }) return } const { grund, fehler } = await auswerfenMitGrund(this.api, geraet) this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`) this.kontext.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. this.wache?.runde() } // ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ───────────────────────────── async pruefen(id: string): Promise { if (process.platform !== 'win32') return const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN) if ('fehler' in ergebnis) { this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht: { urteil: 'nicht-lesbar', text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`, fehlerhafteBloecke: 0, controllerfehler: 0, sonstige: 0, letzte: [], stunden: GESUNDHEIT_STUNDEN, }, }) return } // 5.7.0 (F11): nur die Ereignisse DIESES Geräts — \Device\CdRomN // hinter dem Buchstaben, nicht die einer Festplatte oder eines zweiten // Laufwerks. Ohne Gerätename (Nachbau, Fehlschlag) zählt nur cdrom. let geraet = '' try { geraet = this.api?.geraeteName(id) ?? '' } catch (fehler) { this.kontext.protokoll.schreiben(`Laufwerk ${id}: Gerätename nicht ermittelbar (${String(fehler)})`) } const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN, undefined, geraet) this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`) this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht }) } }