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:
co-authored by
Claude Fable 5.1
parent
6678c81592
commit
3dc6798c79
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user