// src/features/featuresIpc.js // IPC-Handler für: Upload-Vorschau mit Geheimnis-Warnung, Server-Diagnose, // lokale Änderungen (Status/Diff/Commit), Pull Requests & Issues und Repo-Backup. const fs = require('fs'); const os = require('os'); const path = require('path'); const dns = require('dns').promises; const net = require('net'); const http = require('http'); const https = require('https'); const { spawn } = require('child_process'); const axios = require('axios'); const { normalizeAndValidateBaseUrl, uploadItemsViaGiteaApi } = require('../git/apiHandler.js'); const { scanForSecrets } = require('./secretScan.js'); const { createRepoBackup, authHeaderArgs } = require('../backup/GiteaRepoBackup.js'); const { commitIdentity } = require('../git/gitBinary.js'); const { createRepoRestore } = require('../backup/GiteaRepoRestore.js'); const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 }; const api = axios.create({ httpAgent: new http.Agent(agentOptions), httpsAgent: new https.Agent(agentOptions), timeout: 30000, validateStatus: () => true }); const enc = encodeURIComponent; const repoApiPath = (owner, repo) => `/repos/${enc(owner)}/${enc(repo)}`; function giteaContext(readCredentials) { const c = readCredentials() || {}; if (!c.giteaURL || !c.giteaToken) throw new Error('Gitea-Zugangsdaten fehlen. Bitte in den Einstellungen eintragen.'); const base = normalizeAndValidateBaseUrl(c.giteaURL); return { base, apiBase: `${base}/api/v1`, token: c.giteaToken, headers: { Authorization: `token ${c.giteaToken}` } }; } async function giteaCall(ctx, method, apiPath, { params, data, timeout } = {}) { const res = await api.request({ method, url: ctx.apiBase + apiPath, params, data, headers: ctx.headers, timeout }); if (res.status >= 200 && res.status < 300) return res.data; const err = new Error((res.data && res.data.message) || `HTTP ${res.status}`); err.status = res.status; throw err; } // Git mit Zeitlimit, ohne den Hauptprozess zu blockieren. function runGit(args, { cwd, timeoutMs = 60000 } = {}) { return new Promise((resolve) => { let child; try { child = spawn('git', args, { cwd, windowsHide: true, env: process.env }); } catch (e) { resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) }); return; } const out = []; const err = []; let timedOut = false; const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch (_) {} }, timeoutMs); child.stdout.on('data', c => out.push(c)); child.stderr.on('data', c => err.push(c)); child.on('error', e => err.push(Buffer.from(String(e && e.message || e)))); child.on('close', (code) => { clearTimeout(timer); resolve({ status: timedOut ? -2 : code, timedOut, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }); }); child.stdin.end(); }); } // Token nie in Fehlermeldungen oder Logs durchreichen. function redact(text, token) { let s = String(text || ''); if (token) s = s.split(token).join('***'); return s.replace(/\/\/[^/@\s]+@/g, '//***@'); } function isPackFailure(stderr) { return /early eof|index-pack|bad pack header|unexpected disconnect|remote end hung up|rpc failed/i.test(String(stderr || '')); } /* ---------------------------------------------------------------- Upload-Vorschau ---------------------------------------------------------------- */ async function previewUpload({ paths, destPath }, deps) { const { items, skipped, ignored, blocked } = await deps.buildItemsFromPaths(paths, destPath); const files = []; let totalBytes = 0; for (const item of items) { let size = 0; try { size = (await fs.promises.stat(item.localFile)).size; } catch (_) {} totalBytes += size; files.push({ localFile: item.localFile, targetPath: item.targetPath, size }); } const prefs = deps.prefs ? deps.prefs.get() : { uploadSecretScan: true, uploadPreview: 'always' }; // Geheimnis-Erkennung lässt sich unter Einstellungen → Uploads abschalten const secrets = prefs.uploadSecretScan ? await scanForSecrets(files) : new Map(); for (const f of files) { const reason = secrets.get(f.localFile); if (reason) f.secret = reason; } const ignoredEntries = [ ...skipped.filter(s => s.error === 'gitignore').map(s => ({ path: s.path, isDir: isDirSafe(s.path) })), ...(ignored || []) ]; return { ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size, blocked: blocked || [], previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan }; } function isDirSafe(p) { try { return fs.statSync(p).isDirectory(); } catch (_) { return false; } } /* ---------------------------------------------------------------- Server-Diagnose ---------------------------------------------------------------- */ const SERVER_HINT_COMMANDS = 'docker logs --tail 300 gitea 2>&1 | grep -iE "pack|upload-pack|error|killed"\ndf -h\nfree -m'; async function probeFamily(ctx, family) { const lib = ctx.base.startsWith('https:') ? https : http; const agent = new lib.Agent({ family, keepAlive: false }); const started = Date.now(); try { const res = await axios.get(`${ctx.apiBase}/version`, { httpAgent: agent, httpsAgent: agent, timeout: 8000, validateStatus: () => true }); return { ok: res.status < 500, ms: Date.now() - started, status: res.status }; } catch (e) { return { ok: false, error: e.code || e.message }; } finally { agent.destroy(); } } async function runDiagnosis(data, deps) { const checks = []; const add = (label, status, detail, hint = null) => checks.push({ label, status, detail, hint }); let ctx; try { ctx = giteaContext(deps.readCredentials); add('Zugangsdaten', 'ok', `Server: ${ctx.base}`); } catch (e) { add('Zugangsdaten', 'fail', e.message, 'In den Einstellungen Gitea-URL und Token eintragen.'); return { ok: true, checks }; } // Namensauflösung und Erreichbarkeit pro Adressfamilie const host = new URL(ctx.base).hostname.replace(/^\[|\]$/g, ''); let families = []; if (net.isIP(host)) { families = [net.isIP(host)]; add('Namensauflösung', 'ok', `IP-Adresse direkt angegeben (IPv${families[0]})`); } else { try { const addrs = await dns.lookup(host, { all: true }); const v4 = addrs.filter(a => a.family === 4).map(a => a.address); const v6 = addrs.filter(a => a.family === 6).map(a => a.address); if (v4.length) families.push(4); if (v6.length) families.push(6); add('Namensauflösung', 'ok', [v4.length ? `IPv4: ${v4.join(', ')}` : null, v6.length ? `IPv6: ${v6.join(', ')}` : null].filter(Boolean).join(' · ')); } catch (e) { add('Namensauflösung', 'fail', `${host} lässt sich nicht auflösen (${e.code || e.message})`, 'DNS-Eintrag (bei ipv64.net: DynDNS-Update) prüfen und ob der Name richtig geschrieben ist.'); return { ok: true, checks }; } } const reach = {}; for (const fam of families) reach[fam] = await probeFamily(ctx, fam); const working = families.filter(f => reach[f].ok); for (const fam of families) { const r = reach[fam]; if (r.ok) add(`Erreichbar über IPv${fam}`, 'ok', `Antwort in ${r.ms} ms`); else add(`Erreichbar über IPv${fam}`, working.length ? 'warn' : 'fail', `Keine Verbindung (${r.error || 'HTTP ' + r.status})`, working.length ? `Die App weicht automatisch auf IPv${working[0]} aus. Falls IPv${fam} gewollt ist: Firewall/Portfreigabe und ${fam === 6 ? 'AAAA' : 'A'}-Eintrag prüfen.` : 'Server läuft nicht oder Port/Firewall/Reverse-Proxy blockiert.'); } if (!working.length) return { ok: true, checks }; // API und Token let login = null; try { const v = await giteaCall(ctx, 'get', '/version'); add('Gitea-API', 'ok', `Gitea ${v && v.version ? v.version : '(Version unbekannt)'}`); } catch (e) { add('Gitea-API', 'fail', e.message, 'Gitea-URL prüfen, sie muss auf die Startseite von Gitea zeigen (ohne /api).'); return { ok: true, checks }; } try { const u = await giteaCall(ctx, 'get', '/user'); login = u.login; add('Token', 'ok', `Angemeldet als ${u.login}`); } catch (e) { add('Token', 'fail', e.status === 401 ? 'Token ungültig oder abgelaufen' : e.message, 'In Gitea unter Einstellungen → Anwendungen einen neuen Token erzeugen.'); return { ok: true, checks }; } // Git-Transport mit einem echten Repo testen let owner = data && data.owner; let repo = data && data.repo; if (!owner || !repo) { try { const repos = await giteaCall(ctx, 'get', '/user/repos', { params: { limit: 1 } }); if (repos && repos[0]) { owner = repos[0].owner.login; repo = repos[0].name; } } catch (_) {} } if (!owner || !repo) { add('Git-Transport', 'warn', 'Kein Repository zum Testen gefunden'); } else { const u = new URL(ctx.base); const authUrl = `${u.protocol}//${enc(ctx.token)}@${u.host}${u.pathname.replace(/\/$/, '')}/${owner}/${repo}.git`; const lr = await runGit(['ls-remote', '--heads', authUrl], { timeoutMs: 30000 }); if (lr.status !== 0) { add('Git-Anmeldung', 'fail', `ls-remote für ${owner}/${repo} fehlgeschlagen: ${redact(lr.stderr, ctx.token).trim().slice(0, 300)}`, 'Token braucht Lese- und Schreibrechte für Repositories. Reverse-Proxy muss /info/refs durchlassen.'); } else { add('Git-Anmeldung', 'ok', `${owner}/${repo} erreichbar`); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-diag-')); try { const started = Date.now(); let pk = await runGit(['clone', '--bare', '--depth', '1', '--filter=tree:0', authUrl, path.join(tmp, 'r.git')], { timeoutMs: 90000 }); if (pk.status !== 0 && /filter|not recognized|unsupported/i.test(pk.stderr) && !isPackFailure(pk.stderr)) { pk = await runGit(['clone', '--bare', '--depth', '1', authUrl, path.join(tmp, 'r2.git')], { timeoutMs: 90000 }); } if (pk.status === 0) { add('Git-Pakete', 'ok', `Server liefert Pakete aus (${Date.now() - started} ms)`); if (deps.clearGitTransportBlock && deps.clearGitTransportBlock(u.host)) { add('Schneller Git-Weg', 'ok', 'Sperre aufgehoben: Uploads und Löschungen laufen wieder über Git statt über die langsame API.'); } } else if (pk.timedOut) { add('Git-Pakete', 'fail', 'Zeitüberschreitung beim Abholen eines Pakets', 'Verbindung oder Server sehr langsam. Reverse-Proxy-Timeouts prüfen.'); } else if (isPackFailure(pk.stderr)) { add('Git-Pakete', 'fail', 'Der Server bricht beim Ausliefern von Git-Paketen ab (early EOF). Deshalb laufen Uploads und Löschungen über die langsame API.', 'Das liegt am Server. Häufige Ursachen: Datenträger voll, zu wenig RAM für git pack-objects, defektes Repo oder ein Proxy, der die Antwort abschneidet. Auf dem Server prüfen:\n' + SERVER_HINT_COMMANDS); } else { add('Git-Pakete', 'fail', redact(pk.stderr, ctx.token).trim().slice(0, 300)); } } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} } } } add('Speicherplatz & RAM', 'info', 'Lassen sich über die Gitea-API nicht abfragen.', 'Auf dem Server prüfen mit:\ndf -h\nfree -m'); return { ok: true, checks, login }; } /* ---------------------------------------------------------------- Lokale Änderungen ---------------------------------------------------------------- */ const STATUS_LABELS = { M: 'geändert', A: 'neu', D: 'gelöscht', R: 'umbenannt', C: 'kopiert', U: 'Konflikt', '?': 'neu (nicht verfolgt)', T: 'Typ geändert' }; function parsePorcelainZ(out) { const parts = out.split('\0'); const files = []; for (let i = 0; i < parts.length; i++) { const entry = parts[i]; if (!entry) continue; const x = entry[0]; const y = entry[1]; const file = entry.slice(3); let from = null; if (x === 'R' || x === 'C') { from = parts[i + 1]; i++; } const code = x === '?' ? '?' : (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) ? 'U' : (y !== ' ' ? y : x); files.push({ path: file, from, code, label: STATUS_LABELS[code] || code, staged: x !== ' ' && x !== '?' }); } return files; } async function localGit(data, deps = {}) { const folder = data && data.folder; if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'Ordner nicht gefunden' }; const top = await runGit(['-C', folder, 'rev-parse', '--show-toplevel']); if (top.status !== 0) return { ok: false, notRepo: true, error: 'Dieser Ordner ist kein Git-Repository.' }; const root = path.resolve(top.stdout.trim()); switch (data.action) { case 'status': { const st = await runGit(['-C', root, 'status', '--porcelain=v1', '-z', '-uall']); if (st.status !== 0) return { ok: false, error: st.stderr.trim() }; const br = await runGit(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']); const ahead = await runGit(['-C', root, 'rev-list', '--count', '@{u}..HEAD']); return { ok: true, root, branch: br.status === 0 ? br.stdout.trim() : '(noch kein Commit)', ahead: ahead.status === 0 ? Number(ahead.stdout.trim()) || 0 : null, files: parsePorcelainZ(st.stdout) }; } case 'diff': { const file = String(data.file || ''); if (!file) return { ok: false, error: 'Keine Datei' }; const abs = path.join(root, file); if (data.code === '?' || data.code === 'A') { try { const st = await fs.promises.stat(abs); if (st.size > 300 * 1024) return { ok: true, diff: `Neue Datei (${Math.round(st.size / 1024)} KB), zu groß für die Vorschau.` }; const buf = await fs.promises.readFile(abs); if (buf.subarray(0, 8000).includes(0)) return { ok: true, diff: 'Neue Binärdatei' }; return { ok: true, diff: buf.toString('utf8').split('\n').map(l => '+' + l).join('\n'), allNew: true }; } catch (e) { return { ok: false, error: e.message }; } } const d = await runGit(['-C', root, 'diff', 'HEAD', '--no-color', '--', file]); if (d.status !== 0) return { ok: false, error: d.stderr.trim() }; return { ok: true, diff: d.stdout.length > 400000 ? d.stdout.slice(0, 400000) + '\n… (gekürzt)' : (d.stdout || 'Keine inhaltlichen Unterschiede (z. B. nur Zeilenenden).') }; } case 'commit': { const files = Array.isArray(data.files) ? data.files.filter(Boolean) : []; const message = String(data.message || '').trim(); if (!files.length) return { ok: false, error: 'Keine Dateien ausgewählt' }; if (!message) return { ok: false, error: 'Commit-Nachricht fehlt' }; // Nur die ausgewählten Dateien in den Commit nehmen. await runGit(['-C', root, 'reset', '-q']); for (let i = 0; i < files.length; i += 100) { const r = await runGit(['-C', root, 'add', '-A', '--', ...files.slice(i, i + 100)]); if (r.status !== 0) return { ok: false, error: r.stderr.trim() }; } let c = await runGit(['-C', root, 'commit', '-m', message]); if (c.status !== 0 && /tell me who you are|user\.email|user\.name/i.test(c.stderr)) { const id = commitIdentity(deps.prefs && deps.prefs.get()); c = await runGit(['-C', root, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message]); } if (c.status !== 0) return { ok: false, error: (c.stderr || c.stdout).trim() }; return { ok: true, output: c.stdout.trim() }; } case 'push': { const p = await runGit(['-C', root, 'push', 'origin', 'HEAD'], { timeoutMs: 600000 }); if (p.status !== 0) { return { ok: false, error: redact(p.stderr || p.stdout, null).trim() || 'Push fehlgeschlagen', hint: isPackFailure(p.stderr) ? 'Der Server bricht die Übertragung ab. Server-Diagnose in den Einstellungen ausführen.' : null }; } return { ok: true, output: p.stderr.trim() }; } case 'discard': { const file = String(data.file || ''); if (!file) return { ok: false, error: 'Keine Datei' }; if (data.code === '?') { await fs.promises.rm(path.join(root, file), { force: true }); return { ok: true }; } const r = await runGit(['-C', root, 'checkout', 'HEAD', '--', file]); return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr.trim() }; } default: return { ok: false, error: 'Unbekannte Aktion' }; } } /* ---------------------------------------------------------------- Pull Requests & Issues ---------------------------------------------------------------- */ function mapUser(u) { return u ? { login: u.login, avatar: u.avatar_url } : null; } function mapIssue(i) { return { number: i.number, title: i.title, body: i.body || '', state: i.state, user: mapUser(i.user), comments: i.comments, created: i.created_at, updated: i.updated_at, labels: (i.labels || []).map(l => ({ name: l.name, color: l.color })), url: i.html_url, isPull: !!i.pull_request }; } function mapPull(p) { return { ...mapIssue(p), isPull: true, head: p.head && p.head.label, base: p.base && p.base.label, mergeable: p.mergeable, merged: !!p.merged }; } async function giteaCollab(data, deps) { const ctx = giteaContext(deps.readCredentials); const { owner, repo } = data || {}; if (!owner || !repo) return { ok: false, error: 'Repository fehlt' }; const rp = repoApiPath(owner, repo); const idx = Number(data.index); const state = ['open', 'closed', 'all'].includes(data.state) ? data.state : 'open'; const page = Math.max(1, Number(data.page) || 1); switch (data.action) { case 'listPulls': { const list = await giteaCall(ctx, 'get', `${rp}/pulls`, { params: { state, page, limit: 30, sort: 'recentupdate' } }); return { ok: true, items: (list || []).map(mapPull) }; } case 'listIssues': { const list = await giteaCall(ctx, 'get', `${rp}/issues`, { params: { state, page, limit: 30, type: 'issues' } }); return { ok: true, items: (list || []).map(mapIssue) }; } case 'get': { const item = data.isPull ? mapPull(await giteaCall(ctx, 'get', `${rp}/pulls/${idx}`)) : mapIssue(await giteaCall(ctx, 'get', `${rp}/issues/${idx}`)); const comments = await giteaCall(ctx, 'get', `${rp}/issues/${idx}/comments`); return { ok: true, item, comments: (comments || []).map(c => ({ id: c.id, body: c.body || '', user: mapUser(c.user), created: c.created_at })) }; } case 'comment': { const body = String(data.body || '').trim(); if (!body) return { ok: false, error: 'Kommentar ist leer' }; await giteaCall(ctx, 'post', `${rp}/issues/${idx}/comments`, { data: { body } }); return { ok: true }; } case 'setState': { if (!['open', 'closed'].includes(data.state)) return { ok: false, error: 'Ungültiger Status' }; await giteaCall(ctx, 'patch', `${rp}/issues/${idx}`, { data: { state: data.state } }); return { ok: true }; } case 'merge': { const style = ['merge', 'rebase', 'rebase-merge', 'squash'].includes(data.style) ? data.style : 'merge'; await giteaCall(ctx, 'post', `${rp}/pulls/${idx}/merge`, { data: { Do: style, delete_branch_after_merge: !!data.deleteBranch }, timeout: 120000 }); return { ok: true }; } case 'createIssue': { const title = String(data.title || '').trim(); if (!title) return { ok: false, error: 'Titel fehlt' }; const created = await giteaCall(ctx, 'post', `${rp}/issues`, { data: { title, body: String(data.body || '') } }); return { ok: true, item: mapIssue(created) }; } default: return { ok: false, error: 'Unbekannte Aktion' }; } } /* ---------------------------------------------------------------- Registrierung ---------------------------------------------------------------- */ function wrap(fn) { return async (event, data) => { try { return await fn(data || {}, event); } catch (e) { console.error('[features]', e && e.message ? e.message : e); return { ok: false, error: e && e.message ? e.message : String(e) }; } }; } function registerFeatureIpc(deps) { const { ipcMain } = deps; ipcMain.handle('preview-upload-paths', wrap((data) => previewUpload(data, deps))); ipcMain.handle('run-server-diagnosis', wrap((data) => runDiagnosis(data, deps))); ipcMain.handle('local-git', wrap((data) => localGit(data, deps))); // App-Einstellungen (ohne Geheimnisse) ipcMain.handle('prefs-get', wrap(() => ({ ok: true, prefs: deps.prefs ? deps.prefs.get() : {} }))); ipcMain.handle('prefs-set', wrap((data) => { if (!deps.prefs) return { ok: false, error: 'Einstellungen nicht verfügbar' }; const r = deps.prefs.set(data); return { ok: r.rejected.length === 0, prefs: r.prefs, rejected: r.rejected, error: r.rejected.includes('gitEmail') ? 'Die E-Mail-Adresse ist ungültig und wurde nicht übernommen.' : undefined }; })); // Gesperrter schneller Git-Weg (nach "early EOF") anzeigen und von Hand freigeben ipcMain.handle('git-transport-state', wrap(() => { const blocks = deps.getGitTransportBlocks ? deps.getGitTransportBlocks() : {}; return { ok: true, blocked: Object.entries(blocks).map(([host, since]) => ({ host, since: new Date(since).toISOString() })) }; })); ipcMain.handle('git-transport-clear', wrap((data) => { const host = String((data && data.host) || ''); return { ok: !!(deps.clearGitTransportBlock && deps.clearGitTransportBlock(host)) }; })); ipcMain.handle('gitea-collab', wrap((data) => giteaCollab(data, deps))); const backup = createRepoBackup({ configPath: path.join(deps.app.getPath('userData'), 'repo-backup.json'), getContext: () => giteaContext(deps.readCredentials), giteaCall, runGit, isPackFailure, redact }); ipcMain.handle('repo-backup-get-config', wrap(() => ({ ok: true, ...backup.getState() }))); ipcMain.handle('repo-backup-set-config', wrap((data) => ({ ok: true, ...backup.setConfig(data) }))); // Fortschritt an alle Fenster melden – auch bei einem geplanten Backup und für später geöffnete Ansichten backup.onProgress((p) => { if (deps.broadcast) deps.broadcast('repo-backup-progress', p); // Windows-Benachrichtigung am Ende – je nach Einstellung immer, nur bei Problemen oder nie if (!p || (p.phase !== 'done' && p.phase !== 'error') || !deps.notify) return; const mode = backup.getState().config.notify; const s = p.summary; const problem = p.phase === 'error' || (s && (s.failed > 0 || s.cancelled)); if (mode === 'never' || (mode === 'errors' && !problem)) return; if (p.phase === 'error') deps.notify('Repo-Backup fehlgeschlagen', p.error || 'Unbekannter Fehler'); else if (s.cancelled) deps.notify('Repo-Backup abgebrochen', `${s.ok} von ${s.total} Repos gesichert.`); else if (s.failed) deps.notify('Repo-Backup mit Fehlern', `${s.ok} von ${s.total} gesichert, ${s.failed} fehlgeschlagen. Details in den Einstellungen unter Backup.`); else deps.notify('Repo-Backup fertig', `${s.ok} von ${s.total} Repos gesichert.`); }); // Ordner im Explorer öffnen – nur feste, bekannte Ziele, keine beliebigen Pfade aus dem Renderer ipcMain.handle('open-known-folder', wrap(async (data) => { const targets = { backup: () => backup.getState().config.targetDir, appData: () => deps.app.getPath('userData'), logs: () => deps.app.getPath('logs') }; const resolve = targets[data && data.which]; const dir = resolve && resolve(); if (!dir) return { ok: false, error: 'Kein Ordner festgelegt.' }; if (!fs.existsSync(dir)) return { ok: false, error: `Ordner "${dir}" nicht gefunden.` }; const err = deps.openPath ? await deps.openPath(dir) : 'Öffnen nicht möglich'; return err ? { ok: false, error: err } : { ok: true, path: dir }; })); ipcMain.handle('repo-backup-run-now', wrap(() => backup.runNow(null))); ipcMain.handle('repo-backup-cancel', wrap(() => backup.cancel())); backup.startScheduler(); const restore = createRepoRestore({ getContext: () => giteaContext(deps.readCredentials), giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs, identity: () => commitIdentity(deps.prefs && deps.prefs.get()) }); ipcMain.handle('repo-backup-list', wrap((data) => { const dir = (data && data.dir) || backup.getState().config.targetDir; if (!dir) return { ok: false, error: 'Kein Backup-Ordner festgelegt.' }; if (!fs.existsSync(dir)) return { ok: false, error: `Backup-Ordner "${dir}" nicht gefunden.` }; return { ok: true, dir, backups: restore.listBackups(dir) }; })); ipcMain.handle('repo-restore-local', wrap((data) => restore.restoreLocal(data))); ipcMain.handle('repo-restore-gitea', wrap((data, event) => restore.restoreToGitea(data, (p) => { try { event.sender.send('repo-restore-progress', p); } catch (_) {} }))); return { backup, restore }; } module.exports = { registerFeatureIpc, parsePorcelainZ };