diff --git a/beweise/leine-breakaway.js b/beweise/leine-breakaway.js new file mode 100644 index 0000000..ac753cb --- /dev/null +++ b/beweise/leine-breakaway.js @@ -0,0 +1,109 @@ +// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines +// Elternprozesses — und stirbt ein normales Kind weiterhin mit? +// +// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete +// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine +// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil +// electron-updater ihn per spawn(…, { detached: true }) startet und +// `detached` unter Windows KEIN Breakaway setzt. +// +// Aufbau (drei Läufe, der äußere ruft die inneren): +// node beweise/leine-breakaway.js +// -> startet zweimal sich selbst als „innen": +// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal +// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK, +// Enkel mit CREATE_BREAKAWAY_FROM_JOB +// Beide beenden sich sofort. Danach schaut der äußere Lauf nach, +// welcher Enkel noch lebt. +// +// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er. +// +// Ausführen: node beweise/leine-breakaway.js +const { execFileSync, spawnSync } = require('node:child_process') +const path = require('node:path') + +const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi')) + +const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation +const KILL_ON_JOB_CLOSE = 0x2000 +const BREAKAWAY_OK = 0x0800 +const CREATE_BREAKAWAY_FROM_JOB = 0x01000000 +const CREATE_NO_WINDOW = 0x08000000 +const STRUKTUR_GROESSE = 144 +const LIMIT_FLAGS_OFFSET = 16 +const STARTUPINFO_GROESSE = 104 +const PROCESS_INFORMATION_GROESSE = 24 + +const kernel32 = koffi.load('kernel32.dll') +const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)') +const SetInformationJobObject = kernel32.func( + 'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)', +) +const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)') +const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()') +const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()') +const CreateProcessW = kernel32.func( + 'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)', +) + +/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */ +const ENKEL = 'C:\\Windows\\System32\\cmd.exe' +const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1' + +function innen(mitBreakaway) { + const job = CreateJobObjectW(null, null) + const info = Buffer.alloc(STRUKTUR_GROESSE) + info.writeUInt32LE(KILL_ON_JOB_CLOSE | (mitBreakaway ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET) + SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE) + AssignProcessToJobObject(job, GetCurrentProcess()) + + const si = Buffer.alloc(STARTUPINFO_GROESSE) + si.writeUInt32LE(STARTUPINFO_GROESSE, 0) + const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE) + const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le') + // CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole. + // Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit + // IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026 + // lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt + // nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt. + const flags = CREATE_NO_WINDOW | (mitBreakaway ? CREATE_BREAKAWAY_FROM_JOB : 0) + + const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi) + if (!ok) { + console.log(`PID=0 FEHLER=${GetLastError()}`) + return + } + // dwProcessId steht in PROCESS_INFORMATION an Offset 16. + console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`) +} + +function lebt(pid) { + const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' }) + return (ergebnis.stdout || '').includes(String(pid)) +} + +function aussen() { + const faelle = [ + ['OHNE Ausklinken (so macht es electron-updater)', 'innen-ohne', false], + ['MIT Ausklinken (so macht es Rippy ab 5.1.4)', 'innen-mit', true], + ] + for (const [name, modus, erwartetLebend] of faelle) { + const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim() + const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0) + const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0) + // Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also + // zugeschnappt, falls sie zuschnappt. + const nochDa = pid > 0 ? lebt(pid) : false + const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG' + console.log( + `${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` + + `\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`, + ) + if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T']) + } +} + +const modus = process.argv[2] ?? '' +if (modus === 'innen-ohne') innen(false) +else if (modus === 'innen-mit') innen(true) +else aussen() diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 85d6cfd..100e4d3 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,25 +1,27 @@ -Version 5.1.3 +Version 5.1.4 -Das Fenster gehört jetzt Rippy. -Der graue Windows-Rahmen ist weg. Minimieren, Maximieren und Schließen -sitzen oben rechts im Programm selbst, gezogen wird an der Kopfzeile — -Doppelklick darauf maximiert. Die Kanten lassen sich weiter ziehen wie -gewohnt. Schließen versteckt Rippy weiterhin nur im Infobereich, ein -laufender Rip merkt davon nichts. +Das Update installiert sich jetzt wirklich. +Bisher wurde Rippy beim Update sauber beendet — und dann passierte nichts. +Der Grund: Rippy hält alle seine Programme an einer Leine, damit beim +Absturz nichts hängenbleibt und die Disc festhält. Der Update-Installer +hing versehentlich an derselben Leine und wurde in dem Moment +mitgerissen, in dem Rippy sich für ihn beendete. Er startet jetzt +ausdrücklich neben der Leine; alles andere bleibt angeleint wie vorher. -Nichts klebt mehr links. -Die Seite ist jetzt mittig und wächst mit dem Fenster mit, statt auf -halber Breite abzubrechen. Im schmalen Fenster rutschen die Kacheln -untereinander, statt sich zu quetschen; die Einstellungen legen ihre -Abschnittswahl dann als Zeile über die Karten. +Aus 5.1.3: Das Fenster gehört Rippy. +Kein grauer Windows-Rahmen mehr. Minimieren, Maximieren und Schließen +sitzen oben rechts im Programm, gezogen wird an der Kopfzeile. + +Aus 5.1.3: Nichts klebt mehr links. +Die Seite ist mittig und wächst mit dem Fenster mit. Im schmalen Fenster +rutschen die Kacheln untereinander, statt sich zu quetschen. Aus 5.1.2: Rippy sagt Bescheid, wenn ein Update da ist. -Diese Karte hier ist der Beweis — mit Versionsnummer und dieser Liste. -In den Einstellungen gibt es den Knopf "Jetzt auf Updates prüfen". +Diese Karte hier ist der Beweis. In den Einstellungen gibt es dazu den +Knopf "Jetzt auf Updates prüfen". Aus 5.1.1: Serien-Discs werden richtig erkannt. Disc 2 von "Spartacus: Gods of the Arena" galt als der Film "Spartacus" -von 1960. Rippy fragt jetzt die Disc selbst: Ihr Inhaltsverzeichnis -nennt die Folgen. Disc-Nummer und Folgenzahl stehen neben dem Titel. +von 1960. Rippy fragt jetzt die Disc selbst. Unverändert: Updates warten immer, bis kein Rip mehr läuft. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 7922d91..5241257 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.1.3", + "version": "5.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.1.3", + "version": "5.1.4", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index df8faa1..c1ab979 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.1.3", + "version": "5.1.4", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "main": "out/haupt/index.js", "author": "KrBrZ", diff --git a/rippy-windows/src/haupt/index.ts b/rippy-windows/src/haupt/index.ts index e0a6160..759ea37 100644 --- a/rippy-windows/src/haupt/index.ts +++ b/rippy-windows/src/haupt/index.ts @@ -87,6 +87,9 @@ function start(): void { // vorher lag sie still im Haupt und war erst beim nächsten // Laden zu sehen (Commander-Fund 01.09.2026). () => statusSenden(), + // Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit + // der close-Horcher das Fenster nicht bloß versteckt. + () => beenden(), ) } } else if (nachricht.art === 'installer-starten') { diff --git a/rippy-windows/src/haupt/kommandozeile.ts b/rippy-windows/src/haupt/kommandozeile.ts new file mode 100644 index 0000000..e5ebd52 --- /dev/null +++ b/rippy-windows/src/haupt/kommandozeile.ts @@ -0,0 +1,49 @@ +// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit +// die Ampel das auf Linux prüfen kann (BAUEN.md). +// +// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie +// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen: +// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der +// Installer startet nie. Die Regeln sind die von CommandLineToArgvW — +// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt. + +/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */ +export function argumentQuoten(arg: string): string { + // Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es + // ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er. + if (arg.length > 0 && !/["\s]/.test(arg)) return arg + + let ergebnis = '"' + let rueckstriche = 0 + for (const zeichen of arg) { + if (zeichen === '\\') { + rueckstriche += 1 + continue + } + if (zeichen === '"') { + // Vor einem Anführungszeichen zählen Rückstriche doppelt, und das + // Anführungszeichen selbst bekommt einen eigenen davor. + ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"' + rueckstriche = 0 + continue + } + ergebnis += '\\'.repeat(rueckstriche) + zeichen + rueckstriche = 0 + } + // Am Ende steht das schließende Anführungszeichen — die Rückstriche + // davor zählen deshalb ebenfalls doppelt. + return ergebnis + '\\'.repeat(rueckstriche * 2) + '"' +} + +/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */ +export function kommandozeileBauen(programm: string, argumente: readonly string[]): string { + return [programm, ...argumente].map(argumentQuoten).join(' ') +} + +/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy + * danach wieder startet. Gemessen an der installierten Bibliothek + * (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer, + * `/S` für „still", `--force-run` für „danach starten". Genau diese drei + * benutzt electron-updater selbst — wir starten den Installer nur auf + * einem anderen Weg (außerhalb der Prozess-Leine). */ +export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run'] diff --git a/rippy-windows/src/haupt/leine.ts b/rippy-windows/src/haupt/leine.ts index b3df7ce..3d8a3ee 100644 --- a/rippy-windows/src/haupt/leine.ts +++ b/rippy-windows/src/haupt/leine.ts @@ -17,11 +17,44 @@ // rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der // ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei. import koffi from 'koffi' +import { kommandozeileBauen } from './kommandozeile' // Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js: const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 +// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht +// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei +// denn, sie verlangen das Ausklinken beim Anlegen selbst +// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit +// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also +// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu. +// +// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von +// electron-updater wird per `spawn(…, { detached: true })` gestartet +// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached` +// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied +// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um +// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber +// beendet aber startet NICHT neu in der neuen version." +const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800 + +/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */ +const CREATE_BREAKAWAY_FROM_JOB = 0x01000000 + +// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den +// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus +// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein +// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR — +// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung +// zeigte deshalb erst ein falsches Negativ. +const CREATE_NO_WINDOW = 0x08000000 + +// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0; +// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs). +const STARTUPINFO_GROESSE = 104 +const PROCESS_INFORMATION_GROESSE = 24 + // JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist // x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION // bei Offset 16. @@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus { } const info = Buffer.alloc(STRUKTUR_GROESSE) - info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET) + info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET) if ( !SetInformationJobObject( job, @@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus { return gesetzt } } + +/** + * Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt. + * + * Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige + * Kind, das Rippys eigenen Tod überleben MUSS — er installiert ja gerade + * über Rippy drüber und startet es danach wieder. Alles andere (Kern, + * makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert. + * + * Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der + * entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über + * den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst + * benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der + * `update-info.json` steht `isAdminRightsRequired: false`) — er liefe also + * wieder als Kind und stürbe wieder mit. + */ +export function ausserhalbDerLeineStarten( + programm: string, + argumente: readonly string[], +): { ok: boolean; fehler: string } { + try { + const kernel32 = koffi.load('kernel32.dll') + const CreateProcessW = kernel32.func( + 'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)', + ) + const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()') + + const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE) + startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0) + const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE) + // CreateProcessW DARF diese Zeichenkette verändern — deshalb ein + // beschreibbarer Puffer und nicht die Zeichenkette selbst. + const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le') + + const gestartet = CreateProcessW( + programm, + zeile, + null, + null, + false, + CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW, + null, + null, + startupInfo, + prozessInfo, + ) + if (!gestartet) { + // Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war + // nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine + // ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung). + return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` } + } + // Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich + // unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu + // schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand + // ohne Wirkung. + return { ok: true, fehler: '' } + } catch (fehler) { + return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` } + } +} diff --git a/rippy-windows/src/haupt/update.ts b/rippy-windows/src/haupt/update.ts index c8cb657..c342cc8 100644 --- a/rippy-windows/src/haupt/update.ts +++ b/rippy-windows/src/haupt/update.ts @@ -15,6 +15,8 @@ // Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster. import { app } from 'electron' import electronUpdater from 'electron-updater' +import { INSTALLER_ARGUMENTE } from './kommandozeile' +import { ausserhalbDerLeineStarten } from './leine' import { neuerungenText, updateUrlPruefen } from './update-regeln' export { @@ -46,6 +48,12 @@ let aenderungMelden: () => void = () => { } let ripLaeuftJetzt: () => boolean = () => false let scharf = false +/** Der geladene Installer — leer, solange nichts bereitliegt. */ +let installerPfad = '' +/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich + * nicht gegenseitig zwei Installer auf den Hals hetzen. */ +let installerGestartet = false +let rippyBeenden: () => void = () => app.quit() export function updateStand(): UpdateStand { return { ...stand } @@ -66,9 +74,11 @@ export function updateEinrichten( beiAenderung: () => void = () => { // ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar }, + beenden: () => void = () => app.quit(), ): void { aenderungMelden = beiAenderung ripLaeuftJetzt = ripLaeuft + rippyBeenden = beenden if (!app.isPackaged) { setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' }) return @@ -83,10 +93,24 @@ export function updateEinrichten( autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() }) } autoUpdater.autoDownload = false - // Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip. - autoUpdater.autoInstallOnAppQuit = true + // WIR installieren, nicht electron-updater (01.09.2026, am lebenden + // Objekt bezahlt): Sein eigener Weg startet den Installer per + // `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN + // Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3) + // und starb in genau der Sekunde, in der Rippy sich für ihn beendete. + // Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu." + autoUpdater.autoInstallOnAppQuit = false scharf = true + // Beim Beenden — egal ob über das Tray, den Update-Knopf oder das + // Herunterfahren von Windows — bekommt ein bereitliegendes Update noch + // seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor + // diese Zeile zurückkommt. + app.on('will-quit', () => { + if (!stand.bereit || ripLaeuftJetzt()) return + installerAnwerfen() + }) + autoUpdater.on('update-available', (info) => { const neuerungen = neuerungenText(info.releaseNotes) if (ripLaeuft()) { @@ -111,13 +135,24 @@ export function updateEinrichten( }) // Der Commander wollte die Meldung sehen, nicht suchen müssen. melden(`Update verfügbar: Version ${info.version} wird geladen.`) - void autoUpdater.downloadUpdate().catch((fehler: unknown) => { - setzen({ - laeuft: false, - fortschritt: -1, - text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`, + void autoUpdater + .downloadUpdate() + .then((pfade: string[]) => { + // downloadUpdate liefert die abgelegten Dateien zurück + // (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin + // ist der Installer, den wir gleich selbst starten. Liegt die Datei + // vom letzten Lauf schon im Zwischenspeicher, kommt sie hier + // ebenfalls heraus, ohne erneut zu laden. + const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? '' + if (exe.length > 0) installerPfad = exe + }) + .catch((fehler: unknown) => { + setzen({ + laeuft: false, + fortschritt: -1, + text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`, + }) }) - }) }) autoUpdater.on('download-progress', (fortschritt) => { setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) }) @@ -177,6 +212,22 @@ export function updatePruefenJetzt(): void { pruefen() } +/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob + * er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */ +function installerAnwerfen(): { ok: boolean; grund: string } { + if (installerGestartet) return { ok: true, grund: '' } + if (installerPfad.length === 0) { + return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' } + } + const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE) + if (!urteil.ok) { + setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` }) + return { ok: false, grund: urteil.fehler } + } + installerGestartet = true + return { ok: true, grund: '' } +} + /** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn * wirklich etwas bereitliegt — und nie mitten in einem Rip. */ export function updateInstallierenJetzt(): { ok: boolean; grund: string } { @@ -186,8 +237,10 @@ export function updateInstallierenJetzt(): { ok: boolean; grund: string } { if (ripLaeuftJetzt()) { return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' } } - // quitAndInstall schließt alle Fenster und beendet die App; der - // Installer läuft danach und startet Rippy wieder. - autoUpdater.quitAndInstall() + // Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy + // weg ist, und startet es hinterher wieder (`--force-run`). + const urteil = installerAnwerfen() + if (!urteil.ok) return urteil + rippyBeenden() return { ok: true, grund: '' } } diff --git a/rippy-windows/test/update.test.ts b/rippy-windows/test/update.test.ts index c279162..80c6294 100644 --- a/rippy-windows/test/update.test.ts +++ b/rippy-windows/test/update.test.ts @@ -2,6 +2,7 @@ // Ohne Code-Signing (Entscheid 5) ist der Transportweg die EINZIGE // Absicherung des Selbst-Updates. import { describe, expect, it } from 'vitest' +import { argumentQuoten, kommandozeileBauen, INSTALLER_ARGUMENTE } from '../src/haupt/kommandozeile' import { neuerungenText, updateUrlPruefen, @@ -68,3 +69,46 @@ describe('neuerungenText', () => { expect(neuerungenText('x'.repeat(NEUERUNGEN_HOECHSTENS * 2)).length).toBe(NEUERUNGEN_HOECHSTENS) }) }) + +// Seit 5.1.4 startet Rippy den Update-Installer SELBST, außerhalb der +// Prozess-Leine (§ 3.3) — electron-updaters eigener Weg lief als Kind und +// wurde beim Beenden von der Leine erschlagen. CreateProcessW nimmt EINE +// Zeichenkette statt einer Argumentliste; wer sie naiv zusammenklebt, +// verliert jeden Pfad mit Leerzeichen. +describe('kommandozeileBauen — die Regeln von CommandLineToArgvW', () => { + it('Harmloses bleibt unangetastet', () => { + expect(argumentQuoten('--updated')).toBe('--updated') + expect(argumentQuoten('/S')).toBe('/S') + }) + + it('DER FALL: ein Pfad mit Leerzeichen bleibt EIN Argument', () => { + // Ohne Anführungszeichen zerfiele das in „C:\\Users\\Tobi" und „Neu\\…" + // — der Installer startete nie. + expect(argumentQuoten('C:\\Users\\Tobi Neu\\RippySetup.exe')).toBe('"C:\\Users\\Tobi Neu\\RippySetup.exe"') + }) + + it('Rückstriche vor dem Schluss-Anführungszeichen zählen doppelt', () => { + // Sonst würde das abschließende " selbst escaped und die Zeile bliebe offen. + expect(argumentQuoten('C:\\Pfad mit Lücke\\')).toBe('"C:\\Pfad mit Lücke\\\\"') + }) + + it('Anführungszeichen im Argument werden escaped', () => { + expect(argumentQuoten('sag "hallo"')).toBe('"sag \\"hallo\\""') + expect(argumentQuoten('a\\"b')).toBe('"a\\\\\\"b"') + }) + + it('das leere Argument verschwindet nicht', () => { + expect(argumentQuoten('')).toBe('""') + }) + + it('die ganze Zeile, wie sie an CreateProcessW geht', () => { + const zeile = kommandozeileBauen('C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe', INSTALLER_ARGUMENTE) + expect(zeile).toBe('"C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe" --updated /S --force-run') + }) + + it('die Argumente sind die von electron-updater gemessenen', () => { + // NsisUpdater.js doInstall: --updated immer, /S für still, + // --force-run für „danach wieder starten". + expect(INSTALLER_ARGUMENTE).toEqual(['--updated', '/S', '--force-run']) + }) +})