fix(v5): Das Update installierte nie — die eigene Leine erschlug den Installer
Ampel / ampel (push) Successful in 1m41s
Ampel / ampel (push) Successful in 1m41s
Commander: "Rippy wird zwar sauber beendet aber startet NICHT neu in der
neuen version."
Ursache, auf drei Beinen belegt:
1. Bibliothek: electron-updater startet den Installer per
spawn(..., { detached: true }) (BaseUpdater.js, an der installierten
Fassung nachgelesen). `detached` setzt unter Windows KEIN Breakaway.
2. Leine: leine.ts setzt KILL_ON_JOB_CLOSE, und Kinder erben die
Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
3. Platte: pending\RippySetup-5.1.3.exe lag geladen da (18:11:47),
installiert war weiter 5.1.2. Der Download klappte, nur die
Installation nicht.
Der Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.
Fix — die Leine bleibt, der Installer klinkt sich aus:
* leine.ts setzt zusaetzlich JOB_OBJECT_LIMIT_BREAKAWAY_OK. Das aendert
fuer sich genommen NICHTS: Kinder erben weiter, ausser sie verlangen
das Ausklinken selbst. Kern, makemkvcon und HandBrakeCLI bleiben
angeleint, die rc10-Waisen-Falle (Paragraph 3.3) ist unveraendert zu.
* Neu: ausserhalbDerLeineStarten() startet EIN Programm per CreateProcessW
mit CREATE_BREAKAWAY_FROM_JOB. Liegt in leine.ts, weil dort die
Arbeitsgruppe verwaltet wird — und weil Waechter R1 koffi nur dort und
in kern/laufwerk/win32.ts erlaubt.
* update.ts installiert jetzt SELBST: autoInstallOnAppQuit ist aus, der
Installerpfad kommt aus downloadUpdate() (laut AppUpdater.d.ts "Paths to
downloaded files"), die Argumente sind die von electron-updater
gemessenen (--updated /S --force-run). Gestartet wird beim Beenden
(will-quit) und ueber den Knopf.
* Neu kommandozeile.ts (pur, ohne koffi/Electron): CreateProcessW nimmt
EINE Zeichenkette. Ohne die Regeln von CommandLineToArgvW zerfiele ein
Pfad wie "C:\Users\Tobi Neu\..." in zwei Argumente.
BEWIESEN, nicht behauptet — neu: beweise/leine-breakaway.js
OHNE Ausklinken (so macht es electron-updater)
lebt nach dem Tod des Elternprozesses: NEIN -> WIE ERWARTET
MIT Ausklinken (so macht es Rippy ab 5.1.4)
lebt nach dem Tod des Elternprozesses: JA -> WIE ERWARTET
Der erste Messlauf zeigte ein falsches Negativ: Der Enkel starb an der
sterbenden KONSOLE des Elternprozesses, nicht an der Arbeitsgruppe. Erst
mit CREATE_NO_WINDOW (eigene Konsole) misst der Versuch, was er messen
soll. Diese Lehre steht als Kommentar im Code und der Flag ist auch im
echten Aufruf gesetzt.
237 Tests gruen (vorher 230), Typpruefung sauber. Version 5.1.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d53e8526f
commit
051cab3afa
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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']
|
||||
@@ -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)}` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user