docs: KONZEPT-WINDOWS — Rippy v5 als eigenstaendiges Electron-Programm

Die heutige Windows-Fassung ist der Docker-Code in einer EXE: PyInstaller
buendelt docker/api und docker/worker, uvicorn laeuft auf 127.0.0.1:7788,
pywebview stellt ein Fenster davor. Elf Release-Kandidaten in drei Tagen,
und die Funde sind fast alle derselbe Typ — Linux-Annahmen, die unter
Windows etwas anderes bedeuten (timeout ls -d, shutil.which, /app,
/dev/{name}, F: ohne Trenner).

Das Konzept beschreibt den Neubau als Windows-Programm: Electron 44,
TypeScript durchgehend, Einzelplatz, kein Python, kein HTTP-Server.

Vier Entscheide des Commanders (30.08.2026) sind eingearbeitet:
alles TypeScript/Node · Docker bleibt unangetastet · reiner Einzelplatz ·
eigener Branch.

Vor der ersten Zeile Konzept gemessen (Regel D), Belege in beweise/:

  * Win32 aus Node an G: (HL-DT-ST BD-RE BU40N) — Laufwerkssuche,
    CreateFileW, QUERY_PROPERTY (Modell + Serial), CHECK_VERIFY2,
    GET_LENGTH_INFO (33,76 GB -> Blu-ray). IOCTL_CDROM_DISK_TYPE
    antwortet mit Fehler 50 — derselbe Befund wie im Python-Treiber.
  * Prozess-Leine (Job Object): Kind stirbt mit dem per taskkill /F
    ohne /T abgeschossenen Elternprozess. Die Messung aus rc10, in
    Node nachgestellt.
  * node:sqlite ist in Node 24 eingebaut — in Electron noch ungeprueft,
    ausdruecklich als offener Punkt markiert.

Akutestes Risiko im Dokument: MakeMKVs freier Beta-Key laeuft Ende
September 2026 ab, die Kaufseite fuer die Dauerlizenz ist seit Mai defekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 15:38:39 +02:00
co-authored by Claude Opus 5
parent 9050fea3f0
commit cb811bec28
4 changed files with 1043 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## Zwei weitere Messungen aus § 3, ohne eigenes Skript
**`node:sqlite` ist in Node 24 eingebaut:**
```
node -e "const s=require('node:sqlite'); const db=new s.DatabaseSync(':memory:'); db.exec('CREATE TABLE t (a TEXT)'); db.prepare('INSERT INTO t VALUES (?)').run('geht'); console.log(db.prepare('SELECT a FROM t').get().a)"
geht
```
⚠️ Das ist in **Node 24.19.0** gemessen, nicht in Electron. Electron baut sein
Node mit eigenen Flags — ob `node:sqlite` dort vorhanden ist, ist der erste
Handgriff in Etappe W-0.
**npm 11 blockiert Install-Skripte:**
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. Die Warnung
gehört in die Bau-Anleitung, sonst sucht eines Tages jemand an der falschen
Stelle.
+109
View File
@@ -0,0 +1,109 @@
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
// NUR LESEND — kein Auswerfen, kein Verriegeln.
const koffi = require('koffi');
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
const ctl = (typ, fn, methode, zugriff) =>
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
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)');
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
const DRIVE_CDROM = 5;
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
const maske = GetLogicalDrives();
const optische = [];
for (let i = 0; i < 26; i++) {
if (!(maske & (1 << i))) continue;
const buchstabe = String.fromCharCode(65 + i);
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
}
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
const lw = optische[0];
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
function oeffnen(zugriff) {
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
const adr = koffi.address(h);
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
return { h, fehler: 0 };
}
const ohneMedium = oeffnen(0);
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
const mitLesen = oeffnen(GENERIC_READ);
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
const h = ohneMedium.h;
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
const rueck = Buffer.alloc(4);
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
const anfrage = Buffer.alloc(12);
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
const antwort = Buffer.alloc(1024);
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
const text = (off) => {
const start = antwort.readUInt32LE(off);
if (!start || start >= antwort.length) return '';
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
return antwort.toString('latin1', start, ende).trim();
};
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
} else {
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
}
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
// ── 5. Audio- oder Datenspur?
const typBuf = Buffer.alloc(1);
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
const t = typBuf[0];
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
} else {
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
}
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
const laenge = Buffer.alloc(8);
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
const bytes = laenge.readBigUInt64LE(0);
const gb = Number(bytes) / 1e9;
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
} else {
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
}
CloseHandle(h);
if (mitLesen.h) CloseHandle(mitLesen.h);
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
+50
View File
@@ -0,0 +1,50 @@
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
// makemkvcon Rippy und haelt das Laufwerk fest.
const koffi = require('koffi');
const { spawn } = require('child_process');
const kernel32 = koffi.load('kernel32.dll');
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
// JobObjectExtendedLimitInformation = 9 (winnt.h)
const JobObjectExtendedLimitInformation = 9;
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
const job = CreateJobObjectW(null, null);
if (koffi.address(job) === 0n) {
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
const info = Buffer.alloc(144);
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
console.log('ELTERN-PID=' + process.pid);
console.log('KIND-PID=' + kind.pid);
// Offen halten, bis uns jemand hart abschiesst.
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);