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,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
|
||||
}
|
||||
Reference in New Issue
Block a user