Ampel / ampel (push) Successful in 1m35s
Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine Sektions-Navigation, rechts Karten mit je einem Satz Einordnung — Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression (Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung), Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV (eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf, Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand, Versionszeile) und Werkzeuge — NEU mit den drei „Eigener Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher ohne UI). Dazu: #einstellungen/#bibliothek als Startanker (RIPPY_START_BEREICH), RIPPY_SMOKE_WARTE_MS für Bild-Beweise, die auf späte Kern-Antworten warten (Einstellungen+Encoder-Messung kommen nach den 750 ms). Version 5.1.0 — erste Fassung ohne Vorab-Suffix, der Bau erzeugt damit erstmals latest.yml (der Update-Kanal aus § 6.8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
|
|
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
|
|
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
|
|
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
|
|
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
|
|
//
|
|
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
|
|
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
|
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
|
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
|
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
|
|
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 { autostartAktiv, autostartSetzen } from './autostart'
|
|
import { fensterErstellen } from './fenster'
|
|
import { KernVerwaltung } from './kernstart'
|
|
import { leineSetzen, type LeineStatus } from './leine'
|
|
import { trayErstellen, trayHinweis } from './tray'
|
|
import { updateEinrichten, updateStand } from './update'
|
|
|
|
const smokeModus = process.argv.includes('--smoke')
|
|
|
|
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
|
if (smokeModus) {
|
|
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
|
}
|
|
|
|
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
|
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
|
|
if (!app.requestSingleInstanceLock()) {
|
|
app.exit(0)
|
|
} else {
|
|
start()
|
|
}
|
|
|
|
function start(): void {
|
|
// 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}`)
|
|
}
|
|
|
|
let fenster: BrowserWindow | null = null
|
|
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
|
|
let beendenGewollt = smokeModus
|
|
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
|
|
// warten dann.
|
|
const aktiveVorgaenge = new Set<string>()
|
|
let updateGestartet = false
|
|
|
|
const smoke = {
|
|
fenster: false,
|
|
kern: false,
|
|
datenbank: false,
|
|
leine: leine.ok,
|
|
pong: false,
|
|
}
|
|
|
|
const kernVerwaltung = new KernVerwaltung(
|
|
join(app.getPath('userData'), 'rippy.db'),
|
|
(nachricht) => {
|
|
if (nachricht.art === 'kern-bereit') {
|
|
kernBereit = nachricht
|
|
smoke.kern = true
|
|
smoke.datenbank = nachricht.datenbank.ok
|
|
if (!nachricht.datenbank.ok) {
|
|
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
|
}
|
|
statusSenden()
|
|
smokePruefen()
|
|
} else if (nachricht.art === 'rip-status') {
|
|
ripStatusVerarbeiten(nachricht.status)
|
|
} else if (nachricht.art === 'einstellungen') {
|
|
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
|
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
|
if (!updateGestartet && !smokeModus) {
|
|
updateGestartet = true
|
|
updateEinrichten(
|
|
nachricht.werte['updateUrl'] ?? '',
|
|
() => aktiveVorgaenge.size > 0,
|
|
(text) => toast('Rippy-Update', text),
|
|
)
|
|
}
|
|
} else if (nachricht.art === 'installer-starten') {
|
|
void installerStarten(nachricht.pfad)
|
|
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
|
toast(
|
|
'Disc erkannt',
|
|
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
|
(nachricht.info.schonGerippt !== null
|
|
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
|
|
: ''),
|
|
)
|
|
}
|
|
},
|
|
() => statusSenden(),
|
|
smokeModus,
|
|
)
|
|
|
|
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
|
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
|
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
|
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
|
aktiveVorgaenge.add(status.id)
|
|
} else {
|
|
aktiveVorgaenge.delete(status.id)
|
|
}
|
|
if (fenster !== null && !fenster.isDestroyed()) {
|
|
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
|
|
fenster.setProgressBar(status.prozent / 100)
|
|
} else if (status.phase === 'fehler') {
|
|
fenster.setProgressBar(1, { mode: 'error' })
|
|
} else {
|
|
fenster.setProgressBar(-1)
|
|
}
|
|
}
|
|
if (status.phase === 'rippt') {
|
|
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
|
} else if (status.phase === 'komprimiert') {
|
|
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
|
} else {
|
|
trayHinweis('')
|
|
}
|
|
if (smokeModus) return
|
|
if (status.phase === 'fertig') {
|
|
toast('Rippy ist fertig', status.text)
|
|
} else if (status.phase === 'fehler') {
|
|
toast('Rippy: Fehler', status.text)
|
|
}
|
|
}
|
|
|
|
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
|
|
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
|
|
// Manifest Administratorrechte an — CreateProcess bricht dann mit
|
|
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
|
|
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
|
|
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
|
|
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
|
|
async function installerStarten(pfad: string): Promise<void> {
|
|
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
|
|
// Rippys eigenem Downloads-Ordner.
|
|
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
|
|
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
|
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
|
|
return
|
|
}
|
|
const fehler = await shell.openPath(pfad)
|
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
|
|
}
|
|
|
|
function toast(titel: string, text: string): void {
|
|
try {
|
|
if (!Notification.isSupported()) return
|
|
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
|
|
meldung.on('click', () => fensterZeigen())
|
|
meldung.show()
|
|
} catch (fehler) {
|
|
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
|
|
}
|
|
}
|
|
|
|
function statusSenden(): void {
|
|
if (fenster === null || fenster.isDestroyed()) return
|
|
const status: HauptStatus = {
|
|
version: app.getVersion(),
|
|
leine,
|
|
kern: kernVerwaltung.zustand(),
|
|
autostart: autostartAktiv(),
|
|
update: updateStand(),
|
|
}
|
|
fenster.webContents.send('haupt-status', status)
|
|
}
|
|
|
|
function fensterVerdrahten(): void {
|
|
if (fenster === null) return
|
|
fenster.webContents.on('did-finish-load', () => {
|
|
smoke.fenster = true
|
|
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
|
|
kernVerwaltung.fensterVerbinden(fenster!.webContents)
|
|
statusSenden()
|
|
smokePruefen()
|
|
})
|
|
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
|
fenster.on('close', (ereignis) => {
|
|
if (beendenGewollt) return
|
|
ereignis.preventDefault()
|
|
fenster?.hide()
|
|
})
|
|
}
|
|
|
|
function fensterZeigen(): void {
|
|
if (fenster === null || fenster.isDestroyed()) {
|
|
fenster = fensterErstellen()
|
|
fensterVerdrahten()
|
|
return
|
|
}
|
|
if (fenster.isMinimized()) fenster.restore()
|
|
fenster.show()
|
|
fenster.focus()
|
|
}
|
|
|
|
function beenden(): void {
|
|
beendenGewollt = true
|
|
app.quit()
|
|
}
|
|
|
|
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
|
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
|
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
|
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
|
|
if (art === 'kern-pong') {
|
|
smoke.pong = true
|
|
smokePruefen()
|
|
}
|
|
})
|
|
|
|
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
|
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
|
autostartSetzen(an === true)
|
|
statusSenden()
|
|
return autostartAktiv()
|
|
})
|
|
|
|
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
|
|
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
|
|
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
|
|
if (fenster === null) return null
|
|
const ergebnis = await dialog.showOpenDialog(fenster, {
|
|
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
|
|
properties: ['openDirectory', 'createDirectory'],
|
|
})
|
|
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
|
|
})
|
|
|
|
app.on('second-instance', () => {
|
|
fensterZeigen()
|
|
})
|
|
|
|
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
|
|
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
|
|
app.on('window-all-closed', () => {
|
|
if (beendenGewollt) app.quit()
|
|
})
|
|
|
|
app.on('before-quit', () => {
|
|
beendenGewollt = true
|
|
kernVerwaltung.beenden()
|
|
})
|
|
|
|
void app.whenReady().then(() => {
|
|
kernVerwaltung.starten()
|
|
if (!smokeModus) {
|
|
trayErstellen(fensterZeigen, beenden)
|
|
}
|
|
fenster = fensterErstellen()
|
|
fensterVerdrahten()
|
|
})
|
|
|
|
let smokeAbgeschlossen = false
|
|
|
|
function smokePruefen(): void {
|
|
if (!smokeModus || smokeAbgeschlossen) return
|
|
const fehlt = Object.entries(smoke)
|
|
.filter(([, wert]) => !wert)
|
|
.map(([name]) => name)
|
|
if (fehlt.length > 0) return
|
|
smokeAbgeschlossen = true
|
|
void smokeAbschliessen()
|
|
}
|
|
|
|
async function smokeAbschliessen(): Promise<void> {
|
|
const bildPfad = process.env['RIPPY_SMOKE_BILD']
|
|
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
|
|
try {
|
|
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
|
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
|
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
|
|
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
|
|
// späte Kern-Antworten warten.
|
|
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
|
|
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
|
|
const bild = await fenster.webContents.capturePage()
|
|
await writeFile(bildPfad, bild.toPNG())
|
|
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
|
} catch (fehler) {
|
|
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
|
|
}
|
|
}
|
|
const kernInfo =
|
|
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
|
|
console.log(
|
|
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
|
|
)
|
|
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
|
|
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
|
|
kernVerwaltung.beenden()
|
|
setTimeout(() => app.exit(0), 100)
|
|
}
|
|
|
|
if (smokeModus) {
|
|
setTimeout(() => {
|
|
if (smokeAbgeschlossen) return
|
|
smokeAbgeschlossen = true
|
|
const fehlt = Object.entries(smoke)
|
|
.filter(([, wert]) => !wert)
|
|
.map(([name]) => name)
|
|
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
|
|
kernVerwaltung.beenden()
|
|
setTimeout(() => app.exit(2), 100)
|
|
}, 25_000)
|
|
}
|
|
}
|