hmgr 0.0.5 → 0.0.7

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
@@ -2,19 +2,42 @@
2
2
  // registriert IPC-Handler für den Renderer und implementiert den Rückfrage-Port als Dialog im
3
3
  // Fenster. Kein zweiter Prozess: der Kern läuft direkt im Main-Prozess, die GUI ist ein zweiter
4
4
  // Kompositions-Einstieg neben der CLI (ARCHITEKTUR.md „Eingang").
5
- import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
5
+ import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
6
6
  import path from 'node:path';
7
- import os from 'node:os';
8
7
  import { randomUUID } from 'node:crypto';
9
8
  import { stat } from 'node:fs/promises';
10
9
  import { fileURLToPath } from 'node:url';
11
10
  import { bauen, umgebungLesen } from "../cli/main.js";
12
11
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
12
+ import { paketVersion } from "../adapter/paket-version.js";
13
13
  import { quelleName, quelleSchluessel } from "../kern/modell.js";
14
+ import { kannProfile } from "../ports/harness.js";
14
15
  import { iconFuerQuelle } from "./icons.js";
15
16
  import { entscheideOeffnen } from "./oeffnen.js";
17
+ import { mcpUebernahmeSerialisieren, mcpUebersichtSerialisieren, mcpWirkungSerialisieren } from "./mcp-ansicht.js";
18
+ import { beobachten } from "./beobachter.js";
19
+ import { loginPathUebernehmen } from "./login-pfad.js";
16
20
  const hierVerzeichnis = path.dirname(fileURLToPath(import.meta.url));
17
21
  const smoketest = process.env.HMGR_GUI_SMOKETEST === '1';
22
+ /**
23
+ * Nur eine hmgr-Instanz je Nutzer (E67). Grund: zwei laufende Fenster schrieben nacheinander auf
24
+ * dieselbe `state.json` und überschrieben sich gegenseitig — der Zustand ist einer, nicht einer je
25
+ * Fenster. Die zweite Instanz bekommt den Lock nicht und beendet sich; die erste holt in
26
+ * `second-instance` ihr Fenster nach vorn, damit der zweite Start nicht wirkungslos aussieht.
27
+ * Gilt auf allen Plattformen: unter macOS ist der Doppelstart selten (das Dock startet nicht neu),
28
+ * unter Windows ist ein zweiter Klick im Startmenü der Normalfall.
29
+ *
30
+ * Der Lock wird ganz früh geholt, noch vor `bauen()` und der PATH-Ermittlung: eine Instanz, die
31
+ * ohnehin gleich wieder geht, soll nichts mehr anfassen und keine Shell mehr starten.
32
+ *
33
+ * Im Smoke-Test ist er aus. Er hängt am Electron-userData-Verzeichnis, und das leitet macOS nicht
34
+ * über das gefälschte HOME um (gemessen, siehe E67) — eine offene hmgr-GUI auf dem Rechner würde
35
+ * den Smoke-Lauf sonst stumm abwürgen. Dieselbe Zurückhaltung wie bei Watcher und
36
+ * Auto-Aktualisieren: was einen automatisierten Lauf von außen stören kann, bleibt im Smoke aus.
37
+ */
38
+ const einzelinstanz = smoketest || app.requestSingleInstanceLock();
39
+ if (!einzelinstanz)
40
+ app.quit();
18
41
  const umgebung = umgebungLesen();
19
42
  /** Cache der Quellen-Icons (E60), unterhalb von HMGR_HOME wie der Zustand: Tests/Smoke fassen so
20
43
  nie ein echtes Verzeichnis an. */
@@ -23,10 +46,16 @@ const iconCacheVerzeichnis = path.join(umgebung.hmgrHome, 'icon-cache');
23
46
  const SECHS_STUNDEN_MS = 6 * 60 * 60 * 1000;
24
47
  /**
25
48
  * Fake der claude-CLI für den Smoke-Test (E40 R4), nach dem Muster von `fakeClaude` in
26
- * tests/helfer.ts: nie `code: 0`, also ohne Plugin-Fähigkeit, wie ohne installierte CLI. So fasst
27
- * ein automatisierter Lauf formal nie die echte claude-CLI an, auch wenn sie auf dem PATH liegt.
49
+ * tests/helfer.ts. Kein Kindprozess, keine echte CLI: ein automatisierter Lauf fasst die echte
50
+ * claude-CLI formal nie an, auch wenn sie auf dem PATH liegt.
51
+ *
52
+ * `--version` läuft durch, damit Claude Code im Smoke-Test seine Fähigkeiten bekommt (E61: die
53
+ * MCP-Fähigkeit hängt an der vorhandenen CLI). Sonst blieben die Claude-Code-Zeilen der MCP-Ansicht
54
+ * ungetestet. Jeder andere Aufruf scheitert mit Code 1: die lesenden Pfade tragen das (Plugin-Liste
55
+ * leer, `eingehaengte` liest ohnehin nur ~/.claude.json), und die schreibenden bleiben so
56
+ * verschlossen — der Smoke-Test hängt nichts ein und aus, er öffnet die Dialoge nur.
28
57
  */
29
- const smoketestClaudeAusfuehrer = async () => ({ stdout: '', code: 1 });
58
+ const smoketestClaudeAusfuehrer = async (args) => args[0] === '--version' ? { stdout: 'claude 0.0.0-smoke\n', code: 0 } : { stdout: '', code: 1 };
30
59
  let hauptfenster = null;
31
60
  /**
32
61
  * Rückfrage-Port als Dialog im Renderer (E28, E34): der Main-Prozess schickt die Frage per
@@ -63,12 +92,34 @@ class GuiRueckfrage {
63
92
  return typeof antwort === 'number' ? antwort : undefined;
64
93
  }
65
94
  }
95
+ /**
96
+ * PATH aus der Login-Shell (F47=O2, E63): aus dem Finder gestartet bekommt die App nur den
97
+ * macOS-Minimal-PATH, ohne z. B. ~/.local/bin mit der claude-CLI. Die Ermittlung läuft ab hier
98
+ * parallel zum Electron-Start; gewartet wird zentral in `auffangen`, und damit vor jedem
99
+ * Adapter-Aufruf, denn jeder IPC-Handler geht durch `auffangen` und Spawns gibt es nur dort
100
+ * (claude-code.ts, git.ts — Watcher, Fokus-Reload und Auto-Aktualisieren stoßen den Renderer an,
101
+ * der wieder über IPC hereinkommt).
102
+ *
103
+ * Bewusst kein Top-Level-`await`: im ESM-Main-Entry läuft vor `ready` nur, was die Imports als
104
+ * Nebeneffekt tun; ein Await dort verschiebt die Registrierung aller App-Handler und verzögert
105
+ * das Fenster um die Laufzeit der Shell (~2 s). So erscheint das Fenster sofort, und höchstens
106
+ * der erste Datenabruf wartet. Im Smoke-Test unterbleibt die Ermittlung ganz (nie eine echte
107
+ * Shell, Muster: Watcher/Auto-Aktualisieren sind im Smoke auch aus). Der `catch` ist die letzte
108
+ * Leine: dieser Aufruf darf die App unter keinen Umständen stören.
109
+ *
110
+ * Unter Windows steigt `loginPathUebernehmen` selbst sofort aus (E67): dort gibt es keine
111
+ * Login-Shell im POSIX-Sinn und keine PATH-Lücke zu füllen.
112
+ */
113
+ const pathBereit = smoketest || !einzelinstanz
114
+ ? Promise.resolve()
115
+ : loginPathUebernehmen().catch(() => undefined);
66
116
  const rueckfrage = new GuiRueckfrage();
67
- const { anwendungsfaelle } = bauen(umgebung, {
117
+ const { anwendungsfaelle, ports } = bauen(umgebung, {
68
118
  rueckfrage,
69
119
  ...(smoketest ? { claudeAusfuehrer: smoketestClaudeAusfuehrer } : {}),
70
120
  });
71
121
  async function auffangen(fn) {
122
+ await pathBereit; // erst der Login-PATH (F47=O2), dann der Adapter — sonst findet er die CLI nicht
72
123
  try {
73
124
  return { ok: true, daten: await fn() };
74
125
  }
@@ -86,6 +137,14 @@ function zielZustandSerialisieren(z) {
86
137
  gruende: Object.fromEntries(z.gruende),
87
138
  ignoriert: z.ignoriert,
88
139
  uebernahme: Object.fromEntries(z.uebernahme),
140
+ // Bundles des Ziels (E69): Adresse und Anzeigename der Quelle kommen mit, damit der Renderer
141
+ // die Gruppe beschriften kann, ohne die Quelle selbst nachzuschlagen.
142
+ bundles: z.bundles.map((b) => ({
143
+ name: b.name,
144
+ skills: b.skills,
145
+ quelleText: quelleAlsText(b.quelle),
146
+ quelleName: quelleName(b.quelle),
147
+ })),
89
148
  istRegistriert: z.istRegistriert,
90
149
  kannSkillOrdner: z.kannSkillOrdner,
91
150
  ...(z.plugins === undefined ? {} : { plugins: z.plugins }),
@@ -97,8 +156,13 @@ function zielAusIpc(harness, ort) {
97
156
  }
98
157
  function registriereIpc() {
99
158
  ipcMain.handle('hmgr:version', async () => auffangen(async () => app.getVersion()));
100
- // Für pfadKurz im Renderer (Anzeige-Kürzung auf ~): nur lesend, nie in einen anderen Aufruf zurückgespeist.
101
- ipcMain.handle('hmgr:home', async () => auffangen(async () => os.homedir()));
159
+ // Für pfadKurz im Renderer (Anzeige-Kürzung auf ~): nur lesend, nie in einen anderen Aufruf
160
+ // zurückgespeist. Dasselbe Home wie der Kern (`umgebungLesen`: `HOME` vor `os.homedir()`, E68) —
161
+ // sonst kürzt die Anzeige dort nicht, wo HOME abweichend gesetzt ist (MSYS/Git-Bash).
162
+ ipcMain.handle('hmgr:home', async () => auffangen(async () => umgebung.home));
163
+ // Ebenfalls nur für die Anzeige (E68): der Renderer leitet daraus den Pfadtrenner ab, mit dem er
164
+ // eigene Anzeigepfade zusammensetzt („~/.claude.json" bzw. „~\.claude.json").
165
+ ipcMain.handle('hmgr:plattform', async () => auffangen(async () => process.platform));
102
166
  ipcMain.handle('hmgr:istZustand', async () => auffangen(async () => (await anwendungsfaelle.istZustand()).map(zielZustandSerialisieren)));
103
167
  ipcMain.handle('hmgr:marketplaces', async () => auffangen(async () => (await anwendungsfaelle.marketplaces()).map((m) => ({
104
168
  quelleText: quelleAlsText(m.quelle),
@@ -126,6 +190,7 @@ function registriereIpc() {
126
190
  ...(skill.modell === undefined ? {} : { modell: skill.modell }),
127
191
  ...(skill.eigenerKontext === undefined ? {} : { eigenerKontext: skill.eigenerKontext }),
128
192
  ...(skill.argumentHinweis === undefined ? {} : { argumentHinweis: skill.argumentHinweis }),
193
+ ...(skill.geaendert === undefined ? {} : { geaendert: skill.geaendert }),
129
194
  quelleText: quelleAlsText(eintrag.quelle),
130
195
  quelleName: quelleName(eintrag.quelle),
131
196
  quelleSchluessel: quelleSchluessel(eintrag.quelle),
@@ -134,6 +199,45 @@ function registriereIpc() {
134
199
  }
135
200
  return ergebnis;
136
201
  }));
202
+ // Bundles aller bekannten Quellen (E69): flach wie der Skill-Katalog, je Bundle seine Quelle und
203
+ // die Zahl seiner Mitglieder. Kaputte Definitionen kommen als eigene Liste mit, damit die GUI sie
204
+ // nennen kann, statt sie zu verschlucken.
205
+ ipcMain.handle('hmgr:bundles', async () => auffangen(async () => {
206
+ const bundles = [];
207
+ const kaputte = [];
208
+ for (const eintrag of await anwendungsfaelle.bundleKatalog()) {
209
+ for (const bundle of eintrag.bundles)
210
+ bundles.push({
211
+ name: bundle.name,
212
+ beschreibung: bundle.beschreibung,
213
+ mitglieder: bundle.mitglieder.length,
214
+ // Anzeige im Dialog: aus welchen Quellen das Bundle Skills holt, ohne Dubletten.
215
+ quellen: [...new Set(bundle.mitglieder.map((m) => quelleAlsText(m.quelle)))],
216
+ quelleText: quelleAlsText(bundle.quelle),
217
+ quelleName: quelleName(bundle.quelle),
218
+ });
219
+ for (const kaputt of eintrag.kaputte)
220
+ kaputte.push({ quelleName: quelleName(eintrag.quelle), datei: kaputt.datei, fehler: kaputt.fehler });
221
+ }
222
+ return { bundles, kaputte };
223
+ }));
224
+ // Installieren und Entfernen wirken auf das Bundle als Ganzes (E69). Der Zeiger ist nicht im
225
+ // Spiel, die Zugehörigkeit steht im Zustand — anders als bei linkLegen braucht es kein `lokal`.
226
+ ipcMain.handle('hmgr:bundleInstallieren', async (_event, args) => auffangen(async () => {
227
+ const ergebnis = await anwendungsfaelle.bundleInstallieren({
228
+ bundle: args.bundle,
229
+ ziel: zielAusIpc(args.harness, args.ort),
230
+ ...(args.quelle === undefined ? {} : { quelle: args.quelle }),
231
+ });
232
+ return {
233
+ name: ergebnis.bundle.name,
234
+ gelegt: ergebnis.gelegt,
235
+ uebernommen: ergebnis.uebernommen,
236
+ neueQuellen: ergebnis.neueQuellen.map((q) => quelleName(q)),
237
+ ...(ergebnis.hinweis === undefined ? {} : { hinweis: ergebnis.hinweis }),
238
+ };
239
+ }));
240
+ ipcMain.handle('hmgr:bundleEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.bundleEntfernen({ bundle: args.bundle, ziel: zielAusIpc(args.harness, args.ort) })));
137
241
  // Nur die Herkunft, ohne die Quellen zu lesen: der Katalog daneben tut das schon.
138
242
  ipcMain.handle('hmgr:quellen', async () => auffangen(async () => (await anwendungsfaelle.quellen()).map((quelle) => ({
139
243
  art: quelle.art,
@@ -230,7 +334,7 @@ function registriereIpc() {
230
334
  // Quelle öffnen (E60): die Protokoll-Weiche steht in oeffnen.ts (rein, testbar), hier nur die
231
335
  // Wirkung. `shell.openExternal` bekommt ausschließlich http(s)-URLs, `shell.openPath` nur einen
232
336
  // Ordner, den es wirklich gibt — nichts anderes wird zugelassen. Alles, was es gibt, aber kein
233
- // Ordner ist, wird im Finder nur gezeigt, nicht geöffnet: `openPath` startet sonst eine Datei
337
+ // Ordner ist, wird im Dateimanager nur gezeigt, nicht geöffnet: `openPath` startet sonst eine Datei
234
338
  // mit ihrem Standardprogramm, und ein Pfad aus dem Renderer soll nichts starten können.
235
339
  ipcMain.handle('hmgr:quelleOeffnen', async (_event, quelleText) => auffangen(async () => {
236
340
  const entscheidung = entscheideOeffnen(quelleAusText(quelleText));
@@ -247,7 +351,83 @@ function registriereIpc() {
247
351
  }
248
352
  return entscheidung;
249
353
  }));
354
+ // MCP-Ansicht (E61-Folgerunde): Whitelisting und der F39-Vermerk je Karte stehen in mcp-ansicht.ts
355
+ // (rein, ohne Electron, wie oeffnen.ts und icons.ts), damit sie testbar sind. Der Main-Prozess
356
+ // ruft nur noch auf und liefert die eigene Prozess-Umgebung für die F39-Prüfung.
357
+ ipcMain.handle('hmgr:mcpUebersicht', async () => auffangen(async () => mcpUebersichtSerialisieren(await anwendungsfaelle.mcpUebersicht(), process.env)));
358
+ // Plätze für den Einhängen-Dialog (F42): die Fähigkeit hängt am Adapter, nicht am Namen (E16),
359
+ // deshalb kommt die Liste unverändert aus dem Kern statt aus einer Harness-Fallunterscheidung hier.
360
+ ipcMain.handle('hmgr:mcpPlaetze', async () => auffangen(async () => anwendungsfaelle.mcpPlaetze()));
361
+ ipcMain.handle('hmgr:mcpEinhaengen', async (_event, args) => auffangen(async () => mcpWirkungSerialisieren(await anwendungsfaelle.mcpEinhaengen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
362
+ ipcMain.handle('hmgr:mcpAushaengen', async (_event, args) => auffangen(async () => mcpWirkungSerialisieren(await anwendungsfaelle.mcpAushaengen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
363
+ // Übernehmen aus der geteilten .mcp.json (E64): die Bestätigung holt der Renderer ein, hier läuft
364
+ // nur die Ausführung. Der Name muss nicht im Katalog stehen.
365
+ ipcMain.handle('hmgr:mcpUebernehmen', async (_event, args) => auffangen(async () => mcpUebernahmeSerialisieren(await anwendungsfaelle.mcpUebernehmen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
366
+ }
367
+ /** Mindestabstand zwischen zwei Fokus-Reloads (E62): schnelles Fenster-Wechseln löst keine Lawine
368
+ von Anzeige-Neuladungen aus. Getrennt vom Entprellen im Datei-Watcher (das sammelt Events vor dem
369
+ ersten Aufruf), hier wird zusätzlich zwischen aufeinanderfolgenden Aufrufen gedrosselt (trailing:
370
+ ein Aufruf innerhalb der Sperrzeit wird nicht verworfen, sondern ans Ende der Sperrzeit verschoben). */
371
+ const FOKUS_RELOAD_MIN_ABSTAND_MS = 3000;
372
+ /** Sammelzeit des Datei-Watchers (E62): mehrere Events in kurzer Folge lösen einen Reload aus. */
373
+ const BEOBACHTER_ENTPRELLEN_MS = 400;
374
+ /**
375
+ * Pfade, die die GUI kennt und darum beobachtet (E62): hmgr-Zustand samt MCP-Katalog, die
376
+ * Harness-MCP-Konfigurationen (`~/.claude.json`, Hermes' `config.yaml`) und die `.env`-Dateien, die
377
+ * die F39-Prüfung beim MCP-Einhängen liest — bei Hermes liegt die `.env` je Ort neben der
378
+ * `config.yaml` (so liest sie der Adapter in `mcp.envDatei`), darum auch je Profil. Die Profile
379
+ * werden einmal beim Start am Harness selbst erfragt (`profile()`, ein Verzeichnis-Blick, kein
380
+ * ganzer Erhebungslauf); ein zur Laufzeit neu angelegtes Profil beobachtet der Watcher nicht
381
+ * dynamisch, das deckt der Fokus-Reload ab (E62).
382
+ *
383
+ * Nicht dabei: die geteilten `.mcp.json` der Arbeitsverzeichnisse, die die MCP-Übersicht seit dem
384
+ * E61-Nachtrag mitliest. Ihre Liste ist zur Laufzeit veränderlich, wie die der Hermes-Profile, und
385
+ * damit gilt für sie dasselbe: der Fokus-Reload deckt sie ab.
386
+ */
387
+ async function beobachtetePfade() {
388
+ const hermesHome = path.join(umgebung.home, '.hermes');
389
+ const pfade = [
390
+ path.join(umgebung.hmgrHome, 'state.json'),
391
+ path.join(umgebung.hmgrHome, 'mcp.yaml'),
392
+ path.join(umgebung.home, '.claude.json'),
393
+ path.join(hermesHome, 'config.yaml'),
394
+ path.join(hermesHome, '.env'),
395
+ ];
396
+ const hermes = ports.harnesses.find((h) => h.name === 'hermes');
397
+ if (hermes !== undefined && kannProfile(hermes))
398
+ for (const name of await hermes.profile().catch(() => [])) {
399
+ const profilHome = path.join(hermesHome, 'profiles', name);
400
+ pfade.push(path.join(profilHome, 'config.yaml'), path.join(profilHome, '.env'));
401
+ }
402
+ return pfade;
250
403
  }
404
+ /**
405
+ * Stößt beim Renderer ein stilles Neuladen der Anzeige an (E62), nie einen Aktualisieren-Lauf mit
406
+ * Netzzugriff. Gedrosselt (trailing throttle): unmittelbar, wenn seit dem letzten Anstoß genug Zeit
407
+ * verging, sonst genau ein nachgeholter Anstoß am Ende der Sperrzeit.
408
+ */
409
+ function neuLadenAnstossenFabrik(minAbstandMs) {
410
+ let letzter = 0;
411
+ let timer;
412
+ return function anstossen() {
413
+ const jetzt = Date.now();
414
+ const seitLetztem = jetzt - letzter;
415
+ if (seitLetztem >= minAbstandMs) {
416
+ letzter = jetzt;
417
+ hauptfenster?.webContents.send('hmgr:neuLadenAnstossen');
418
+ return;
419
+ }
420
+ if (timer !== undefined)
421
+ return; // schon ein Anstoß fürs Ende der Sperrzeit vorgemerkt
422
+ timer = setTimeout(() => {
423
+ timer = undefined;
424
+ letzter = Date.now();
425
+ hauptfenster?.webContents.send('hmgr:neuLadenAnstossen');
426
+ }, minAbstandMs - seitLetztem);
427
+ };
428
+ }
429
+ const neuLadenAnstossen = neuLadenAnstossenFabrik(FOKUS_RELOAD_MIN_ABSTAND_MS);
430
+ let beobachter;
251
431
  function fensterOeffnen() {
252
432
  hauptfenster = new BrowserWindow({
253
433
  width: 1280,
@@ -255,6 +435,11 @@ function fensterOeffnen() {
255
435
  minWidth: 860,
256
436
  minHeight: 560,
257
437
  backgroundColor: '#E3E7E0', // --platte, verhindert einen weißen Blitz beim Start
438
+ // E72: der Fenstertitel nennt die Version, damit im Feld erkennbar ist, welcher Stand läuft.
439
+ // Nicht `app.getVersion()`: das liefert nur im gepackten Bundle die Paketversion, beim Start
440
+ // über `electron dist/gui/main.js` (npm run gui, Smoke-Test) dagegen die Electron-Version —
441
+ // dort liegt neben dem Hauptskript keine package.json (gemessen: 44.0.0 statt 0.0.6).
442
+ title: `hmgr ${paketVersion()}`,
258
443
  // Im Smoke-Test bleibt das Fenster unsichtbar: der Renderer lädt trotzdem vollständig, der
259
444
  // DOM-Auszug kommt per executeJavaScript. So klaut ein automatisierter Lauf niemandem den
260
445
  // Eingabefokus und öffnet kein sichtbares Fenster.
@@ -269,6 +454,9 @@ function fensterOeffnen() {
269
454
  hauptfenster.on('closed', () => {
270
455
  hauptfenster = null;
271
456
  });
457
+ // Der <title> der index.html setzt den Fenstertitel beim Laden sonst zurück und überschriebe die
458
+ // Option oben (E72). Der DOM-Titel bleibt dabei, wie er ist, nur das Fenster folgt ihm nicht mehr.
459
+ hauptfenster.on('page-title-updated', (ereignis) => ereignis.preventDefault());
272
460
  // Renderer-Konsole im Main-Log, für Fehlersuche und den Smoke-Test.
273
461
  hauptfenster.webContents.on('console-message', (event) => {
274
462
  console.log(`[renderer] ${event.message}`);
@@ -368,11 +556,17 @@ function fensterOeffnen() {
368
556
  // E60: Beschreibung in der Ziel-Tabelle (nicht nur im Katalog), „manuell" entfällt,
369
557
  // Zahnrad statt Sonne am Einstellungen-Knopf.
370
558
  var zielBeschreibung = document.querySelector('#zielliste .linkliste .was')?.textContent ?? null;
559
+ // E55-Marken auch in der Ziel-Tabelle, unter der Beschreibung (2026-09-05).
560
+ var zielMarken = document.querySelector('#zielliste .linkliste .wasfeld .marken')?.textContent
561
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
371
562
  var zielManuellVorhanden = (document.getElementById('zielliste')?.textContent ?? '').includes('manuell');
372
563
  var zahnradVorhanden = (document.querySelector('#einstellungen svg')?.innerHTML ?? '').includes('6.47 3.56');
373
564
  // Katalog (E40 R3, R5): Beschreibung aus dem Fixture muss in der Skill-Zeile stehen.
374
565
  document.querySelector('[data-ansicht="katalog"]')?.click();
375
566
  var katalogBeschreibung = document.querySelector('#katalog .zeile .beschreibung')?.textContent ?? null;
567
+ // E70/F70=O1: die Herkunftszeile trägt hinter dem Ref das Änderungsdatum des Skills.
568
+ var katalogHerkunft = document.querySelector('#katalog .zeile .herkunft')?.textContent
569
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
376
570
  // Besonderheiten aus dem Frontmatter (E55): Marken an der Zeile, Details erst nach
377
571
  // einem Klick auf den Aufklapp-Knopf.
378
572
  var katalogMarken = document.querySelector('#katalog .zeile .marken')?.textContent
@@ -390,12 +584,173 @@ function fensterOeffnen() {
390
584
  var dskKnopf = Array.from(document.querySelectorAll('button'))
391
585
  .find((b) => b.textContent === 'Skills hinzufügen');
392
586
  var dskMarken = null;
587
+ // Bundle-Sektion im selben Dialog (E69, F68): sie steht dort, wo die Entscheidung
588
+ // fällt, was ins Ziel kommt, und zeigt je Bundle Beschreibung und Herkunft.
589
+ var dskBundleBoxSichtbar = false;
590
+ var dskBundleText = null;
393
591
  if (dskKnopf) {
394
592
  dskKnopf.click();
395
593
  dskMarken = document.querySelector('#dsk-liste .wahlzeile .marken')?.textContent
396
594
  ?.replace(/\\s+/g, ' ').trim() ?? null;
595
+ dskBundleBoxSichtbar = document.getElementById('dsk-bundlebox')?.hidden === false;
596
+ dskBundleText = document.querySelector('#dsk-bundles .wahlzeile')?.textContent
597
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
397
598
  document.getElementById('d-skills')?.close();
398
599
  }
600
+ // Bundle-Gruppe an der Ziel-Karte (E69): Kopf mit Name, Quelle und Entfernen, darunter
601
+ // die Zeilen der Mitglieder — sie stehen in der Gruppe, nicht in der allgemeinen Liste.
602
+ var bundleGruppeText = document.querySelector('#zielliste .bundlegruppe .bundlekopf')?.textContent
603
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
604
+ var bundleGruppeZeilen = Array.from(document.querySelectorAll('#zielliste .bundlegruppe .linkliste .name'))
605
+ .map((n) => n.textContent.trim()).join(',');
606
+ // Entfernen an der Einzelzeile innerhalb der Gruppe sagt, dass das Bundle zerbricht.
607
+ var bundleZerbrichtText = null;
608
+ try {
609
+ document.querySelector('#zielliste .bundlegruppe .linkliste .knopf')?.click();
610
+ bundleZerbrichtText = document.getElementById('dx-text')?.textContent ?? null;
611
+ document.querySelector('#d-entfernen button[value="nein"]')?.click();
612
+ } catch (fehler) {
613
+ bundleZerbrichtText = String(fehler);
614
+ }
615
+ // MCP-Ansicht (E61-Folgerunde, F46: abgespeckt zum reinen Katalog): Navigationsreihenfolge
616
+ // Ziele, MCP, Plugins, Skills, Karten mit Name/Beschreibung/Homepage-Link, F39-Warnzeile,
617
+ // Einhängen-Dialog öffnen/abbrechen ohne IPC-Schreibaktion. Orte-Liste und Fremdkasten
618
+ // gehören seit F46 nicht mehr in diese Ansicht (die Prüfung darauf ist unten invertiert),
619
+ // sie leben nur noch am MCP-Abschnitt der Ziel-Karten (weiter unten unverändert geprüft).
620
+ var navReihenfolge = Array.from(document.querySelectorAll('.nav [data-ansicht]'))
621
+ .map((b) => b.dataset.ansicht).join(',');
622
+ document.querySelector('[data-ansicht="mcp"]')?.click();
623
+ var mcpKartenText = document.getElementById('mcpliste')?.textContent
624
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
625
+ var mcpHeimLinkVorhanden = document.querySelector('#mcpliste .mcpkopf .qlink') !== null;
626
+ var mcpWarnzeileVorhanden = document.querySelector('#mcpliste .mcp .ruhezeile') !== null;
627
+ // F46: die Orte-Liste je Karte und der Fremdkasten der Ansicht sind ganz weg, nicht nur
628
+ // leer — beide Selektoren dürfen im DOM überhaupt nicht mehr auftauchen.
629
+ var mcpOrteVorhanden = document.querySelector('#mcpliste .mcporte') !== null;
630
+ var mcpFremdkastenVorhanden = document.getElementById('mcpfremd') !== null;
631
+ var mcpFehler = null;
632
+ var dMcpOffenNachOeffnen = false;
633
+ var dMcpOffenNachAbbrechen = true;
634
+ // Die Wahlzeilen des Einhängen-Dialogs (F42): sie kommen aus der Adapter-Fähigkeit,
635
+ // hier steht, welche Orte zur Wahl stehen und wohin der Eintrag jeweils ginge. Zwei
636
+ // Karten, weil die Zeilen verschieden aussehen: „mail" hängt an beiden Home-Orten
637
+ // schon (gesperrte Zeile), „teamwork" nirgends (die Notizen je Ort).
638
+ var mcpWahlText = null;
639
+ var mcpWahlTextFrei = null;
640
+ try {
641
+ var mcpEinKnoepfe = Array.from(document.querySelectorAll('#mcpliste .mcpkopf button'))
642
+ .filter((b) => (b.textContent || '').indexOf('Einhängen') === 0);
643
+ var mcpWahlOeffnen = async (knopf) => {
644
+ knopf?.click();
645
+ // window.hmgr.mcpPlaetze() ist asynchron: kurz nachfassen, bis die Wahlzeilen stehen.
646
+ await new Promise((r) => setTimeout(r, 200));
647
+ return document.getElementById('dm-orte')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
648
+ };
649
+ mcpWahlText = await mcpWahlOeffnen(mcpEinKnoepfe[0]);
650
+ dMcpOffenNachOeffnen = document.getElementById('d-mcp')?.open === true;
651
+ document.querySelector('#d-mcp button[value="abbruch"]')?.click();
652
+ dMcpOffenNachAbbrechen = document.getElementById('d-mcp')?.open === true;
653
+ mcpWahlTextFrei = await mcpWahlOeffnen(mcpEinKnoepfe[1]);
654
+ document.querySelector('#d-mcp button[value="abbruch"]')?.click();
655
+ } catch (fehler) {
656
+ mcpFehler = String(fehler);
657
+ }
658
+ // Aushängen-Rückfrage (F46: nur noch über den Entfernen-Knopf am MCP-Abschnitt einer
659
+ // Ziel-Karte auslösbar, die MCP-Ansicht selbst hat dafür keinen Knopf mehr).
660
+ var mcpAusFehler = null;
661
+ var dMcpAusOffenNachOeffnen = false;
662
+ var dMcpAusOffenNachAbbrechen = true;
663
+ // Seit E64 stehen im MCP-Abschnitt zwei Knopfarten nebeneinander (Entfernen und
664
+ // Übernehmen), deshalb wird hier nicht mehr der erste Knopf genommen, sondern der
665
+ // Entfernen-Knopf an seinem aria-label erkannt.
666
+ var mcpZeilenKnoepfe = Array.from(document.querySelectorAll('#zielliste .mcpkartezeilen button'));
667
+ try {
668
+ var mcpAusKnopf = mcpZeilenKnoepfe.find(function (b) {
669
+ return (b.getAttribute('aria-label') || '').indexOf('aushängen') > -1;
670
+ });
671
+ mcpAusKnopf?.click();
672
+ dMcpAusOffenNachOeffnen = document.getElementById('d-mcpaus')?.open === true;
673
+ document.querySelector('#d-mcpaus button[value="nein"]')?.click();
674
+ dMcpAusOffenNachAbbrechen = document.getElementById('d-mcpaus')?.open === true;
675
+ } catch (fehler) {
676
+ mcpAusFehler = String(fehler);
677
+ }
678
+ // Übernehmen aus der geteilten .mcp.json (E64): Knopf an einer geteilten Zeile,
679
+ // Rückfrage öffnen und über „Nein" wieder schließen — ohne IPC-Schreibaktion, wie bei
680
+ // den anderen MCP-Dialogen. Der Text der Rückfrage läuft mit, er ist die Warnung an
681
+ // den Nutzer und darf nicht stillschweigend verschwinden.
682
+ var mcpUebFehler = null;
683
+ var dMcpUebOffenNachOeffnen = false;
684
+ var dMcpUebOffenNachAbbrechen = true;
685
+ var mcpUebText = null;
686
+ try {
687
+ var mcpUebKnopf = mcpZeilenKnoepfe.find(function (b) {
688
+ return (b.textContent || '').indexOf('Übernehmen') === 0;
689
+ });
690
+ mcpUebKnopf?.click();
691
+ dMcpUebOffenNachOeffnen = document.getElementById('d-mcpueb')?.open === true;
692
+ mcpUebText = document.getElementById('dmu-text')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
693
+ document.querySelector('#d-mcpueb button[value="nein"]')?.click();
694
+ dMcpUebOffenNachAbbrechen = document.getElementById('d-mcpueb')?.open === true;
695
+ } catch (fehler) {
696
+ mcpUebFehler = String(fehler);
697
+ }
698
+ // MCP an der Ziel-Karte (E61-GUI-Nachtrag), Gegenrichtung zur MCP-Ansicht: Abschnitt und
699
+ // Knopf an der Hermes-Home-Karte (Katalog-Zeile mit Entfernen, Fremd-Zeile ohne Knopf)
700
+ // und an der kunde-x-Verzeichnis-Karte (zwei Zeilen aus der geteilten .mcp.json, seit
701
+ // E64 beide mit Übernehmen-Knopf), dazu die Knopf-Sichtbarkeit: an der agents-Karte
702
+ // darf weder Abschnitt noch Knopf stehen, agents implementiert die MCP-Fähigkeit
703
+ // nicht (E16).
704
+ var zielKarten = Array.from(document.querySelectorAll('#zielliste .ziel'));
705
+ var karteMitKopftext = function (t) {
706
+ return zielKarten.find(function (k) {
707
+ return (k.querySelector('.zielkopf')?.textContent || '').indexOf(t) > -1;
708
+ });
709
+ };
710
+ var mcpHinzuKnopfAn = function (karte) {
711
+ return karte
712
+ ? Array.from(karte.querySelectorAll('button')).find((b) => (b.textContent || '').indexOf('MCP hinzufügen') === 0)
713
+ : undefined;
714
+ };
715
+ var hermesKarte = karteMitKopftext('Hermes · Home');
716
+ var hermesMcpText = hermesKarte?.querySelector('.mcpkarteabschnitt')?.textContent
717
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
718
+ var hermesMcpZeilenKnopfZahl = hermesKarte?.querySelectorAll('.mcpkartezeilen button').length ?? 0;
719
+ var hermesMcpKnopfVorhanden = mcpHinzuKnopfAn(hermesKarte) !== undefined;
720
+ var kundeXKarte = karteMitKopftext('kunde-x');
721
+ var kundeXMcpText = kundeXKarte?.querySelector('.mcpkarteabschnitt')?.textContent
722
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
723
+ var kundeXMcpZeilenKnopfZahl = kundeXKarte?.querySelectorAll('.mcpkartezeilen button').length ?? 0;
724
+ var kundeXMcpKnopfVorhanden = mcpHinzuKnopfAn(kundeXKarte) !== undefined;
725
+ // Kartenkopf-Zahl (E71): der Kopf zählt Skills, gebündelt wie der Körper zeilt. An der
726
+ // Verzeichnis-Karte kunde-x hängen dieselben zwei Skills an zwei Harnesses, eine Summe
727
+ // über die Abschnitte ergäbe hier 4.
728
+ var kopfZahlVon = function (karte) {
729
+ return karte?.querySelector('.zielkopf .zahl')?.textContent?.trim() ?? null;
730
+ };
731
+ var kundeXKopfZahl = kopfZahlVon(kundeXKarte);
732
+ var hermesKopfZahl = kopfZahlVon(hermesKarte);
733
+ var agentsKarte = karteMitKopftext('Agents · Home');
734
+ // Ohne diese Zusicherung wären die beiden Abwesenheits-Prüfungen darunter leer erfüllt,
735
+ // sobald die Karte selbst aus der Liste fiele: „kein Abschnitt" hieße dann nur „keine Karte".
736
+ var agentsKarteVorhanden = agentsKarte !== undefined;
737
+ var agentsMcpAbschnittVorhanden = (agentsKarte?.querySelector('.mcpkarteabschnitt') ?? null) !== null;
738
+ var agentsMcpKnopfVorhanden = mcpHinzuKnopfAn(agentsKarte) !== undefined;
739
+ // Dialog „MCP hinzufügen" (E47-Muster): öffnen an der Hermes-Karte und über Abbrechen
740
+ // wieder schließen, ohne IPC-Schreibaktion (mcpEinhaengen liefe erst nach „Einhängen").
741
+ var dmhFehler = null;
742
+ var dMcpHinzuOffenNachOeffnen = false;
743
+ var dMcpHinzuOffenNachAbbrechen = true;
744
+ var dmhListeText = null;
745
+ try {
746
+ mcpHinzuKnopfAn(hermesKarte)?.click();
747
+ dMcpHinzuOffenNachOeffnen = document.getElementById('d-mcp-hinzufuegen')?.open === true;
748
+ dmhListeText = document.getElementById('dmh-liste')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
749
+ document.querySelector('#d-mcp-hinzufuegen button[value="abbruch"]')?.click();
750
+ dMcpHinzuOffenNachAbbrechen = document.getElementById('d-mcp-hinzufuegen')?.open === true;
751
+ } catch (fehler) {
752
+ dmhFehler = String(fehler);
753
+ }
399
754
  // Schriften aus fonts/ (Zusatzpunkt 6): erzwingt das Laden und belegt es danach.
400
755
  await Promise.all([
401
756
  document.fonts.load('400 16px Inter'),
@@ -409,15 +764,22 @@ function fensterOeffnen() {
409
764
  einstellungenOffen,
410
765
  harnessSchalter,
411
766
  zielBeschreibung,
767
+ zielMarken,
412
768
  zielManuellVorhanden,
413
769
  zahnradVorhanden,
414
770
  katalogBeschreibung,
771
+ katalogHerkunft,
415
772
  katalogMarken,
416
773
  katalogDetailsZu,
417
774
  katalogAufgeklappt,
418
775
  katalogDetails,
419
776
  katalogMarkenWarnend,
420
777
  dskMarken,
778
+ dskBundleBoxSichtbar,
779
+ dskBundleText,
780
+ bundleGruppeText,
781
+ bundleGruppeZeilen,
782
+ bundleZerbrichtText,
421
783
  interGeladen: document.fonts.check('400 16px Inter'),
422
784
  monoGeladen: document.fonts.check('500 16px "JetBrains Mono"'),
423
785
  quelleneuVorhanden,
@@ -443,9 +805,45 @@ function fensterOeffnen() {
443
805
  dSkillordnerOffenNachOeffnen,
444
806
  dSkillordnerOffenNachAbbrechen,
445
807
  skillOrdnerFehler,
808
+ navReihenfolge,
809
+ mcpKartenText,
810
+ mcpHeimLinkVorhanden,
811
+ mcpWarnzeileVorhanden,
812
+ mcpOrteVorhanden,
813
+ mcpFremdkastenVorhanden,
814
+ dMcpOffenNachOeffnen,
815
+ dMcpOffenNachAbbrechen,
816
+ mcpWahlText,
817
+ mcpWahlTextFrei,
818
+ mcpFehler,
819
+ dMcpAusOffenNachOeffnen,
820
+ dMcpAusOffenNachAbbrechen,
821
+ mcpAusFehler,
822
+ dMcpUebOffenNachOeffnen,
823
+ dMcpUebOffenNachAbbrechen,
824
+ mcpUebText,
825
+ mcpUebFehler,
826
+ hermesMcpText,
827
+ hermesMcpZeilenKnopfZahl,
828
+ hermesMcpKnopfVorhanden,
829
+ kundeXMcpText,
830
+ kundeXMcpZeilenKnopfZahl,
831
+ kundeXMcpKnopfVorhanden,
832
+ kundeXKopfZahl,
833
+ hermesKopfZahl,
834
+ agentsKarteVorhanden,
835
+ agentsMcpAbschnittVorhanden,
836
+ agentsMcpKnopfVorhanden,
837
+ dMcpHinzuOffenNachOeffnen,
838
+ dMcpHinzuOffenNachAbbrechen,
839
+ dmhListeText,
840
+ dmhFehler,
446
841
  });
447
842
  })()`)
448
843
  .then((auszug) => console.log(`[smoke] DOM-Auszug: ${auszug}`))
844
+ // E72: der Fenstertitel steht nicht im DOM (dort steht weiter der <title> der
845
+ // index.html), er lässt sich nur hier im Main-Prozess ablesen.
846
+ .then(() => console.log(`[smoke] Fenster: ${JSON.stringify({ fenstertitel: fenster.getTitle() })}`))
449
847
  .finally(() => app.quit());
450
848
  }, 3000);
451
849
  });
@@ -461,20 +859,65 @@ function fensterOeffnen() {
461
859
  const intervall = setInterval(() => fenster.webContents.send('hmgr:aktualisierenAnstossen'), SECHS_STUNDEN_MS);
462
860
  fenster.on('closed', () => clearInterval(intervall));
463
861
  }
862
+ // Die GUI erfährt von Änderungen außerhalb ihrer selbst (E62): Fokus-Reload und Datei-Watcher.
863
+ // Beide stoßen nur ein stilles Neuladen der Anzeige an, nie einen Aktualisieren-Lauf mit
864
+ // Netzzugriff. Im Smoke-Test keins von beiden, dieselbe Zurückhaltung wie beim automatischen
865
+ // Aktualisieren (E44): ein Event dürfte sonst in den DOM-Auszug pfuschen.
866
+ if (!smoketest) {
867
+ const fenster = hauptfenster;
868
+ fenster.on('focus', () => neuLadenAnstossen());
869
+ void beobachtetePfade().then((pfade) => {
870
+ if (fenster.isDestroyed())
871
+ return; // Fenster schon zu, während die Pfade ermittelt wurden
872
+ beobachter = beobachten(pfade, () => neuLadenAnstossen(), { entprellenMs: BEOBACHTER_ENTPRELLEN_MS });
873
+ });
874
+ fenster.on('closed', () => {
875
+ beobachter?.close();
876
+ beobachter = undefined;
877
+ });
878
+ }
464
879
  void hauptfenster.loadFile(path.join(hierVerzeichnis, 'renderer', 'index.html'));
465
880
  }
881
+ /**
882
+ * Holt das vorhandene Fenster nach vorn — die Antwort der laufenden Instanz auf einen zweiten
883
+ * Start (E67). Minimiert kommt es erst wieder hoch, dann bekommt es den Fokus.
884
+ */
885
+ function fensterNachVornHolen() {
886
+ if (hauptfenster === null || hauptfenster.isDestroyed())
887
+ return;
888
+ if (hauptfenster.isMinimized())
889
+ hauptfenster.restore();
890
+ hauptfenster.show();
891
+ hauptfenster.focus();
892
+ }
466
893
  registriereIpc();
467
- app.whenReady().then(() => {
468
- // Im Smoke-Test kein Dock-Icon und kein Fokuswechsel: das Fenster bleibt unsichtbar (show:false
469
- // oben), macOS würde aber ohne diese Zeile trotzdem ein Dock-Icon zeigen und aktivieren.
470
- if (smoketest && process.platform === 'darwin')
471
- app.dock?.hide();
472
- fensterOeffnen();
473
- app.on('activate', () => {
474
- if (BrowserWindow.getAllWindows().length === 0)
475
- fensterOeffnen();
894
+ if (einzelinstanz) {
895
+ app.on('second-instance', () => fensterNachVornHolen());
896
+ app.whenReady().then(() => {
897
+ // Im Smoke-Test kein Dock-Icon und kein Fokuswechsel: das Fenster bleibt unsichtbar (show:false
898
+ // oben), macOS würde aber ohne diese Zeile trotzdem ein Dock-Icon zeigen und aktivieren.
899
+ if (smoketest && process.platform === 'darwin')
900
+ app.dock?.hide();
901
+ // Windows-Fensterkonventionen (E67), beide nur dort:
902
+ // — Die Taskleiste gruppiert Fenster und Verknüpfungen über die AppUserModelID. Ohne sie steht
903
+ // der Prozess unter einer von Windows geratenen ID, und das angeheftete Startmenü-Symbol
904
+ // gehört dann nicht zum laufenden Fenster. Dieselbe ID wie die `appId` im build-Block, denn
905
+ // die schreibt der NSIS-Installer in die Verknüpfung.
906
+ // — Electron blendet unter Windows sonst sein Standardmenü ein (Datei/Bearbeiten/Ansicht/…),
907
+ // eine Menüleiste, die hmgr nicht bedient und die nur Platz über der Ansicht kostet. macOS
908
+ // bleibt unangetastet: dort ist die Menüleiste des Systems Pflicht (Beenden, Kopieren,
909
+ // Einfügen hängen daran).
910
+ if (process.platform === 'win32') {
911
+ app.setAppUserModelId('de.timkaufmann.hmgr');
912
+ Menu.setApplicationMenu(null);
913
+ }
914
+ fensterOeffnen();
915
+ app.on('activate', () => {
916
+ if (BrowserWindow.getAllWindows().length === 0)
917
+ fensterOeffnen();
918
+ });
476
919
  });
477
- });
920
+ }
478
921
  app.on('window-all-closed', () => {
479
922
  if (process.platform !== 'darwin')
480
923
  app.quit();