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
+43 -11
View File
@@ -1,17 +1,29 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
import { BrowserWindow } from 'electron'
//
// 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
// sofern sie noch auf einem Bildschirm liegt.
import { BrowserWindow, screen } from 'electron'
import { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(): BrowserWindow {
export function fensterErstellen(profilOrdner: string): BrowserWindow {
const lageDatei = join(profilOrdner, 'fenster-lage.json')
const lage = anfangsLage(
lageLesen(lageDatei),
screen.getPrimaryDisplay().workArea,
screen.getAllDisplays().map((d) => d.workArea),
)
const fenster = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 860,
minHeight: 560,
x: lage.x,
y: lage.y,
width: lage.width,
height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true,
autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
@@ -35,17 +47,37 @@ export function fensterErstellen(): BrowserWindow {
sandbox: true,
},
})
if (lage.maximiert) fenster.maximize()
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
let zeiger: NodeJS.Timeout | null = null
const merken = (): void => {
if (zeiger !== null) clearTimeout(zeiger)
zeiger = setTimeout(() => {
if (fenster.isDestroyed()) return
const maximiert = fenster.isMaximized()
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
lageSchreiben(lageDatei, neu)
}, 400)
}
fenster.on('resize', merken)
fenster.on('move', merken)
fenster.on('maximize', merken)
fenster.on('unmaximize', merken)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #einrichtung) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) {
+85
View File
@@ -0,0 +1,85 @@
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
// PUR, damit die Ampel die Rechnung prüft:
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
// Bildschirm.
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
// Fenster nicht ins Nirgendwo schieben).
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface Lage {
x: number
y: number
width: number
height: number
maximiert: boolean
}
export interface Bereich {
x: number
y: number
width: number
height: number
}
export const MINDEST_BREITE = 1100
export const MINDEST_HOEHE = 720
export const ANTEIL = 0.8
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
export function startLage(arbeitsbereich: Bereich): Lage {
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
return {
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
width: Math.min(width, arbeitsbereich.width),
height: Math.min(height, arbeitsbereich.height),
maximiert: false,
}
}
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
if (lage.width < 400 || lage.height < 300) return false
return bildschirme.some((b) => {
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
const links = Math.max(lage.x, b.x)
const unten = Math.min(lage.y + lage.height, b.y + b.height)
const oben = Math.max(lage.y, b.y)
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
return rechts - links >= 200 && unten - oben >= 100
})
}
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
return startLage(arbeitsbereich)
}
export function lageLesen(pfad: string): Lage | null {
try {
if (!existsSync(pfad)) return null
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
if (typeof roh !== 'object' || roh === null) return null
const l = roh as Record<string, unknown>
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
} catch {
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
return null
}
}
export function lageSchreiben(pfad: string, lage: Lage): void {
try {
writeFileSync(pfad, JSON.stringify(lage))
} catch {
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
}
}
+48 -4
View File
@@ -13,6 +13,7 @@ import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { Protokoll } from '../gemeinsam/protokoll'
import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
@@ -47,12 +48,19 @@ if (!app.requestSingleInstanceLock()) {
}
function start(): void {
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
const protokollOrdner = join(app.getPath('userData'), 'logs')
const protokoll = new Protokoll(protokollOrdner, 'haupt')
protokoll.aufraeumen()
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen()
if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
}
let fenster: BrowserWindow | null = null
@@ -80,6 +88,7 @@ function start(): void {
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
@@ -93,7 +102,10 @@ function start(): void {
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text),
(text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
@@ -122,7 +134,10 @@ function start(): void {
)
}
},
() => statusSenden(),
(zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus,
)
@@ -222,6 +237,7 @@ function start(): void {
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
}
fenster.webContents.send('haupt-status', status)
}
@@ -250,7 +266,7 @@ function start(): void {
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen()
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
return
}
@@ -327,6 +343,33 @@ function start(): void {
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
// Programme, sonst nichts.
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
properties: ['openFile'],
filters: [
{ name: 'Programme', extensions: ['exe'] },
{ name: 'Alle Dateien', extensions: ['*'] },
],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
return await shell.openPath(pfad)
})
ipcMain.handle('protokoll-oeffnen', async () => {
return await shell.openPath(protokollOrdner)
})
app.on('second-instance', () => {
fensterZeigen()
})
@@ -339,6 +382,7 @@ function start(): void {
app.on('before-quit', () => {
beendenGewollt = true
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden()
})
@@ -347,7 +391,7 @@ function start(): void {
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
})