hmgr 0.0.4 → 0.0.5

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.
@@ -0,0 +1,481 @@
1
+ // hmgr-GUI, Electron-Main (E40). Baut dieselben Anwendungsfälle wie die CLI (src/cli/main.ts),
2
+ // registriert IPC-Handler für den Renderer und implementiert den Rückfrage-Port als Dialog im
3
+ // Fenster. Kein zweiter Prozess: der Kern läuft direkt im Main-Prozess, die GUI ist ein zweiter
4
+ // Kompositions-Einstieg neben der CLI (ARCHITEKTUR.md „Eingang").
5
+ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { stat } from 'node:fs/promises';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { bauen, umgebungLesen } from "../cli/main.js";
12
+ import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
13
+ import { quelleName, quelleSchluessel } from "../kern/modell.js";
14
+ import { iconFuerQuelle } from "./icons.js";
15
+ import { entscheideOeffnen } from "./oeffnen.js";
16
+ const hierVerzeichnis = path.dirname(fileURLToPath(import.meta.url));
17
+ const smoketest = process.env.HMGR_GUI_SMOKETEST === '1';
18
+ const umgebung = umgebungLesen();
19
+ /** Cache der Quellen-Icons (E60), unterhalb von HMGR_HOME wie der Zustand: Tests/Smoke fassen so
20
+ nie ein echtes Verzeichnis an. */
21
+ const iconCacheVerzeichnis = path.join(umgebung.hmgrHome, 'icon-cache');
22
+ /** Intervall des selbsttätigen Aktualisierens (E44): fest, keine Einstellung. */
23
+ const SECHS_STUNDEN_MS = 6 * 60 * 60 * 1000;
24
+ /**
25
+ * 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.
28
+ */
29
+ const smoketestClaudeAusfuehrer = async () => ({ stdout: '', code: 1 });
30
+ let hauptfenster = null;
31
+ /**
32
+ * Rückfrage-Port als Dialog im Renderer (E28, E34): der Main-Prozess schickt die Frage per
33
+ * `webContents.send`, der Renderer zeigt einen <dialog> im Stil des Entwurfs und schickt die
34
+ * Antwort über einen Kanal mit Korrelations-ID zurück. Ohne offenes Fenster kann niemand antworten,
35
+ * dort gilt wie am Terminal ohne TTY: Nein bzw. keine Antwort (E34).
36
+ */
37
+ class GuiRueckfrage {
38
+ ausstehend = new Map();
39
+ constructor() {
40
+ ipcMain.on('hmgr:rueckfrage:antwort', (_event, nachricht) => {
41
+ const aufloesen = this.ausstehend.get(nachricht.id);
42
+ if (aufloesen === undefined)
43
+ return;
44
+ this.ausstehend.delete(nachricht.id);
45
+ aufloesen(nachricht.antwort);
46
+ });
47
+ }
48
+ stellen(payload) {
49
+ if (hauptfenster === null)
50
+ return Promise.resolve(payload.art === 'bestaetigen' ? false : undefined);
51
+ const id = randomUUID();
52
+ return new Promise((aufloesen) => {
53
+ this.ausstehend.set(id, aufloesen);
54
+ hauptfenster?.webContents.send('hmgr:rueckfrage', { id, ...payload });
55
+ });
56
+ }
57
+ async bestaetigen(frage) {
58
+ const antwort = await this.stellen({ art: 'bestaetigen', frage });
59
+ return antwort === true;
60
+ }
61
+ async auswaehlen(frage, optionen) {
62
+ const antwort = await this.stellen({ art: 'auswaehlen', frage, optionen });
63
+ return typeof antwort === 'number' ? antwort : undefined;
64
+ }
65
+ }
66
+ const rueckfrage = new GuiRueckfrage();
67
+ const { anwendungsfaelle } = bauen(umgebung, {
68
+ rueckfrage,
69
+ ...(smoketest ? { claudeAusfuehrer: smoketestClaudeAusfuehrer } : {}),
70
+ });
71
+ async function auffangen(fn) {
72
+ try {
73
+ return { ok: true, daten: await fn() };
74
+ }
75
+ catch (fehler) {
76
+ return { ok: false, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
77
+ }
78
+ }
79
+ /** ZielZustand über IPC: Maps werden zu einfachen Objekten, der Rest ist schon JSON-tauglich. */
80
+ function zielZustandSerialisieren(z) {
81
+ return {
82
+ ziel: z.ziel,
83
+ verzeichnis: z.verzeichnis,
84
+ eintraege: z.eintraege,
85
+ einschraenkungen: Object.fromEntries(z.einschraenkungen),
86
+ gruende: Object.fromEntries(z.gruende),
87
+ ignoriert: z.ignoriert,
88
+ uebernahme: Object.fromEntries(z.uebernahme),
89
+ istRegistriert: z.istRegistriert,
90
+ kannSkillOrdner: z.kannSkillOrdner,
91
+ ...(z.plugins === undefined ? {} : { plugins: z.plugins }),
92
+ ...(z.verwalteterSkillOrdner === undefined ? {} : { verwalteterSkillOrdner: z.verwalteterSkillOrdner }),
93
+ };
94
+ }
95
+ function zielAusIpc(harness, ort) {
96
+ return { harness, ort };
97
+ }
98
+ function registriereIpc() {
99
+ 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()));
102
+ ipcMain.handle('hmgr:istZustand', async () => auffangen(async () => (await anwendungsfaelle.istZustand()).map(zielZustandSerialisieren)));
103
+ ipcMain.handle('hmgr:marketplaces', async () => auffangen(async () => (await anwendungsfaelle.marketplaces()).map((m) => ({
104
+ quelleText: quelleAlsText(m.quelle),
105
+ name: m.name,
106
+ plugins: m.plugins,
107
+ }))));
108
+ // Skill-Katalog über alle bekannten Quellen (E40 R3): über den Anwendungsfall `katalog`, nicht
109
+ // mehr direkt über die Ports. Eine nicht erreichbare Quelle trägt einen Fehlervermerk (E12-Muster
110
+ // wie istZustand/marketplaces), ihre Skills fehlen einfach in der flachen Liste.
111
+ ipcMain.handle('hmgr:skills', async () => auffangen(async () => {
112
+ const ergebnis = [];
113
+ for (const eintrag of await anwendungsfaelle.katalog()) {
114
+ if (eintrag.ref === undefined)
115
+ continue; // nicht erreichbar, keine Skills
116
+ for (const skill of eintrag.skills)
117
+ ergebnis.push({
118
+ name: skill.name,
119
+ pfad: skill.pfad,
120
+ ...(skill.beschreibung === undefined ? {} : { beschreibung: skill.beschreibung }),
121
+ ...(skill.nurPerBefehl === undefined ? {} : { nurPerBefehl: skill.nurPerBefehl }),
122
+ ...(skill.nichtPerBefehl === undefined ? {} : { nichtPerBefehl: skill.nichtPerBefehl }),
123
+ ...(skill.registriertHooks === undefined ? {} : { registriertHooks: skill.registriertHooks }),
124
+ ...(skill.werkzeugeErlaubt === undefined ? {} : { werkzeugeErlaubt: skill.werkzeugeErlaubt }),
125
+ ...(skill.kompatibilitaet === undefined ? {} : { kompatibilitaet: skill.kompatibilitaet }),
126
+ ...(skill.modell === undefined ? {} : { modell: skill.modell }),
127
+ ...(skill.eigenerKontext === undefined ? {} : { eigenerKontext: skill.eigenerKontext }),
128
+ ...(skill.argumentHinweis === undefined ? {} : { argumentHinweis: skill.argumentHinweis }),
129
+ quelleText: quelleAlsText(eintrag.quelle),
130
+ quelleName: quelleName(eintrag.quelle),
131
+ quelleSchluessel: quelleSchluessel(eintrag.quelle),
132
+ ref: eintrag.ref,
133
+ });
134
+ }
135
+ return ergebnis;
136
+ }));
137
+ // Nur die Herkunft, ohne die Quellen zu lesen: der Katalog daneben tut das schon.
138
+ ipcMain.handle('hmgr:quellen', async () => auffangen(async () => (await anwendungsfaelle.quellen()).map((quelle) => ({
139
+ art: quelle.art,
140
+ text: quelleAlsText(quelle),
141
+ name: quelleName(quelle),
142
+ }))));
143
+ // Quelle-Verwaltung in den Einstellungen (E54, E59): Text-zu-Quelle passiert im Main-Prozess, der
144
+ // Renderer kennt nur Zeichenketten. Der Anzeigename kommt serverseitig mit, damit dem Renderer nie
145
+ // ein weggefiltertes Feld fehlt (E59: „name" ist reine Anzeige, geht nicht in quelleAusText ein).
146
+ ipcMain.handle('hmgr:quelleHinzufuegen', async (_event, text, name) => auffangen(async () => {
147
+ const ergebnis = await anwendungsfaelle.quelleHinzufuegen(quelleAusText(text), name);
148
+ return {
149
+ quelle: {
150
+ art: ergebnis.quelle.art,
151
+ text: quelleAlsText(ergebnis.quelle),
152
+ name: quelleName(ergebnis.quelle),
153
+ },
154
+ skills: ergebnis.skills,
155
+ marketplace: ergebnis.marketplace,
156
+ bekannt: ergebnis.bekannt,
157
+ };
158
+ }));
159
+ ipcMain.handle('hmgr:quelleEntfernen', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleEntfernen(quelleAusText(text))));
160
+ ipcMain.handle('hmgr:quelleUmbenennen', async (_event, text, name) => auffangen(async () => {
161
+ const umbenannt = await anwendungsfaelle.quelleUmbenennen(quelleAusText(text), name);
162
+ return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt) };
163
+ }));
164
+ // Aufräum-Angebot nach dem Entfernen einer Quelle (E58): Inventar vor dem eigentlichen Entfernen
165
+ // erheben (der Renderer ruft das, bevor er quelleEntfernen aufruft), dann bei Zustimmung abräumen.
166
+ // Beide Ergebnisse reichen unverändert durch: schon reine Daten, keine Maps oder Funktionen darin.
167
+ ipcMain.handle('hmgr:quelleAufraeumInventar', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleAufraeumInventar(quelleAusText(text))));
168
+ ipcMain.handle('hmgr:quelleAufraeumen', async (_event, inventar) => auffangen(async () => anwendungsfaelle.quelleAufraeumen(inventar)));
169
+ // Die GUI arbeitet an einem Arbeitsverzeichnis wie die CLI mit --local (E57, F32): Links werden
170
+ // lokal gelegt und entfernt, der Zeiger bleibt unangetastet, der Kern hängt bei einer bestehenden
171
+ // Bindung von sich aus einen Hinweis an. Für Home und Profil ist `lokal` folgenlos (E26).
172
+ ipcMain.handle('hmgr:linkLegen', async (_event, args) => auffangen(async () => anwendungsfaelle.linkLegen({
173
+ skill: args.skill,
174
+ ziel: zielAusIpc(args.harness, args.ort),
175
+ ...(args.quelle === undefined ? {} : { quelle: args.quelle }),
176
+ lokal: args.ort.art === 'arbeitsverzeichnis',
177
+ })));
178
+ ipcMain.handle('hmgr:linkEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.linkEntfernen({
179
+ skill: args.skill,
180
+ ziel: zielAusIpc(args.harness, args.ort),
181
+ lokal: args.ort.art === 'arbeitsverzeichnis',
182
+ })));
183
+ ipcMain.handle('hmgr:eintragIgnorieren', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragIgnorieren({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
184
+ ipcMain.handle('hmgr:eintragBeachten', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragBeachten({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
185
+ // Auch die Übernahme legt Links und schriebe ohne `lokal` den Zeiger (E31 über linkAusFund), sie
186
+ // trifft also dieselbe Sperre wie link (E57). In der GUI arbeitet sie darum wie die CLI mit
187
+ // --local (F32), sonst bräche der Knopf an jedem gebundenen Arbeitsverzeichnis ab.
188
+ ipcMain.handle('hmgr:uebernehmen', async (_event, args) => auffangen(async () => anwendungsfaelle.uebernehmen({
189
+ ziel: zielAusIpc(args.harness, args.ort),
190
+ ...(args.name === undefined ? {} : { name: args.name }),
191
+ probelauf: args.probelauf,
192
+ lokal: args.ort.art === 'arbeitsverzeichnis',
193
+ })));
194
+ ipcMain.handle('hmgr:eintragLoeschen', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragLoeschen({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
195
+ ipcMain.handle('hmgr:aktualisieren', async (_event, args) => auffangen(async () => anwendungsfaelle.aktualisieren(args)));
196
+ ipcMain.handle('hmgr:arbeitsverzeichnisHinzufuegen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad)));
197
+ ipcMain.handle('hmgr:arbeitsverzeichnisVergessen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisVergessen(pfad)));
198
+ // Nativer Verzeichnis-Dialog fürs Pfadfeld in „Verzeichnis hinzufügen" (A1). Im Smoke-Test darf
199
+ // nie ein echter Systemdialog aufgehen: sofort null, wie bei Abbruch.
200
+ ipcMain.handle('hmgr:verzeichnisWaehlen', async () => auffangen(async () => {
201
+ if (smoketest || hauptfenster === null)
202
+ return null;
203
+ const ergebnis = await dialog.showOpenDialog(hauptfenster, {
204
+ properties: ['openDirectory', 'createDirectory'],
205
+ });
206
+ if (ergebnis.canceled || ergebnis.filePaths.length === 0)
207
+ return null;
208
+ return ergebnis.filePaths[0] ?? null;
209
+ }));
210
+ // Harness-Schalter im Einstellungen-Modal (E38, GRILL.md Entwurfskritik 4).
211
+ ipcMain.handle('hmgr:harnessTypen', async () => auffangen(async () => anwendungsfaelle.harnessTypen()));
212
+ ipcMain.handle('hmgr:aktiveHarnesses', async () => auffangen(async () => anwendungsfaelle.aktiveHarnesses()));
213
+ // Für den Vermerk „nicht installiert" an den Schaltern (E42).
214
+ ipcMain.handle('hmgr:nichtVorhandeneHarnesses', async () => auffangen(async () => anwendungsfaelle.nichtVorhandeneHarnesses()));
215
+ ipcMain.handle('hmgr:harnessAktivieren', async (_event, name) => auffangen(async () => anwendungsfaelle.harnessAktivieren(name)));
216
+ ipcMain.handle('hmgr:harnessDeaktivieren', async (_event, name) => auffangen(async () => anwendungsfaelle.harnessDeaktivieren(name)));
217
+ ipcMain.handle('hmgr:saeubern', async (_event, name) => auffangen(async () => anwendungsfaelle.saeubern(name)));
218
+ // Verwalteter Hermes-Skill-Ordner (E56): Zeigen kommt schon über hmgr:istZustand, hier nur Ändern.
219
+ ipcMain.handle('hmgr:skillOrdnerFestlegen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerFestlegen(zielAusIpc(args.harness, args.ort), args.pfad)));
220
+ ipcMain.handle('hmgr:skillOrdnerEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerEntfernen(zielAusIpc(args.harness, args.ort))));
221
+ ipcMain.handle('hmgr:skillOrdnerLoeschen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerLoeschen(zielAusIpc(args.harness, args.ort), args.pfad)));
222
+ // Quellen-Icon (E60): nur im Main-Prozess geladen, als data-URL geliefert (CSP erlaubt img-src
223
+ // data:). Im Smoke-Test nie ein Netzzugriff: sofort null, der Renderer zeigt dann das generische
224
+ // Symbol. Ein Fehlschlag wirft nie (auffangen fängt ihn, iconFuerQuelle selbst wirft ohnehin nicht).
225
+ ipcMain.handle('hmgr:icon', async (_event, quelleText) => auffangen(async () => {
226
+ if (smoketest)
227
+ return null;
228
+ return iconFuerQuelle(quelleAusText(quelleText), { cacheVerzeichnis: iconCacheVerzeichnis });
229
+ }));
230
+ // Quelle öffnen (E60): die Protokoll-Weiche steht in oeffnen.ts (rein, testbar), hier nur die
231
+ // Wirkung. `shell.openExternal` bekommt ausschließlich http(s)-URLs, `shell.openPath` nur einen
232
+ // 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
234
+ // mit ihrem Standardprogramm, und ein Pfad aus dem Renderer soll nichts starten können.
235
+ ipcMain.handle('hmgr:quelleOeffnen', async (_event, quelleText) => auffangen(async () => {
236
+ const entscheidung = entscheideOeffnen(quelleAusText(quelleText));
237
+ if (entscheidung.art === 'browser')
238
+ await shell.openExternal(entscheidung.url);
239
+ else if (entscheidung.art === 'ordner') {
240
+ const info = await stat(entscheidung.pfad).catch(() => undefined);
241
+ if (info === undefined)
242
+ throw new Error(`Es gibt „${entscheidung.pfad}" nicht (mehr).`);
243
+ if (info.isDirectory())
244
+ await shell.openPath(entscheidung.pfad);
245
+ else
246
+ shell.showItemInFolder(entscheidung.pfad);
247
+ }
248
+ return entscheidung;
249
+ }));
250
+ }
251
+ function fensterOeffnen() {
252
+ hauptfenster = new BrowserWindow({
253
+ width: 1280,
254
+ height: 860,
255
+ minWidth: 860,
256
+ minHeight: 560,
257
+ backgroundColor: '#E3E7E0', // --platte, verhindert einen weißen Blitz beim Start
258
+ // Im Smoke-Test bleibt das Fenster unsichtbar: der Renderer lädt trotzdem vollständig, der
259
+ // DOM-Auszug kommt per executeJavaScript. So klaut ein automatisierter Lauf niemandem den
260
+ // Eingabefokus und öffnet kein sichtbares Fenster.
261
+ show: !smoketest,
262
+ webPreferences: {
263
+ preload: path.join(hierVerzeichnis, 'preload.cjs'),
264
+ contextIsolation: true,
265
+ nodeIntegration: false,
266
+ sandbox: true,
267
+ },
268
+ });
269
+ hauptfenster.on('closed', () => {
270
+ hauptfenster = null;
271
+ });
272
+ // Renderer-Konsole im Main-Log, für Fehlersuche und den Smoke-Test.
273
+ hauptfenster.webContents.on('console-message', (event) => {
274
+ console.log(`[renderer] ${event.message}`);
275
+ });
276
+ // Nur für den Smoke-Test (scripts/gui-smoke-test.ts): ein DOM-Auszug im Log, danach Ende.
277
+ if (smoketest) {
278
+ const fenster = hauptfenster;
279
+ fenster.webContents.once('did-finish-load', () => {
280
+ setTimeout(() => {
281
+ void fenster.webContents
282
+ .executeJavaScript(`(async () => {
283
+ // Einstellungen-Modal (E40 R2): öffnen, die Harness-Schalter ablesen, wieder schließen.
284
+ document.getElementById('einstellungen')?.click();
285
+ var einstellungenOffen = document.getElementById('d-einstellungen')?.open === true;
286
+ var harnessSchalter = Array.from(document.querySelectorAll('#harnessliste input[type=checkbox]'))
287
+ .map((i) => i.dataset.harness + ':' + i.checked)
288
+ .join(',');
289
+ // Quellen-Abschnitt der Einstellungen (E54): Knopf und Dialoge vorhanden, der
290
+ // Hinzufügen-Dialog lässt sich öffnen und per Abbrechen ohne Fehler wieder schließen.
291
+ // Kein echter Quellen-Lauf: der Abbrechen-Knopf löst keinen IPC-Aufruf aus.
292
+ var quelleneuVorhanden = document.getElementById('quelleneu') !== null;
293
+ var dialogeQuelleVorhanden =
294
+ document.getElementById('d-quelle') !== null && document.getElementById('d-quelle-vergessen') !== null;
295
+ var quelleFehler = null;
296
+ var dQuelleOffenNachOeffnen = false;
297
+ var dQuelleOffenNachAbbrechen = true;
298
+ try {
299
+ document.getElementById('quelleneu')?.click();
300
+ dQuelleOffenNachOeffnen = document.getElementById('d-quelle')?.open === true;
301
+ document.querySelector('#d-quelle button[value="abbruch"]')?.click();
302
+ dQuelleOffenNachAbbrechen = document.getElementById('d-quelle')?.open === true;
303
+ } catch (fehler) {
304
+ quelleFehler = String(fehler);
305
+ }
306
+ // Aufräum-Angebot nach Quelle entfernen (E58): kein echter Lauf (der würde Checkouts
307
+ // und Links wirklich löschen), nur ob der Dialog existiert und sich öffnen und über
308
+ // Behalten schließen lässt, ohne Fehler.
309
+ var dialogQuelleAufraeumenVorhanden = document.getElementById('d-quelle-aufraeumen') !== null;
310
+ var quelleAufraeumenFehler = null;
311
+ var dQuelleAufraeumenOffenNachOeffnen = false;
312
+ var dQuelleAufraeumenOffenNachBehalten = true;
313
+ try {
314
+ var dQuelleAufraeumen = document.getElementById('d-quelle-aufraeumen');
315
+ dQuelleAufraeumen?.showModal();
316
+ dQuelleAufraeumenOffenNachOeffnen = dQuelleAufraeumen?.open === true;
317
+ document.querySelector('#d-quelle-aufraeumen button[value="behalten"]')?.click();
318
+ dQuelleAufraeumenOffenNachBehalten = dQuelleAufraeumen?.open === true;
319
+ } catch (fehler) {
320
+ quelleAufraeumenFehler = String(fehler);
321
+ }
322
+ // Anzeigename der Quelle (E59): die Einstellungen-Liste zeigt den abgeleiteten Namen
323
+ // „skills-repo" statt der vollen Fixture-URL, das Namensfeld im Hinzufügen-Dialog gibt
324
+ // es zusätzlich zum URL/Pfad-Feld.
325
+ var quellenlisteNamen = Array.from(document.querySelectorAll('#quellenliste b'))
326
+ .map((b) => b.textContent)
327
+ .join(',');
328
+ var dqNameVorhanden = document.getElementById('dq-name') !== null;
329
+ // Umbenennen (E59): der Knopf je Quelle öffnet den Dialog mit dem aktuellen Namen im
330
+ // Feld, Abbrechen schließt ihn wieder. Beide Knöpfe der Zeile liegen zusammen in
331
+ // einem Kasten, sonst lägen sie im Grid übereinander.
332
+ var quellenknoepfeZahl = document.querySelectorAll('#quellenliste .quellenknoepfe').length;
333
+ var quelleUmbenennenFehler = null;
334
+ var dQuUOffenNachOeffnen = false;
335
+ var dQuUName = null;
336
+ var dQuUOffenNachAbbrechen = true;
337
+ try {
338
+ var dQuU = document.getElementById('d-quelle-umbenennen');
339
+ // E60: der Umbenennen-Knopf ist jetzt ein Stift-Icon-Knopf ohne Text (Klasse
340
+ // „umbenennen", aria-label statt Beschriftung).
341
+ document.querySelector('#quellenliste .umbenennen')?.click();
342
+ dQuUOffenNachOeffnen = dQuU?.open === true;
343
+ dQuUName = document.getElementById('dqu-name')?.value ?? null;
344
+ document.querySelector('#d-quelle-umbenennen button[value="abbruch"]')?.click();
345
+ dQuUOffenNachAbbrechen = dQuU?.open === true;
346
+ } catch (fehler) {
347
+ quelleUmbenennenFehler = String(fehler);
348
+ }
349
+ document.getElementById('d-einstellungen')?.close();
350
+ // Konfigzeile am Hermes-Ziel (E56): der verwaltete Pfad steht in der Karte, auch
351
+ // ungeklappt (hidden ändert textContent nicht). Dazu der Festlegen-Weg am unmanaged
352
+ // Profil, Öffnen und Abbrechen ohne Fehler, nach dem Muster der d-quelle-Prüfungen.
353
+ var konfigzeileEl = document.querySelector('.konfigzeile');
354
+ var konfigzeileText = konfigzeileEl ? konfigzeileEl.textContent.replace(/\\s+/g, ' ').trim() : null;
355
+ var skillOrdnerFehler = null;
356
+ var dSkillordnerOffenNachOeffnen = false;
357
+ var dSkillordnerOffenNachAbbrechen = true;
358
+ try {
359
+ var festlegenKnopf = Array.from(document.querySelectorAll('button'))
360
+ .find((b) => b.textContent === 'Skill-Ordner festlegen …');
361
+ festlegenKnopf?.click();
362
+ dSkillordnerOffenNachOeffnen = document.getElementById('d-skillordner')?.open === true;
363
+ document.querySelector('#d-skillordner button[value="abbruch"]')?.click();
364
+ dSkillordnerOffenNachAbbrechen = document.getElementById('d-skillordner')?.open === true;
365
+ } catch (fehler) {
366
+ skillOrdnerFehler = String(fehler);
367
+ }
368
+ // E60: Beschreibung in der Ziel-Tabelle (nicht nur im Katalog), „manuell" entfällt,
369
+ // Zahnrad statt Sonne am Einstellungen-Knopf.
370
+ var zielBeschreibung = document.querySelector('#zielliste .linkliste .was')?.textContent ?? null;
371
+ var zielManuellVorhanden = (document.getElementById('zielliste')?.textContent ?? '').includes('manuell');
372
+ var zahnradVorhanden = (document.querySelector('#einstellungen svg')?.innerHTML ?? '').includes('6.47 3.56');
373
+ // Katalog (E40 R3, R5): Beschreibung aus dem Fixture muss in der Skill-Zeile stehen.
374
+ document.querySelector('[data-ansicht="katalog"]')?.click();
375
+ var katalogBeschreibung = document.querySelector('#katalog .zeile .beschreibung')?.textContent ?? null;
376
+ // Besonderheiten aus dem Frontmatter (E55): Marken an der Zeile, Details erst nach
377
+ // einem Klick auf den Aufklapp-Knopf.
378
+ var katalogMarken = document.querySelector('#katalog .zeile .marken')?.textContent
379
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
380
+ var aufklapp = document.querySelector('#katalog .zeile .aufklappknopf');
381
+ var katalogDetailsZu = document.querySelector('#katalog .zeile .skillkoerper')?.hidden ?? null;
382
+ aufklapp?.click();
383
+ var katalogAufgeklappt = aufklapp?.getAttribute('aria-expanded') ?? null;
384
+ var katalogDetails = document.querySelector('#katalog .zeile .skillkoerper:not([hidden])')?.textContent
385
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
386
+ // Sicherheitsrelevante Marken (F11): Hooks und Werkzeuge tragen die warnende Klasse.
387
+ var katalogMarkenWarnend = Array.from(document.querySelectorAll('#katalog .zeile .merker.warnend'))
388
+ .map((m) => m.textContent).join(',');
389
+ // Skills-hinzufügen-Dialog (F13): dieselben Marken an der Wahlzeile.
390
+ var dskKnopf = Array.from(document.querySelectorAll('button'))
391
+ .find((b) => b.textContent === 'Skills hinzufügen');
392
+ var dskMarken = null;
393
+ if (dskKnopf) {
394
+ dskKnopf.click();
395
+ dskMarken = document.querySelector('#dsk-liste .wahlzeile .marken')?.textContent
396
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
397
+ document.getElementById('d-skills')?.close();
398
+ }
399
+ // Schriften aus fonts/ (Zusatzpunkt 6): erzwingt das Laden und belegt es danach.
400
+ await Promise.all([
401
+ document.fonts.load('400 16px Inter'),
402
+ document.fonts.load('500 16px "JetBrains Mono"'),
403
+ ]);
404
+ return JSON.stringify({
405
+ titel: document.title,
406
+ kopfzahlen: document.getElementById('kopfzahlen')?.textContent,
407
+ zielliste: document.getElementById('zielliste')?.textContent?.replace(/\\s+/g, ' ').trim().slice(0, 600),
408
+ meldung: document.getElementById('meldungtext')?.textContent,
409
+ einstellungenOffen,
410
+ harnessSchalter,
411
+ zielBeschreibung,
412
+ zielManuellVorhanden,
413
+ zahnradVorhanden,
414
+ katalogBeschreibung,
415
+ katalogMarken,
416
+ katalogDetailsZu,
417
+ katalogAufgeklappt,
418
+ katalogDetails,
419
+ katalogMarkenWarnend,
420
+ dskMarken,
421
+ interGeladen: document.fonts.check('400 16px Inter'),
422
+ monoGeladen: document.fonts.check('500 16px "JetBrains Mono"'),
423
+ quelleneuVorhanden,
424
+ dialogeQuelleVorhanden,
425
+ quellenlisteNamen,
426
+ dqNameVorhanden,
427
+ quellenknoepfeZahl,
428
+ dQuUOffenNachOeffnen,
429
+ dQuUName,
430
+ dQuUOffenNachAbbrechen,
431
+ quelleUmbenennenFehler,
432
+ dQuelleOffenNachOeffnen,
433
+ dQuelleOffenNachAbbrechen,
434
+ quelleFehler,
435
+ dialogQuelleAufraeumenVorhanden,
436
+ dQuelleAufraeumenOffenNachOeffnen,
437
+ dQuelleAufraeumenOffenNachBehalten,
438
+ quelleAufraeumenFehler,
439
+ // Hinweiszeile im Erfolgsdialog (E26, F32): vorhanden und im Ruhezustand leer.
440
+ deHinweisVorhanden: document.getElementById('de-hinweis') !== null,
441
+ deHinweisVerborgen: document.getElementById('de-hinweis')?.hidden === true,
442
+ konfigzeileText,
443
+ dSkillordnerOffenNachOeffnen,
444
+ dSkillordnerOffenNachAbbrechen,
445
+ skillOrdnerFehler,
446
+ });
447
+ })()`)
448
+ .then((auszug) => console.log(`[smoke] DOM-Auszug: ${auszug}`))
449
+ .finally(() => app.quit());
450
+ }, 3000);
451
+ });
452
+ }
453
+ // Selbsttätiges Aktualisieren (E44): einmal, sobald der Renderer bereit ist, danach alle sechs
454
+ // Stunden, solange das Fenster offen bleibt. Im Smoke-Test nie, sonst würde ein automatisierter
455
+ // Lauf einen echten Git-Fetch anstoßen.
456
+ if (!smoketest) {
457
+ const fenster = hauptfenster;
458
+ fenster.webContents.once('did-finish-load', () => {
459
+ fenster.webContents.send('hmgr:aktualisierenAnstossen');
460
+ });
461
+ const intervall = setInterval(() => fenster.webContents.send('hmgr:aktualisierenAnstossen'), SECHS_STUNDEN_MS);
462
+ fenster.on('closed', () => clearInterval(intervall));
463
+ }
464
+ void hauptfenster.loadFile(path.join(hierVerzeichnis, 'renderer', 'index.html'));
465
+ }
466
+ 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();
476
+ });
477
+ });
478
+ app.on('window-all-closed', () => {
479
+ if (process.platform !== 'darwin')
480
+ app.quit();
481
+ });
@@ -0,0 +1,33 @@
1
+ // Entscheidung, wie ein Klick auf eine Quelle sie öffnet (E60). Reine Funktion, ohne Electron:
2
+ // der IPC-Handler in main.ts ruft sie und reicht das Ergebnis an `shell.openExternal` (nur für
3
+ // http/https) oder `shell.openPath` (lokaler Ordner) weiter — nichts anderes wird zugelassen,
4
+ // kein beliebiges Protokoll geht an eine der beiden Electron-Funktionen.
5
+ import { fileURLToPath } from 'node:url';
6
+ import { urlOrt } from "../kern/quelle-ort.js";
7
+ /**
8
+ * http(s)-Quellen öffnen ihre Adresse direkt im Browser. Eine SSH- oder scp-Adresse
9
+ * (`git@host:besitzer/repo.git`) hat keine Webseite als Adresse, aber Host, Besitzer und Repo sind
10
+ * bekannt (E36), also wird daraus die https-Repo-Seite gebaut — `shell.openExternal` bekommt so nie
11
+ * die ssh-Adresse selbst zu sehen. Lokale Quellen und `file://`-Quellen öffnen ihren Pfad im
12
+ * Finder. Alles, woraus sich weder eine http(s)-Adresse noch ein Pfad gewinnen lässt, wird
13
+ * abgelehnt: kein anderes Protokoll geht an eine Electron-Shell-Funktion.
14
+ */
15
+ export function entscheideOeffnen(quelle) {
16
+ if (quelle.art === 'lokal')
17
+ return { art: 'ordner', pfad: quelle.pfad };
18
+ const text = quelle.url.trim();
19
+ if (/^https?:\/\//i.test(text))
20
+ return { art: 'browser', url: text };
21
+ if (/^file:\/\//i.test(text)) {
22
+ try {
23
+ return { art: 'ordner', pfad: fileURLToPath(text) };
24
+ }
25
+ catch {
26
+ return { art: 'abgelehnt' };
27
+ }
28
+ }
29
+ const ort = urlOrt(text);
30
+ if (ort && ort.host !== 'local')
31
+ return { art: 'browser', url: `https://${ort.host}/${ort.besitzer}/${ort.repo}` };
32
+ return { art: 'abgelehnt' };
33
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const { contextBridge, ipcRenderer } = require('electron');
4
+ contextBridge.exposeInMainWorld('hmgr', {
5
+ version: () => ipcRenderer.invoke('hmgr:version'),
6
+ /** Home-Verzeichnis, nur für pfadKurz im Renderer (Anzeige-Kürzung auf ~). */
7
+ home: () => ipcRenderer.invoke('hmgr:home'),
8
+ istZustand: () => ipcRenderer.invoke('hmgr:istZustand'),
9
+ marketplaces: () => ipcRenderer.invoke('hmgr:marketplaces'),
10
+ skills: () => ipcRenderer.invoke('hmgr:skills'),
11
+ quellen: () => ipcRenderer.invoke('hmgr:quellen'),
12
+ /** Icon einer Quelle als data-URL, oder null (E60). */
13
+ icon: (text) => ipcRenderer.invoke('hmgr:icon', text),
14
+ /** Öffnet eine Quelle: Repo-Seite im Browser oder Ordner im Finder (E60). */
15
+ quelleOeffnen: (text) => ipcRenderer.invoke('hmgr:quelleOeffnen', text),
16
+ quelleHinzufuegen: (text, name) => ipcRenderer.invoke('hmgr:quelleHinzufuegen', text, name),
17
+ quelleEntfernen: (text) => ipcRenderer.invoke('hmgr:quelleEntfernen', text),
18
+ /** Anzeigename einer Quelle ändern (E59), Identität bleibt ihre URL/ihr Pfad (E36). */
19
+ quelleUmbenennen: (text, name) => ipcRenderer.invoke('hmgr:quelleUmbenennen', text, name),
20
+ /** Aufräum-Angebot nach dem Entfernen einer Quelle (E58): Inventar vor quelleEntfernen erheben. */
21
+ quelleAufraeumInventar: (text) => ipcRenderer.invoke('hmgr:quelleAufraeumInventar', text),
22
+ quelleAufraeumen: (inventar) => ipcRenderer.invoke('hmgr:quelleAufraeumen', inventar),
23
+ linkLegen: (args) => ipcRenderer.invoke('hmgr:linkLegen', args),
24
+ linkEntfernen: (args) => ipcRenderer.invoke('hmgr:linkEntfernen', args),
25
+ eintragIgnorieren: (args) => ipcRenderer.invoke('hmgr:eintragIgnorieren', args),
26
+ eintragBeachten: (args) => ipcRenderer.invoke('hmgr:eintragBeachten', args),
27
+ eintragLoeschen: (args) => ipcRenderer.invoke('hmgr:eintragLoeschen', args),
28
+ uebernehmen: (args) => ipcRenderer.invoke('hmgr:uebernehmen', args),
29
+ aktualisieren: (args) => ipcRenderer.invoke('hmgr:aktualisieren', args),
30
+ arbeitsverzeichnisHinzufuegen: (pfad) => ipcRenderer.invoke('hmgr:arbeitsverzeichnisHinzufuegen', pfad),
31
+ arbeitsverzeichnisVergessen: (pfad) => ipcRenderer.invoke('hmgr:arbeitsverzeichnisVergessen', pfad),
32
+ /** Nativer Verzeichnis-Dialog fürs Pfadfeld in „Verzeichnis hinzufügen" (A1). */
33
+ verzeichnisWaehlen: () => ipcRenderer.invoke('hmgr:verzeichnisWaehlen'),
34
+ harnessTypen: () => ipcRenderer.invoke('hmgr:harnessTypen'),
35
+ aktiveHarnesses: () => ipcRenderer.invoke('hmgr:aktiveHarnesses'),
36
+ nichtVorhandeneHarnesses: () => ipcRenderer.invoke('hmgr:nichtVorhandeneHarnesses'),
37
+ harnessAktivieren: (name) => ipcRenderer.invoke('hmgr:harnessAktivieren', name),
38
+ harnessDeaktivieren: (name) => ipcRenderer.invoke('hmgr:harnessDeaktivieren', name),
39
+ saeubern: (name) => ipcRenderer.invoke('hmgr:saeubern', name),
40
+ /** Verwalteter Hermes-Skill-Ordner (E56): zeigen kommt über hmgr:istZustand, hier nur Ändern. */
41
+ skillOrdnerFestlegen: (args) => ipcRenderer.invoke('hmgr:skillOrdnerFestlegen', args),
42
+ skillOrdnerEntfernen: (args) => ipcRenderer.invoke('hmgr:skillOrdnerEntfernen', args),
43
+ skillOrdnerLoeschen: (args) => ipcRenderer.invoke('hmgr:skillOrdnerLoeschen', args),
44
+ /** Rückfrage-Dialog (E28, E34): der Main-Prozess ruft, der Renderer meldet die Antwort zurück. */
45
+ onRueckfrage: (hoerer) => {
46
+ ipcRenderer.on('hmgr:rueckfrage', (_event, nachricht) => hoerer(nachricht));
47
+ },
48
+ antwortenRueckfrage: (id, antwort) => {
49
+ ipcRenderer.send('hmgr:rueckfrage:antwort', { id, antwort });
50
+ },
51
+ /** Anstoß zum selbsttätigen Aktualisieren (E44): Main ruft nach dem Start und alle sechs Stunden. */
52
+ onAktualisierenAnstossen: (hoerer) => {
53
+ ipcRenderer.on('hmgr:aktualisierenAnstossen', () => hoerer());
54
+ },
55
+ });