hmgr 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gui/main.js CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { bauen, umgebungLesen } from "../cli/main.js";
14
14
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
15
15
  import { paketVersion } from "../adapter/paket-version.js";
16
+ import { infoPlistPfad, MeldungsPuffer, neuereVersionAufPlatte } from "./selbst-update.js";
16
17
  import { quelleName, quelleSchluessel } from "../kern/modell.js";
17
18
  import { kannProfile } from "../ports/harness.js";
18
19
  import { iconFuerQuelle } from "./icons.js";
@@ -27,6 +28,8 @@ const smoketest = process.env.HMGR_GUI_SMOKETEST === '1';
27
28
  // E84 Teil 1: Pfad eines im Fixture noch unbekannten Verzeichnisses, das der Smoke-Lauf per Dialog
28
29
  // hinzufügt, um den Sprung zur neuen Karte zu belegen. Nur zur Konstruktionszeit des DOM-Auszug-
29
30
  // Skripts gelesen (Node-Seite), nicht zur Laufzeit im Renderer.
31
+ /** E90-Nachtrag: Smoke-Lauf, in dem die Platten-Erkennung schon vor dem Laden der Seite feuert. */
32
+ const smoketestFrueh = process.env.HMGR_GUI_SMOKE_FRUEH === '1';
30
33
  const smoketestNeuesVerzeichnis = process.env.HMGR_SMOKETEST_NEUES_VERZEICHNIS ?? '';
31
34
  /**
32
35
  * Nur eine hmgr-Instanz je Nutzer (E67). Grund: zwei laufende Fenster schrieben nacheinander auf
@@ -331,10 +334,18 @@ function registriereIpc() {
331
334
  // Neustart-Knopf an der GUI-Update-Zeile (E80): installiert das geladene Paket sofort, statt auf
332
335
  // das nächste Beenden zu warten. Im Smoke-Test ein No-op — dort läuft `updatePruefen` ohnehin nie,
333
336
  // der Knopf bleibt verborgen, aber ein Absturz durch ein fehlendes Update darf trotzdem nie sein.
337
+ // E90: mit einem geladenen Windows-Paket `quitAndInstall`, sonst (macOS: das Bundle liegt schon
338
+ // ersetzt auf der Platte) ein Neustart des Prozesses. Entscheidend ist das Merker-Flag, nicht die
339
+ // Plattform allein.
334
340
  ipcMain.on('hmgr:neuStarten', () => {
335
341
  if (smoketest)
336
342
  return;
337
- autoUpdater.quitAndInstall();
343
+ if (geladenesPaketVorhanden) {
344
+ autoUpdater.quitAndInstall();
345
+ return;
346
+ }
347
+ app.relaunch();
348
+ app.exit(0);
338
349
  });
339
350
  ipcMain.handle('hmgr:arbeitsverzeichnisHinzufuegen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad)));
340
351
  ipcMain.handle('hmgr:arbeitsverzeichnisVergessen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisVergessen(pfad)));
@@ -498,8 +509,12 @@ function fensterOeffnen() {
498
509
  sandbox: true,
499
510
  },
500
511
  });
512
+ meldungsPuffer.bereitAbmelden();
513
+ hauptfenster.webContents.on('did-start-loading', () => meldungsPuffer.bereitAbmelden());
514
+ hauptfenster.webContents.on('did-finish-load', () => meldungsPuffer.bereitSetzen());
501
515
  hauptfenster.on('closed', () => {
502
516
  hauptfenster = null;
517
+ zuletztGemeldeteGuiVersion = null;
503
518
  });
504
519
  // Der <title> der index.html setzt den Fenstertitel beim Laden sonst zurück und überschriebe die
505
520
  // Option oben (E72). Der DOM-Titel bleibt dabei, wie er ist, nur das Fenster folgt ihm nicht mehr.
@@ -509,7 +524,21 @@ function fensterOeffnen() {
509
524
  console.log(`[renderer] ${event.message}`);
510
525
  });
511
526
  // Nur für den Smoke-Test (scripts/gui-smoke-test.ts): ein DOM-Auszug im Log, danach Ende.
512
- if (smoketest) {
527
+ if (smoketest && smoketestFrueh) {
528
+ // E90-Nachtrag: die Platten-Erkennung feuert schon vor dem Laden der Seite (direkt nach der
529
+ // Fenstererzeugung); die Meldung muss trotzdem ankommen. Kein großer DOM-Auszug, nur das Modal.
530
+ const fenster = hauptfenster;
531
+ plattenVersionPruefen(true);
532
+ fenster.webContents.once('did-finish-load', () => {
533
+ setTimeout(() => {
534
+ void fenster.webContents
535
+ .executeJavaScript(NEUSTART_AUSZUG_SKRIPT)
536
+ .then((auszug) => console.log(`[smoke] Neustart-Modal früh: ${String(auszug)}`))
537
+ .finally(() => app.quit());
538
+ }, 1500);
539
+ });
540
+ }
541
+ if (smoketest && !smoketestFrueh) {
513
542
  const fenster = hauptfenster;
514
543
  fenster.webContents.once('did-finish-load', () => {
515
544
  setTimeout(() => {
@@ -1202,6 +1231,20 @@ function fensterOeffnen() {
1202
1231
  // E72: der Fenstertitel steht nicht im DOM (dort steht weiter der <title> der
1203
1232
  // index.html), er lässt sich nur hier im Main-Prozess ablesen.
1204
1233
  .then(() => console.log(`[smoke] Fenster: ${JSON.stringify({ fenstertitel: fenster.getTitle() })}`))
1234
+ // E90: erst nach dem DOM-Auszug die Platten-Erkennung auslösen (Naht über
1235
+ // HMGR_GUI_SMOKE_PLATTENVERSION), damit die Leer-Prüfungen der Update-Zeile darin gelten.
1236
+ // Danach ein zweiter, kleiner Auszug nur zum Neustart-Modal.
1237
+ .then(async () => {
1238
+ if (process.env.HMGR_GUI_SMOKE_PLATTENVERSION === undefined)
1239
+ return;
1240
+ // Der Rest-Dialog (d-erfolg) öffnet über einen 90-ms-Timer nach der letzten Aktion des
1241
+ // Auszugs; ohne dieses Abwarten löste die Erkennung mal vor, mal nach ihm aus.
1242
+ await fenster.webContents.executeJavaScript(`new Promise(function (r) { var n = 0; (function w() { if (document.querySelector('dialog[open]') || ++n > 30) r(); else setTimeout(w, 50); })(); })`);
1243
+ plattenVersionPruefen(true);
1244
+ await new Promise((r) => setTimeout(r, 800));
1245
+ const auszug = await fenster.webContents.executeJavaScript(NEUSTART_AUSZUG_SKRIPT);
1246
+ console.log(`[smoke] Neustart-Modal: ${String(auszug)}`);
1247
+ })
1205
1248
  .finally(() => app.quit());
1206
1249
  }, 3000);
1207
1250
  });
@@ -1215,7 +1258,10 @@ function fensterOeffnen() {
1215
1258
  fenster.webContents.once('did-finish-load', () => {
1216
1259
  fenster.webContents.send('hmgr:aktualisierenAnstossen', 'start');
1217
1260
  });
1218
- const intervall = setInterval(() => fenster.webContents.send('hmgr:aktualisierenAnstossen', 'intervall'), SECHS_STUNDEN_MS);
1261
+ const intervall = setInterval(() => {
1262
+ fenster.webContents.send('hmgr:aktualisierenAnstossen', 'intervall');
1263
+ plattenVersionPruefen();
1264
+ }, SECHS_STUNDEN_MS);
1219
1265
  fenster.on('closed', () => clearInterval(intervall));
1220
1266
  }
1221
1267
  // Die GUI erfährt von Änderungen außerhalb ihrer selbst (E62): Fokus-Reload und Datei-Watcher.
@@ -1224,7 +1270,10 @@ function fensterOeffnen() {
1224
1270
  // Aktualisieren (E44): ein Event dürfte sonst in den DOM-Auszug pfuschen.
1225
1271
  if (!smoketest) {
1226
1272
  const fenster = hauptfenster;
1227
- fenster.on('focus', () => neuLadenAnstossen());
1273
+ fenster.on('focus', () => {
1274
+ neuLadenAnstossen();
1275
+ plattenVersionPruefen();
1276
+ });
1228
1277
  void beobachtetePfade().then((pfade) => {
1229
1278
  if (fenster.isDestroyed())
1230
1279
  return; // Fenster schon zu, während die Pfade ermittelt wurden
@@ -1281,7 +1330,8 @@ function updatePruefen() {
1281
1330
  .catch((fehler) => {
1282
1331
  console.log(`[verlauf] ${fehler instanceof Error ? fehler.message : String(fehler)}`);
1283
1332
  });
1284
- selbstUpdateMelden({ art: 'gui', version: guiVersion });
1333
+ geladenesPaketVorhanden = true;
1334
+ selbstUpdateMelden({ art: 'gui', version: guiVersion, neuStartNoetig: 'geladen' });
1285
1335
  });
1286
1336
  }
1287
1337
  void autoUpdater.checkForUpdatesAndNotify().catch((fehler) => {
@@ -1292,11 +1342,78 @@ function updatePruefen() {
1292
1342
  console.log(`[updater] ${fehler instanceof Error ? fehler.message : String(fehler)}`);
1293
1343
  }
1294
1344
  }
1295
- /** Ein Hinweis für die Sidebar (E77). Ist das Fenster schon zu, verfällt er stillschweigend. */
1296
- function selbstUpdateMelden(nachricht) {
1345
+ /** Das Skript für den Modal-Auszug im Smoke-Test (Neustart-Modal, Sidebar-Zeile, „Später"). */
1346
+ const NEUSTART_AUSZUG_SKRIPT = `(async () => {
1347
+ var dlg = document.getElementById('d-neustart');
1348
+ // Ein anderer Dialog steht noch offen (Rest des ersten Auszugs): das Modal wartet
1349
+ // solange, und öffnet erst, wenn der andere zu ist.
1350
+ var anderePlatzhalter = Array.from(document.querySelectorAll('dialog[open]')).filter(function (d) { return d !== dlg; });
1351
+ var wartetSolangeAndererOffen = anderePlatzhalter.length > 0 && dlg?.open !== true;
1352
+ anderePlatzhalter.forEach(function (d) { d.close(); });
1353
+ await new Promise(function (r) { setTimeout(r, 300); });
1354
+ var offen = dlg?.open === true;
1355
+ var titel = document.getElementById('dn-titel')?.textContent ?? null;
1356
+ var zeile = document.getElementById('selbstupdate-gui');
1357
+ var zeileText = zeile && !zeile.hidden ? zeile.textContent : null;
1358
+ var neustartKnopfSichtbar = document.getElementById('selbstupdate-neustart')?.hidden === false;
1359
+ var knopfTexte = Array.from(dlg?.querySelectorAll('.dfuss button') ?? []).map(function (b) { return b.textContent; }).join('|');
1360
+ dlg?.querySelector('button[value="spaeter"]')?.click();
1361
+ var nachSpaeterOffen = dlg?.open === true;
1362
+ return JSON.stringify({ wartetSolangeAndererOffen, offen, titel, zeileText, neustartKnopfSichtbar, knopfTexte, nachSpaeterOffen });
1363
+ })()`;
1364
+ /**
1365
+ * Ein Hinweis für die Sidebar (E77). Ist das Fenster schon zu, verfällt er stillschweigend. Solange
1366
+ * die Seite nicht geladen ist, hat der Renderer seinen Listener noch nicht (E90-Nachtrag): der
1367
+ * Puffer hält je Art die letzte Nachricht und sendet sie nach `did-finish-load` — das Skript am
1368
+ * Ende des Bodys registriert `onSelbstUpdate` synchron, `did-finish-load` kommt erst danach.
1369
+ */
1370
+ const meldungsPuffer = new MeldungsPuffer((nachricht) => {
1297
1371
  if (hauptfenster === null || hauptfenster.isDestroyed())
1298
1372
  return;
1299
1373
  hauptfenster.webContents.send('hmgr:selbstUpdate', nachricht);
1374
+ });
1375
+ function selbstUpdateMelden(nachricht) {
1376
+ if (hauptfenster === null || hauptfenster.isDestroyed())
1377
+ return;
1378
+ meldungsPuffer.melden(nachricht);
1379
+ }
1380
+ /** Windows: ein Paket ist geladen und wartet auf `quitAndInstall` (E90, Merker für `hmgr:neuStarten`). */
1381
+ let geladenesPaketVorhanden = false;
1382
+ /** Die beim Start festgehaltene Version des laufenden Prozesses. */
1383
+ const laufendeVersion = paketVersion();
1384
+ const PLATTEN_PRUEFUNG_MIN_ABSTAND_MS = 30_000;
1385
+ let letztePlattenPruefung = 0;
1386
+ let zuletztGemeldeteGuiVersion = null;
1387
+ /**
1388
+ * E90, macOS-Weg: das App-Bundle wird im Hintergrund vom Deploy-Skript (ditto/rsync) ersetzt, der
1389
+ * laufende Prozess merkt das nicht. Verglichen wird die Version in `Contents/Info.plist` mit der
1390
+ * beim Start festgehaltenen. Die Info.plist statt der package.json in `app.asar`, weil Electron den
1391
+ * asar-Header je Pfad zwischenspeichert und ein ersetztes Archiv nicht neu liest. Nur gepackt und
1392
+ * nur auf macOS (nur dort gibt es ein Bundle mit Info.plist); gedrosselt auf einen Lauf je 30 s.
1393
+ * Im Smoke-Test liefert `HMGR_GUI_SMOKE_PLATTENVERSION` eine künstliche Platten-Version, sonst
1394
+ * läuft dort nie eine echte Prüfung.
1395
+ */
1396
+ function plattenVersionPruefen(erzwingen = false) {
1397
+ let platte;
1398
+ if (smoketest) {
1399
+ if (!erzwingen)
1400
+ return;
1401
+ const kuenstlich = process.env.HMGR_GUI_SMOKE_PLATTENVERSION;
1402
+ platte = kuenstlich !== undefined && kuenstlich !== '' ? kuenstlich : null;
1403
+ }
1404
+ else {
1405
+ if (!app.isPackaged || process.platform !== 'darwin')
1406
+ return;
1407
+ const jetzt = Date.now();
1408
+ if (jetzt - letztePlattenPruefung < PLATTEN_PRUEFUNG_MIN_ABSTAND_MS)
1409
+ return;
1410
+ letztePlattenPruefung = jetzt;
1411
+ platte = neuereVersionAufPlatte(infoPlistPfad(process.execPath), laufendeVersion);
1412
+ }
1413
+ if (platte === null || platte === zuletztGemeldeteGuiVersion)
1414
+ return;
1415
+ zuletztGemeldeteGuiVersion = platte;
1416
+ selbstUpdateMelden({ art: 'gui', version: platte, neuStartNoetig: 'installiert' });
1300
1417
  }
1301
1418
  /**
1302
1419
  * Die daneben installierte hmgr-CLI gegen npm (E77, F83=O1). Läuft auf allen Plattformen — anders
@@ -255,6 +255,21 @@
255
255
  </form>
256
256
  </dialog>
257
257
 
258
+ <!-- Neustart-Angebot (E90): ein Update für hmgr selbst liegt bereit. -->
259
+ <dialog id="d-neustart">
260
+ <form method="dialog">
261
+ <div class="dkopf">
262
+ <span class="etikett">Update</span>
263
+ <h3 id="dn-titel"></h3>
264
+ <p>hmgr startet dafür neu. Offene Dialoge gehen dabei verloren.</p>
265
+ </div>
266
+ <div class="dfuss">
267
+ <button class="knopf leise" value="spaeter">Später</button>
268
+ <button class="knopf signal" value="jetzt">Jetzt neu starten</button>
269
+ </div>
270
+ </form>
271
+ </dialog>
272
+
258
273
  <dialog id="d-erfolg">
259
274
  <form method="dialog">
260
275
  <div class="dkopf">
@@ -2668,11 +2668,39 @@
2668
2668
  die CLI installiert die GUI nicht selbst (F82). Die CLI-Zeile ist seit E82 eine Warnung, keine
2669
2669
  bloße Ansage: eine alte CLI (vor 0.0.7, kennt --version nicht) schreibt dieselben Dateien
2670
2670
  (state.json, verlauf.json) wie die neue GUI und kennt Bundles, Verlauf und WSL-Heime nicht. */
2671
+ /* Neustart-Modal (E90): je Version einmal. Nach „Später" kommt es für dieselbe Version nicht
2672
+ wieder (nur im Speicher, ein App-Neustart setzt es zurück). Steht gerade ein anderer Dialog
2673
+ offen, wartet es, bis der zu ist. */
2674
+ var neustartAngebotVersion = null;
2675
+ var neustartAbgelehnt = null;
2676
+ function neustartAnbieten(version) {
2677
+ if (version === neustartAbgelehnt) return;
2678
+ neustartAngebotVersion = version;
2679
+ neustartAngebotOeffnen();
2680
+ }
2681
+ function neustartAngebotOeffnen() {
2682
+ if (neustartAngebotVersion === null || neustartAngebotVersion === neustartAbgelehnt) return;
2683
+ if (document.querySelector('dialog[open]')) return;
2684
+ $('dn-titel').textContent = 'hmgr ' + neustartAngebotVersion + ' ist bereit';
2685
+ zeige($('d-neustart'));
2686
+ }
2687
+ $('d-neustart').addEventListener('close', function () {
2688
+ if ($('d-neustart').returnValue === 'jetzt') window.hmgr.neuStarten();
2689
+ else neustartAbgelehnt = neustartAngebotVersion;
2690
+ });
2691
+ // `close` bubbelt nicht, der Capture-Listener am Dokument sieht aber jeden geschlossenen Dialog.
2692
+ document.addEventListener('close', function (ev) {
2693
+ if (ev.target !== $('d-neustart')) setTimeout(neustartAngebotOeffnen, 0);
2694
+ }, true);
2695
+
2671
2696
  function selbstUpdateZeigen(nachricht) {
2672
2697
  if (nachricht.art === 'gui') {
2673
- $('selbstupdate-gui').textContent = 'hmgr ' + nachricht.version + ' bereit — wird beim Beenden installiert';
2698
+ $('selbstupdate-gui').textContent = nachricht.neuStartNoetig === 'installiert'
2699
+ ? 'hmgr ' + nachricht.version + ' ist installiert — Neustart lädt sie'
2700
+ : 'hmgr ' + nachricht.version + ' bereit — wird beim Beenden installiert';
2674
2701
  $('selbstupdate-gui').hidden = false;
2675
2702
  $('selbstupdate-neustart').hidden = false;
2703
+ neustartAnbieten(nachricht.version);
2676
2704
  } else if (nachricht.art === 'cli') {
2677
2705
  var p = $('selbstupdate-cli');
2678
2706
  p.textContent = '';
@@ -0,0 +1,105 @@
1
+ // Erkennung „das App-Bundle auf der Platte ist neuer als der laufende Prozess" (E90). Reine
2
+ // Funktionen ohne Electron-Import, damit sie ohne App testbar sind; das Verdrahten liegt in main.ts.
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ function zerlegen(version) {
6
+ const treffer = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
7
+ if (treffer === null)
8
+ return null;
9
+ return {
10
+ kern: [Number(treffer[1]), Number(treffer[2]), Number(treffer[3])],
11
+ vorab: treffer[4] === undefined ? [] : treffer[4].split('.'),
12
+ };
13
+ }
14
+ /** Semver-Rangfolge: negativ, 0 oder positiv; Vorabversion ist kleiner als ihre Release. */
15
+ function vergleichen(a, b) {
16
+ for (let i = 0; i < 3; i++) {
17
+ const unterschied = a.kern[i] - b.kern[i];
18
+ if (unterschied !== 0)
19
+ return unterschied;
20
+ }
21
+ if (a.vorab.length === 0 && b.vorab.length === 0)
22
+ return 0;
23
+ if (a.vorab.length === 0)
24
+ return 1;
25
+ if (b.vorab.length === 0)
26
+ return -1;
27
+ for (let i = 0; i < Math.max(a.vorab.length, b.vorab.length); i++) {
28
+ const x = a.vorab[i];
29
+ const y = b.vorab[i];
30
+ if (x === undefined)
31
+ return -1;
32
+ if (y === undefined)
33
+ return 1;
34
+ const xZahl = /^\d+$/.test(x);
35
+ const yZahl = /^\d+$/.test(y);
36
+ if (xZahl && yZahl) {
37
+ if (Number(x) !== Number(y))
38
+ return Number(x) - Number(y);
39
+ }
40
+ else if (xZahl !== yZahl) {
41
+ return xZahl ? -1 : 1;
42
+ }
43
+ else if (x !== y) {
44
+ return x < y ? -1 : 1;
45
+ }
46
+ }
47
+ return 0;
48
+ }
49
+ /** `true`, wenn `platte` eine echt größere Version ist als `laufend`. Unlesbares gilt als „nein". */
50
+ export function istNeuer(platte, laufend) {
51
+ const p = zerlegen(platte);
52
+ const l = zerlegen(laufend);
53
+ return p !== null && l !== null && vergleichen(p, l) > 0;
54
+ }
55
+ /** `…/hmgr.app/Contents/MacOS/hmgr` → `…/hmgr.app/Contents/Info.plist`. */
56
+ export function infoPlistPfad(execPath) {
57
+ return path.join(path.dirname(execPath), '..', 'Info.plist');
58
+ }
59
+ /** Die CFBundleShortVersionString aus einer XML-Info.plist; `null` bei allem Unlesbaren. */
60
+ export function plistVersion(plistPfad) {
61
+ try {
62
+ const text = readFileSync(plistPfad, 'utf8');
63
+ const treffer = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
64
+ return treffer?.[1]?.trim() || null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** Die Platten-Version, wenn sie neuer ist als die laufende, sonst `null`. Wirft nie. */
71
+ export function neuereVersionAufPlatte(plistPfad, laufend) {
72
+ const platte = plistVersion(plistPfad);
73
+ return platte !== null && istNeuer(platte, laufend) ? platte : null;
74
+ }
75
+ /**
76
+ * Puffer für Meldungen an den Renderer (E90-Nachtrag). Solange die Seite nicht geladen ist, hat der
77
+ * Renderer seinen Listener noch nicht registriert, ein `webContents.send` ginge ins Leere. Bis
78
+ * `bereitSetzen()` wird deshalb je `art` nur die letzte Nachricht behalten und dann einmal
79
+ * gesendet; danach geht alles sofort durch. `bereitAbmelden()` (neues Laden der Seite) schaltet
80
+ * wieder auf Puffern. Ohne Electron-Import, damit ohne App testbar.
81
+ */
82
+ export class MeldungsPuffer {
83
+ bereit = false;
84
+ wartend = new Map();
85
+ senden;
86
+ constructor(senden) {
87
+ this.senden = senden;
88
+ }
89
+ melden(nachricht) {
90
+ if (this.bereit)
91
+ this.senden(nachricht);
92
+ else
93
+ this.wartend.set(nachricht.art, nachricht);
94
+ }
95
+ bereitSetzen() {
96
+ this.bereit = true;
97
+ const gepuffert = [...this.wartend.values()];
98
+ this.wartend.clear();
99
+ for (const nachricht of gepuffert)
100
+ this.senden(nachricht);
101
+ }
102
+ bereitAbmelden() {
103
+ this.bereit = false;
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",