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>
186 lines
7.6 KiB
TypeScript
186 lines
7.6 KiB
TypeScript
// 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
|
|
}
|