diff --git a/main.js b/main.js index 759696f..0b4b60a 100644 --- a/main.js +++ b/main.js @@ -28,9 +28,6 @@ function initUpdaterAsync() { setTimeout(() => { try { if (!Updater) Updater = require('./updater.js'); - if (Updater && !updater) { - updater = new Updater(); - } } catch (e) { console.warn('Updater init deferred error:', e.message); } @@ -64,6 +61,7 @@ const { updateGiteaRepoAvatar, updateGiteaRepoVisibility, updateGiteaRepoTopics, + updateGiteaRepoArchived, migrateRepoToGitea ,listGiteaTopicsCatalog, // GitHub @@ -87,6 +85,7 @@ const { updateGithubRepoVisibility, updateGithubRepoDefaultBranch, updateGithubRepoTopics, + updateGithubRepoArchived, deleteGithubRepo } = require('./src/git/apiHandler.js'); @@ -116,6 +115,8 @@ const RETRY_MAX_ATTEMPTS = 8; let retryQueue = []; let retryQueueRunning = false; let retryQueueTimer = null; +const DIAGNOSTIC_EVENT_LIMIT = 1000; +let diagnosticsEvents = []; let credentialReadIssueLogged = false; let lastCredentialReadStatus = { state: 'idle', @@ -143,6 +144,48 @@ function logCredentialIssueOnce(message) { console.warn(message); } +function normalizeDiagnosticDetails(details) { + if (details == null) return null; + if (details instanceof Error) { + return { + name: details.name, + message: details.message, + stack: details.stack + }; + } + try { + return JSON.parse(JSON.stringify(details)); + } catch (_) { + return { value: String(details) }; + } +} + +function recordDiagnosticEvent(level, source, message, details = null) { + const event = { + ts: new Date().toISOString(), + level: String(level || 'INFO').toUpperCase(), + source: String(source || 'main'), + message: String(message || ''), + details: normalizeDiagnosticDetails(details) + }; + diagnosticsEvents.push(event); + if (diagnosticsEvents.length > DIAGNOSTIC_EVENT_LIMIT) { + diagnosticsEvents.splice(0, diagnosticsEvents.length - DIAGNOSTIC_EVENT_LIMIT); + } +} + +process.on('warning', (warning) => { + recordDiagnosticEvent('WARN', 'process.warning', warning?.message || 'process warning', warning); +}); + +process.on('unhandledRejection', (reason) => { + recordDiagnosticEvent('ERROR', 'process.unhandledRejection', 'Unhandled promise rejection', reason); +}); + +process.on('uncaughtExceptionMonitor', (error) => { + recordDiagnosticEvent('ERROR', 'process.uncaughtExceptionMonitor', error?.message || 'Uncaught exception', error); +}); + function quarantineCredentialsFile(filePath, reason = 'invalid-format') { try { if (!fs.existsSync(filePath)) return null; @@ -892,8 +935,12 @@ ipcMain.on('renderer-debug-log', (_event, payload) => { const message = String(payload?.message || 'renderer-log'); const details = payload?.payload; logger[level === 'error' ? 'error' : (level === 'warn' ? 'warn' : 'info')]('renderer', message, details); + if (level === 'error' || level === 'warn') { + recordDiagnosticEvent(level.toUpperCase(), 'renderer', message, details); + } } catch (e) { logger.warn('renderer-debug-log', 'Logging failed', { error: e.message }); + recordDiagnosticEvent('WARN', 'renderer-debug-log', 'Logging failed', e); } }); @@ -1030,6 +1077,43 @@ ipcMain.handle('update-gitea-repo-topics', async (event, data) => { } }); +ipcMain.handle('update-repo-archived', async (event, data) => { + try { + const { owner, repo, archived, platform } = data || {}; + const creds = readCredentials(); + + if (platform === 'github') { + const githubToken = (data && data.token) || (creds && creds.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const updated = await updateGithubRepoArchived({ + token: githubToken, + owner, + repo, + archived: !!archived + }); + return { ok: true, repo: updated }; + } + + if (!creds?.giteaToken || !creds?.giteaURL) { + return { ok: false, error: 'Gitea Token oder URL fehlt.' }; + } + + const updated = await updateGiteaRepoArchived({ + token: creds.giteaToken, + url: creds.giteaURL, + owner, + repo, + archived: !!archived + }); + return { ok: true, repo: updated }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('update-repo-archived error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + ipcMain.handle('get-gitea-topics-catalog', async () => { try { const creds = readCredentials(); @@ -1521,7 +1605,6 @@ ipcMain.handle('deleteFile', async (event, data) => { const owner = data.owner; const repo = data.repo; const filePath = data.path; - const softDelete = data.softDelete !== false; if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; const urlObj = new URL(giteaUrl); @@ -1533,31 +1616,6 @@ ipcMain.handle('deleteFile', async (event, data) => { ? data.branch : ((data.ref && data.ref !== 'HEAD') ? data.ref : null); - async function resolveGiteaDefaultBranch() { - return new Promise((resolve) => { - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - method: 'GET', - headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } - }, (res) => { - let body = ''; - res.on('data', chunk => body += chunk); - res.on('end', () => { - try { - const parsed = JSON.parse(body || '{}'); - resolve(parsed.default_branch || null); - } catch (_) { - resolve(null); - } - }); - }); - req.on('error', () => resolve(null)); - req.end(); - }); - } - // Helper: GET contents from Gitea API function giteaGet(path) { return new Promise((resolve, reject) => { @@ -1580,7 +1638,7 @@ ipcMain.handle('deleteFile', async (event, data) => { } // Helper: DELETE a single file by path + sha - function giteaDeleteFile(filePath, sha) { + async function giteaDeleteFile(filePath, sha, retryCount = 0) { return new Promise((resolve) => { const payload = { message: `Delete ${filePath} via Git Manager GUI`, @@ -1597,12 +1655,20 @@ ipcMain.handle('deleteFile', async (event, data) => { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } - }, (res) => { + }, async (res) => { let respBody = ''; res.on('data', chunk => respBody += chunk); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true }); - else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${respBody}` }); + res.on('end', async () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ ok: true }); + } else if (res.statusCode === 409 && retryCount < 2) { + // Conflict: retry with backoff + await new Promise(r => setTimeout(r, 100 + retryCount * 100)); + const retry = await giteaDeleteFile(filePath, sha, retryCount + 1); + resolve(retry); + } else { + resolve({ ok: false, error: `HTTP ${res.statusCode}: ${respBody}` }); + } }); }); req.on('error', (e) => resolve({ ok: false, error: String(e) })); @@ -1625,89 +1691,29 @@ ipcMain.handle('deleteFile', async (event, data) => { if (item.type === 'dir') { const sub = await collectAllFiles(item.path); files.push(...sub); - } else if (item.type === 'file') { + } else if (item.sha) { + // Treat files, symlinks, submodules etc. all as deletable entries with a SHA files.push({ path: item.path, sha: item.sha }); } + // Items without SHA (e.g. empty placeholders) are silently skipped } } else if (contents && contents.type === 'file' && contents.sha) { // It's a single file files.push({ path: contents.path, sha: contents.sha }); + } else if (contents && contents.sha) { + // Symlink or other single entry with a SHA + files.push({ path: contents.path, sha: contents.sha }); } else if (contents && contents.type === 'dir' && contents.path) { // Defensive fallback: some endpoints can return dir objects. const sub = await collectAllFiles(contents.path); files.push(...sub); } else { - throw new Error(`Unbekannte Antwort: ${JSON.stringify(contents)}`); + const preview = JSON.stringify(contents).slice(0, 200); + throw new Error(`Unbekannte Antwort: ${preview}`); } return files; } - async function getFileContentBase64(path) { - const payload = await giteaGet(path); - if (payload && typeof payload.content === 'string') { - return payload.content.replace(/\n/g, ''); - } - const decoded = await getGiteaFileContent({ - token, - url: giteaUrl, - owner, - repo, - path, - ref: useBranch || 'HEAD' - }); - return Buffer.from(decoded || '', 'utf8').toString('base64'); - } - - function giteaWriteFile(path, method, bodyObj) { - return new Promise((resolve) => { - const body = JSON.stringify(bodyObj || {}); - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, - method, - headers: { - 'Authorization': `token ${token}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - }, (res) => { - let respBody = ''; - res.on('data', chunk => respBody += chunk); - res.on('end', () => { - resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: respBody }); - }); - }); - req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); - req.write(body); - req.end(); - }); - } - - async function upsertTrashFile(path, contentBase64, branchName) { - const existing = await giteaGet(path); - const existingSha = (existing && existing.sha) ? existing.sha : null; - const payload = { - message: `Soft-delete copy ${path}`, - content: contentBase64 - }; - if (branchName) payload.branch = branchName; - - if (existingSha) { - payload.sha = existingSha; - const putRes = await giteaWriteFile(path, 'PUT', payload); - if (!putRes.ok) throw new Error(`Trash-Update fehlgeschlagen (${putRes.statusCode}): ${putRes.body}`); - return; - } - - const postRes = await giteaWriteFile(path, 'POST', payload); - if (postRes.ok) return; - - // Fallback: einige Server akzeptieren nur PUT, selbst für neue Dateien. - const putRes = await giteaWriteFile(path, 'PUT', payload); - if (!putRes.ok) throw new Error(`Trash-Create fehlgeschlagen (POST ${postRes.statusCode} / PUT ${putRes.statusCode})`); - } - async function executeDeleteBatch(files) { const deleteOps = files.map(f => async () => { @@ -1716,7 +1722,8 @@ ipcMain.handle('deleteFile', async (event, data) => { } ); - const deleteResults = await runParallel(deleteOps, 4); + // Gitea: mit Concurrency=2 + Retry-Logik bei 409-Konflikten für bessere Performance + const deleteResults = await runParallel(deleteOps, 2); const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok)); return { deleteResults, failedEntries }; } @@ -1734,23 +1741,7 @@ ipcMain.handle('deleteFile', async (event, data) => { return { ok: true, deleted: 0 }; } - let trashRoot = null; - let softDeleteFailures = []; - if (softDelete) { - const effectiveBranch = useBranch || await resolveGiteaDefaultBranch() || 'main'; - trashRoot = `_trash/${new Date().toISOString().replace(/[.:]/g, '-')}`; - for (const file of filesToDelete) { - try { - const contentBase64 = await getFileContentBase64(file.path); - const trashPath = `${trashRoot}/${String(file.path).replace(/^\/+/, '')}`; - await upsertTrashFile(trashPath, contentBase64, effectiveBranch); - } catch (e) { - softDeleteFailures.push({ path: file.path, error: String(e) }); - } - } - } - - // First delete attempt + // Delete attempt let { failedEntries } = await executeDeleteBatch(filesToDelete); if (failedEntries.length > 0) { @@ -1787,16 +1778,7 @@ ipcMain.handle('deleteFile', async (event, data) => { logger.info('deleteFile', `Deleted ${filesToDelete.length} files successfully`); invalidateRepoContentsCache(owner, repo); - return { - ok: true, - deleted: filesToDelete.length, - softDeleted: softDelete, - trashRoot, - softDeleteFailed: softDeleteFailures.length, - softDeleteWarning: softDeleteFailures.length > 0 - ? `${softDeleteFailures.length} Dateien konnten nicht in den Papierkorb verschoben werden.` - : null - }; + return { ok: true, deleted: filesToDelete.length }; } // --- LOCAL DELETION --- @@ -1925,360 +1907,6 @@ ipcMain.handle('get-gitea-repo-contents', async (event, data) => { } }); -async function purgeGiteaTrashEntries({ token, url, owner, repo, ref = 'HEAD', olderThanDays = 7 }) { - const base = String(url || '').trim().replace(/\/+$/, ''); - if (!base || !/^https?:\/\//i.test(base)) throw new Error('Invalid Gitea base URL'); - - const cutoffMs = Number(olderThanDays) <= 0 - ? Number.POSITIVE_INFINITY - : Date.now() - (Number(olderThanDays) * 24 * 60 * 60 * 1000); - - function parseTrashTimestamp(ts) { - const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/); - if (!m) return NaN; - const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`; - return Date.parse(iso); - } - - async function walk(path, out) { - const res = await getGiteaRepoContents({ token, url: base, owner, repo, path, ref }); - const items = Array.isArray(res?.items) ? res.items : []; - for (const item of items) { - if (item.type === 'dir') { - await walk(item.path, out); - } else if (item.type === 'file') { - out.push(item); - } - } - } - - const root = await getGiteaRepoContents({ token, url: base, owner, repo, path: '_trash', ref }); - if (!root?.ok || !Array.isArray(root.items) || root.items.length === 0) { - return { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; - } - - const allFiles = []; - await walk('_trash', allFiles); - - const targets = allFiles.filter((f) => { - const m = String(f.path || '').match(/^_trash\/([^/]+)\//); - if (!m) return false; - const ts = parseTrashTimestamp(m[1]); - if (!Number.isFinite(ts)) return false; - return ts <= cutoffMs; - }); - - if (targets.length === 0) { - return { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; - } - - const urlObj = new URL(base); - const protocol = urlObj.protocol === 'https:' ? https : http; - const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); - const branchName = ref && ref !== 'HEAD' ? ref : null; - - function deleteFile(path, sha, withBranch = true) { - return new Promise((resolve) => { - const payload = { message: `Purge ${path} from trash`, sha }; - if (withBranch && branchName) payload.branch = branchName; - const body = JSON.stringify(payload); - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, - method: 'DELETE', - headers: { - 'Authorization': `token ${token}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - }, (res) => { - let resp = ''; - res.on('data', c => resp += c); - res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: resp })); - }); - req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); - req.write(body); - req.end(); - }); - } - - let failedFiles = 0; - for (const file of targets) { - let r = await deleteFile(file.path, file.sha, true); - if (!r.ok && branchName) r = await deleteFile(file.path, file.sha, false); - if (!r.ok) failedFiles++; - } - - const purgedFiles = targets.length - failedFiles; - const roots = new Set(targets.map(f => (String(f.path).match(/^_trash\/([^/]+)\//) || [])[1]).filter(Boolean)); - - return { - ok: failedFiles === 0, - purgedFiles, - purgedRoots: roots.size, - failedFiles - }; -} - -ipcMain.handle('list-gitea-trash', async (event, data) => { - try { - const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - const owner = data && data.owner; - const repo = data && data.repo; - const ref = data && data.ref ? data.ref : 'HEAD'; - const autoPurge = !data || data.autoPurge !== false; - const autoPurgeDays = (data && Number.isFinite(Number(data.autoPurgeDays))) ? Number(data.autoPurgeDays) : 7; - - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; - if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; - - let purgeSummary = { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; - if (autoPurge) { - purgeSummary = await purgeGiteaTrashEntries({ token, url, owner, repo, ref, olderThanDays: autoPurgeDays }); - } - - const trashRoot = '_trash'; - const entries = []; - - async function walk(path) { - const res = await getGiteaRepoContents({ token, url, owner, repo, path, ref }); - const items = Array.isArray(res?.items) ? res.items : []; - for (const item of items) { - if (item.type === 'dir') { - await walk(item.path); - continue; - } - if (item.type !== 'file') continue; - const m = String(item.path || '').match(/^_trash\/([^/]+)\/(.+)$/); - if (!m) continue; - entries.push({ - trashPath: item.path, - restoreTimestamp: m[1], - originalPath: m[2], - name: item.name, - size: item.size || 0 - }); - } - } - - const trashRes = await getGiteaRepoContents({ token, url, owner, repo, path: trashRoot, ref }); - if (!trashRes?.ok || !Array.isArray(trashRes.items) || trashRes.items.length === 0) { - return { ok: true, items: [] }; - } - - await walk(trashRoot); - entries.sort((a, b) => String(b.restoreTimestamp).localeCompare(String(a.restoreTimestamp))); - return { ok: true, items: entries, purgeSummary }; - } catch (e) { - console.error('list-gitea-trash error', e); - return { ok: false, error: String(e) }; - } -}); - -ipcMain.handle('purge-gitea-trash', async (event, data) => { - try { - const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - const owner = data && data.owner; - const repo = data && data.repo; - const ref = data && data.ref ? data.ref : 'HEAD'; - const olderThanDays = (data && Number.isFinite(Number(data.olderThanDays))) ? Number(data.olderThanDays) : 7; - - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; - if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; - - const result = await purgeGiteaTrashEntries({ token, url, owner, repo, ref, olderThanDays }); - invalidateRepoContentsCache(owner, repo); - return { ok: result.ok, ...result }; - } catch (e) { - console.error('purge-gitea-trash error', e); - return { ok: false, error: String(e) }; - } -}); - -ipcMain.handle('restore-gitea-trash-item', async (event, data) => { - try { - const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - const owner = data && data.owner; - const repo = data && data.repo; - const trashPath = data && data.trashPath; - const restorePath = data && data.restorePath; - const ref = data && data.ref ? data.ref : 'HEAD'; - - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; - if (!owner || !repo || !trashPath || !restorePath) return { ok: false, error: 'missing-restore-data' }; - - const urlObj = new URL(url); - const protocol = urlObj.protocol === 'https:' ? https : http; - const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); - - function giteaGet(path, refName = null) { - return new Promise((resolve, reject) => { - const refQuery = refName && refName !== 'HEAD' ? `?ref=${encodeURIComponent(refName)}` : ''; - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`, - method: 'GET', - headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } - }, (res) => { - let body = ''; - res.on('data', chunk => body += chunk); - res.on('end', () => { - try { resolve(JSON.parse(body || '{}')); } catch (e) { reject(e); } - }); - }); - req.on('error', reject); - req.end(); - }); - } - - function giteaWrite(path, method, payload) { - return new Promise((resolve) => { - const body = JSON.stringify(payload || {}); - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, - method, - headers: { - 'Authorization': `token ${token}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - }, (res) => { - let resp = ''; - res.on('data', c => resp += c); - res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: resp })); - }); - req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); - req.write(body); - req.end(); - }); - } - - async function resolveBranch() { - if (ref && ref !== 'HEAD') return ref; - try { - const req = await new Promise((resolve) => { - const r = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - method: 'GET', - headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } - }, (res) => { - let body = ''; - res.on('data', c => body += c); - res.on('end', () => { - try { resolve(JSON.parse(body || '{}')); } catch (_) { resolve({}); } - }); - }); - r.on('error', () => resolve({})); - r.end(); - }); - return req.default_branch || 'main'; - } catch (_) { - return 'main'; - } - } - - const sourcePayload = await giteaGet(trashPath, ref); - const sourceItem = sourcePayload && sourcePayload.type === 'file' ? sourcePayload : null; - if (!sourceItem || sourceItem.type !== 'file' || !sourceItem.sha) { - return { ok: false, error: 'trash-source-not-found' }; - } - - const contentBase64 = String(sourcePayload.content || '').replace(/\n/g, ''); - if (!contentBase64) return { ok: false, error: 'trash-source-content-empty' }; - - const branchName = await resolveBranch(); - const targetPayload = await giteaGet(restorePath, branchName); - const targetSha = targetPayload && targetPayload.sha ? targetPayload.sha : null; - const upsertPayload = { - message: `Restore ${restorePath} from trash`, - content: contentBase64, - branch: branchName - }; - if (targetSha) upsertPayload.sha = targetSha; - - let writeRes; - if (targetSha) { - writeRes = await giteaWrite(restorePath, 'PUT', upsertPayload); - } else { - writeRes = await giteaWrite(restorePath, 'POST', upsertPayload); - if (!writeRes.ok) { - writeRes = await giteaWrite(restorePath, 'PUT', upsertPayload); - } - } - if (!writeRes.ok) { - return { ok: false, error: `Restore-Schreiben fehlgeschlagen: HTTP ${writeRes.statusCode}` }; - } - - async function deleteTrashEntry(includeBranch) { - const payload = { - message: `Delete ${trashPath} after restore`, - sha: sourceItem.sha - }; - if (includeBranch && branchName) payload.branch = branchName; - const body = JSON.stringify(payload); - return await new Promise((resolve) => { - const req = protocol.request({ - hostname: urlObj.hostname, - port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${trashPath.split('/').map(encodeURIComponent).join('/')}`, - method: 'DELETE', - headers: { - 'Authorization': `token ${token}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - }, (res) => { - let resp = ''; - res.on('data', c => resp += c); - res.on('end', () => resolve({ statusCode: res.statusCode, body: resp })); - }); - req.on('error', (e) => resolve({ statusCode: 0, body: String(e) })); - req.write(body); - req.end(); - }); - } - - let deleteResult = await deleteTrashEntry(true); - if (!(deleteResult.statusCode >= 200 && deleteResult.statusCode < 300)) { - // Fallback for servers that reject branch on delete payload. - deleteResult = await deleteTrashEntry(false); - } - - invalidateRepoContentsCache(owner, repo); - if (!(deleteResult.statusCode >= 200 && deleteResult.statusCode < 300)) { - logger.warn('restore-gitea-trash-item', 'Restore succeeded but trash cleanup failed', { - owner, - repo, - trashPath, - statusCode: deleteResult.statusCode, - body: String(deleteResult.body || '') - }); - return { - ok: true, - restoredPath: restorePath, - warning: `Wiederhergestellt, aber Papierkorb-Eintrag konnte nicht entfernt werden (HTTP ${deleteResult.statusCode}).` - }; - } - - return { ok: true, restoredPath: restorePath }; - } catch (e) { - console.error('restore-gitea-trash-item error', e); - return { ok: false, error: String(e) }; - } -}); - ipcMain.handle('get-gitea-file-content', async (event, data) => { try { const credentials = readCredentials(); @@ -4067,6 +3695,42 @@ function getRecentFilePath() { return ppath.join(app.getPath('userData'), 'recent.json'); } +function safeReadJsonFile(filePath, fallbackValue) { + try { + if (!fs.existsSync(filePath)) return fallbackValue; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_) { + return fallbackValue; + } +} + +function createStampForFileName(date = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function getFileMeta(filePath) { + try { + if (!filePath || !fs.existsSync(filePath)) { + return { path: filePath || '', exists: false }; + } + const stat = fs.statSync(filePath); + return { + path: filePath, + exists: true, + size: stat.size, + mtime: stat.mtime ? stat.mtime.toISOString() : null, + ctime: stat.ctime ? stat.ctime.toISOString() : null + }; + } catch (e) { + return { + path: filePath || '', + exists: false, + error: String(e?.message || e) + }; + } +} + ipcMain.handle('load-favorites', async () => { try { const p = getFavoritesFilePath(); @@ -4106,6 +3770,290 @@ ipcMain.handle('save-recent', async (event, recent) => { } }); +ipcMain.handle('export-settings-bundle', async () => { + try { + const credentials = readCredentials() || {}; + const favorites = safeReadJsonFile(getFavoritesFilePath(), []); + const recent = safeReadJsonFile(getRecentFilePath(), []); + + const settingsWithoutSecrets = { + giteaURL: credentials.giteaURL || '', + avatarB64: credentials.avatarB64 || null, + featureFavorites: !!credentials.featureFavorites, + featureRecent: !!credentials.featureRecent, + compactMode: !!credentials.compactMode, + featureColoredIcons: credentials.featureColoredIcons !== false, + favCollapsedFavorites: !!credentials.favCollapsedFavorites, + favCollapsedRecent: !!credentials.favCollapsedRecent + }; + + const encryptedSecretsPayload = encryptLegacyCredentials(JSON.stringify({ + githubToken: credentials.githubToken || '', + giteaToken: credentials.giteaToken || '' + })).toString('base64'); + + const bundle = { + bundleType: 'git-manager-settings-backup', + schemaVersion: 1, + exportedAt: new Date().toISOString(), + appVersion: app.getVersion(), + platform: process.platform, + settings: settingsWithoutSecrets, + encryptedSecrets: { + algorithm: 'legacyAesCbc', + payloadBase64: encryptedSecretsPayload + }, + favorites: Array.isArray(favorites) ? favorites : [], + recent: Array.isArray(recent) ? recent : [] + }; + + const fileName = `git-manager-backup-${createStampForFileName()}.json`; + const result = await dialog.showSaveDialog({ + title: 'Backup exportieren', + defaultPath: ppath.join(app.getPath('documents'), fileName), + filters: [ + { name: 'JSON', extensions: ['json'] } + ] + }); + + if (result.canceled || !result.filePath) return { ok: false, canceled: true }; + + fs.writeFileSync(result.filePath, JSON.stringify(bundle, null, 2), 'utf8'); + logger.info('settings-backup', 'Backup exported', { filePath: result.filePath }); + return { ok: true, filePath: result.filePath }; + } catch (e) { + logger.error('settings-backup', 'Backup export failed', { error: e.message }); + return { ok: false, error: String(e?.message || e) }; + } +}); + +ipcMain.handle('import-settings-bundle', async () => { + try { + const result = await dialog.showOpenDialog({ + title: 'Backup importieren', + properties: ['openFile'], + filters: [ + { name: 'JSON', extensions: ['json'] } + ] + }); + + if (result.canceled || !result.filePaths || !result.filePaths[0]) return { ok: false, canceled: true }; + + const filePath = result.filePaths[0]; + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + + if (!parsed || parsed.bundleType !== 'git-manager-settings-backup') { + return { ok: false, error: 'Ungueltige Backup-Datei.' }; + } + + const existing = readCredentials() || {}; + const settings = parsed.settings && typeof parsed.settings === 'object' ? parsed.settings : {}; + + let importedSecrets = { githubToken: '', giteaToken: '' }; + const encryptedPayload = parsed?.encryptedSecrets?.payloadBase64; + if (encryptedPayload) { + try { + importedSecrets = decryptLegacyCredentials(Buffer.from(String(encryptedPayload), 'base64')); + } catch (_) { + return { ok: false, error: 'Backup konnte nicht entschluesselt werden.' }; + } + } + + const mergedCredentials = { + ...existing, + ...settings, + githubToken: String(importedSecrets.githubToken || existing.githubToken || ''), + giteaToken: String(importedSecrets.giteaToken || existing.giteaToken || '') + }; + + persistCredentials(mergedCredentials); + + if (Array.isArray(parsed.favorites)) { + fs.writeFileSync(getFavoritesFilePath(), JSON.stringify(parsed.favorites, null, 2), 'utf8'); + } + if (Array.isArray(parsed.recent)) { + fs.writeFileSync(getRecentFilePath(), JSON.stringify(parsed.recent.slice(0, 20), null, 2), 'utf8'); + } + + logger.info('settings-backup', 'Backup imported', { + filePath, + favorites: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0, + recent: Array.isArray(parsed.recent) ? parsed.recent.length : 0 + }); + + return { + ok: true, + filePath, + favoritesImported: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0, + recentImported: Array.isArray(parsed.recent) ? parsed.recent.length : 0 + }; + } catch (e) { + logger.error('settings-backup', 'Backup import failed', { error: e.message }); + return { ok: false, error: String(e?.message || e) }; + } +}); + +ipcMain.handle('create-diagnostics-package', async () => { + try { + const recentLogs = logger.getRecent(1200); + const capturedEvents = diagnosticsEvents.slice(-600); + const credentials = readCredentials() || {}; + const favorites = safeReadJsonFile(getFavoritesFilePath(), []); + const recent = safeReadJsonFile(getRecentFilePath(), []); + const userDataPath = app.getPath('userData'); + const appPaths = { + userData: userDataPath, + sessionData: app.getPath('sessionData'), + cache: app.getPath('cache'), + temp: app.getPath('temp'), + logs: app.getPath('logs'), + documents: app.getPath('documents') + }; + const windows = BrowserWindow.getAllWindows().map((w, idx) => { + let bounds = null; + try { bounds = w.getBounds(); } catch (_) {} + return { + index: idx, + visible: w.isVisible(), + focused: w.isFocused(), + minimized: w.isMinimized(), + maximized: w.isMaximized(), + bounds + }; + }); + + const settingsSnapshot = { + featureFavorites: !!credentials.featureFavorites, + featureRecent: !!credentials.featureRecent, + compactMode: !!credentials.compactMode, + featureColoredIcons: credentials.featureColoredIcons !== false, + favCollapsedFavorites: !!credentials.favCollapsedFavorites, + favCollapsedRecent: !!credentials.favCollapsedRecent, + giteaUrlConfigured: !!String(credentials.giteaURL || '').trim(), + githubTokenConfigured: !!String(credentials.githubToken || '').trim(), + giteaTokenConfigured: !!String(credentials.giteaToken || '').trim() + }; + + const resourceUsage = { + processUptimeSec: Number(process.uptime().toFixed(3)), + rss: process.memoryUsage().rss, + heapTotal: process.memoryUsage().heapTotal, + heapUsed: process.memoryUsage().heapUsed, + external: process.memoryUsage().external, + arrayBuffers: process.memoryUsage().arrayBuffers, + cpuUsage: process.cpuUsage() + }; + + const errorBySource = {}; + const errorByLevel = {}; + for (const item of [...(Array.isArray(recentLogs) ? recentLogs : []), ...(Array.isArray(capturedEvents) ? capturedEvents : [])]) { + const level = String(item?.level || 'INFO').toUpperCase(); + const source = String(item?.source || item?.context || 'unknown'); + errorByLevel[level] = (errorByLevel[level] || 0) + 1; + errorBySource[source] = (errorBySource[source] || 0) + 1; + } + + const debug = { + generatedAt: new Date().toISOString(), + appVersion: app.getVersion(), + appName: app.getName(), + platform: process.platform, + arch: process.arch, + os: { + release: os.release(), + type: os.type(), + version: os.version ? os.version() : null, + hostname: os.hostname(), + uptimeSec: os.uptime() + }, + runtime: { + node: process.versions.node, + electron: process.versions.electron, + chrome: process.versions.chrome, + v8: process.versions.v8 + }, + appPaths, + storageFiles: { + credentialsPrimary: getFileMeta(getCredentialsFilePath()), + credentialsLegacy: getFileMeta(getLegacyWorkspaceCredentialsFilePath()), + favorites: getFileMeta(getFavoritesFilePath()), + recent: getFileMeta(getRecentFilePath()) + }, + windowState: windows, + settingsSnapshot, + collections: { + favoritesCount: Array.isArray(favorites) ? favorites.length : 0, + recentCount: Array.isArray(recent) ? recent.length : 0 + }, + resourceUsage, + autostart: app.getLoginItemSettings({ args: ['--hidden'] }), + processInfo: { + pid: process.pid, + ppid: process.ppid, + argv: process.argv, + execPath: process.execPath, + cwd: process.cwd(), + env: { + NODE_ENV: process.env.NODE_ENV || null, + APPDATA: process.env.APPDATA || null, + LOCALAPPDATA: process.env.LOCALAPPDATA || null, + TEMP: process.env.TEMP || null, + TMP: process.env.TMP || null + } + }, + credentialStatus: getCredentialReadStatus(), + cacheStats: { + repos: caches.repos.size(), + fileTree: caches.fileTree.size(), + api: caches.api.size() + }, + retryQueue: { + size: Array.isArray(retryQueue) ? retryQueue.length : 0, + sample: Array.isArray(retryQueue) ? retryQueue.slice(0, 10) : [] + }, + logs: recentLogs, + capturedEvents, + counts: { + logs: Array.isArray(recentLogs) ? recentLogs.length : 0, + capturedEvents: Array.isArray(capturedEvents) ? capturedEvents.length : 0, + errorsByLevel: errorByLevel, + errorsBySource: errorBySource + }, + guidance: { + note: 'Diese Datei kann an den Support weitergegeben werden. Tokens werden nicht im Klartext exportiert.', + recommendation: 'Reproduziere den Fehler direkt vor der Diagnose-Erstellung für maximale Aussagekraft.' + } + }; + + const logErrors = (Array.isArray(debug.logs) ? debug.logs : []) + .filter(l => ['ERROR', 'WARN'].includes(String(l?.level || '').toUpperCase())) + .slice(-80); + const eventErrors = (Array.isArray(capturedEvents) ? capturedEvents : []) + .filter(e => ['ERROR', 'WARN'].includes(String(e?.level || '').toUpperCase())) + .slice(-80); + debug.lastErrors = [...logErrors, ...eventErrors].slice(-120); + + const fileName = `git-manager-diagnostics-${createStampForFileName()}.json`; + const result = await dialog.showSaveDialog({ + title: 'Diagnosepaket speichern', + defaultPath: ppath.join(app.getPath('documents'), fileName), + filters: [ + { name: 'JSON', extensions: ['json'] } + ] + }); + + if (result.canceled || !result.filePath) return { ok: false, canceled: true }; + + fs.writeFileSync(result.filePath, JSON.stringify(debug, null, 2), 'utf8'); + logger.info('diagnostics', 'Diagnostics package created', { filePath: result.filePath }); + return { ok: true, filePath: result.filePath }; + } catch (e) { + logger.error('diagnostics', 'Diagnostics package failed', { error: e.message }); + return { ok: false, error: String(e?.message || e) }; + } +}); + // main.js - Updater IPC Handlers // Window Controls @@ -4185,11 +4133,17 @@ ipcMain.handle('check-for-updates', async (event, options = {}) => { console.log("[Main] Update-Check angefordert..."); try { const silent = Boolean(options && options.silent); + if (!Updater) { + Updater = require('./updater.js'); + } + const win = BrowserWindow.fromWebContents(event.sender); if (!updater) { - const win = BrowserWindow.fromWebContents(event.sender); - if (win) updater = new Updater(win); + if (win && Updater) updater = new Updater(win); + } else if (win) { + updater.mainWindow = win; } if (updater) await updater.checkForUpdates(silent); + else return { ok: false, error: 'Updater konnte nicht initialisiert werden.' }; return { ok: true }; } catch (error) { console.error('[Main] Fehler beim Update-Check:', error); @@ -4201,9 +4155,14 @@ ipcMain.handle('check-for-updates', async (event, options = {}) => { ipcMain.handle('start-update-download', async (event, asset) => { console.log("[Main] Download-Signal erhalten für:", asset ? asset.name : "Unbekannt"); try { + if (!Updater) { + Updater = require('./updater.js'); + } + const win = BrowserWindow.fromWebContents(event.sender); if (!updater) { - const win = BrowserWindow.fromWebContents(event.sender); updater = new Updater(win); + } else if (win) { + updater.mainWindow = win; } if (asset && asset.browser_download_url) { await updater.startDownload(asset);