540 lines
25 KiB
JavaScript
540 lines
25 KiB
JavaScript
// 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 };
|