feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
+185
View File
@@ -0,0 +1,185 @@
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
// Nullen für das, was die Disc nicht mehr hergibt:
//
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
//
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
// nicht (Commander 01.09.2026).
//
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
import { open } from 'node:fs/promises'
import { writeFileSync } from 'node:fs'
export const BLOCK_BYTES = 1024 * 1024
export const FEIN_BYTES = 64 * 1024
export const SEKTOR_BYTES = 2048
export interface UnlesbareStelle {
offset: number
bytes: number
}
export type RettungsErgebnis =
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
* viele es wurden. Wirft bei einem Lesefehler. */
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
export interface RettungsOptionen {
leser: Leser
schreiber: Schreiber
erwarteteBytes: number
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
abbruch?: AbortSignal
/** Versuche je Stück — Vorgabe 2. */
versuche?: number
}
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
for (let i = 0; i < versuche; i++) {
try {
const n = await leser(puffer, laenge, position)
if (n === laenge) return true
} catch {
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
}
}
return false
}
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
const letzte = stellen[stellen.length - 1]
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
letzte.bytes += bytes
return
}
stellen.push({ offset, bytes })
}
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
const versuche = o.versuche ?? 2
const puffer = Buffer.alloc(BLOCK_BYTES)
const feinPuffer = Buffer.alloc(FEIN_BYTES)
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
const stellen: UnlesbareStelle[] = []
let unlesbareSektoren = 0
let gelesen = 0
let letzterProzent = -1
const melden = (): void => {
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
if (prozent !== letzterProzent) {
letzterProzent = prozent
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
}
}
if (o.erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
}
try {
while (gelesen < o.erwarteteBytes) {
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
const m = Math.min(FEIN_BYTES, n - teil)
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
feinPuffer.copy(puffer, teil, 0, m)
continue
}
for (let s = 0; s < m; s += SEKTOR_BYTES) {
const k = Math.min(SEKTOR_BYTES, m - s)
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
sektorPuffer.copy(puffer, teil + s, 0, k)
} else {
puffer.fill(0, teil + s, teil + s + k)
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
stelleHinzu(stellen, gelesen + teil + s, k)
}
}
}
}
await o.schreiber(puffer, n)
gelesen += n
melden()
}
} catch (fehler) {
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
}
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
}
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
const zeilen = [
`Rettungs-Abbild von ${quelle}`,
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
]
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
return zeilen.join('\n') + '\n'
}
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
export async function rettungsAbbildDatei(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
abbruch?: AbortSignal,
): Promise<RettungsErgebnis> {
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
const q = quelle
const z = ziel
let ergebnis: RettungsErgebnis
try {
ergebnis = await rettungsAbbild({
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
schreiber: async (puffer, laenge) => {
await z.write(puffer, 0, laenge)
},
erwarteteBytes,
aufFortschritt,
abbruch,
})
} finally {
await q.close()
await z.close()
}
try {
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
} catch {
// Der Bericht ist Beigabe — das Abbild zählt.
}
return ergebnis
}