diff --git a/README.md b/README.md index 5cb36ce..719180e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,15 @@ Verwalte deine Repositories intuitiv ohne Kommandozeile — mit Favoriten, Verla - 📤 **Drag & Drop Upload** — ziehe Dateien ins App-Fenster zum hochladen - 📥 **Drag & Drop Download** — ziehe Repos raus zum herunterladen - 🔄 **Rename & Move** — Dateien verwalten direkt im Editor +- ⏹️ **Abbrechen** — Uploads, Downloads und Batch-Clones lassen sich per Knopf oder Esc stoppen +- ⏱️ **Fortschritt mit MB und Restzeit** — bei großen Uploads siehst du, ob es noch läuft +- 🔁 **Nur Fehlgeschlagene wiederholen** — nach einem Teilfehler nicht alles neu hochladen +- ⏸️ **Pausieren und Fortsetzen** — Übertragungen anhalten, auch über das Tray-Menü +- 🕘 **Upload-Verlauf** — die letzten 100 Uploads, filterbar nach Ergebnis und Repo, Klick öffnet das Repo, Export als CSV +- 🛡️ **Zugangsdaten-Sperre** — `credentials.json`, `.env`, Schlüssel und Ähnliches werden auf allen Upload-Wegen nie hochgeladen +- 🔔 **Windows-Meldung bei Upload-Ende** — wenn die App im Tray versteckt oder nicht im Vordergrund ist +- 📄 **.gitignore anlegen** — Vorlagen für Node.js, .NET, Electron, Python, Java, PHP und Unity +- 📦 **Release für den Updater** — Installer wählen, Prüfsumme abgleichen, Tag prüfen, `.sha256` mit hochladen ### 📊 Versionscontrol - 📊 **Commit-History** mit Filter nach Autor und Nachricht @@ -131,7 +140,7 @@ npm start | Ctrl+K | Repo-Suche fokussieren | | Ctrl+S | Datei im Editor speichern | | Ctrl+F | Im Editor suchen | -| Escape | Suche schließen | +| Escape | Suche schließen · laufenden Upload/Download abbrechen (wenn kein Dialog offen ist) | | Rechtsklick | Kontextmenü (Repos & Dateien) | | Drag raus | Datei/Repo herunterladen | | Drag rein | Datei hochladen | @@ -176,6 +185,19 @@ Fehler gefunden oder Idee für neue Features? ## 📝 Changelog +### v2.2.1 +- ⏹️ Abbrechen-Knopf (und Esc) für Uploads, Ordner-Downloads und Batch-Clones +- ⏸️ Pausieren und Fortsetzen im Fortschrittsbalken und im Tray-Menü +- 🔁 „Nur Fehlgeschlagene wiederholen“ bei Teilfehlern von Drag-&-Drop- und Ordner-Uploads +- ⏱️ Fortschrittsanzeige mit übertragenen MB und geschätzter Restzeit +- 🕘 Upload-Verlauf mit Filter, Klick zum Öffnen des Repos und CSV-Export +- 🛡️ Feste Sperre für Dateien mit Zugangsdaten auf allen Upload-Wegen (abschaltbar) +- 🔔 Windows-Meldung, wenn ein Upload endet, während die App nicht vorn ist +- 📄 `.gitignore`-Vorlagen im Repo, 📦 Release-Assistent für den Updater +- 🎨 Aufgeräumte Toolbar mit eigener Repo-Leiste, neues Tray-Menü mit Hover-Markierung +- ✨ Startanimation wie bei AeroMC: das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (abschaltbar, beim Autostart im Tray nie) +- 🧪 `npm run test:ui`: Oberflächen-Testlauf + ### v2.1.3 (13. Mai 2026) - ✨ Projektname wird korrekt ausgeblendet bei Wechsel zur Übersicht - 🐛 Bug-Fix: Zurück-Button setzt Gravur korrekt zurück diff --git a/__tests__/gitignoreTemplates.test.js b/__tests__/gitignoreTemplates.test.js index f996209..0c695d5 100644 --- a/__tests__/gitignoreTemplates.test.js +++ b/__tests__/gitignoreTemplates.test.js @@ -2,9 +2,17 @@ const test = require('node:test'); const assert = require('node:assert'); const { listTemplates, buildGitignore } = require('../src/features/gitignoreTemplates.js'); -test('listTemplates nennt Node, .NET und Electron', () => { - const keys = listTemplates().map(t => t.key); - assert.deepStrictEqual(keys, ['node', 'dotnet', 'electron']); +test('listTemplates nennt alle Vorlagen mit Beschriftung', () => { + const list = listTemplates(); + assert.deepStrictEqual(list.map(t => t.key), ['node', 'dotnet', 'electron', 'python', 'java', 'php', 'unity']); + assert.ok(list.every(t => t.label)); +}); + +test('buildGitignore: Vorlagen für Python, Java, PHP und Unity enthalten ihre Kerneinträge', () => { + assert.match(buildGitignore(['python']), /__pycache__\//); + assert.match(buildGitignore(['java']), /target\//); + assert.match(buildGitignore(['php']), /vendor\//); + assert.match(buildGitignore(['unity']), /\[Ll\]ibrary\//); }); test('buildGitignore enthält immer den allgemeinen Teil', () => { diff --git a/__tests__/pauseGate.test.js b/__tests__/pauseGate.test.js new file mode 100644 index 0000000..b095c0d --- /dev/null +++ b/__tests__/pauseGate.test.js @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { createPauseGate } = require('../src/features/pauseGate.js'); + +const tick = () => new Promise((r) => setTimeout(r, 20)); + +test('Ohne Pause läuft wait() sofort durch', async () => { + const gate = createPauseGate(); + await gate.wait(); + await gate.wait(new AbortController().signal); + assert.strictEqual(gate.isPaused(), false); +}); + +test('Während der Pause bleibt wait() stehen und läuft nach resume() weiter', async () => { + const gate = createPauseGate(); + gate.pause(); + let done = false; + const p = gate.wait().then(() => { done = true; }); + await tick(); + assert.strictEqual(done, false); + assert.strictEqual(gate.waitingCount(), 1); + gate.resume(); + await p; + assert.strictEqual(done, true); + assert.strictEqual(gate.waitingCount(), 0); +}); + +test('resume() gibt alle Wartenden frei', async () => { + const gate = createPauseGate(); + gate.pause(); + const all = Promise.all([gate.wait(), gate.wait(), gate.wait()]); + await tick(); + assert.strictEqual(gate.waitingCount(), 3); + gate.resume(); + await all; +}); + +test('Abbruch während der Pause löst wait() mit dem Abbruchfehler aus', async () => { + const gate = createPauseGate(); + gate.pause(); + const c = new AbortController(); + const p = gate.wait(c.signal); + await tick(); + c.abort(); + await assert.rejects(p, (e) => e.code === 'UPLOAD_CANCELLED'); + assert.strictEqual(gate.waitingCount(), 0); +}); + +test('Bereits abgebrochenes Signal wirft sofort, auch ohne Pause', async () => { + const gate = createPauseGate(); + const c = new AbortController(); + c.abort(); + await assert.rejects(gate.wait(c.signal), (e) => e.code === 'UPLOAD_CANCELLED'); +}); + +test('onChange meldet nur echte Wechsel', () => { + const states = []; + const gate = createPauseGate({ onChange: (p) => states.push(p) }); + assert.strictEqual(gate.pause(), true); + assert.strictEqual(gate.pause(), false); + assert.strictEqual(gate.resume(), true); + assert.strictEqual(gate.resume(), false); + assert.deepStrictEqual(states, [true, false]); +}); + +test('Ein abgebrochener Wartender blockiert die übrigen nicht', async () => { + const gate = createPauseGate(); + gate.pause(); + const c = new AbortController(); + const a = gate.wait(c.signal).catch((e) => e.code); + const b = gate.wait(); + await tick(); + c.abort(); + assert.strictEqual(await a, 'UPLOAD_CANCELLED'); + gate.resume(); + await b; +}); diff --git a/__tests__/releaseAssist.test.js b/__tests__/releaseAssist.test.js new file mode 100644 index 0000000..fb8703a --- /dev/null +++ b/__tests__/releaseAssist.test.js @@ -0,0 +1,64 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { + parseVersionFromFileName, compareVersions, parseSidecar, checkNewTag, buildReleaseBody +} = require('../src/features/releaseAssist.js'); + +const HASH = '5ca7f0c680b6d73a92eadb5e90f8164f7f3fa52071ffaac54803b0e5ec95f35d'; + +test('Version aus dem Dateinamen', () => { + assert.strictEqual(parseVersionFromFileName('Git-Manager-GUI-Setup-2.2.1.exe'), '2.2.1'); + assert.strictEqual(parseVersionFromFileName('Git Manager GUI Setup 2.1.10.exe'), '2.1.10'); + assert.strictEqual(parseVersionFromFileName('setup.exe'), null); + assert.strictEqual(parseVersionFromFileName(null), null); +}); + +test('Versionen werden numerisch verglichen, nicht als Text', () => { + assert.strictEqual(compareVersions('2.2.1', '2.2.0'), 1); + assert.strictEqual(compareVersions('v2.10.0', 'v2.9.9'), 1); + assert.strictEqual(compareVersions('2.2.1', 'v2.2.1'), 0); + assert.strictEqual(compareVersions('2.1.9', '2.2.0'), -1); + assert.deepStrictEqual(['v2.10.0', 'v2.9.0', 'v2.2.1'].sort(compareVersions), ['v2.2.1', 'v2.9.0', 'v2.10.0']); +}); + +test('Sidecar-Datei: Hash wird gefunden, auch mit Großbuchstaben', () => { + assert.strictEqual(parseSidecar(`${HASH} Git-Manager-GUI-Setup-2.2.1.exe\n`), HASH); + assert.strictEqual(parseSidecar(HASH.toUpperCase()), HASH); + assert.strictEqual(parseSidecar('kein Hash'), null); + assert.strictEqual(parseSidecar('abc123'), null); +}); + +test('Neuer Tag: Format, Duplikat und Reihenfolge werden geprüft', () => { + const releases = [{ tag_name: 'v2.2.0' }, { tag_name: 'v2.1.9' }]; + assert.strictEqual(checkNewTag('v2.2.1', releases).ok, true); + assert.strictEqual(checkNewTag('2.2.1', releases).ok, true); + assert.strictEqual(checkNewTag('v2.10.0', releases).ok, true); + + assert.match(checkNewTag('release-2', releases).reason, /Version wie v2\.2\.1/); + assert.match(checkNewTag('v2.2.0', releases).reason, /gibt es schon/); + assert.match(checkNewTag('V2.2.0', releases).reason, /gibt es schon/); + assert.match(checkNewTag('v2.1.5', releases).reason, /nicht neuer/); +}); + +test('Neuer Tag: ohne vorhandene Releases ist jede Version erlaubt', () => { + assert.strictEqual(checkNewTag('v1.0.0', []).ok, true); + assert.strictEqual(checkNewTag('v1.0.0', null).ok, true); + assert.strictEqual(checkNewTag('v1.0.0', [{ tag_name: 'nightly' }]).ok, true); +}); + +test('Release-Text: Prüfsummen im Format, das der Updater liest', () => { + const body = buildReleaseBody('## Neu\n\n- Abbrechen\n', [{ name: 'Git-Manager-GUI-Setup-2.2.1.exe', sha256: HASH.toUpperCase() }]); + assert.match(body, /^## Neu/); + assert.ok(body.includes(`- Git-Manager-GUI-Setup-2.2.1.exe: ${HASH}`)); + + // genau so liest updater.js die Zeile + const line = body.split('\n').find((l) => l.toLowerCase().includes('git-manager-gui-setup-2.2.1.exe') && /\b[a-f0-9]{64}\b/i.test(l)); + assert.ok(line); + assert.strictEqual(line.match(/\b([a-fA-F0-9]{64})\b/)[1], HASH); +}); + +test('Release-Text: ungültige Prüfsummen und leere Notizen', () => { + assert.strictEqual(buildReleaseBody('Nur Text', [{ name: 'a.exe', sha256: 'zu kurz' }]), 'Nur Text'); + assert.strictEqual(buildReleaseBody('', []), ''); + assert.ok(buildReleaseBody('', [{ name: 'a.exe', sha256: HASH }]).startsWith('### Prüfsummen')); +}); diff --git a/__tests__/splash.test.js b/__tests__/splash.test.js new file mode 100644 index 0000000..b183273 --- /dev/null +++ b/__tests__/splash.test.js @@ -0,0 +1,36 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { remainingMs, centeredBounds, MIN_SPIN_MS } = require('../src/features/splash.js'); + +test('Startanimation: wurde das Logo nie gezeigt, gibt es keine Wartezeit', () => { + assert.strictEqual(remainingMs(0, 5000, 1600), 0); +}); + +test('Startanimation: das Logo dreht sich mindestens minMs', () => { + assert.strictEqual(remainingMs(1000, 1300, 1600), 1300); + assert.strictEqual(remainingMs(1000, 1000, 1600), 1600); +}); + +test('Startanimation: nach Ablauf von minMs wird nicht mehr gewartet', () => { + assert.strictEqual(remainingMs(1000, 2600, 1600), 0); + assert.strictEqual(remainingMs(1000, 9000, 1600), 0); +}); + +test('Startanimation: Mindestdauer ist wie bei AeroMC 1,6 s', () => { + assert.strictEqual(MIN_SPIN_MS, 1600); +}); + +test('Startanimation: Fenster wird im Arbeitsbereich zentriert', () => { + const b = centeredBounds({ x: 0, y: 0, width: 1920, height: 1040 }, 1200, 820); + assert.deepStrictEqual(b, { x: 360, y: 110, width: 1200, height: 820 }); +}); + +test('Startanimation: Arbeitsbereich mit Versatz und mehrere Monitore', () => { + const b = centeredBounds({ x: -1920, y: 40, width: 1920, height: 1000 }, 1200, 820); + assert.deepStrictEqual(b, { x: -1560, y: 130, width: 1200, height: 820 }); +}); + +test('Startanimation: auf kleinen Bildschirmen wird das Fenster begrenzt', () => { + const b = centeredBounds({ x: 0, y: 0, width: 1024, height: 700 }, 1200, 820); + assert.deepStrictEqual(b, { x: 0, y: 0, width: 1024, height: 700 }); +}); diff --git a/__tests__/transferNotice.test.js b/__tests__/transferNotice.test.js new file mode 100644 index 0000000..801e101 --- /dev/null +++ b/__tests__/transferNotice.test.js @@ -0,0 +1,28 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { describeTransfer } = require('../src/features/transferNotice.js'); + +test('Erfolg nennt Repo und Dateizahl', () => { + assert.deepStrictEqual(describeTransfer({ status: 'ok', repo: 'a/b', files: 3 }), { title: 'Upload fertig', body: 'a/b: 3 Dateien hochgeladen.' }); + assert.match(describeTransfer({ status: 'ok', repo: 'a/b', files: 1 }).body, /1 Datei hochgeladen/); +}); + +test('Erfolg ohne bekannte Dateizahl bleibt lesbar', () => { + assert.strictEqual(describeTransfer({ status: 'ok', repo: 'a/b', files: null }).body, 'a/b: Upload hochgeladen.'); +}); + +test('Abgebrochene Uploads melden nichts', () => { + assert.strictEqual(describeTransfer({ status: 'abgebrochen', repo: 'a/b' }), null); + assert.strictEqual(describeTransfer(null), null); +}); + +test('Fehler zeigt fehlgeschlagene Dateien oder den Fehlertext', () => { + assert.strictEqual(describeTransfer({ status: 'fehler', repo: 'a/b', failed: 2 }).title, 'Upload fehlgeschlagen'); + assert.match(describeTransfer({ status: 'fehler', repo: 'a/b', failed: 2 }).body, /2 Dateien fehlgeschlagen/); + assert.match(describeTransfer({ status: 'fehler', repo: 'a/b', error: 'Server nicht erreichbar' }).body, /Server nicht erreichbar/); + assert.match(describeTransfer({ status: 'fehler', repo: '' }).body, /Unbekannter Fehler/); +}); + +test('Langer Fehlertext wird gekürzt', () => { + assert.ok(describeTransfer({ status: 'fehler', repo: 'a/b', error: 'x'.repeat(500) }).body.length < 200); +}); diff --git a/__tests__/trayMenu.test.js b/__tests__/trayMenu.test.js new file mode 100644 index 0000000..a6878d1 --- /dev/null +++ b/__tests__/trayMenu.test.js @@ -0,0 +1,133 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { buildTrayTemplate, trayTooltip } = require('../src/features/trayMenu.js'); + +const noop = () => {}; +const base = { version: '2.2.1', onOpen: noop, onHistory: noop, onCancel: noop, onAutostart: noop, onQuit: noop }; +const labels = (tpl) => tpl.filter(i => i.label).map(i => i.label); +const find = (tpl, label) => tpl.find(i => i.label === label); + +test('Tooltip nennt Version und zeigt eine laufende Übertragung', () => { + assert.strictEqual(trayTooltip('2.2.1', 0), 'Git Manager Explorer Pro v2.2.1'); + assert.match(trayTooltip('2.2.1', 2), /Übertragung läuft/); + assert.match(trayTooltip('2.2.1', 1, true), /Übertragung pausiert/); + assert.strictEqual(trayTooltip('2.2.1', 0, true), 'Git Manager Explorer Pro v2.2.1'); +}); + +test('Menü im Ruhezustand: Kopfzeile, Status, Aktionen; Abbrechen gesperrt', () => { + const tpl = buildTrayTemplate(base); + assert.deepStrictEqual(labels(tpl), [ + 'Git Manager Explorer Pro v2.2.1', + 'Keine laufende Übertragung', + 'Fenster öffnen', + 'Upload-Verlauf', + 'Übertragung pausieren', + 'Laufende Übertragung abbrechen', + 'Mit Windows starten', + 'Beenden' + ]); + assert.strictEqual(find(tpl, 'Laufende Übertragung abbrechen').enabled, false); + assert.strictEqual(tpl[0].enabled, false); +}); + +test('Menü bei laufender Übertragung: Zähler, Abbrechen freigeschaltet', () => { + const one = buildTrayTemplate({ ...base, active: 1 }); + assert.ok(labels(one).includes('1 Übertragung läuft')); + assert.strictEqual(find(one, 'Laufende Übertragung abbrechen').enabled, true); + assert.ok(labels(buildTrayTemplate({ ...base, active: 3 })).includes('3 Übertragungen laufen')); +}); + +test('Retry-Queue erscheint nur, wenn etwas wartet', () => { + assert.ok(!labels(buildTrayTemplate(base)).some(l => l.startsWith('Retry-Queue'))); + assert.ok(labels(buildTrayTemplate({ ...base, queued: 4 })).includes('Retry-Queue: 4 wartend')); +}); + +test('Autostart ist ein Häkchen und spiegelt den Zustand', () => { + assert.strictEqual(find(buildTrayTemplate({ ...base, autostart: true }), 'Mit Windows starten').checked, true); + const off = find(buildTrayTemplate(base), 'Mit Windows starten'); + assert.strictEqual(off.type, 'checkbox'); + assert.strictEqual(off.checked, false); +}); + +test('Einträge rufen die übergebenen Aktionen auf', () => { + const calls = []; + const tpl = buildTrayTemplate({ + ...base, + active: 1, + onOpen: () => calls.push('open'), + onHistory: () => calls.push('history'), + onCancel: () => calls.push('cancel'), + onQuit: () => calls.push('quit') + }); + for (const l of ['Fenster öffnen', 'Upload-Verlauf', 'Laufende Übertragung abbrechen', 'Beenden']) find(tpl, l).click(); + assert.deepStrictEqual(calls, ['open', 'history', 'cancel', 'quit']); +}); + +const { serializeTemplate, runAction } = require('../src/features/trayMenu.js'); +const { computePosition } = require('../src/features/trayPopup.js'); + +test('serializeTemplate: nur Daten, keine Funktionen, Trenner bleiben erhalten', () => { + const plain = serializeTemplate(buildTrayTemplate({ ...base, active: 1, autostart: true })); + assert.ok(plain.every(i => Object.values(i).every(v => typeof v !== 'function'))); + assert.strictEqual(plain.filter(i => i.type === 'separator').length, 3); + const cancel = plain.find(i => i.id === 'cancel'); + assert.deepStrictEqual(cancel, { id: 'cancel', label: 'Laufende Übertragung abbrechen', type: 'normal', enabled: true, checked: false }); + assert.strictEqual(plain.find(i => i.id === 'autostart').checked, true); + assert.doesNotThrow(() => JSON.stringify(plain)); +}); + +test('runAction: führt Aktionen per id aus, Häkchen wird umgeschaltet', () => { + const calls = []; + const tpl = buildTrayTemplate({ + ...base, + active: 1, + autostart: false, + onOpen: () => calls.push('open'), + onAutostart: (item) => calls.push(`autostart:${item.checked}`) + }); + assert.strictEqual(runAction(tpl, 'open'), true); + assert.strictEqual(runAction(tpl, 'autostart'), true); + assert.deepStrictEqual(calls, ['open', 'autostart:true']); +}); + +test('runAction: gesperrte oder unbekannte Einträge tun nichts', () => { + let cancelled = false; + const tpl = buildTrayTemplate({ ...base, active: 0, onCancel: () => { cancelled = true; } }); + assert.strictEqual(runAction(tpl, 'cancel'), false); + assert.strictEqual(runAction(tpl, 'gibt-es-nicht'), false); + assert.strictEqual(runAction(tpl, 'header'), false); + assert.strictEqual(cancelled, false); +}); + +test('computePosition: Menü bleibt im sichtbaren Bereich', () => { + const wa = { x: 0, y: 0, width: 1920, height: 1040 }; + // Zeiger in der Taskleiste unten rechts: Menü sitzt direkt über der Taskleiste, nicht außerhalb rechts + const bottomRight = computePosition({ x: 1900, y: 1060 }, wa, 286, 300); + assert.strictEqual(bottomRight.y, 1040 - 300 - 8); + assert.ok(bottomRight.x + 286 <= 1920 - 8); + // Zeiger weit links: nicht links aus dem Bildschirm + assert.ok(computePosition({ x: 2, y: 1060 }, wa, 286, 300).x >= 8); + // Taskleiste oben + assert.strictEqual(computePosition({ x: 500, y: 10 }, { ...wa, y: 40, height: 1000 }, 286, 300).y, 48); +}); + +test('Pause: Eintrag wechselt zwischen pausieren und fortsetzen, nur bei laufender Übertragung wählbar', () => { + const idle = buildTrayTemplate(base); + assert.strictEqual(find(idle, 'Übertragung pausieren').enabled, false); + + const running = buildTrayTemplate({ ...base, active: 1 }); + assert.strictEqual(find(running, 'Übertragung pausieren').enabled, true); + + const paused = buildTrayTemplate({ ...base, active: 2, paused: true }); + assert.ok(labels(paused).includes('Übertragung fortsetzen')); + assert.ok(labels(paused).includes('2 Übertragungen pausiert')); + assert.ok(!labels(paused).includes('Übertragung pausieren')); + assert.ok(labels(buildTrayTemplate({ ...base, active: 1, paused: true })).includes('1 Übertragung pausiert')); +}); + +test('Pause-Eintrag ruft onPause auf', () => { + const calls = []; + const tpl = buildTrayTemplate({ ...base, active: 1, onPause: () => calls.push('pause') }); + assert.strictEqual(runAction(tpl, 'pause'), true); + assert.deepStrictEqual(calls, ['pause']); +}); diff --git a/__tests__/uploadBlocklist.test.js b/__tests__/uploadBlocklist.test.js new file mode 100644 index 0000000..f65a83e --- /dev/null +++ b/__tests__/uploadBlocklist.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { blockedReason, partitionBlocked } = require('../src/features/uploadBlocklist.js'); + +test('Sperre: Dateien mit Zugangsdaten werden erkannt', () => { + for (const name of ['credentials.json', 'data/credentials.json', 'C:\\proj\\.env', '.env.production', 'id_rsa', 'release.keystore', 'server.pem', 'secrets.json', '.npmrc']) { + assert.ok(blockedReason(name, {}), `${name} sollte gesperrt sein`); + } +}); + +test('Sperre: normale Dateien und Vorlagen bleiben erlaubt', () => { + for (const name of ['main.js', 'package.json', 'README.md', '.env.example', 'config.sample.json', 'id_rsa.pub', 'credentials.md', 'src/credentials/index.js']) { + assert.strictEqual(blockedReason(name, {}), null, `${name} darf hochgeladen werden`); + } +}); + +test('Sperre: Einstellung kann sie abschalten, Standard ist an', () => { + assert.ok(blockedReason('credentials.json', undefined)); + assert.ok(blockedReason('credentials.json', { uploadBlockSecrets: true })); + assert.strictEqual(blockedReason('credentials.json', { uploadBlockSecrets: false }), null); +}); + +test('partitionBlocked: trennt Einträge und behält die Reihenfolge', () => { + const items = [ + { localFile: 'a/main.js', targetPath: 'a/main.js' }, + { localFile: 'a/data/credentials.json', targetPath: 'a/data/credentials.json' }, + { localFile: 'a/.env', targetPath: 'a/.env' }, + { localFile: 'a/README.md', targetPath: 'a/README.md' } + ]; + const { allowed, blocked } = partitionBlocked(items, {}); + assert.deepStrictEqual(allowed.map(i => i.localFile), ['a/main.js', 'a/README.md']); + assert.deepStrictEqual(blocked.map(b => b.path), ['a/data/credentials.json', 'a/.env']); + assert.ok(blocked.every(b => b.reason)); + assert.strictEqual(partitionBlocked(items, { uploadBlockSecrets: false }).blocked.length, 0); +}); diff --git a/__tests__/uploadCancel.test.js b/__tests__/uploadCancel.test.js new file mode 100644 index 0000000..e6b9051 --- /dev/null +++ b/__tests__/uploadCancel.test.js @@ -0,0 +1,146 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { + uploadItemsViaGiteaApi, + createUploadCancelledError, + isUploadCancelledError, + throwIfUploadCancelled +} = require('../src/git/apiHandler.js'); + +test('Abbruchfehler: Erkennung und throwIfUploadCancelled', () => { + assert.strictEqual(isUploadCancelledError(createUploadCancelledError()), true); + assert.strictEqual(isUploadCancelledError(new Error('anderer Fehler')), false); + assert.strictEqual(isUploadCancelledError(null), false); + + assert.doesNotThrow(() => throwIfUploadCancelled(undefined)); + assert.doesNotThrow(() => throwIfUploadCancelled(new AbortController().signal)); + const c = new AbortController(); + c.abort(); + assert.throws(() => throwIfUploadCancelled(c.signal), (e) => isUploadCancelledError(e)); +}); + +function makeTempFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-cancel-')); + const file = path.join(dir, 'a.txt'); + fs.writeFileSync(file, 'hallo'); + return { dir, file }; +} + +test('API-Upload: bereits abgebrochenes Signal sendet nichts', async () => { + const { dir, file } = makeTempFile(); + try { + const c = new AbortController(); + c.abort(); + await assert.rejects( + uploadItemsViaGiteaApi({ + token: 't', url: 'http://127.0.0.1:1', owner: 'o', repo: 'r', branch: 'main', + items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', signal: c.signal + }), + (e) => isUploadCancelledError(e) + ); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('API-Upload: Abbruch während der Übertragung wirft Abbruchfehler und landet nicht in "failed"', async () => { + const { dir, file } = makeTempFile(); + let postSeen = false; + const server = http.createServer((req, res) => { + if (req.method === 'POST') { + postSeen = true; + setTimeout(() => { res.writeHead(201, { 'content-type': 'application/json' }); res.end('{}'); }, 3000); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ default_branch: 'main', tree: [], entries: [] })); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + const c = new AbortController(); + const started = Date.now(); + setTimeout(() => c.abort(), 400); + await assert.rejects( + uploadItemsViaGiteaApi({ + token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main', + items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', signal: c.signal + }), + (e) => isUploadCancelledError(e) + ); + assert.ok(postSeen, 'Der POST muss gestartet sein, sonst prüft der Test nichts'); + assert.ok(Date.now() - started < 2500, 'Abbruch muss sofort greifen, nicht erst nach der Serverantwort'); + } finally { + server.closeAllConnections?.(); + await new Promise(r => server.close(r)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const { createPauseGate } = require('../src/features/pauseGate.js'); + +function gitea(onPost) { + return http.createServer((req, res) => { + if (req.method === 'POST') { + onPost(); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end('{}'); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ default_branch: 'main', tree: [], entries: [] })); + }); +} + +test('API-Upload: während der Pause wird nichts gesendet, nach resume() schon', async () => { + const { dir, file } = makeTempFile(); + let posts = 0; + const server = gitea(() => { posts++; }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + const gate = createPauseGate(); + try { + gate.pause(); + const upload = uploadItemsViaGiteaApi({ + token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main', + items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', + waitIfPaused: (s) => gate.wait(s) + }); + await new Promise(r => setTimeout(r, 700)); + assert.strictEqual(posts, 0, 'Während der Pause darf kein Paket gesendet werden'); + gate.resume(); + const result = await upload; + assert.strictEqual(posts, 1); + assert.deepStrictEqual(result.uploaded, ['a.txt']); + } finally { + await new Promise(r => server.close(r)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('API-Upload: Abbruch während der Pause beendet den Upload sofort', async () => { + const { dir, file } = makeTempFile(); + let posts = 0; + const server = gitea(() => { posts++; }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + const gate = createPauseGate(); + try { + gate.pause(); + const c = new AbortController(); + const upload = uploadItemsViaGiteaApi({ + token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main', + items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', + signal: c.signal, waitIfPaused: (s) => gate.wait(s) + }); + setTimeout(() => c.abort(), 400); + await assert.rejects(upload, (e) => isUploadCancelledError(e)); + assert.strictEqual(posts, 0); + assert.strictEqual(gate.waitingCount(), 0); + } finally { + await new Promise(r => server.close(r)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/__tests__/uploadHistory.test.js b/__tests__/uploadHistory.test.js new file mode 100644 index 0000000..4762f95 --- /dev/null +++ b/__tests__/uploadHistory.test.js @@ -0,0 +1,113 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { buildEntry, filterEntries, createUploadHistory } = require('../src/features/uploadHistory.js'); + +function memoryFs(initial) { + const files = new Map(initial ? [['h.json', initial]] : []); + return { + files, + readFileSync: (f) => { if (!files.has(f)) throw new Error('ENOENT'); return files.get(f); }, + writeFileSync: (f, c) => { files.set(f, c); } + }; +} + +test('buildEntry: erfolgreicher Upload', () => { + const e = buildEntry('Drag & Drop', { owner: 'a', repo: 'b' }, { ok: true, results: [{}, {}, {}] }, 1000, null, 4000); + assert.strictEqual(e.status, 'ok'); + assert.strictEqual(e.repo, 'a/b'); + assert.strictEqual(e.files, 3); + assert.strictEqual(e.durationMs, 3000); + assert.strictEqual(e.error, ''); +}); + +test('buildEntry: abgebrochen ist kein Fehler und trägt keinen Fehlertext', () => { + const e = buildEntry('Ordner', { owner: 'a', repo: 'b' }, { ok: false, cancelled: true, error: 'Upload abgebrochen' }, 0, null, 10); + assert.strictEqual(e.status, 'abgebrochen'); + assert.strictEqual(e.error, ''); +}); + +test('buildEntry: Fehler und geworfene Ausnahmen', () => { + const failed = buildEntry('Dateien', { owner: 'a', repo: 'b' }, { ok: false, error: 'kaputt', failedCount: 2 }, 0, null, 10); + assert.strictEqual(failed.status, 'fehler'); + assert.strictEqual(failed.error, 'kaputt'); + assert.strictEqual(failed.failed, 2); + + const thrown = buildEntry('Dateien', {}, null, 0, new Error('boom'), 10); + assert.strictEqual(thrown.status, 'fehler'); + assert.strictEqual(thrown.error, 'boom'); + assert.strictEqual(thrown.repo, ''); +}); + +test('buildEntry: Einzeldatei zählt als eine Datei, lange Fehler werden gekürzt', () => { + assert.strictEqual(buildEntry('x', {}, { ok: true, singleFile: true }, 0, null, 1).files, 1); + assert.strictEqual(buildEntry('x', {}, { ok: false, error: 'e'.repeat(1000) }, 0, null, 1).error.length, 300); +}); + +test('filterEntries: nach Status und Repo-Suche', () => { + const list = [ + { repo: 'a/alpha', status: 'ok' }, + { repo: 'a/beta', status: 'fehler' }, + { repo: 'b/Alpha-2', status: 'abgebrochen' } + ]; + assert.strictEqual(filterEntries(list).length, 3); + assert.deepStrictEqual(filterEntries(list, { status: 'fehler' }).map(e => e.repo), ['a/beta']); + assert.deepStrictEqual(filterEntries(list, { query: 'ALPHA' }).map(e => e.repo), ['a/alpha', 'b/Alpha-2']); + assert.strictEqual(filterEntries(list, { status: 'ok', query: 'beta' }).length, 0); +}); + +test('Verlauf: neueste zuerst, auf max Einträge begrenzt', () => { + const history = createUploadHistory({ file: 'h.json', max: 3, fs: memoryFs() }); + for (let i = 1; i <= 5; i++) history.record({ n: i }); + assert.deepStrictEqual(history.read().map(e => e.n), [5, 4, 3]); +}); + +test('Verlauf: kaputte oder fehlende Datei ergibt eine leere Liste', () => { + assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs() }).read(), []); + assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs('{kaputt') }).read(), []); + assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs('{"a":1}') }).read(), []); +}); + +test('Verlauf: leeren', () => { + const history = createUploadHistory({ file: 'h.json', fs: memoryFs() }); + history.record({ n: 1 }); + history.clear(); + assert.deepStrictEqual(history.read(), []); +}); + +test('Verlauf: Schreibfehler werfen nicht', () => { + const broken = { readFileSync: () => '[]', writeFileSync: () => { throw new Error('Platte voll'); } }; + assert.strictEqual(createUploadHistory({ file: 'h.json', fs: broken }).record({ n: 1 }), false); +}); + +const { toCsv, formatDate } = require('../src/features/uploadHistory.js'); + +test('formatDate: TT.MM.JJJJ HH:MM:SS, ungültige Eingabe ergibt leeren Text', () => { + assert.match(formatDate('2026-10-22T07:11:53.080Z'), /^\d{2}\.\d{2}\.2026 \d{2}:\d{2}:\d{2}$/); + assert.strictEqual(formatDate('kein Datum'), ''); +}); + +test('toCsv: BOM, Semikolon, Windows-Zeilenenden, Kopfzeile', () => { + const csv = toCsv([{ at: '2026-10-22T07:11:53.080Z', repo: 'a/b', kind: 'Drag & Drop', files: 3, failed: 0, status: 'ok', durationMs: 2500, error: '' }]); + assert.strictEqual(csv.charCodeAt(0), 0xFEFF); + const lines = csv.slice(1).split('\r\n'); + assert.strictEqual(lines[0], 'Datum;Repo;Art;Dateien;Fehlgeschlagen;Ergebnis;Dauer (s);Fehler'); + assert.match(lines[1], /;a\/b;Drag & Drop;3;0;Erfolgreich;2\.5;$/); + assert.strictEqual(lines[2], ''); +}); + +test('toCsv: Anführungszeichen, Semikolon und Zeilenumbrüche werden maskiert', () => { + const csv = toCsv([{ at: '2026-10-22T07:11:53Z', repo: 'a/b', kind: 'x', files: null, failed: 1, status: 'fehler', durationMs: 100, error: 'sagt "nein"; und\nmehr' }]); + assert.ok(csv.includes('"sagt ""nein""; und\nmehr"')); + assert.match(csv, /;Fehler;0\.1;/); +}); + +test('toCsv: Excel-Formeln werden entschärft', () => { + const csv = toCsv([{ at: '2026-10-22T07:11:53Z', repo: '=cmd|calc', kind: '+x', files: 1, failed: 0, status: 'ok', durationMs: 0, error: '@SUMME(A1)' }]); + assert.ok(csv.includes(";'=cmd|calc;")); + assert.ok(csv.includes(";'+x;")); + assert.ok(csv.includes(";'@SUMME(A1)")); +}); + +test('toCsv: leere Liste liefert nur die Kopfzeile', () => { + assert.strictEqual(toCsv([]).slice(1).split('\r\n').filter(Boolean).length, 1); +}); diff --git a/main.js b/main.js index 1e4fd94..5007219 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,5 @@ // main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup -const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage, Notification, screen } = require('electron'); // Das Setup startet die frisch installierte EXE einmal mit diesem Schalter, damit Windows Defender // sie schon während der Installation prüft (installer/Installer.cs → Vorwaermen). Sofort beenden. if (process.argv.includes('--gm-vorwaermen')) process.exit(0); @@ -23,6 +23,7 @@ applyPrefsToProcess(appPrefs.get()); appPrefs.onChange((p) => { applyPrefsToProcess(p); for (const w of BrowserWindow.getAllWindows()) { + if (trayPopup && w === trayPopup.getWindow()) continue; try { w.webContents.setZoomFactor(p.zoom / 100); } catch (_) {} } }); @@ -414,7 +415,8 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`); const result = await uploadItemsViaGiteaApi({ - token, url, owner, repo, branch, items, message, onProgress, signal + token, url, owner, repo, branch, items, message, onProgress, signal, + waitIfPaused: (s) => transferGate.wait(s) }); console.log(`[${label}] API-Upload fertig:`, { @@ -427,6 +429,16 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item return { ok: result.failed.length === 0, ...result }; } +// Fehler für einen teilweise fehlgeschlagenen API-Upload. Trägt die betroffenen Einträge, +// damit der Renderer nur diese wiederholen kann. +function buildPartialUploadError(gitErr, apiResult, items) { + const failedPaths = new Set((apiResult.failed || []).map(f => f.path)); + const err = new Error(describeApiFallbackFailure(gitErr, apiResult)); + err.failedItems = items.filter(it => failedPaths.has(it.targetPath)); + err.uploadedCount = (apiResult.uploaded || []).length; + return err; +} + // Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen. function describeApiFallbackFailure(gitErr, apiResult) { const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`; @@ -716,6 +728,7 @@ function broadcastRetryQueueUpdate(extra = {}) { for (const win of BrowserWindow.getAllWindows()) { try { win.webContents.send('retry-queue-updated', payload); } catch (_) {} } + try { refreshTray(); } catch (_) {} } function isRetryableNetworkError(errorLike) { @@ -899,6 +912,7 @@ app.commandLine.appendSwitch('enable-memory-coordination'); app / window ----------------------------- */ let tray = null; +let trayPopup = null; function ensureUsableDirectory(dirPath) { try { @@ -969,17 +983,82 @@ function initializeAppStoragePaths() { initializeAppStoragePaths(); +const trayMenu = require('./src/features/trayMenu.js'); +const { createTrayPopup } = require('./src/features/trayPopup.js'); +const startAnimation = require('./src/features/splash.js'); + +function showMainWindow(win) { + if (!win || win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} + +// Tooltip des Tray-Symbols und offenes Menü: zeigen, ob gerade etwas übertragen wird. +// Wird beim Start und Ende jeder abbrechbaren Übertragung und bei Änderungen der Retry-Queue aufgerufen. +function refreshTray() { + if (!tray || tray.isDestroyed()) return; + tray.setToolTip(trayMenu.trayTooltip(app.getVersion(), activeTransferAborts.size, transferGate.isPaused())); + if (trayPopup) trayPopup.refresh(); +} + +// Beenden über das Tray-Menü: läuft noch ein Upload oder Download, vorher nachfragen. +async function quitFromTray(win) { + if (activeTransferAborts.size > 0) { + showMainWindow(win); + const { response } = await dialog.showMessageBox(win, { + type: 'warning', + buttons: ['Beenden und abbrechen', 'Weiter laufen lassen'], + defaultId: 1, + cancelId: 1, + title: 'Übertragung läuft', + message: 'Es läuft noch ein Upload oder Download.', + detail: 'Wenn du jetzt beendest, wird er abgebrochen.' + }); + if (response !== 0) return; + } + app.quit(); +} + +// Menüvorlage mit dem aktuellen Status; wird bei jedem Öffnen und Aktualisieren neu gebaut. +function trayTemplate(win) { + return trayMenu.buildTrayTemplate({ + version: app.getVersion(), + active: activeTransferAborts.size, + queued: Array.isArray(retryQueue) ? retryQueue.length : 0, + paused: transferGate.isPaused(), + autostart: app.getLoginItemSettings({ args: ['--hidden'] }).openAtLogin, + onPause: () => { if (transferGate.isPaused()) transferGate.resume(); else transferGate.pause(); }, + onOpen: () => showMainWindow(win), + onHistory: () => { + showMainWindow(win); + try { win.webContents.send('open-upload-history'); } catch (_) {} + }, + onCancel: () => { + for (const controller of activeTransferAborts) controller.abort(); + refreshTray(); + }, + onAutostart: (item) => app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true, args: ['--hidden'] }), + onQuit: () => { quitFromTray(win); } + }); +} + function createTray(win) { const iconPath = ppath.join(__dirname, 'renderer', 'icon.png'); tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 })); - tray.setToolTip('Git Manager Explorer Pro'); - const menu = Menu.buildFromTemplate([ - { label: 'Öffnen', click: () => { win.show(); win.focus(); } }, - { type: 'separator' }, - { label: 'Beenden', click: () => { app.quit(); } } - ]); - tray.setContextMenu(menu); - tray.on('double-click', () => { win.show(); win.focus(); }); + // Eigenes Menüfenster statt des nativen Menüs: Letzteres zeichnet unter Windows keine Hover-Markierung. + trayPopup = createTrayPopup({ + htmlPath: ppath.join(__dirname, 'renderer', 'tray-menu.html'), + preloadPath: ppath.join(__dirname, 'tray-menu-preload.js'), + getModel: () => trayMenu.serializeTemplate(trayTemplate(win)), + onAction: (id) => trayMenu.runAction(trayTemplate(win), id) + }); + refreshTray(); + // Menüfenster kurz nach dem Start im Hintergrund vorladen, damit der erste Rechtsklick sofort reagiert. + setTimeout(() => { try { trayPopup.warmUp(); } catch (e) { console.warn('[Tray] Vorladen fehlgeschlagen:', e.message); } }, 2000); + tray.on('right-click', () => trayPopup.show()); + tray.on('click', () => showMainWindow(win)); + tray.on('double-click', () => showMainWindow(win)); } function createWindow() { @@ -988,11 +1067,23 @@ function createWindow() { const startHidden = process.argv.includes('--hidden'); + // Startanimation: drehendes Logo, danach baut sich das Fenster von der Mitte aus auf. Das Hauptfenster + // bleibt bis dahin verborgen. Beim versteckten Start (Windows-Autostart im Tray) gibt es sie nie. + const useSplash = !startHidden && appPrefs.get().showSplash; + + // Start- und Hauptfenster bekommen dieselbe Position und Größe. Das Startfenster entsteht zuerst, + // damit das Logo so früh wie möglich erscheint. + let splashHandle = null; + let windowBounds = { width: 1200, height: 820 }; + if (useSplash) { + windowBounds = startAnimation.centeredBounds(screen.getPrimaryDisplay().workArea, 1200, 820); + splashHandle = startAnimation.begin({ htmlPath: ppath.join(__dirname, 'renderer', 'splash.html'), bounds: windowBounds }); + } + const win = new BrowserWindow({ - width: 1200, - height: 820, + ...windowBounds, frame: false, - show: !startHidden, + show: !startHidden && !useSplash, webPreferences: { preload: ppath.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -1027,6 +1118,14 @@ function createWindow() { }); win.loadFile(ppath.join(__dirname, 'renderer', 'index.html')); + if (splashHandle) { + // Die Animation zeigt das Hauptfenster selbst, sobald die Oberfläche geladen ist (oder das Laden scheitert). + const uiReady = new Promise((resolve) => { + win.webContents.once('did-finish-load', resolve); + win.webContents.once('did-fail-load', resolve); + }); + startAnimation.play(win, uiReady, splashHandle); + } win.webContents.on('did-finish-load', () => { try { win.webContents.setZoomFactor(appPrefs.get().zoom / 100); } catch (_) {} }); @@ -1047,6 +1146,9 @@ function createWindow() { win.hide(); } }); + mainWin = win; + // Echtes Schließen: das versteckte Tray-Menüfenster mitschließen, sonst bliebe die App im Hintergrund. + win.on('closed', () => { try { if (trayPopup) trayPopup.destroy(); } catch (_) {} }); } app.whenReady().then(() => { @@ -1069,6 +1171,7 @@ app.whenReady().then(() => { }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { + try { if (trayPopup) trayPopup.destroy(); } catch (_) {} if (retryQueueTimer) { clearInterval(retryQueueTimer); retryQueueTimer = null; @@ -1264,6 +1367,7 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = async function worker() { while (true) { if (signal && signal.aborted) return; + try { await transferGate.wait(signal); } catch (_) { return; } const i = index++; if (i >= tasks.length) return; try { @@ -1292,8 +1396,11 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo, Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge). ----------------------------- */ -const UPLOAD_HISTORY_FILE = ppath.join(app.getPath('userData'), 'upload-history.json'); -const UPLOAD_HISTORY_MAX = 100; +const uploadHistory = require('./src/features/uploadHistory.js').createUploadHistory({ + file: ppath.join(app.getPath('userData'), 'upload-history.json'), + max: 100 +}); +const { buildEntry: buildUploadHistoryEntry, filterEntries: filterUploadHistory, toCsv: uploadHistoryToCsv } = require('./src/features/uploadHistory.js'); const UPLOAD_HISTORY_CHANNELS = { 'upload-paths-to-gitea': 'Drag & Drop', 'upload-local-folder-to-gitea': 'Ordner', @@ -1301,41 +1408,27 @@ const UPLOAD_HISTORY_CHANNELS = { 'upload-and-push': 'Ordner/Datei' }; -function readUploadHistory() { - try { - const parsed = JSON.parse(fs.readFileSync(UPLOAD_HISTORY_FILE, 'utf8')); - return Array.isArray(parsed) ? parsed : []; - } catch (_) { - return []; - } -} +// Windows-Meldung zu einem beendeten Upload, wenn die App nicht im Vordergrund ist +// (im Tray versteckt, minimiert oder von einem anderen Fenster verdeckt). +const transferNotice = require('./src/features/transferNotice.js'); +let mainWin = null; -function recordUploadHistory(entry) { +function maybeNotifyTransfer(entry) { try { - const history = [entry, ...readUploadHistory()].slice(0, UPLOAD_HISTORY_MAX); - fs.writeFileSync(UPLOAD_HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8'); + if (appPrefs.get().notifyTransfers === 'never') return; + const win = mainWin; + const inFront = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused(); + if (inFront) return; + const notice = transferNotice.describeTransfer(entry); + if (!notice || !Notification.isSupported()) return; + const n = new Notification({ title: notice.title, body: notice.body, icon: ppath.join(__dirname, 'assets', 'icon.png') }); + n.on('click', () => showMainWindow(win)); + n.show(); } catch (e) { - console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message); + console.warn('Benachrichtigung:', e.message); } } -function buildUploadHistoryEntry(kind, data, res, startedAt, thrown) { - const results = res && Array.isArray(res.results) ? res.results : null; - let status = 'ok'; - if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler'; - const error = thrown ? String(thrown.message || thrown) : (res && !res.ok && !res.cancelled ? String(res.error || '') : ''); - return { - at: new Date().toISOString(), - kind, - repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '', - files: results ? results.length : (res && res.singleFile ? 1 : null), - failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0, - status, - error: error.slice(0, 300), - durationMs: Date.now() - startedAt - }; -} - const rawIpcHandle = ipcMain.handle.bind(ipcMain); ipcMain.handle = (channel, handler) => { const kind = UPLOAD_HISTORY_CHANNELS[channel]; @@ -1346,17 +1439,40 @@ ipcMain.handle = (channel, handler) => { try { res = await handler(event, data); } catch (e) { - recordUploadHistory(buildUploadHistoryEntry(kind, data, null, startedAt, e)); + const failedEntry = buildUploadHistoryEntry(kind, data, null, startedAt, e); + uploadHistory.record(failedEntry); + maybeNotifyTransfer(failedEntry); throw e; } - recordUploadHistory(buildUploadHistoryEntry(kind, data, res, startedAt, null)); + const entry = buildUploadHistoryEntry(kind, data, res, startedAt, null); + uploadHistory.record(entry); + maybeNotifyTransfer(entry); return res; }); }; -ipcMain.handle('get-upload-history', () => ({ ok: true, history: readUploadHistory() })); +ipcMain.handle('get-upload-history', (_event, data) => ({ ok: true, history: filterUploadHistory(uploadHistory.read(), data || {}) })); +// CSV-Export des (gefilterten) Verlaufs, für deutsches Excel (Semikolon, UTF-8 mit BOM). +ipcMain.handle('export-upload-history-csv', async (event, data) => { + try { + const entries = filterUploadHistory(uploadHistory.read(), data || {}); + if (entries.length === 0) return { ok: false, error: 'Keine Einträge zum Exportieren.' }; + const win = BrowserWindow.fromWebContents(event.sender); + const stamp = new Date().toISOString().slice(0, 10); + const save = await dialog.showSaveDialog(win, { + defaultPath: ppath.join(app.getPath('documents'), `upload-verlauf-${stamp}.csv`), + filters: [{ name: 'CSV-Datei', extensions: ['csv'] }] + }); + if (save.canceled || !save.filePath) return { ok: false, canceled: true }; + fs.writeFileSync(save.filePath, uploadHistoryToCsv(entries), 'utf8'); + return { ok: true, savedTo: save.filePath, count: entries.length }; + } catch (e) { + return { ok: false, error: String(e && e.message || e) }; + } +}); + ipcMain.handle('clear-upload-history', () => { - try { fs.writeFileSync(UPLOAD_HISTORY_FILE, '[]', 'utf8'); return { ok: true }; } + try { uploadHistory.clear(); return { ok: true }; } catch (e) { return { ok: false, error: String(e && e.message || e) }; } }); @@ -2653,6 +2769,11 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { let stat; try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; } if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; } + const blockedReason = blockedSecretReason(localFile); + if (blockedReason) { + results.push({ file: localFile, ok: false, blocked: true, error: `Gesperrt (Zugangsdaten): ${blockedReason}` }); + continue; + } validFiles.push(localFile); } @@ -2696,6 +2817,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { } throwIfUploadCancelled(signal); + await transferGate.wait(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -2886,16 +3008,45 @@ function sumFileSizes(items) { // (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf). const activeTransferAborts = new Set(); +// Pause/Fortsetzen: Übertragungen halten an den Kontrollpunkten an (zwischen Paketen, Dateien und +// Git-Schritten), solange pausiert ist. Ein laufender Clone oder Push wird erst fertig. +const transferGate = require('./src/features/pauseGate.js').createPauseGate({ + onChange: (paused) => { + for (const w of BrowserWindow.getAllWindows()) { + try { if (!w.isDestroyed()) w.webContents.send('transfer-pause-state', { paused }); } catch (_) {} + } + refreshTray(); + } +}); + function beginCancellableUpload() { + // Eine vergessene Pause eines früheren Vorgangs darf den neuen nicht blockieren. + if (activeTransferAborts.size === 0) transferGate.resume(); const controller = new AbortController(); activeTransferAborts.add(controller); + refreshTray(); return controller; } function endCancellableUpload(controller) { activeTransferAborts.delete(controller); + if (activeTransferAborts.size === 0) transferGate.resume(); + refreshTray(); } +ipcMain.handle('pause-upload', () => { + if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' }; + transferGate.pause(); + return { ok: true, paused: true }; +}); + +ipcMain.handle('resume-upload', () => { + transferGate.resume(); + return { ok: true, paused: false }; +}); + +ipcMain.handle('get-pause-state', () => ({ ok: true, paused: transferGate.isPaused() })); + ipcMain.handle('cancel-upload', () => { if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' }; for (const controller of activeTransferAborts) controller.abort(); @@ -2926,8 +3077,13 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { // Exclude-Liste: .git, node_modules etc. überspringen const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; + // Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt den Ordner neu einzulesen. + const retryItems = Array.isArray(data.items) + ? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile)) + : null; const isIgnored = await createUploadIgnore(localFolder); - (function walk(dir) { + if (retryItems && retryItems.length) items.push(...retryItems); + else (function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { if (excludeList.includes(entry)) continue; @@ -2950,8 +3106,11 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { } })(localFolder); + // Dateien mit Zugangsdaten bleiben außen vor. + const blockedFiles = removeBlockedInPlace(items); + const total = items.length; - if (total === 0) return { ok: true, results: [] }; + if (total === 0) return { ok: true, results: [], blockedFiles }; const totalBytes = sumFileSizes(items); // Git-basierter Upload: alle Dateien in einem einzigen Commit. @@ -3001,6 +3160,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { // git add + commit + push throwIfUploadCancelled(signal); + await transferGate.wait(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -3036,7 +3196,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000); const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath })); - return { ok: true, results: successResults, failedCount: 0 }; + return { ok: true, results: successResults, failedCount: 0, blockedFiles }; } catch (gitErr) { console.error('[FolderUpload] Git-Fehler:', String(gitErr)); @@ -3064,16 +3224,21 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { }); if (!apiResult.ok) { - throw new Error(describeApiFallbackFailure(gitErr, apiResult)); + throw buildPartialUploadError(gitErr, apiResult, items); } const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath })); - return { ok: true, results: successResults, failedCount: 0, viaApi: true }; + return { ok: true, results: successResults, failedCount: 0, viaApi: true, blockedFiles }; } } catch (e) { if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' }; console.error('upload-local-folder-to-gitea error', e); - return { ok: false, error: String(e) }; + return { + ok: false, + error: String(e), + retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined, + uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined + }; } finally { endCancellableUpload(uploadAbort); } @@ -3142,6 +3307,21 @@ async function createUploadIgnore(target, cache = new Map()) { const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; +// Feste Sperre für Dateien mit Zugangsdaten (Einstellung "Zugangsdaten nie hochladen"). +const uploadBlocklist = require('./src/features/uploadBlocklist.js'); + +// Grund, warum eine Datei nicht hochgeladen werden darf, oder null. +function blockedSecretReason(filePath) { + return uploadBlocklist.blockedReason(filePath, appPrefs.get()); +} + +// Entfernt gesperrte Dateien aus der Liste (an Ort und Stelle) und liefert sie zurück. +function removeBlockedInPlace(items) { + const { allowed, blocked } = uploadBlocklist.partitionBlocked(items, appPrefs.get()); + items.splice(0, items.length, ...allowed); + return blocked; +} + // Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste // { localFile, targetPath } um. Dateien -> destPath/, Ordner -> // destPath// (identisch zum bisherigen Verhalten). @@ -3183,7 +3363,8 @@ async function buildItemsFromPaths(paths, destPath) { skipped.push({ path: p, error: 'unsupported' }); } } - return { items, skipped, ignored }; + const blocked = removeBlockedInPlace(items); + return { items, skipped, ignored, blocked }; } // Klont das Repo (shallow), kopiert alle items hinein, commit + push. @@ -3218,6 +3399,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes if (onProgress) onProgress(60); throwIfUploadCancelled(signal); + await transferGate.wait(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -3254,12 +3436,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes }); if (!apiResult.ok) { - // Die fehlgeschlagenen Dateien mitgeben, damit der Renderer nur diese wiederholen kann. - const failedPaths = new Set((apiResult.failed || []).map(f => f.path)); - const err = new Error(describeApiFallbackFailure(gitErr, apiResult)); - err.failedItems = items.filter(it => failedPaths.has(it.targetPath)); - err.uploadedCount = (apiResult.uploaded || []).length; - throw err; + throw buildPartialUploadError(gitErr, apiResult, items); } return { hasChanges: apiResult.uploaded.length > 0, viaApi: true }; @@ -3296,13 +3473,22 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => { try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {} const built = retryItems && retryItems.length - ? { items: retryItems, skipped: [], ignored: [] } + ? { items: retryItems, skipped: [], ignored: [], blocked: removeBlockedInPlace(retryItems) } : await buildItemsFromPaths(paths, destPath); const skipped = built.skipped; const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []); const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items; if (items.length === 0) { - return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId }; + const onlyBlocked = Array.isArray(built.blocked) && built.blocked.length > 0; + return { + ok: false, + error: onlyBlocked + ? 'Es blieb nichts zum Hochladen übrig: Alle Dateien enthalten vermutlich Zugangsdaten und sind gesperrt.' + : 'Keine Dateien zum Hochladen gefunden', + skipped, + blockedFiles: built.blocked, + debugId: uploadDebugId + }; } const total = items.length; @@ -3322,7 +3508,7 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => { console.log('[UploadPaths] done', { uploadDebugId, total }); const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath })); - return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId }; + return { ok: true, results, failedCount: 0, skipped, blockedFiles: built.blocked, debugId: uploadDebugId }; } catch (e) { if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId }; console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) }); @@ -3643,6 +3829,15 @@ ipcMain.handle('upload-and-push', async (event, data) => { // --- FALL 1: Einzelne Datei --- if (isFile) { + const singleBlocked = blockedSecretReason(data.localFolder); + if (singleBlocked) { + return { + ok: false, + error: `Gesperrt: „${ppath.basename(data.localFolder)}“ enthält vermutlich Zugangsdaten (${singleBlocked}). Die Sperre lässt sich unter Einstellungen → Uploads abschalten.`, + blockedFiles: [{ path: data.localFolder, reason: singleBlocked }], + debugId: uploadDebugId + }; + } console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId }); const raw = fs.readFileSync(data.localFolder); const base64 = raw.toString('base64'); @@ -3754,6 +3949,8 @@ ipcMain.handle('upload-and-push', async (event, data) => { const folderName = ppath.basename(data.localFolder); // Dateien/Ordner, die niemals hochgeladen werden sollen const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; + // Dateien mit Zugangsdaten, die der Git-Weg beim Kopieren ausgelassen hat + const blockedInCopy = []; // === PRIMÄRWEG: git push ========================================= // Ein einziger komprimierter Transfer + genau EIN Commit statt @@ -3794,6 +3991,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { destDirInRepo = ppath.join(destDirInRepo, folderName); ensureDir(destDirInRepo); + await transferGate.wait(signal); // Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen const isIgnoredLocal = await createUploadIgnore(data.localFolder); fs.cpSync(data.localFolder, destDirInRepo, { @@ -3802,6 +4000,10 @@ ipcMain.handle('upload-and-push', async (event, data) => { if (excludeList.includes(ppath.basename(src))) return false; let isDir = false; try { isDir = fs.statSync(src).isDirectory(); } catch (_) {} + if (!isDir) { + const reason = blockedSecretReason(src); + if (reason) { blockedInCopy.push({ path: src, reason }); return false; } + } return !isIgnoredLocal(src, isDir); } }); @@ -3814,7 +4016,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { if (!/nothing to commit/i.test(String(commitErr))) throw commitErr; console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId }); setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); - return { ok: true, usedGit: true, results: [], debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' }; + return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' }; } let pushBranch = branch; @@ -3828,7 +4030,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch }); setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); - return { ok: true, usedGit: true, results: [], debugId: uploadDebugId }; + return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId }; } catch (gitErr) { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} throw gitErr; @@ -3874,9 +4076,12 @@ ipcMain.handle('upload-and-push', async (event, data) => { } })(data.localFolder); + // Dateien mit Zugangsdaten bleiben außen vor. + const blockedFiles = removeBlockedInPlace(items); + const total = items.length; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; - if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId }; + if (total === 0) return { ok: true, usedGit: false, results: [], blockedFiles, debugId: uploadDebugId }; console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath }); @@ -3902,7 +4107,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal); const failedCount = uploadResults.filter(r => !r.ok).length; console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length }); - return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount }; + return { ok: failedCount === 0, usedGit: false, results: uploadResults, blockedFiles, debugId: uploadDebugId, failedCount }; } catch (e) { if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId }; console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) }); @@ -3966,6 +4171,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { let cancelled = false; for (let i = 0; i < repos.length; i++) { if (signal.aborted) { cancelled = true; break; } + try { await transferGate.wait(signal); } catch (e) { if (isUploadCancelledError(e)) { cancelled = true; break; } throw e; } const item = repos[i]; const progressBase = { index: i + 1, total: repos.length, repo: item.id, action }; sendProgress({ ...progressBase, status: 'running' }); @@ -4405,6 +4611,69 @@ ipcMain.handle('get-release', async (event, data) => { }); // Create a new release +// Release für den Updater: Datei prüfen und Release vorbereiten (Tag, Prüfsummen im Text) +const releaseAssist = require('./src/features/releaseAssist.js'); + +function sha256OfFile(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +// Liefert Name, Größe, SHA-256 und Version einer Release-Datei und gleicht sie mit ".sha256" ab. +ipcMain.handle('inspect-release-file', async (_event, data) => { + try { + const filePath = String((data && data.filePath) || ''); + const stat = fs.statSync(filePath); + if (!stat.isFile()) return { ok: false, error: 'Das ist keine Datei.' }; + const name = ppath.basename(filePath); + const sha256 = await sha256OfFile(filePath); + const sidecarPath = `${filePath}.sha256`; + const hasSidecar = fs.existsSync(sidecarPath); + const sidecarSha256 = hasSidecar ? releaseAssist.parseSidecar(fs.readFileSync(sidecarPath, 'utf8')) : null; + return { + ok: true, + name, + size: stat.size, + sha256, + version: releaseAssist.parseVersionFromFileName(name), + sidecarPath: hasSidecar ? sidecarPath : null, + sidecarSha256, + sidecarMatches: sidecarSha256 ? sidecarSha256 === sha256 : null + }; + } catch (e) { + return { ok: false, error: String(e && e.message || e) }; + } +}); + +// Prüft den Tag gegen die vorhandenen Releases und baut den Release-Text mit den Prüfsummen. +ipcMain.handle('prepare-release', async (_event, data) => { + try { + const credentials = readCredentials(); + if (!credentials) return { ok: false, error: 'no-credentials' }; + let releases; + try { + if (data.platform === 'github') { + if (!credentials.githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + releases = await listGithubReleases({ token: credentials.githubToken, owner: data.owner, repo: data.repo }); + } else { + releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo }); + } + } catch (e) { + return { ok: false, error: `Vorhandene Releases konnten nicht geprüft werden: ${String(e && e.message || e)}` }; + } + const check = releaseAssist.checkNewTag(data.tag, releases); + if (!check.ok) return { ok: false, error: check.reason }; + return { ok: true, body: releaseAssist.buildReleaseBody(data.notes, data.assets), newest: check.newest }; + } catch (e) { + return { ok: false, error: String(e && e.message || e) }; + } +}); + ipcMain.handle('create-release', async (event, data) => { try { const credentials = readCredentials(); diff --git a/package-lock.json b/package-lock.json index a98f360..bc7270f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-manager-gui", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-manager-gui", - "version": "2.2.0", + "version": "2.2.1", "dependencies": { "archiver": "^6.0.0", "axios": "^1.13.5", diff --git a/package.json b/package.json index 01c5a20..7555bc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-manager-gui", - "version": "2.2.0", + "version": "2.2.1", "description": "Git Manager GUI - Verwaltung von Git Repositories", "author": "M_Viper", "main": "main.js", @@ -8,7 +8,8 @@ "start": "electron .", "build": "electron-builder --win dir && node scripts/build-setup.js", "test": "node --test __tests__/*.test.js", - "pdf": "electron generate-pdf.js" + "pdf": "electron generate-pdf.js", + "test:ui": "node scripts/ui-smoke.js" }, "dependencies": { "archiver": "^6.0.0", diff --git a/preload.js b/preload.js index 2dc9c77..11d3fa5 100644 --- a/preload.js +++ b/preload.js @@ -40,8 +40,17 @@ contextBridge.exposeInMainWorld('electronAPI', { uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data), uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data), cancelUpload: () => ipcRenderer.invoke('cancel-upload'), - getUploadHistory: () => ipcRenderer.invoke('get-upload-history'), + pauseUpload: () => ipcRenderer.invoke('pause-upload'), + resumeUpload: () => ipcRenderer.invoke('resume-upload'), + getPauseState: () => ipcRenderer.invoke('get-pause-state'), + onTransferPauseState: (cb) => { + const listener = (_event, payload) => cb(payload || {}); + ipcRenderer.on('transfer-pause-state', listener); + return () => ipcRenderer.removeListener('transfer-pause-state', listener); + }, + getUploadHistory: (data) => ipcRenderer.invoke('get-upload-history', data), clearUploadHistory: () => ipcRenderer.invoke('clear-upload-history'), + exportUploadHistoryCsv: (data) => ipcRenderer.invoke('export-upload-history-csv', data), getGitignoreTemplates: () => ipcRenderer.invoke('get-gitignore-templates'), buildGitignore: (data) => ipcRenderer.invoke('build-gitignore', data), @@ -96,6 +105,8 @@ contextBridge.exposeInMainWorld('electronAPI', { editRelease: (data) => ipcRenderer.invoke('edit-release', data), deleteRelease: (data) => ipcRenderer.invoke('delete-release', data), uploadReleaseAsset: (data) => ipcRenderer.invoke('upload-release-asset', data), + inspectReleaseFile: (data) => ipcRenderer.invoke('inspect-release-file', data), + prepareRelease: (data) => ipcRenderer.invoke('prepare-release', data), deleteReleaseAsset: (data) => ipcRenderer.invoke('delete-release-asset', data), downloadReleaseArchive: (data) => ipcRenderer.invoke('download-release-archive', data), @@ -186,6 +197,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('repo-backup-progress', listener); return () => ipcRenderer.removeListener('repo-backup-progress', listener); }, + // Tray-Menü → "Upload-Verlauf": Renderer soll den Verlauf-Dialog öffnen. + onOpenUploadHistory: (cb) => { + const listener = () => cb(); + ipcRenderer.on('open-upload-history', listener); + return () => ipcRenderer.removeListener('open-upload-history', listener); + }, onFolderUploadProgress: (cb) => { const listener = (event, payload) => { try { cb(payload); } catch (_) {} }; ipcRenderer.on('folder-upload-progress', listener); diff --git a/renderer/features.js b/renderer/features.js index 3c380e4..925ec8e 100644 --- a/renderer/features.js +++ b/renderer/features.js @@ -121,7 +121,8 @@ const relLocal = (p) => (baseDir && p.startsWith(baseDir) ? p.slice(baseDir.length + 1) : p).split('\\').join('/'); // Einstellung "Upload-Vorschau: nur bei Auffälligkeiten" – ohne Zugangsdaten und ohne Dateiflut direkt hochladen - if (res.previewMode === 'warnings' && secrets.length === 0 && files.length > 0 && files.length <= 1000) { + const blocked = res.blocked || []; + if (res.previewMode === 'warnings' && secrets.length === 0 && blocked.length === 0 && files.length > 0 && files.length <= 1000) { return { proceed: true, excludeFiles: [] }; } @@ -142,6 +143,21 @@ m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: 'Es bleibt keine Datei zum Hochladen übrig.' })); } + if (blocked.length) { + const list = el('div', { class: 'fx-list' }); + for (const b of blocked.slice(0, 25)) { + list.append(el('div', { class: 'fx-list-row' }, [ + el('span', { class: 'fx-mono', text: b.targetPath || b.path }), + el('span', { class: 'fx-muted', text: b.reason }) + ])); + } + m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [ + el('strong', { text: `🛡️ ${blocked.length} Datei(en) gesperrt und werden nicht hochgeladen` }), + el('div', { text: 'Dateien mit Zugangsdaten werden nie hochgeladen. Die Sperre lässt sich unter Einstellungen → Uploads abschalten.' }), + list + ])); + } + const secretChecks = []; if (secrets.length) { const list = el('div', { class: 'fx-list' }); diff --git a/renderer/index.html b/renderer/index.html index 569ab5d..7fb7efc 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -95,7 +95,7 @@
Werkzeuge - +
@@ -394,6 +394,16 @@ +
@@ -403,6 +413,13 @@
+
+ + +
@@ -471,6 +488,16 @@ +
diff --git a/renderer/renderer.js b/renderer/renderer.js index 230009e..296a887 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -862,6 +862,46 @@ function showChipContextMenu(ev, entry, type) { setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10); } +// Teilfehler eines Uploads: fragt nach und sendet nur die fehlgeschlagenen Dateien erneut +// (höchstens 3 Wiederholungen). `retryCall(failedItems)` führt den Upload erneut aus. +async function retryFailedUploads(res, retryCall) { + for (let attempt = 0; attempt < 3; attempt++) { + const failedItems = !res?.ok && !res?.cancelled && Array.isArray(res?.retryItems) ? res.retryItems : []; + if (failedItems.length === 0) break; + showProgress(100, 'Upload teilweise fehlgeschlagen'); + const uploadedInfo = res.uploadedCount ? `${res.uploadedCount} Datei(en) wurden hochgeladen, ` : ''; + const again = await showActionConfirmModal({ + title: 'Upload teilweise fehlgeschlagen', + message: `${uploadedInfo}${failedItems.length} Datei(en) nicht.\n\n${res.error || ''}\n\nNur die fehlgeschlagenen Dateien erneut senden?`, + confirmText: 'Nur Fehlgeschlagene wiederholen', + danger: true + }); + if (!again) break; + showProgress(15, `Wiederhole ${failedItems.length} Datei(en) ...`, { cancellable: true }); + res = await retryCall(failedItems); + } + return res; +} + +// Hinweis, wenn die feste Sperre Dateien mit Zugangsdaten vom Upload ausgenommen hat. +function warnBlockedFiles(res) { + const blocked = Array.isArray(res?.blockedFiles) ? res.blockedFiles : []; + if (blocked.length === 0) return; + const names = blocked.slice(0, 3).map(b => ppathBasename(b.path)).join(', '); + showWarning(`🛡️ ${blocked.length} Datei(en) mit Zugangsdaten wurden nicht hochgeladen: ${names}${blocked.length > 3 ? ' …' : ''}`); +} + +// Ordner-Upload mit Wiederholung der fehlgeschlagenen Dateien. +async function uploadFolderWithRetry(params) { + const res = await window.electronAPI.uploadLocalFolderToGitea(params); + warnBlockedFiles(res); + return retryFailedUploads(res, (failedItems) => window.electronAPI.uploadLocalFolderToGitea({ + ...params, + items: failedItems, + messagePrefix: `${params.messagePrefix || 'Upload folder via GUI'} (Wiederholung)` + })); +} + async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl = null, branch = 'HEAD' }) { try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:start', { owner, repo, destPath, branch, pathCount: Array.isArray(paths) ? paths.length : 0 }); } catch (_) {} console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:start', { @@ -913,30 +953,16 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl excludeFiles, messagePrefix: 'Upload via GUI' }), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout + warnBlockedFiles(res); - // Teilfehler: nur die fehlgeschlagenen Dateien erneut senden (höchstens 3 Wiederholungen). - for (let attempt = 0; attempt < 3; attempt++) { - const failedItems = !res?.ok && !res?.cancelled && Array.isArray(res?.retryItems) ? res.retryItems : []; - if (failedItems.length === 0) break; - showProgress(100, 'Upload teilweise fehlgeschlagen'); - const uploadedInfo = res.uploadedCount ? `${res.uploadedCount} Datei(en) wurden hochgeladen, ` : ''; - const again = await showActionConfirmModal({ - title: 'Upload teilweise fehlgeschlagen', - message: `${uploadedInfo}${failedItems.length} Datei(en) nicht.\n\n${res.error || ''}\n\nNur die fehlgeschlagenen Dateien erneut senden?`, - confirmText: 'Nur Fehlgeschlagene wiederholen', - danger: true - }); - if (!again) break; - showProgress(15, `Wiederhole ${failedItems.length} Datei(en) ...`, { cancellable: true }); - res = await withTimeout(window.electronAPI.uploadPathsToGitea({ - items: failedItems, - owner, - repo, - destPath, - branch, - messagePrefix: 'Upload via GUI (Wiederholung)' - }), 900000, 'upload-paths-to-gitea'); - } + res = await retryFailedUploads(res, (failedItems) => withTimeout(window.electronAPI.uploadPathsToGitea({ + items: failedItems, + owner, + repo, + destPath, + branch, + messagePrefix: 'Upload via GUI (Wiederholung)' + }), 900000, 'upload-paths-to-gitea')); if (res?.cancelled) { hideProgress(); @@ -2712,7 +2738,24 @@ function ensureProgressUI() { cancel.textContent = 'Wird abgebrochen …'; try { await window.electronAPI.cancelUpload(); } catch (_) {} }); - container.appendChild(cancel); + // Pause-Knopf: hält die Übertragung an den nächsten Kontrollpunkten an und setzt sie fort. + const pause = document.createElement('button'); + pause.id = 'folderProgressPause'; + pause.textContent = PAUSE_LABEL; + pause.style.cssText = cancel.style.cssText; + pause.addEventListener('click', async () => { + pause.disabled = true; + try { + if (transferPaused) await window.electronAPI.resumeUpload(); + else await window.electronAPI.pauseUpload(); + } catch (_) {} + pause.disabled = false; + }); + + const buttonRow = document.createElement('div'); + buttonRow.style.cssText = 'display:flex;gap:8px;'; + buttonRow.append(pause, cancel); + container.appendChild(buttonRow); // Esc bricht die laufende Übertragung ab, solange kein Dialog offen ist. document.addEventListener('keydown', (e) => { @@ -2726,6 +2769,22 @@ function ensureProgressUI() { } const CANCEL_LABEL = 'Abbrechen (Esc)'; +const PAUSE_LABEL = 'Pausieren'; +const RESUME_LABEL = 'Fortsetzen'; +const PAUSED_SUFFIX = ' · ⏸ pausiert'; +let transferPaused = false; + +// Zeigt den Pausen-Zustand im Fortschrittsbalken (Knopfbeschriftung und Hinweis im Text). +function applyPauseState(paused) { + transferPaused = !!paused; + const pause = $('folderProgressPause'); + const txt = $('folderProgressText'); + if (pause) pause.textContent = transferPaused ? RESUME_LABEL : PAUSE_LABEL; + if (txt) { + const base = txt.innerText.replace(PAUSED_SUFFIX, ''); + txt.innerText = transferPaused ? base + PAUSED_SUFFIX : base; + } +} function formatTransferBytes(bytes) { if (!(bytes > 0)) return '0 KB'; @@ -2768,8 +2827,10 @@ function showProgress(percent, text, { cancellable = false } = {}) { const bar = $('folderProgressBar'); const txt = $('folderProgressText'); const cancel = $('folderProgressCancel'); - if (txt) txt.innerText = text || ''; + const pause = $('folderProgressPause'); + if (txt) txt.innerText = (text || '') + (transferPaused && cancellable ? PAUSED_SUFFIX : ''); if (bar) bar.style.width = `${Math.min(100, Math.max(0, percent))}%`; + if (pause) pause.style.display = cancellable ? 'inline-block' : 'none'; if (cancel) { cancel.style.display = cancellable ? 'inline-block' : 'none'; // Ein neuer Upload startet wieder mit aktivem Knopf; bei laufendem Abbruch bleibt er gesperrt. @@ -2786,6 +2847,10 @@ function hideProgress() { // Knopf für den nächsten Vorgang zurücksetzen (nach einem Abbruch ist er gesperrt). const cancel = $('folderProgressCancel'); if (cancel) { cancel.disabled = false; cancel.textContent = CANCEL_LABEL; cancel.style.display = 'none'; } + const pause = $('folderProgressPause'); + if (pause) { pause.disabled = false; pause.style.display = 'none'; } + transferPaused = false; + if (pause) pause.textContent = PAUSE_LABEL; }, 500); } } @@ -4922,7 +4987,7 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate = const sel = await window.electronAPI.selectFolder(); if (sel) { showProgress(0, 'Upload...', { cancellable: true }); - const res = await window.electronAPI.uploadLocalFolderToGitea({ + const res = await uploadFolderWithRetry({ localFolder: sel, owner, repo: repoName, @@ -5618,30 +5683,148 @@ async function createGitignoreForRepo(owner, repo) { } } -// Zeigt die zuletzt aufgezeichneten Uploads; der Bestätigen-Knopf leert den Verlauf. +// Zeigt die zuletzt aufgezeichneten Uploads, filterbar nach Ergebnis und Repo-Name. +// Ein Klick auf einen Eintrag öffnet das Repo, „CSV exportieren“ speichert die gefilterte Liste. async function showUploadHistory() { - const res = await window.electronAPI.getUploadHistory(); - const history = (res && res.history) || []; - if (history.length === 0) { - await showInfoModal('Upload-Verlauf', 'Noch keine Uploads aufgezeichnet.'); - return; - } const icons = { ok: '✅', abgebrochen: '⏹️', fehler: '❌' }; - const lines = history.slice(0, 25).map((e) => { - const when = new Date(e.at).toLocaleString('de-DE'); - const files = e.files != null ? `${e.files} Datei(en)` : '–'; - const failed = e.failed ? `, ${e.failed} fehlgeschlagen` : ''; - const took = e.durationMs ? ` · ${formatRemaining(e.durationMs / 1000)}` : ''; - const err = e.error ? `\n ${e.error}` : ''; - return `${icons[e.status] || '•'} ${when} · ${e.repo || '?'} · ${files}${failed} · ${e.kind}${took}${err}`; - }); - if (history.length > 25) lines.push(`… und ${history.length - 25} ältere Einträge`); - const clear = await showActionConfirmModal({ - title: 'Upload-Verlauf', - message: lines.join('\n'), - confirmText: 'Verlauf leeren' - }); - if (clear) await window.electronAPI.clearUploadHistory(); + + const modal = document.createElement('div'); + modal.className = 'modal confirm-modal'; + modal.style.zIndex = '99999'; + const card = document.createElement('div'); + card.className = 'modalContent card confirm-modal-content'; + card.style.cssText = 'width:min(760px,92vw);max-width:92vw;'; + + const titleEl = document.createElement('h2'); + titleEl.className = 'confirm-modal-title'; + titleEl.textContent = '🕘 Upload-Verlauf'; + + const filterRow = document.createElement('div'); + filterRow.style.cssText = 'display:flex;gap:8px;margin:10px 0;flex-wrap:wrap;'; + const statusSel = document.createElement('select'); + statusSel.style.cssText = 'width:auto;flex:0 0 160px;'; + for (const [value, label] of [['alle', 'Alle'], ['ok', 'Erfolgreich'], ['abgebrochen', 'Abgebrochen'], ['fehler', 'Fehler']]) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + statusSel.appendChild(opt); + } + const search = document.createElement('input'); + search.type = 'search'; + search.placeholder = 'Repo suchen …'; + search.style.cssText = 'width:auto;flex:1;min-width:160px;'; + filterRow.appendChild(statusSel); + filterRow.appendChild(search); + + const listEl = document.createElement('div'); + listEl.className = 'confirm-modal-message'; + listEl.style.cssText = 'max-height:50vh;overflow:auto;font-size:12px;line-height:1.6;'; + + const hint = document.createElement('div'); + hint.style.cssText = 'font-size:11px;opacity:0.7;margin-top:6px;'; + hint.textContent = 'Ein Klick auf einen Eintrag öffnet das Repo.'; + + const btnWrap = document.createElement('div'); + btnWrap.className = 'modal-buttons confirm-modal-buttons'; + const closeBtn = document.createElement('button'); + closeBtn.className = 'confirm-modal-btn confirm-modal-btn--primary'; + closeBtn.textContent = 'Schließen'; + const exportBtn = document.createElement('button'); + exportBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary'; + exportBtn.textContent = 'CSV exportieren'; + const clearBtn = document.createElement('button'); + clearBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary'; + clearBtn.textContent = 'Verlauf leeren'; + btnWrap.append(closeBtn, exportBtn, clearBtn); + + card.append(titleEl, filterRow, listEl, hint, btnWrap); + modal.appendChild(card); + document.body.appendChild(modal); + + const currentFilter = () => ({ status: statusSel.value, query: search.value }); + + const render = async () => { + const res = await window.electronAPI.getUploadHistory(currentFilter()); + const history = (res && res.history) || []; + listEl.textContent = ''; + exportBtn.disabled = history.length === 0; + if (history.length === 0) { + listEl.textContent = statusSel.value === 'alle' && !search.value.trim() + ? 'Noch keine Uploads aufgezeichnet.' + : 'Keine Einträge für diesen Filter.'; + return; + } + for (const e of history.slice(0, 40)) { + const row = document.createElement('div'); + row.className = 'upload-history-row'; + const [owner, repo] = String(e.repo || '').split('/'); + const canOpen = !!(owner && repo); + if (canOpen) row.title = `${e.repo} öffnen`; + else row.style.cursor = 'default'; + + const when = new Date(e.at).toLocaleString('de-DE'); + const files = e.files != null ? `${e.files} Datei(en)` : '–'; + const failed = e.failed ? `, ${e.failed} fehlgeschlagen` : ''; + const took = e.durationMs ? ` · ${formatRemaining(e.durationMs / 1000)}` : ''; + const main = document.createElement('div'); + main.textContent = `${icons[e.status] || '•'} ${when} · ${e.repo || '?'} · ${files}${failed} · ${e.kind}${took}`; + row.appendChild(main); + if (e.error) { + const err = document.createElement('div'); + err.className = 'upload-history-err'; + err.textContent = e.error; + row.appendChild(err); + } + if (canOpen) { + row.addEventListener('click', () => { + close(); + loadRepoContents(owner, repo, ''); + }); + } + listEl.appendChild(row); + } + if (history.length > 40) { + const more = document.createElement('div'); + more.style.cssText = 'opacity:0.7;padding:6px 8px;'; + more.textContent = `… und ${history.length - 40} ältere Einträge (im CSV-Export enthalten)`; + listEl.appendChild(more); + } + }; + + const onKey = (e) => { if (e.key === 'Escape') close(); }; + function close() { + document.removeEventListener('keydown', onKey); + modal.remove(); + } + document.addEventListener('keydown', onKey); + closeBtn.onclick = close; + + exportBtn.onclick = async () => { + exportBtn.disabled = true; + try { + const res = await window.electronAPI.exportUploadHistoryCsv(currentFilter()); + if (res && res.ok) showSuccess(`Verlauf exportiert: ${res.count} Einträge → ${res.savedTo}`); + else if (res && !res.canceled) showError('Export fehlgeschlagen: ' + (res.error || 'Unbekannter Fehler')); + } finally { + exportBtn.disabled = false; + } + }; + + clearBtn.onclick = async () => { + const ok = await showActionConfirmModal({ + title: 'Verlauf leeren', + message: 'Alle aufgezeichneten Uploads werden gelöscht.', + confirmText: 'Leeren', + danger: true + }); + if (!ok) return; + await window.electronAPI.clearUploadHistory(); + render(); + }; + statusSel.onchange = render; + let searchTimer = null; + search.oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(render, 150); }; + render(); } async function cleanupGitignoredFiles(owner, repo, repoPath) { @@ -5875,7 +6058,7 @@ function setupBackgroundContextMenu() { showProgress(0, `Lade "${clipboard.item.name}" hoch...`, { cancellable: true }); try { const res = clipboard.item.isDirectory - ? await window.electronAPI.uploadLocalFolderToGitea({ + ? await uploadFolderWithRetry({ localFolder: clipboard.item.path, owner, repo, @@ -5890,6 +6073,7 @@ function setupBackgroundContextMenu() { destPath: currentPath, branch: getDefaultBranch(owner, repo) }); + warnBlockedFiles(res); if (res?.cancelled) { setStatus('Upload abgebrochen'); return; } if (!res?.ok) throw new Error(res?.error || 'Upload fehlgeschlagen'); showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`); @@ -6918,6 +7102,16 @@ window.addEventListener('DOMContentLoaded', async () => { } }); + // Pause/Fortsetzen (auch vom Tray-Menü ausgelöst) + if (window.electronAPI.onTransferPauseState) { + window.electronAPI.onTransferPauseState(({ paused }) => applyPauseState(paused)); + } + + // Tray-Menü "Upload-Verlauf" + if (window.electronAPI.onOpenUploadHistory) { + window.electronAPI.onOpenUploadHistory(() => { showUploadHistory(); }); + } + // Progress listeners window.electronAPI.onFolderUploadProgress(p => { showProgress(p.percent, describeTransferProgress('Upload', p), { cancellable: true }); @@ -7438,6 +7632,12 @@ function showCreateReleaseModal(owner, repo) { assetGroup.appendChild(assetRow); card.appendChild(assetGroup); + // Ergebnis der Prüfung des gewählten Installers (Prüfsumme, Abgleich mit der .sha256-Datei) + const assetInfoBox = document.createElement('div'); + assetInfoBox.id = 'releaseAssetInfo'; + assetInfoBox.style.cssText = 'display:none;margin:-4px 0 12px;padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.5;border:1px solid rgba(255,255,255,0.12);'; + card.appendChild(assetInfoBox); + const optionsGroup = document.createElement('div'); optionsGroup.className = 'input-group'; optionsGroup.style.cssText = 'display: flex; gap: 20px;'; @@ -7480,17 +7680,67 @@ function showCreateReleaseModal(owner, repo) { // Variable zum Speichern des gewählten Dateipfads let selectedAssetPath = null; + // Prüfergebnis zum gewählten Asset (Name, SHA-256, Version, .sha256-Datei) oder null + let assetInfo = null; + + const showAssetInfo = (kind, lines) => { + const colors = { + ok: ['rgba(74,222,128,0.12)', 'rgba(74,222,128,0.4)'], + warn: ['rgba(251,191,36,0.12)', 'rgba(251,191,36,0.4)'], + error: ['rgba(248,113,113,0.14)', 'rgba(248,113,113,0.5)'] + }[kind]; + assetInfoBox.style.display = 'block'; + assetInfoBox.style.background = colors[0]; + assetInfoBox.style.borderColor = colors[1]; + assetInfoBox.textContent = ''; + for (const line of lines) { + const div = document.createElement('div'); + div.textContent = line; + assetInfoBox.appendChild(div); + } + }; + + // Prüft die gewählte Datei (Prüfsumme, Version, .sha256-Datei) und füllt Tag und Name vor. + const applyAsset = async (filePath) => { + selectedAssetPath = filePath; + assetInfo = null; + const fileName = filePath.split(/[\\/]/).pop(); + $('releaseAssetInput').value = fileName; + $('releaseAssetInput').style.color = 'var(--text-primary)'; + $('releaseAssetInput').style.borderColor = 'var(--accent-primary)'; + showAssetInfo('warn', ['Prüfe Datei und berechne SHA-256 …']); + + const info = await window.electronAPI.inspectReleaseFile({ filePath }); + if (!info || !info.ok) { + showAssetInfo('error', [`Datei konnte nicht geprüft werden: ${info?.error || 'Unbekannter Fehler'}`]); + return; + } + assetInfo = info; + + if (info.version) { + if (!$('releaseTag').value.trim()) $('releaseTag').value = `v${info.version}`; + if (!$('releaseName').value.trim()) $('releaseName').value = `Version ${info.version}`; + } + + const lines = [`${info.name} · ${formatTransferBytes(info.size)}`, `SHA-256: ${info.sha256}`]; + if (info.sidecarMatches === true) { + lines.push('✓ Stimmt mit der Datei .sha256 überein. Beide werden hochgeladen.'); + showAssetInfo('ok', lines); + } else if (info.sidecarMatches === false) { + lines.push(`✗ Die Datei .sha256 nennt eine andere Prüfsumme (${info.sidecarSha256}). Der Installer wurde nach dem Bauen verändert oder die Prüfsumme gehört zu einer anderen Version. Das Release wird so nicht erstellt.`); + showAssetInfo('error', lines); + } else { + lines.push('Keine .sha256-Datei neben dem Installer gefunden. Die Prüfsumme wird nur in den Release-Text eingetragen.'); + showAssetInfo('warn', lines); + } + }; // Event Listener: Datei auswählen $('btnSelectReleaseAsset').onclick = async () => { try { const res = await window.electronAPI.selectFile(); if (res.ok && res.files && res.files.length > 0) { - selectedAssetPath = res.files[0]; - const fileName = selectedAssetPath.split(/[\\/]/).pop(); - $('releaseAssetInput').value = fileName; - $('releaseAssetInput').style.color = 'var(--text-primary)'; - $('releaseAssetInput').style.borderColor = 'var(--accent-primary)'; + await applyAsset(res.files[0]); } } catch (error) { console.error('Fehler beim Auswählen der Datei:', error); @@ -7511,7 +7761,30 @@ function showCreateReleaseModal(owner, repo) { await showInfoModal('Eingabe fehlt', 'Tag Version ist erforderlich!'); return; } - + + // Mit gewähltem Installer: Prüfsumme abgleichen, Tag gegen vorhandene Releases prüfen und die + // Prüfsumme in den Release-Text schreiben, damit der Updater das Release findet und verifiziert. + let releaseBody = body; + if (assetInfo) { + if (assetInfo.sidecarMatches === false) { + await showInfoModal('Prüfsumme stimmt nicht', 'Die Datei .sha256 passt nicht zum Installer. Bitte neu bauen (npm run build) und den Installer erneut wählen.', true); + return; + } + const prep = await window.electronAPI.prepareRelease({ + owner, + repo, + tag, + notes: body, + assets: [{ name: assetInfo.name, sha256: assetInfo.sha256 }], + platform: currentState.platform + }); + if (!prep || !prep.ok) { + await showInfoModal('Release nicht möglich', prep?.error || 'Unbekannter Fehler', true); + return; + } + releaseBody = prep.body; + } + setStatus('Creating release...'); try { @@ -7521,7 +7794,7 @@ function showCreateReleaseModal(owner, repo) { repo, tag_name: tag, name, - body, + body: releaseBody, target_commitish: target, prerelease, draft, @@ -7545,6 +7818,20 @@ function showCreateReleaseModal(owner, repo) { }); if (uploadRes.ok) { + // Prüfsummen-Datei neben den Installer legen, so sucht der Updater sie. + if (assetInfo && assetInfo.sidecarPath) { + showProgress(80, 'Lade Prüfsummen-Datei hoch…'); + const sidecarRes = await window.electronAPI.uploadReleaseAsset({ + owner, + repo, + releaseId: res.release.id, + filePath: assetInfo.sidecarPath, + fileName: assetInfo.sidecarPath.split(/[\\/]/).pop() + }); + if (!sidecarRes || !sidecarRes.ok) { + showWarning('Release erstellt, aber die .sha256-Datei konnte nicht hochgeladen werden. Die Prüfsumme steht im Release-Text.'); + } + } setStatus(`Release "${tag}" und Asset erstellt!`); } else { console.error('Asset Upload fehlgeschlagen:', uploadRes.error); diff --git a/renderer/settings-extra.js b/renderer/settings-extra.js index ba296c6..ef48dd8 100644 --- a/renderer/settings-extra.js +++ b/renderer/settings-extra.js @@ -38,10 +38,13 @@ set('prefZoom', String(p.zoom)); if ($('prefZoom') && !$('prefZoom').value) $('prefZoom').value = '100'; set('prefAutoUpdate', p.autoUpdateCheck); + set('prefShowSplash', p.showSplash); set('prefCloseAction', p.closeAction); set('prefCloneDir', p.defaultCloneDir); set('prefSecretScan', p.uploadSecretScan); + set('prefBlockSecrets', p.uploadBlockSecrets); set('prefUploadPreview', p.uploadPreview); + set('prefNotifyTransfers', p.notifyTransfers); set('prefUploadExcludes', p.uploadExtraExcludes); } @@ -54,10 +57,13 @@ theme: val('prefTheme'), zoom: Number(val('prefZoom')), autoUpdateCheck: chk('prefAutoUpdate'), + showSplash: chk('prefShowSplash'), closeAction: val('prefCloseAction'), defaultCloneDir: val('prefCloneDir'), uploadSecretScan: chk('prefSecretScan'), + uploadBlockSecrets: chk('prefBlockSecrets'), uploadPreview: val('prefUploadPreview'), + notifyTransfers: val('prefNotifyTransfers'), uploadExtraExcludes: val('prefUploadExcludes') }; } diff --git a/tray-menu-preload.js b/tray-menu-preload.js new file mode 100644 index 0000000..7aadcf3 --- /dev/null +++ b/tray-menu-preload.js @@ -0,0 +1,9 @@ +// Preload für das Tray-Menüfenster (renderer/tray-menu.html): nur die vier nötigen Kanäle. +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('trayApi', { + onState: (cb) => ipcRenderer.on('tray-menu-state', (_event, state) => cb(state)), + action: (id) => ipcRenderer.send('tray-menu:action', id), + resize: (height) => ipcRenderer.send('tray-menu:resize', height), + close: () => ipcRenderer.send('tray-menu:close') +});