// 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 ppath = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); const { execSync, spawnSync } = require('child_process'); const https = require('https'); const Updater = require('./updater.js'); // Auto-Updater let updater = null; const { createRepoGitHub, createRepoGitea, checkGiteaConnection, listGiteaRepos, getGiteaUserHeatmap, getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile, getGiteaCurrentUser, getGiteaCommits, getGiteaCommit, getGiteaCommitDiff, getGiteaCommitFiles, searchGiteaCommits, getGiteaBranches, listGiteaReleases, getGiteaRelease, createGiteaRelease, editGiteaRelease, deleteGiteaRelease, uploadReleaseAsset, deleteReleaseAsset, updateGiteaAvatar, updateGiteaRepoAvatar, updateGiteaRepoVisibility, updateGiteaRepoTopics, migrateRepoToGitea ,listGiteaTopicsCatalog, // GitHub listGithubRepos, getGithubCurrentUser, githubRepoExists, getGithubUserHeatmap, getGithubRepoContents, getGithubFileContent, uploadGithubFile, deleteGithubFile, getGithubCommits, getGithubCommitDiff, getGithubCommitFiles, searchGithubCommits, getGithubBranches, listGithubReleases, createGithubRelease, editGithubRelease, deleteGithubRelease, updateGithubRepoVisibility, updateGithubRepoDefaultBranch, updateGithubRepoTopics, deleteGithubRepo } = require('./src/git/apiHandler.js'); const { initRepo, commitAndPush, getBranches, getCommitLogs } = require('./src/git/gitHandler.js'); // NOTE: credentials/data location is computed via getDataDir() to avoid calling app.getPath before ready function getCredentialsFilePath() { return ppath.join(app.getPath('userData'), 'credentials.json'); } function getLegacyWorkspaceCredentialsFilePath() { return ppath.join(__dirname, 'data', 'credentials.json'); } const ALGORITHM = 'aes-256-cbc'; const SECRET_KEY = crypto.scryptSync('SuperSecretKey123!', 'salt', 32); const IV = Buffer.alloc(16, 0); // default concurrency for parallel uploads/downloads const DEFAULT_CONCURRENCY = 4; // temp drag cleanup delay (ms) const TMP_CLEANUP_MS = 20_000; const RETRY_QUEUE_INTERVAL_MS = 15_000; const RETRY_MAX_ATTEMPTS = 8; let retryQueue = []; let retryQueueRunning = false; let retryQueueTimer = null; let credentialReadIssueLogged = false; let lastCredentialReadStatus = { state: 'idle', reason: null, filePath: null, message: null }; function setCredentialReadStatus(status) { lastCredentialReadStatus = { state: status?.state || 'idle', reason: status?.reason || null, filePath: status?.filePath || null, message: status?.message || null }; } function getCredentialReadStatus() { return { ...lastCredentialReadStatus }; } function logCredentialIssueOnce(message) { if (credentialReadIssueLogged) return; credentialReadIssueLogged = true; console.warn(message); } function quarantineCredentialsFile(filePath, reason = 'invalid-format') { try { if (!fs.existsSync(filePath)) return null; const backupPath = `${filePath}.corrupt-${Date.now()}`; fs.renameSync(filePath, backupPath); return backupPath; } catch (e) { console.warn('Could not quarantine credentials file', e && e.message ? e.message : e); return null; } } function tryParseJsonCredentials(buffer) { try { const parsed = JSON.parse(buffer.toString('utf8')); if (!parsed || typeof parsed !== 'object') return null; const hasKnownKey = ['giteaURL', 'giteaToken', 'githubToken', 'githubUsername'].some((k) => Object.prototype.hasOwnProperty.call(parsed, k)); return hasKnownKey ? parsed : null; } catch (_) { return null; } } function getCredentialFileCandidates() { return Array.from(new Set([ getCredentialsFilePath(), getLegacyWorkspaceCredentialsFilePath() ])); } function ensureParentDirectory(filePath) { const dirPath = ppath.dirname(filePath); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } function encryptLegacyCredentials(json) { const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV); return Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); } function decryptLegacyCredentials(buffer) { const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV); const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]); return JSON.parse(decrypted.toString('utf8')); } function createCredentialsPayload(data) { const json = JSON.stringify(data); if (safeStorage && safeStorage.isEncryptionAvailable()) { const encrypted = safeStorage.encryptString(json); return { v: 2, mode: 'safeStorage', data: encrypted.toString('base64') }; } return { v: 1, mode: 'legacyAesCbc', data: encryptLegacyCredentials(json).toString('base64') }; } function writeCredentialsToFile(filePath, data) { ensureParentDirectory(filePath); fs.writeFileSync(filePath, JSON.stringify(createCredentialsPayload(data)), 'utf8'); } function persistCredentials(data) { const primaryPath = getCredentialsFilePath(); const legacyPath = getLegacyWorkspaceCredentialsFilePath(); const targets = [primaryPath]; if (legacyPath !== primaryPath && fs.existsSync(legacyPath)) { targets.push(legacyPath); } Array.from(new Set(targets)).forEach((filePath) => { writeCredentialsToFile(filePath, data); }); } function tryReadWrappedCredentials(raw) { try { const wrapper = JSON.parse(raw.toString('utf8')); if (!wrapper || typeof wrapper !== 'object' || typeof wrapper.data !== 'string') return null; if (wrapper.v === 2 && wrapper.mode === 'safeStorage') { if (!safeStorage || !safeStorage.isEncryptionAvailable()) { return { credentials: null, needsRewrite: false, reason: 'safeStorage-unavailable', message: 'Gespeicherte Zugangsdaten koennen in dieser Sitzung nicht gelesen werden. Bitte neu anmelden.' }; } try { return { credentials: JSON.parse(safeStorage.decryptString(Buffer.from(wrapper.data, 'base64'))), needsRewrite: false, reason: null, message: null }; } catch (_) { return { credentials: null, needsRewrite: false, reason: 'safeStorage-decrypt-failed', message: 'Gespeicherte Zugangsdaten konnten nicht entschluesselt werden. Bitte Token neu eingeben.' }; } } if (wrapper.v === 1 && (wrapper.mode === 'legacyAesCbc' || wrapper.mode === 'legacyAes')) { return { credentials: decryptLegacyCredentials(Buffer.from(wrapper.data, 'base64')), needsRewrite: !!(safeStorage && safeStorage.isEncryptionAvailable()), reason: null, message: null }; } } catch (_) { return null; } return null; } function readCredentialsFromFile(filePath) { const raw = fs.readFileSync(filePath); const wrappedCreds = tryReadWrappedCredentials(raw); if (wrappedCreds) { return { ...wrappedCreds, filePath }; } const plainJsonCreds = tryParseJsonCredentials(raw); if (plainJsonCreds) { return { credentials: plainJsonCreds, needsRewrite: true, reason: null, message: null, filePath }; } try { return { credentials: decryptLegacyCredentials(raw), needsRewrite: true, reason: null, message: null, filePath }; } catch (e) { quarantineCredentialsFile(filePath, 'legacy-decrypt-failed'); return { credentials: null, needsRewrite: false, reason: 'legacy-decrypt-failed', message: 'Gespeicherte Zugangsdaten waren unlesbar und wurden zurueckgesetzt. Bitte Zugangsdaten neu eingeben.', filePath }; } } function getRetryQueueFilePath() { return ppath.join(app.getPath('userData'), 'retry-queue.json'); } function readRetryQueueFromDisk() { try { const file = getRetryQueueFilePath(); if (!fs.existsSync(file)) return []; const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(parsed) ? parsed : []; } catch (e) { console.error('readRetryQueueFromDisk error', e); return []; } } function saveRetryQueueToDisk() { try { const file = getRetryQueueFilePath(); const dir = app.getPath('userData'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(retryQueue, null, 2), 'utf8'); } catch (e) { console.error('saveRetryQueueToDisk error', e); } } function broadcastRetryQueueUpdate(extra = {}) { const payload = { size: retryQueue.length, items: retryQueue.slice(0, 100), ...extra }; for (const win of BrowserWindow.getAllWindows()) { try { win.webContents.send('retry-queue-updated', payload); } catch (_) {} } } function isRetryableNetworkError(errorLike) { const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase(); if (!raw) return false; return ( raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo') || raw.includes('etimedout') || raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('socket hang up') || raw.includes('503') || raw.includes('502') || raw.includes('504') ); } function enqueueRetryWriteTask(data, reason) { const item = { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, type: 'write-gitea-file', payload: { owner: data.owner, repo: data.repo, path: data.path, content: data.content || '', ref: data.ref || 'HEAD' }, attempts: 0, nextRetryAt: Date.now() + 5000, createdAt: new Date().toISOString(), lastError: String(reason || '') }; retryQueue.push(item); saveRetryQueueToDisk(); broadcastRetryQueueUpdate({ event: 'queued', item }); return item; } async function processRetryQueueOnce() { if (retryQueueRunning) { return { ok: true, skipped: true, reason: 'already-running', size: retryQueue.length }; } retryQueueRunning = true; const now = Date.now(); let processed = 0; let succeeded = 0; let failed = 0; try { const credentials = readCredentials(); const token = credentials && credentials.giteaToken; const url = credentials && credentials.giteaURL; if (!token || !url) { return { ok: false, error: 'missing-token-or-url', processed: 0, size: retryQueue.length }; } const survivors = []; for (const item of retryQueue) { if ((item.nextRetryAt || 0) > now) { survivors.push(item); continue; } processed++; if (item.type !== 'write-gitea-file') { survivors.push(item); continue; } try { const payload = item.payload || {}; const base64 = Buffer.from(payload.content || '', 'utf8').toString('base64'); await uploadGiteaFile({ token, url, owner: payload.owner, repo: payload.repo, path: payload.path, contentBase64: base64, message: `Retry edit ${payload.path} via Git Manager GUI`, branch: payload.ref || 'HEAD' }); succeeded++; } catch (e) { const attempts = (item.attempts || 0) + 1; if (attempts >= RETRY_MAX_ATTEMPTS) { failed++; } else { const backoffMs = Math.min(300000, 5000 * Math.pow(2, attempts)); survivors.push({ ...item, attempts, nextRetryAt: Date.now() + backoffMs, lastError: String(e && e.message ? e.message : e) }); } } } retryQueue = survivors; saveRetryQueueToDisk(); broadcastRetryQueueUpdate({ event: 'processed', processed, succeeded, failed }); return { ok: true, processed, succeeded, failed, size: retryQueue.length }; } finally { retryQueueRunning = false; } } /* ----------------------------- Utilities for safe filesystem ops ----------------------------- */ function ensureDir(dirPath) { try { if (fs.existsSync(dirPath)) { if (!fs.statSync(dirPath).isDirectory()) { // If a file exists where we expect a directory, remove it fs.unlinkSync(dirPath); } } fs.mkdirSync(dirPath, { recursive: true }); } catch (e) { // Re-throw with clearer message throw new Error(`ensureDir failed for ${dirPath}: ${e && e.message ? e.message : e}`); } } /** * Create a safe, unique temporary directory under os.tmpdir(). * If an entry exists at that path and it's a file, it will be removed. * If a directory exists it will be removed and recreated to ensure a clean state. * Returns the created directory path. * * baseName should be a short string (no path separators). We append a timestamp to reduce collisions. */ function getSafeTmpDir(baseName) { const safeBase = (baseName || 'tmp').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').substring(0, 64); // Basis-Temp-Ordner für interne Verwaltung const internalBase = ppath.join(os.tmpdir(), 'gitea-drag'); if (!fs.existsSync(internalBase)) fs.mkdirSync(internalBase, { recursive: true }); // Eindeutiger Unterordner, um Kollisionen zu vermeiden const uniqueSub = crypto.randomBytes(8).toString('hex'); const internalDir = ppath.join(internalBase, uniqueSub); fs.mkdirSync(internalDir, { recursive: true }); // Sichtbarer Ordnername = safeBase const finalDir = ppath.join(internalDir, safeBase); fs.mkdirSync(finalDir, { recursive: true }); return finalDir; } /* ----------------------------- app / window ----------------------------- */ let tray = null; function ensureUsableDirectory(dirPath) { try { const stat = fs.existsSync(dirPath) ? fs.lstatSync(dirPath) : null; if (stat && !stat.isDirectory()) { fs.rmSync(dirPath, { force: true }); } fs.mkdirSync(dirPath, { recursive: true }); return true; } catch (_) { return false; } } function isDirectoryWritable(dirPath) { try { if (!ensureUsableDirectory(dirPath)) return false; const probe = ppath.join(dirPath, `.write-probe-${process.pid}-${Date.now()}.tmp`); fs.writeFileSync(probe, 'ok', 'utf8'); fs.rmSync(probe, { force: true }); return true; } catch (_) { return false; } } function initializeAppStoragePaths() { try { // Reduce startup flakiness on Windows when cache folders are locked by stale processes. app.commandLine.appendSwitch('disable-http-cache'); app.commandLine.appendSwitch('disable-gpu-shader-disk-cache'); const appDataBase = process.env.APPDATA || app.getPath('appData'); const appName = (app.getName() || 'git-manager-gui').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_'); const preferredUserData = ppath.join(appDataBase, appName); const fallbackBase = ppath.join(os.tmpdir(), `${appName}-runtime`); const userDataPath = isDirectoryWritable(preferredUserData) ? preferredUserData : fallbackBase; const usingFallbackStorage = userDataPath === fallbackBase; const sessionDataPath = ppath.join(userDataPath, 'SessionData'); const cachePath = ppath.join(userDataPath, 'Cache'); const gpuCachePath = ppath.join(userDataPath, 'GPUCache'); // Ensure key directories are real, writable directories to avoid Chromium cache init failures. const requiredDirs = usingFallbackStorage ? [userDataPath, sessionDataPath, cachePath, gpuCachePath] : [userDataPath]; requiredDirs.forEach((dir) => { if (!ensureUsableDirectory(dir)) { throw new Error(`Storage dir not usable: ${dir}`); } }); app.setPath('userData', userDataPath); // Override Chromium paths only in fallback mode to avoid repeated cache migration attempts. if (usingFallbackStorage) { app.setPath('sessionData', sessionDataPath); app.setPath('cache', cachePath); app.commandLine.appendSwitch('disk-cache-dir', cachePath); app.commandLine.appendSwitch('gpu-shader-disk-cache-path', gpuCachePath); console.warn('Using temporary runtime storage fallback:', fallbackBase); } } catch (e) { console.warn('initializeAppStoragePaths warning', e && e.message ? e.message : e); } } initializeAppStoragePaths(); 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(); }); } function createWindow() { // Entfernt das Menü (File, Edit, View...) komplett Menu.setApplicationMenu(null); const startHidden = process.argv.includes('--hidden'); const win = new BrowserWindow({ width: 1200, height: 820, frame: false, show: !startHidden, webPreferences: { preload: ppath.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false } }); // Externe Fenster immer blockieren und nur erlaubte URLs explizit im System-Browser oeffnen. win.webContents.setWindowOpenHandler(({ url }) => { if (isAllowedExternalUrl(url)) { shell.openExternal(url).catch(() => {}); } return { action: 'deny' }; }); // Verhindert Navigation auf fremde Seiten im Hauptfenster. win.webContents.on('will-navigate', (event, url) => { if (!url.startsWith('file://')) { event.preventDefault(); if (isAllowedExternalUrl(url)) { shell.openExternal(url).catch(() => {}); } } }); // Keine Runtime-Berechtigungen aus dem Renderer erteilen (Kamera, Mikrofon, usw.). win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false); }); win.loadFile(ppath.join(__dirname, 'renderer', 'index.html')); // win.webContents.openDevTools(); createTray(win); // Schließen-Button -> Tray statt Beenden (nur wenn Autostart aktiv) win.on('close', (e) => { const { enabled } = app.getLoginItemSettings(); if (enabled) { e.preventDefault(); win.hide(); } }); } app.whenReady().then(() => { retryQueue = readRetryQueueFromDisk(); createWindow(); broadcastRetryQueueUpdate({ event: 'startup' }); retryQueueTimer = setInterval(() => { processRetryQueueOnce().catch(e => console.error('processRetryQueueOnce timer error', e)); }, RETRY_QUEUE_INTERVAL_MS); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { if (retryQueueTimer) { clearInterval(retryQueueTimer); retryQueueTimer = null; } }); /* ----------------------------- Helper: read credentials ----------------------------- */ function readCredentials() { try { const primaryPath = getCredentialsFilePath(); let lastFailure = null; setCredentialReadStatus({ state: 'empty', reason: 'no-file', filePath: null, message: null }); for (const credentialsFilePath of getCredentialFileCandidates()) { if (!fs.existsSync(credentialsFilePath)) continue; const result = readCredentialsFromFile(credentialsFilePath); if (!result) continue; if (!result.credentials) { lastFailure = result; if (result.message) { logCredentialIssueOnce(result.message); } continue; } if (result.needsRewrite || credentialsFilePath !== primaryPath) { try { persistCredentials(result.credentials); } catch (_) {} } setCredentialReadStatus({ state: 'ok', reason: null, filePath: credentialsFilePath, message: null }); return result.credentials; } if (lastFailure) { setCredentialReadStatus({ state: 'error', reason: lastFailure.reason || 'read-failed', filePath: lastFailure.filePath || null, message: lastFailure.message || null }); } return null; } catch (e) { logCredentialIssueOnce('Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.'); setCredentialReadStatus({ state: 'error', reason: 'read-exception', filePath: null, message: 'Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.' }); return null; } } function sanitizeErrorForLog(errorLike) { const status = errorLike?.response?.status || null; const message = String( errorLike?.response?.data?.message || errorLike?.message || errorLike || 'unknown-error' ); return { status, message }; } function mapIpcError(errorLike) { const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase(); if (!raw) return 'Unbekannter Fehler.'; if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) { return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.'; } if (raw.includes('403') || raw.includes('forbidden') || raw.includes('zugriff verweigert')) { return 'Zugriff verweigert. Bitte Token-Berechtigungen prüfen.'; } if (raw.includes('404') || raw.includes('not found') || raw.includes('nicht gefunden')) { return 'Server oder Ressource nicht gefunden. Bitte URL und Repository prüfen.'; } if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) { return 'Server nicht erreichbar. Bitte DNS, IPv4/IPv6 und Port prüfen.'; } if (raw.includes('timeout') || raw.includes('econnaborted')) { return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.'; } if (raw.includes('http://') || raw.includes('https://') || raw.includes('ungueltige gitea url') || raw.includes('ungültige gitea url') || raw.includes('invalid')) { return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'; } return String(errorLike && errorLike.message ? errorLike.message : errorLike); } function isSafeGitRef(ref) { const value = String(ref || '').trim(); if (!value) return false; if (value.startsWith('-')) return false; if (value.includes('..')) return false; if (/[\s~^:?*\[\\]/.test(value)) return false; return /^[A-Za-z0-9._/-]+$/.test(value); } function sanitizeGitRef(ref, fallback = 'main') { const value = String(ref || '').trim(); return isSafeGitRef(value) ? value : fallback; } function runGitSync(args, cwd, options = {}) { const res = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, ...options }); if (res.status !== 0) { throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim()); } return String(res.stdout || '').trim(); } /* ----------------------------- Generic concurrency runner (worker pool) ----------------------------- */ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null) { const results = new Array(tasks.length); let index = 0; let processed = 0; const total = tasks.length; async function worker() { while (true) { const i = index++; if (i >= tasks.length) return; try { const r = await tasks[i](); results[i] = { ok: true, result: r }; } catch (e) { results[i] = { ok: false, error: String(e) }; } finally { processed++; if (onProgress) { try { onProgress(processed, total); } catch (_) {} } } } } const workers = Array(Math.max(1, Math.min(concurrency, tasks.length))).fill(0).map(() => worker()); await Promise.all(workers); return results; } /* ----------------------------- Basic IPC handlers ----------------------------- */ ipcMain.handle('select-folder', async () => { const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); return result.canceled ? null : result.filePaths[0]; }); ipcMain.handle('select-file', async () => { const result = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] }); if (result.canceled) return { ok: false, files: [] }; return { ok: true, files: result.filePaths }; }); ipcMain.handle('save-credentials', async (event, data) => { try { const CREDENTIALS_FILE = getCredentialsFilePath(); persistCredentials(data); console.log('✅ Credentials gespeichert in:', CREDENTIALS_FILE); return { ok: true }; } catch (e) { console.error('save-credentials error', e); return { ok: false, error: String(e) }; } }); ipcMain.on('renderer-debug-log', (_event, payload) => { try { const level = String(payload?.level || 'log').toLowerCase(); const message = String(payload?.message || 'renderer-log'); const details = payload?.payload; const fn = level === 'error' ? console.error : (level === 'warn' ? console.warn : console.log); fn('[UPLOAD_DEBUG][renderer->main]', message, details || ''); } catch (e) { console.warn('[UPLOAD_DEBUG][renderer->main] logging failed', String(e)); } }); ipcMain.handle('load-credentials', async () => { try { return readCredentials(); } catch (e) { console.error('load-credentials', e); return null; } }); ipcMain.handle('get-credentials-status', async () => { try { readCredentials(); } catch (_) {} return getCredentialReadStatus(); }); ipcMain.handle('get-gitea-current-user', async () => { try { const creds = readCredentials(); if (!creds?.giteaToken || !creds?.giteaURL) return { ok: false, error: 'no-credentials' }; const user = await getGiteaCurrentUser({ token: creds.giteaToken, url: creds.giteaURL }); return { ok: true, user }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('get-gitea-current-user error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('update-gitea-avatar', async (event, { token, url, imageBase64 }) => { try { await updateGiteaAvatar({ token, url, imageBase64 }); console.log('✅ Avatar erfolgreich hochgeladen'); return { ok: true }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('update-gitea-avatar error', errStatus, errMsg, e.response?.data); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('update-gitea-repo-avatar', async (event, { token, url, owner, repo, imageBase64 }) => { try { await updateGiteaRepoAvatar({ token, url, owner, repo, imageBase64 }); console.log(`✅ Repo-Avatar für ${owner}/${repo} hochgeladen`); return { ok: true }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('update-gitea-repo-avatar error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('update-gitea-repo-visibility', async (event, data) => { try { const { token, url, owner, repo, isPrivate, platform } = data || {}; if (platform === 'github') { const credentials = readCredentials(); const githubToken = token || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const updated = await updateGithubRepoVisibility({ token: githubToken, owner, repo, isPrivate }); return { ok: true, repo: updated }; } const updated = await updateGiteaRepoVisibility({ token, url, owner, repo, isPrivate }); 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-gitea-repo-visibility error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('update-gitea-repo-topics', async (event, data) => { try { const { owner, repo, topics, platform } = data || {}; const creds = readCredentials(); if (platform === 'github') { const githubToken = (data.token) || (creds && creds.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const updated = await updateGithubRepoTopics({ token: githubToken, owner, repo, topics: Array.isArray(topics) ? topics : [] }); return { ok: true, repo: updated }; } if (!creds?.giteaToken || !creds?.giteaURL) { return { ok: false, error: 'Gitea Token oder URL fehlt.' }; } const updated = await updateGiteaRepoTopics({ token: creds.giteaToken, url: creds.giteaURL, owner, repo, topics: Array.isArray(topics) ? topics : [] }); 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-gitea-repo-topics error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('get-gitea-topics-catalog', async () => { try { const creds = readCredentials(); if (!creds?.giteaToken || !creds?.giteaURL) { return { ok: false, error: 'Gitea Token oder URL fehlt.' }; } const topics = await listGiteaTopicsCatalog({ token: creds.giteaToken, url: creds.giteaURL }); return { ok: true, topics }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('get-gitea-topics-catalog error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('migrate-repo-to-gitea', async (event, data) => { try { const creds = readCredentials(); if (!creds) return { ok: false, error: 'no-credentials' }; const result = await migrateRepoToGitea({ token: creds.giteaToken, url: creds.giteaURL, cloneUrl: data.cloneUrl, repoName: data.repoName, description: data.description || '', isPrivate: data.isPrivate || false, authToken: data.authToken || '', authUsername: data.authUsername || '' }); return { ok: true, repo: result }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('migrate-repo-to-gitea error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); /* ----------------------------- Repo & git handlers ----------------------------- */ ipcMain.handle('create-repo', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; if (data.platform === 'github') { const repo = await createRepoGitHub({ name: data.name, token: credentials.githubToken, auto_init: data.autoInit || true, license: data.license || '', private: data.private || false }); return { ok: true, repo }; } else if (data.platform === 'gitea') { const repo = await createRepoGitea({ name: data.name, token: credentials.giteaToken, url: credentials.giteaURL, auto_init: data.autoInit || true, license: data.license || '', private: data.private || false }); return { ok: true, repo }; } else return { ok: false, error: 'unknown-platform' }; } catch (e) { console.error('create-repo error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('push-project', async (event, data) => { try { if (!data.folder || !fs.existsSync(data.folder)) return { ok: false, error: 'folder-not-found' }; // Aktuellen Branch ermitteln (NICHT umbenennen!) let currentBranch = data.branch || null; try { const detected = runGitSync(['branch', '--show-current'], data.folder); if (detected) { currentBranch = currentBranch || sanitizeGitRef(detected, 'main'); console.log('Current local branch:', detected); } } catch (e) { console.warn('Could not check local branch (maybe not a git repo yet)', e.message); } // 1. Git initialisieren, adden und commiten await initRepo(data.folder); // 2. Prüfen, ob ein 'origin' Remote existiert let remoteUrl = ''; try { remoteUrl = runGitSync(['remote', 'get-url', 'origin'], data.folder); // Bereits gespeicherte Credential-URLs in sichere Origin-URLs ohne Userinfo konvertieren. try { const remoteObj = new URL(remoteUrl); if (remoteObj.username || remoteObj.password) { remoteObj.username = ''; remoteObj.password = ''; const sanitizedRemoteUrl = remoteObj.toString(); runGitSync(['remote', 'set-url', 'origin', sanitizedRemoteUrl], data.folder); remoteUrl = sanitizedRemoteUrl; } } catch (_) {} } catch (e) { if (data.repoName && data.platform) { const creds = readCredentials(); if (!creds) return { ok: false, error: 'credentials-missing-for-remote' }; const parts = data.repoName.split('/'); if (parts.length === 2) { const owner = parts[0]; const repo = parts[1]; let constructedUrl = ''; let authHeader = ''; let remoteOrigin = ''; if (data.platform === 'gitea' && creds.giteaURL) { try { const urlObj = new URL(creds.giteaURL); constructedUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`; remoteOrigin = `${urlObj.protocol}//${urlObj.host}/`; if (creds.giteaToken) { authHeader = `AUTHORIZATION: token ${creds.giteaToken}`; } } catch (err) { console.error('Invalid Gitea URL', err); } } else if (data.platform === 'github' && creds.githubToken) { constructedUrl = `https://github.com/${owner}/${repo}.git`; remoteOrigin = 'https://github.com/'; authHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`; } if (constructedUrl) { try { runGitSync(['remote', 'add', 'origin', constructedUrl], data.folder, { stdio: 'inherit' }); // Auth nie in der Remote-URL speichern; stattdessen Header scoped auf den Remote-Origin. if (remoteOrigin && authHeader) { runGitSync(['config', `http.${remoteOrigin}.extraheader`, authHeader], data.folder); } console.log(`Remote origin added: ${constructedUrl}`); } catch (err) { return { ok: false, error: 'Failed to add remote origin: ' + String(err) }; } } else { return { ok: false, error: 'Could not construct remote URL.' }; } } else { return { ok: false, error: 'Use format Owner/RepoName.' }; } } } // 3. Pushen (nutze den tatsächlichen Branch - main ODER master) const progressCb = percent => { try { event.sender.send('push-progress', percent); } catch (_) {} }; const commitMsg = data.commitMessage || 'Update from Git Manager GUI'; const pushBranch = sanitizeGitRef(currentBranch || 'main', 'main'); await commitAndPush(data.folder, pushBranch, commitMsg, progressCb); return { ok: true }; } catch (e) { console.error('push-project error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('getBranches', async (event, data) => { try { const branches = await getBranches(data.folder); // Aktuellen Branch ermitteln und nach oben sortieren let currentBranch = null; try { currentBranch = runGitSync(['branch', '--show-current'], data.folder); } catch (_) {} branches.sort((a, b) => { if (a === currentBranch) return -1; if (b === currentBranch) return 1; if (a === 'main') return -1; if (b === 'main') return 1; if (a === 'master') return -1; if (b === 'master') return 1; return 0; }); return { ok: true, branches }; } catch (e) { console.error('getBranches error', e); return { ok: false, error: String(e), branches: [] }; } }); ipcMain.handle('getCommitLogs', async (event, data) => { try { const logs = await getCommitLogs(data.folder, data.count || 50); return { ok: true, logs }; } catch (e) { console.error('getCommitLogs error', e); return { ok: false, error: String(e), logs: [] }; } }); /* ---------------------------------------------------------------- Neue/kompatible Handler: 'get-commits' (Renderer verwendet ggf. diesen) - Unterstützt: 1) Lokale Git-Logs via data.folder -> getCommitLogs 2) Gitea Commits via owner+repo -> getGiteaCommits ---------------------------------------------------------------- */ ipcMain.handle('get-commits', async (event, data) => { try { // 1) Lokale Git-Logs (folder vorhanden) if (data && data.folder) { const logs = await getCommitLogs(data.folder, data.count || 50); return { ok: true, logs }; } // 2) Remote-Commits if (data && data.owner && data.repo) { const credentials = readCredentials(); // GitHub if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte in Settings eintragen.' }; const commits = await getGithubCommits({ token: githubToken, owner: data.owner, repo: data.repo, branch: data.sha || data.branch || '', page: data.page || 1, limit: data.limit || 50 }); return { ok: true, commits }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; // map optional params const page = data.page || 1; const limit = data.limit || 50; const sha = data.sha; // optional branch/sha filter const commits = await getGiteaCommits({ token, url, owner: data.owner, repo: data.repo, page, limit, sha }); return { ok: true, commits }; } return { ok: false, error: 'invalid-parameters-for-get-commits' }; } catch (e) { console.error('get-commits error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('search-commits', async (event, data) => { try { if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' }; const credentials = readCredentials(); if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const commits = await searchGithubCommits({ token: githubToken, owner: data.owner, repo: data.repo, query: data.query || '', branch: data.branch || '' }); return { ok: true, commits }; } const token = (data.token) || (credentials && credentials.giteaToken); const url = (data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const commits = await searchGiteaCommits({ token, url, owner: data.owner, repo: data.repo, query: data.query || '', branch: data.branch || '' }); return { ok: true, commits }; } catch (e) { console.error('search-commits error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('get-repo-branches', async (event, data) => { try { if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' }; const credentials = readCredentials(); if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const branches = await getGithubBranches({ token: githubToken, owner: data.owner, repo: data.repo }); return { ok: true, branches }; } const token = (data.token) || (credentials && credentials.giteaToken); const url = (data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const branches = await getGiteaBranches({ token, url, owner: data.owner, repo: data.repo }); return { ok: true, branches }; } catch (e) { console.error('get-repo-branches error', e); return { ok: false, error: String(e) }; } }); /* ----------------------------- Local file tree functions ----------------------------- */ function buildTree(dirPath, options = {}) { const { exclude = ['node_modules'], maxDepth = 10 } = options; function walk(currentPath, depth) { let stat; try { stat = fs.statSync(currentPath); } catch (e) { return null; } const name = ppath.basename(currentPath); const node = { name, path: currentPath, isDirectory: stat.isDirectory(), children: [], depth }; if (node.isDirectory) { if (exclude.some(ex => currentPath.split(ppath.sep).includes(ex))) return null; if (depth >= maxDepth) return node; let items = []; try { items = fs.readdirSync(currentPath); } catch (e) { return node; } items.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); for (const it of items) { const childPath = ppath.join(currentPath, it); const child = walk(childPath, depth +1); if (child) node.children.push(child); } } return node; } const roots = []; const list = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); for (const entry of list) { const full = ppath.join(dirPath, entry); const n = walk(full, 0); if (n) roots.push(n); } return roots; } ipcMain.handle('getFileTree', async (event, data) => { try { const folder = data && data.folder; if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'folder-not-found' }; const opts = { exclude: (data && data.exclude) || ['node_modules'], maxDepth: (data && data.maxDepth) || 10 }; const tree = buildTree(folder, opts); return { ok: true, tree }; } catch (e) { console.error('getFileTree error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('readFile', async (event, data) => { try { if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' }; const content = fs.readFileSync(data.path, 'utf8'); return { ok: true, content }; } catch (e) { console.error('readFile error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('writeFile', async (event, data) => { try { if (!data || !data.path) return { ok: false, error: 'invalid-path' }; const dir = ppath.dirname(data.path); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(data.path, data.content || '', 'utf8'); return { ok: true }; } catch (e) { console.error('writeFile error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('deleteFile', async (event, data) => { try { // --- GITHUB DELETION --- if (data && data.isGithub) { const credentials = readCredentials(); const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const { owner, repo, path: filePath, ref, branch } = data; if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; const useBranch = (branch && branch !== 'HEAD') ? branch : ((ref && ref !== 'HEAD') ? ref : 'main'); // GitHub API returns arrays for dirs — handle recursive deletion async function deleteGithubEntry(entryPath) { const contents = await getGithubRepoContents({ token: githubToken, owner, repo, path: entryPath, ref: useBranch }); if (!contents.ok) throw new Error(contents.error || 'Inhalt nicht ladbar'); if (contents.items && contents.items.length > 1) { // It's a directory — recurse for (const item of contents.items) { if (item.type === 'dir') await deleteGithubEntry(item.path); else await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, branch: useBranch }); } } else if (contents.items && contents.items.length === 1 && contents.items[0].type !== 'dir') { await deleteGithubFile({ token: githubToken, owner, repo, path: contents.items[0].path, sha: contents.items[0].sha, branch: useBranch }); } else { await deleteGithubFile({ token: githubToken, owner, repo, path: entryPath, branch: useBranch }); } } await deleteGithubEntry(filePath); return { ok: true }; } // --- GITEA DELETION --- if (data && data.isGitea) { const credentials = readCredentials(); const token = (data.token) || (credentials && credentials.giteaToken); const giteaUrl = (data.url) || (credentials && credentials.giteaURL); if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const filePath = data.path; if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; const urlObj = new URL(giteaUrl); const protocol = urlObj.protocol === 'https:' ? https : http; const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); // Helper: GET contents from Gitea API function giteaGet(path) { return new Promise((resolve, reject) => { const req = protocol.request({ hostname: urlObj.hostname, port, path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${data.ref || 'HEAD'}`, 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(); }); } // Helper: DELETE a single file by path + sha function giteaDeleteFile(filePath, sha) { return new Promise((resolve) => { const body = JSON.stringify({ message: `Delete ${filePath} via Git Manager GUI`, sha, branch: data.ref || 'HEAD' }); const req = protocol.request({ hostname: urlObj.hostname, port, path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`, method: 'DELETE', 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', () => { if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true }); else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${respBody}` }); }); }); req.on('error', (e) => resolve({ ok: false, error: String(e) })); req.write(body); req.end(); }); } // Helper: Recursively collect all files in a folder async function collectAllFiles(path) { const contents = await giteaGet(path); const files = []; if (Array.isArray(contents)) { // It's a folder — recurse into it for (const item of contents) { if (item.type === 'dir') { const sub = await collectAllFiles(item.path); files.push(...sub); } else if (item.type === 'file') { files.push({ path: item.path, sha: item.sha }); } } } else if (contents && contents.sha) { // It's a single file files.push({ path: contents.path, sha: contents.sha }); } else { throw new Error(`Unbekannte Antwort: ${JSON.stringify(contents)}`); } return files; } // Collect all files to delete (handles both file and folder) const filesToDelete = await collectAllFiles(filePath); if (filesToDelete.length === 0) { return { ok: false, error: 'Keine Dateien zum Löschen gefunden' }; } // Delete all files sequentially let failed = 0; for (const f of filesToDelete) { const res = await giteaDeleteFile(f.path, f.sha); if (!res.ok) { console.error(`Fehler beim Löschen von ${f.path}:`, res.error); failed++; } } if (failed > 0) { return { ok: false, error: `${failed} von ${filesToDelete.length} Dateien konnten nicht gelöscht werden` }; } return { ok: true, deleted: filesToDelete.length }; } // --- LOCAL DELETION --- if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' }; fs.rmSync(data.path, { recursive: true, force: true }); return { ok: true }; } catch (e) { console.error('deleteFile error', e); return { ok: false, error: String(e) }; } }); /* ----------------------------- Gitea: list repos, contents, file content ----------------------------- */ ipcMain.handle('list-gitea-repos', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const repos = await listGiteaRepos({ token, url }); return { ok: true, repos }; } catch (e) { console.error('list-gitea-repos error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('list-github-repos', async (event, data) => { try { const credentials = readCredentials(); const githubToken = (data && data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte Token in Settings eintragen.' }; const repos = await listGithubRepos({ token: githubToken }); return { ok: true, repos }; } catch (e) { console.error('list-github-repos error', sanitizeErrorForLog(e)); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('get-github-current-user', async () => { try { const credentials = readCredentials(); if (!credentials?.githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const user = await getGithubCurrentUser({ token: credentials.githubToken }); return { ok: true, user }; } catch (e) { const errMsg = e.response?.data?.message || e.response?.data || e.message; const errStatus = e.response?.status; console.error('get-github-current-user error', errStatus, errMsg); return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; } }); ipcMain.handle('get-gitea-user-heatmap', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const result = await getGiteaUserHeatmap({ token, url }); return { ok: true, ...result }; } catch (e) { console.error('get-gitea-user-heatmap error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('get-github-user-heatmap', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.githubToken); if (!token) return { ok: false, error: 'GitHub Token fehlt.' }; const result = await getGithubUserHeatmap({ token, monthsBack: data?.monthsBack }); return { ok: true, ...result }; } catch (e) { console.error('get-github-user-heatmap error', sanitizeErrorForLog(e)); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('get-gitea-repo-contents', async (event, data) => { try { const credentials = readCredentials(); const owner = data.owner; const repo = data.repo; const p = data.path || ''; const ref = data.ref; if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const result = await getGithubRepoContents({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' }); return { ok: true, items: result.items || [], empty: result.empty || false }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const result = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref }); return { ok: true, items: result.items || result, empty: result.empty || false }; } catch (e) { console.error('get-gitea-repo-contents error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('get-gitea-file-content', async (event, data) => { try { const credentials = readCredentials(); const owner = data.owner; const repo = data.repo; const p = data.path; const ref = data.ref; if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' }); return { ok: true, content }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const content = await getGiteaFileContent({ token, url, owner, repo, path: p, ref }); return { ok: true, content }; } catch (e) { console.error('get-gitea-file-content error', e); return { ok: false, error: mapIpcError(e) }; } }); // Alias für Editor: read-gitea-file (für Text und Bilder) ipcMain.handle('read-gitea-file', async (event, data) => { try { const credentials = readCredentials(); const owner = data.owner; const repo = data.repo; const p = data.path; const ref = data.ref || 'HEAD'; // GitHub-Pfad if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(p); const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref }); // getGithubFileContent returns utf8 text; for images we re-encode as base64 if (isImage) { const base64 = Buffer.from(content, 'utf8').toString('base64'); return { ok: true, content: base64 }; } return { ok: true, content }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) { console.error('Missing token or URL'); return { ok: false, error: 'missing-token-or-url' }; } console.log(`read-gitea-file: ${owner}/${repo}/${p} (ref: ${ref})`); // Prüfe ob es eine Bilddatei ist const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(p); if (isImage) { // Für Bilder: Lade als Base64 console.log('Loading as image (Base64)'); const apiUrl = `${url}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(p)}?ref=${ref}`; console.log('Image URL:', apiUrl); return new Promise((resolve) => { try { const protocol = url.startsWith('https') ? https : http; protocol.get(apiUrl, { headers: { 'Authorization': `token ${token}`, 'User-Agent': 'Git-Manager-GUI' } }, (res) => { console.log(`Image response status: ${res.statusCode}`); if (res.statusCode !== 200) { resolve({ ok: false, error: `HTTP ${res.statusCode}` }); return; } const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { try { const buffer = Buffer.concat(chunks); const base64 = buffer.toString('base64'); console.log(`Image loaded: ${base64.length} bytes`); resolve({ ok: true, content: base64 }); } catch (e) { console.error('Base64 conversion error:', e.message); resolve({ ok: false, error: String(e) }); } }); }).on('error', (e) => { console.error('Image HTTP error:', e.message); resolve({ ok: false, error: String(e) }); }); } catch (e) { console.error('Image load try error:', e.message); resolve({ ok: false, error: String(e) }); } }); } else { // Für Text: Nutze normale Funktion console.log('Loading as text file'); const content = await getGiteaFileContent({ token, url, owner, repo, path: p, ref }); console.log(`Text file loaded: ${content.length} chars`); return { ok: true, content }; } } catch (e) { console.error('read-gitea-file error:', e.message, e.stack); return { ok: false, error: String(e) }; } }); ipcMain.handle('upload-gitea-file', async (event, data) => { const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { const credentials = readCredentials(); const owner = data.owner; const repo = data.repo; console.log('[UPLOAD_DEBUG][main] upload-gitea-file:start', { uploadDebugId, owner, repo, platform: data.platform || 'gitea', destPath: data.destPath || '', branch: data.branch || 'HEAD', localPathCount: Array.isArray(data.localPath) ? data.localPath.length : (data.localPath ? 1 : 0) }); // GitHub upload path if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: `GitHub Token fehlt. (${uploadDebugId})` }; const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); const branch = (data.branch && data.branch !== 'HEAD') ? data.branch : 'main'; const message = data.message || 'Upload via Git Manager GUI'; const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []); const results = []; for (const localFile of localFiles) { if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; } const raw = fs.readFileSync(localFile); const base64 = raw.toString('base64'); const fileName = ppath.basename(localFile); const targetPath = destPath ? `${destPath}/${fileName}` : fileName; try { const uploaded = await uploadGithubFile({ token: githubToken, owner, repo, path: targetPath, contentBase64: base64, message: `${message} - ${fileName}`, branch }); results.push({ file: localFile, ok: true, uploaded }); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); } } const failedCount = results.filter(r => !r.ok).length; console.log('[UPLOAD_DEBUG][main] upload-gitea-file:github-done', { uploadDebugId, failedCount, total: results.length }); return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: `missing-token-or-url (${uploadDebugId})` }; const owner2 = owner; // destPath is the target folder in the repo const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); // Branch wird unverändert übernommen (main UND master werden unterstützt) let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const message = data.message || 'Upload via Git Manager GUI'; const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []); const results = []; for (const localFile of localFiles) { if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; } const raw = fs.readFileSync(localFile); const base64 = raw.toString('base64'); const fileName = ppath.basename(localFile); // FIXED: Handle destPath correctly. Always combine destPath + filename. let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${fileName}`; } else { targetPath = fileName; } try { const uploaded = await uploadGiteaFile({ token, url, owner: owner2, repo, path: targetPath, contentBase64: base64, message: `${message} - ${fileName}`, branch }); results.push({ file: localFile, ok: true, uploaded }); } catch (e) { console.error('upload error for', localFile, e); results.push({ file: localFile, ok: false, error: String(e) }); } } const failedCount = results.filter(r => !r.ok).length; console.log('[UPLOAD_DEBUG][main] upload-gitea-file:gitea-done', { uploadDebugId, failedCount, total: results.length }); return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; } catch (e) { console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) }); return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; } }); // Alias für Editor: write-gitea-file ipcMain.handle('write-gitea-file', async (event, data) => { try { const credentials = readCredentials(); const owner = data.owner; const repo = data.repo; const path = data.path; const content = data.content || ''; const ref = data.ref || 'HEAD'; const base64 = Buffer.from(content, 'utf8').toString('base64'); if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const uploaded = await uploadGithubFile({ token: githubToken, owner, repo, path, contentBase64: base64, message: `Edit ${path} via Git Manager GUI`, branch: ref === 'HEAD' ? 'main' : ref }); return { ok: true, uploaded }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const uploaded = await uploadGiteaFile({ token, url, owner, repo, path, contentBase64: base64, message: `Edit ${path} via Git Manager GUI`, branch: ref }); return { ok: true, uploaded }; } catch (e) { console.error('write-gitea-file error', e); if (isRetryableNetworkError(e)) { const queued = enqueueRetryWriteTask(data || {}, e && e.message ? e.message : String(e)); return { ok: true, queued: true, queueId: queued.id, message: 'Netzwerkproblem erkannt. Änderung wurde in die Retry-Queue gelegt.' }; } return { ok: false, error: String(e) }; } }); ipcMain.handle('get-retry-queue', async () => { try { return { ok: true, size: retryQueue.length, items: retryQueue.slice(0, 100) }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('process-retry-queue-now', async () => { try { return await processRetryQueueOnce(); } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('remove-retry-queue-item', async (event, data) => { try { const id = data && data.id; if (!id) return { ok: false, error: 'missing-id' }; const before = retryQueue.length; retryQueue = retryQueue.filter(item => item.id !== id); if (retryQueue.length !== before) { saveRetryQueueToDisk(); broadcastRetryQueueUpdate({ event: 'removed', id }); } return { ok: true, size: retryQueue.length }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const localFolder = data.localFolder; const owner = data.owner; const repo = data.repo; // destPath is the target directory in the repo const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); // Branch robust behandeln: HEAD bedeutet Remote-Default-Branch nutzen let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const messagePrefix = data.messagePrefix || 'Upload folder via GUI'; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; if (!localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'local-folder-not-found' }; const items = []; const folderName = ppath.basename(localFolder); // FIXED EXCLUDE LIST: Filter out .git, node_modules etc. const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; (function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { if (excludeList.includes(entry)) continue; // Skip excluded folders/files const full = ppath.join(dir, entry); let stat; try { stat = fs.statSync(full); } catch(e) { continue; } // Skip unreadable files if (stat.isDirectory()) { walk(full); } else if (stat.isFile()) { const rel = ppath.relative(localFolder, full).split(ppath.sep).join('/'); // FIXED: Respect folder structure. Result: destPath/folderName/rel let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${folderName}/${rel}`; } else { targetPath = `${folderName}/${rel}`; } items.push({ localFile: full, targetPath }); } } })(localFolder); const total = items.length; if (total === 0) return { ok: true, results: [] }; const tasks = items.map(it => async () => { const raw = fs.readFileSync(it.localFile); const base64 = raw.toString('base64'); return uploadGiteaFile({ token, url, owner, repo, path: it.targetPath, contentBase64: base64, message: `${messagePrefix} - ${ppath.basename(it.localFile)}`, branch }); }); const onProgress = (processed, t) => { try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} }; const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); return { ok: true, results }; } catch (e) { console.error('upload-local-folder-to-gitea error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('download-gitea-file', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const filePath = data.path; if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; const content = await getGiteaFileContent({ token, url, owner, repo, path: filePath, ref: data.ref || 'HEAD' }); const save = await dialog.showSaveDialog({ defaultPath: ppath.basename(filePath) }); if (save.canceled || !save.filePath) return { ok: false, error: 'save-canceled' }; fs.writeFileSync(save.filePath, content, 'utf8'); return { ok: true, savedTo: save.filePath }; } catch (e) { console.error('download-gitea-file error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('download-gitea-folder', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const remotePath = (data.path || '').replace(/^\/+/, '').replace(/\/+$/, ''); const concurrency = data.concurrency || DEFAULT_CONCURRENCY; const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) return { ok: false, error: 'save-canceled' }; const destBase = result.filePaths[0]; const allFiles = []; async function gather(pathInRepo) { const _r = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' }); const items = _r.items || _r; for (const item of items) { if (item.type === 'dir') await gather(item.path); else if (item.type === 'file') allFiles.push(item.path); } } await gather(remotePath || ''); const total = allFiles.length; if (total === 0) return { ok: true, savedTo: destBase, files: [] }; const tasks = allFiles.map(remoteFile => async () => { const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: data.ref || 'HEAD' }); const localPath = ppath.join(destBase, remoteFile); fs.mkdirSync(ppath.dirname(localPath), { recursive: true }); fs.writeFileSync(localPath, content, 'utf8'); return localPath; }); const onProgress = (processed, t) => { try { event.sender.send('folder-download-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} }; const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); const savedFiles = results.filter(r => r.ok).map(r => r.result); return { ok: true, savedTo: destBase, files: savedFiles }; } catch (e) { console.error('download-gitea-folder error', e); return { ok: false, error: String(e) }; } }); /* ----------------------------- prepare-download-drag (robust) - stellt sicher, dass alle Dateien komplett geschrieben sind - erkennt Base64 vs UTF-8 und schreibt als Buffer wenn nötig - nutzt getSafeTmpDir() (siehe oben in deiner main.js) ----------------------------- */ function isBase64Like(str) { if (typeof str !== 'string') return false; // Strip whitespace/newlines const s = str.replace(/\s+/g, ''); if (s.length === 0) return false; // Base64 valid chars + padding if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) return false; // length must be multiple of 4 (except maybe line breaks removed) if (s.length % 4 !== 0) return false; try { // Round-trip check (cheap and practical) const decoded = Buffer.from(s, 'base64'); return decoded.toString('base64') === s; } catch (e) { return false; } } ipcMain.handle('prepare-download-drag', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const remotePath = (data.path || '').replace(/^\/+/, '').replace(/\/+$/, ''); // Create a unique temp directory (guarantees clean state) const tmpBase = getSafeTmpDir(repo || 'gitea-repo'); // Gather list of files (recursive) const allFiles = []; async function gather(pathInRepo) { const _r2 = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' }); const items = (_r2.items || _r2) || []; for (const item of items) { if (item.type === 'dir') await gather(item.path); else if (item.type === 'file') allFiles.push(item.path); } } await gather(remotePath || ''); // If no files, return early (still provide empty dir) if (allFiles.length === 0) { // schedule cleanup setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS); return { ok: true, tempPath: tmpBase, files: [] }; } // Download files sequentially or with limited concurrency: const tasks = allFiles.map(remoteFile => async () => { const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: data.ref || 'HEAD' }); const localPath = ppath.join(tmpBase, remoteFile); ensureDir(ppath.dirname(localPath)); // Decide how to write: if Buffer already, write directly. If string, try base64 detection if (Buffer.isBuffer(content)) { fs.writeFileSync(localPath, content); } else if (typeof content === 'string') { if (isBase64Like(content)) { const buf = Buffer.from(content, 'base64'); fs.writeFileSync(localPath, buf); } else { // treat as utf8 text fs.writeFileSync(localPath, content, 'utf8'); } } else { // fallback: convert to string fs.writeFileSync(localPath, String(content), 'utf8'); } return localPath; }); // runLimited ensures concurrency and waits for all writes to finish const results = await runLimited(tasks, data.concurrency || DEFAULT_CONCURRENCY); // verify at least one successful file const successFiles = results.filter(r => r.ok).map(r => r.result); if (successFiles.length === 0) { // cleanup on complete failure try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} return { ok: false, error: 'no-files-downloaded' }; } // give renderer the temp dir (renderer should then call 'ondragstart' with the folder path) // schedule cleanup after delay to keep files available for drag & drop setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS); return { ok: true, tempPath: tmpBase, files: successFiles }; } catch (e) { console.error('prepare-download-drag error', e); return { ok: false, error: String(e) }; } }); /* ----------------------------- ondragstart (no change to API but more defensive) - expects renderer to call window.electronAPI.ondragStart(tempPath) ----------------------------- */ ipcMain.on('ondragstart', async (event, filePath) => { try { if (!filePath || !fs.existsSync(filePath)) { console.warn('ondragstart: path missing or not exists:', filePath); return; } // Prefer folder icon when dragging a directory let icon = nativeImage.createEmpty(); try { // ask platform for file icon; large size for clearer drag icon icon = await app.getFileIcon(filePath, { size: 'large' }); } catch (e) { // ignore, keep empty icon } // startDrag accepts { file } where file can be a directory try { event.sender.startDrag({ file: filePath, icon }); } catch (e) { // some platforms may require a single file — if folder fails, try to drop a placeholder file console.error('startDrag failed for', filePath, e); } } catch (e) { console.error('ondragstart error', e); } }); ipcMain.handle('delete-gitea-repo', async (event, data) => { try { const credentials = readCredentials(); if (data && data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; await deleteGithubRepo({ token: githubToken, owner: data.owner, repo: data.repo }); return { ok: true }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const urlStr = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !urlStr) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const urlObj = new URL(urlStr); const protocol = urlObj.protocol === 'https:' ? https : http; const deletePath = `/api/v1/repos/${owner}/${repo}`; return new Promise((resolve) => { const options = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: deletePath, method: 'DELETE', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } }; const req = protocol.request(options, (res) => { let body = ''; res.on('data', chunk => body += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true }); else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${body}` }); }); }); req.on('error', (e) => resolve({ ok: false, error: String(e) })); req.end(); }); } catch (e) { console.error('delete-gitea-repo error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('upload-and-push', async (event, data) => { const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` }; const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const giteaUrl = (data && data.url) || (credentials && credentials.giteaURL); const owner = data.owner; const repo = data.repo; // Branch robust behandeln: HEAD nutzt den Remote-Default, sonst nur sichere Refs let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const cloneUrl = data.cloneUrl || null; const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); if (!owner || !repo) return { ok: false, error: `missing-owner-or-repo (${uploadDebugId})` }; console.log('[UPLOAD_DEBUG][main] upload-and-push:start', { uploadDebugId, localFolder: data.localFolder, owner, repo, branch, destPath, hasCloneUrl: !!cloneUrl, hasToken: !!token, hasUrl: !!giteaUrl }); const gitExecOptions = { stdio: 'pipe', encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', PAGER: 'cat' }, maxBuffer: 10 * 1024 * 1024 }; // Prüfen ob es eine Datei oder ein Ordner ist const stat = fs.statSync(data.localFolder); const isFile = stat.isFile(); const isDirectory = stat.isDirectory(); console.log('[UPLOAD_DEBUG][main] upload-and-push:path-type', { uploadDebugId, isFile, isDirectory }); // --- FALL 1: Einzelne Datei --- if (isFile) { console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId }); const raw = fs.readFileSync(data.localFolder); const base64 = raw.toString('base64'); const fileName = ppath.basename(data.localFolder); // Pfad-Logik für Ziel let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${fileName}`; } else { targetPath = fileName; } try { // VERSUCH 1: API Upload const result = await uploadGiteaFile({ token, url: giteaUrl, owner, repo, path: targetPath, contentBase64: base64, message: `Upload ${fileName} via GUI`, branch }); return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId }; } catch (e) { console.error('[UPLOAD_DEBUG][main] single-file:api-error', { uploadDebugId, error: String(e && e.message ? e.message : e) }); // FALLBACK: Wenn API fehlschlägt (z.B. 422 SHA Fehler), nutze Git // Git löst das "Überschreiben"-Problem zuverlässig. console.log('Falling back to Git for single file...'); let finalCloneUrl = cloneUrl; if (!finalCloneUrl && giteaUrl) { try { const base = giteaUrl.replace(/\/$/, ''); const urlObj = new URL(base); finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`; } catch (err) { console.error('Invalid URL', err); } } if (!finalCloneUrl) return { ok: false, error: `Cannot fallback to Git: No Clone URL (${uploadDebugId})` }; // Git-Workflow für einzelne Datei simulieren: // 1. Temp Ordner erstellen // 2. Repo klonen // 3. Datei in Ordner kopieren // 4. Commit & Push let authClone = finalCloneUrl; try { const urlObj = new URL(finalCloneUrl); if (token && (urlObj.protocol.startsWith('http'))) { urlObj.username = encodeURIComponent(token); authClone = urlObj.toString(); } } catch (err) {} const tmpDir = getSafeTmpDir(`git-push-file-${owner}-${repo}`); try { const cloneArgs = ['clone', '--depth', '1']; if (branch !== 'HEAD') cloneArgs.push('--branch', branch); cloneArgs.push(authClone, tmpDir); runGitSync(cloneArgs, process.cwd(), gitExecOptions); // Zielort im Repo bestimmen let destDirInRepo = tmpDir; if (destPath) { destDirInRepo = ppath.join(tmpDir, destPath.split('/').join(ppath.sep)); ensureDir(destDirInRepo); } const finalFileDest = ppath.join(destDirInRepo, fileName); // Datei kopieren fs.copyFileSync(data.localFolder, finalFileDest); // Git Befehle runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions); try { runGitSync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {} let pushBranch = branch; if (pushBranch === 'HEAD') { try { pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim(); } catch (_) { pushBranch = 'main'; } } runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions); setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); return { ok: true, usedGit: true, singleFile: true, msg: 'Uploaded via Git (Fallback)', debugId: uploadDebugId }; } catch (gitErr) { console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) }); if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` }; } } } // --- FALL 2: Ordner (Normale Git-Logik) --- if (!isDirectory) { return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` }; } let gitAvailable = true; try { runGitSync(['--version'], process.cwd(), { stdio: 'ignore', env: gitExecOptions.env }); } catch (e) { gitAvailable = false; } let finalCloneUrl = cloneUrl; if (!finalCloneUrl && giteaUrl) { try { const base = giteaUrl.replace(/\/$/, ''); const urlObj = new URL(base); finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`; } catch (e) {} } if (gitAvailable && finalCloneUrl) { let authClone = finalCloneUrl; try { const urlObj = new URL(finalCloneUrl); if (token && (urlObj.protocol.startsWith('http'))) { urlObj.username = encodeURIComponent(token); authClone = urlObj.toString(); } } catch (e) {} const tmpDir = getSafeTmpDir(`gitea-push-${owner}-${repo}`); try { const cloneArgs = ['clone', '--depth', '1']; if (branch !== 'HEAD') cloneArgs.push('--branch', branch); cloneArgs.push(authClone, tmpDir); runGitSync(cloneArgs, process.cwd(), gitExecOptions); // FIXED: Respektieren von destPath und Ordnernamen im Git-Workflow const folderName = ppath.basename(data.localFolder); let targetBaseDir = tmpDir; if (destPath) { // Wenn ein Zielpfad angegeben ist (z.B. 'src'), erstelle diesen Ordner im Repo const osDestPath = destPath.split('/').join(ppath.sep); targetBaseDir = ppath.join(tmpDir, osDestPath); ensureDir(targetBaseDir); } // Ziel ist immer: targetBaseDir/folderName const finalDest = ppath.join(targetBaseDir, folderName); // FIXED: Korrekter Umgang mit fs.cpSync // Wenn finalDest bereits existiert, muss es entfernt werden, damit cpSync funktioniert if (fs.existsSync(finalDest)) { fs.rmSync(finalDest, { recursive: true, force: true }); } // Kopieren if (fs.cpSync) { fs.cpSync(data.localFolder, finalDest, { recursive: true, force: true }); } else { const copyRecursive = (src, dst) => { const st = fs.statSync(src); if (st.isDirectory()) { if (!fs.existsSync(dst)) fs.mkdirSync(dst, { recursive: true }); const entries = fs.readdirSync(src); for (const entry of entries) { copyRecursive(ppath.join(src, entry), ppath.join(dst, entry)); } return; } fs.copyFileSync(src, dst); }; copyRecursive(data.localFolder, finalDest); } try { runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions); try { runGitSync(['-C', tmpDir, 'commit', '-m', 'Update from Git Manager GUI'], process.cwd(), gitExecOptions); } catch (_) {} let pushBranch = branch; if (pushBranch === 'HEAD') { try { pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim(); } catch (_) { pushBranch = 'main'; } } runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions); } catch (e) { throw e; } setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); return { ok: true, usedGit: true, msg: 'Uploaded via git push', debugId: uploadDebugId }; } catch (e) { console.error('[UPLOAD_DEBUG][main] git-flow-error', { uploadDebugId, error: String(e) }); try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} } } // Fallback: API Upload (paralleler Upload) const items = []; const folderName = ppath.basename(data.localFolder); // FIXED EXCLUDE LIST: Filter out .git, node_modules etc. const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; (function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { if (excludeList.includes(entry)) continue; // Skip excluded folders/files const full = ppath.join(dir, entry); let stat; try { stat = fs.statSync(full); } catch(e) { continue; } if (stat.isDirectory()) { walk(full); } else if (stat.isFile()) { const rel = ppath.relative(data.localFolder, full).split(ppath.sep).join('/'); // FIXED: Pfad-Respektierung: destPath/folderName/rel let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${folderName}/${rel}`; } else { targetPath = `${folderName}/${rel}`; } items.push({ localFile: full, targetPath }); } } })(data.localFolder); const total = items.length; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId }; console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath }); const tasks = items.map(it => async () => { const raw = fs.readFileSync(it.localFile); const base64 = raw.toString('base64'); return uploadGiteaFile({ token, url: giteaUrl, owner, repo, path: it.targetPath, contentBase64: base64, message: `Upload via GUI - ${ppath.basename(it.localFile)}`, branch }); }); const onProgress = (processed, t) => { try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} }; const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); 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 }; } catch (e) { console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) }); return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; } }); ipcMain.handle('get-path-type', async (_event, filePath) => { try { if (!filePath || typeof filePath !== 'string') { return { ok: false, error: 'invalid-path' }; } if (!fs.existsSync(filePath)) { return { ok: true, type: 'missing' }; } const stat = fs.statSync(filePath); if (stat.isFile()) return { ok: true, type: 'file' }; if (stat.isDirectory()) return { ok: true, type: 'dir' }; return { ok: true, type: 'other' }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('run-batch-repo-action', async (event, data) => { try { const credentials = readCredentials(); if (!credentials || !credentials.giteaToken || !credentials.giteaURL) { return { ok: false, error: 'missing-token-or-url' }; } const action = (data && data.action) || 'refresh'; const rawRepos = Array.isArray(data && data.repos) ? data.repos : []; const options = (data && data.options) || {}; const repos = rawRepos .map(r => String(r || '').trim()) .filter(Boolean) .map(v => { const [owner, repo] = v.split('/'); return { owner, repo, id: `${owner}/${repo}` }; }) .filter(r => r.owner && r.repo); if (repos.length === 0) { return { ok: false, error: 'no-valid-repositories' }; } const cloneBaseUrl = String(credentials.giteaURL || '').replace(/\/$/, ''); const token = credentials.giteaToken; const sendProgress = (payload) => { try { event.sender.send('batch-action-progress', payload); } catch (_) {} }; const results = []; for (let i = 0; i < repos.length; i++) { const item = repos[i]; const progressBase = { index: i + 1, total: repos.length, repo: item.id, action }; sendProgress({ ...progressBase, status: 'running' }); try { if (action === 'refresh') { await getGiteaRepoContents({ token, url: credentials.giteaURL, owner: item.owner, repo: item.repo, path: '', ref: 'HEAD' }); results.push({ repo: item.id, ok: true, message: 'Repository aktualisiert' }); } else if (action === 'clone') { const targetDir = options.cloneTargetDir; if (!targetDir) throw new Error('clone-target-missing'); const repoDir = ppath.join(targetDir, item.repo); if (fs.existsSync(repoDir)) throw new Error(`target-exists: ${repoDir}`); const cloneUrl = `${cloneBaseUrl}/${item.owner}/${item.repo}.git`; let authCloneUrl = cloneUrl; try { const u = new URL(cloneUrl); if (u.protocol.startsWith('http')) { u.username = encodeURIComponent(token); authCloneUrl = u.toString(); } } catch (_) {} runGitSync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' }); results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` }); } else if (action === 'create-tag') { const tag = String(options.tag || '').trim(); if (!tag) throw new Error('tag-missing'); await createGiteaRelease({ token, url: credentials.giteaURL, owner: item.owner, repo: item.repo, data: { tag_name: tag, name: tag, body: options.body || '', draft: false, prerelease: !!options.prerelease, target_commitish: options.target_commitish || 'HEAD' } }); results.push({ repo: item.id, ok: true, message: `Tag erstellt: ${tag}` }); } else if (action === 'create-release') { const tag = String(options.tag || '').trim(); if (!tag) throw new Error('tag-missing'); const name = String(options.name || tag).trim(); await createGiteaRelease({ token, url: credentials.giteaURL, owner: item.owner, repo: item.repo, data: { tag_name: tag, name, body: options.body || '', draft: !!options.draft, prerelease: !!options.prerelease, target_commitish: options.target_commitish || 'HEAD' } }); results.push({ repo: item.id, ok: true, message: `Release erstellt: ${name}` }); } else { throw new Error(`unknown-action: ${action}`); } sendProgress({ ...progressBase, status: 'ok' }); } catch (e) { const msg = mapIpcError(e); results.push({ repo: item.id, ok: false, error: msg }); sendProgress({ ...progressBase, status: 'error', error: msg }); } } const success = results.filter(r => r.ok).length; const failed = results.length - success; return { ok: true, action, summary: { total: results.length, success, failed }, results }; } catch (e) { console.error('run-batch-repo-action error', e); return { ok: false, error: String(e) }; } }); /* ================================ RENAME / CREATE / MOVE HANDLERS ================================ */ // Gitea: Datei/Ordner umbenennen (= alle Dateien kopieren + alte löschen) ipcMain.handle('rename-gitea-item', async (event, data) => { try { const credentials = readCredentials(); const token = credentials?.giteaToken; const giteaUrl = credentials?.giteaURL; if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' }; const { owner, repo, oldPath, newPath, isDir } = data; const urlObj = new URL(giteaUrl); const protocol = urlObj.protocol === 'https:' ? https : http; const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); function giteaRequest(method, apiPath, body) { return new Promise((resolve, reject) => { const bodyStr = body ? JSON.stringify(body) : null; const req = protocol.request({ hostname: urlObj.hostname, port, path: apiPath, method, headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json', ...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}) } }, (res) => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(b) }); } catch (_) { resolve({ status: res.statusCode, body: b }); } }); }); req.on('error', reject); if (bodyStr) req.write(bodyStr); req.end(); }); } async function collectFiles(path) { const r = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=HEAD`, null); const files = []; if (Array.isArray(r.body)) { for (const item of r.body) { if (item.type === 'dir') files.push(...await collectFiles(item.path)); else files.push({ path: item.path, sha: item.sha }); } } else if (r.body?.sha) { files.push({ path: r.body.path, sha: r.body.sha }); } return files; } async function readFileContent(filePath) { const r = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}?ref=HEAD`, null); return r.body?.content ? r.body.content.replace(/\n/g, '') : ''; } async function uploadFile(targetPath, contentBase64, message) { // Check if exists first (need SHA for update) const check = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}?ref=HEAD`, null); const body = { message, content: contentBase64, branch: 'HEAD' }; if (check.body?.sha) body.sha = check.body.sha; return giteaRequest('POST', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}`, body); } async function deleteFile(filePath, sha) { return giteaRequest('DELETE', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`, { message: `Delete ${filePath} (rename)`, sha, branch: 'HEAD' }); } // Collect all files under oldPath const files = await collectFiles(oldPath); // For each file: read content, upload to newPath, delete from oldPath for (const f of files) { const content = await readFileContent(f.path); const relPath = isDir ? f.path.slice(oldPath.length + 1) : ''; const targetPath = isDir ? `${newPath}/${relPath}` : newPath; await uploadFile(targetPath, content, `Rename: move ${f.path} to ${targetPath}`); await deleteFile(f.path, f.sha); } return { ok: true }; } catch (e) { console.error('rename-gitea-item error', e); return { ok: false, error: String(e) }; } }); // Gitea: Neue Datei oder Ordner (Ordner = Datei mit .gitkeep) ipcMain.handle('create-gitea-item', async (event, data) => { try { const credentials = readCredentials(); const token = credentials?.giteaToken; const giteaUrl = credentials?.giteaURL; if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' }; const { owner, repo, path: itemPath, type } = data; const urlObj = new URL(giteaUrl); const protocol = urlObj.protocol === 'https:' ? https : http; const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); // Für Ordner: .gitkeep Datei anlegen const targetPath = type === 'folder' ? `${itemPath}/.gitkeep` : itemPath; const content = Buffer.from('').toString('base64'); return new Promise((resolve) => { const body = JSON.stringify({ message: `Create ${itemPath}`, content, branch: data.branch || 'HEAD' }); const req = protocol.request({ hostname: urlObj.hostname, port, path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}`, method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (res) => { let b = ''; res.on('data', c => b += c); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true }); else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${b}` }); }); }); req.on('error', e => resolve({ ok: false, error: String(e) })); req.write(body); req.end(); }); } catch (e) { console.error('create-gitea-item error', e); return { ok: false, error: String(e) }; } }); // Lokal: Umbenennen ipcMain.handle('rename-local-item', async (event, data) => { try { const { oldPath, newName } = data; if (!oldPath || !fs.existsSync(oldPath)) return { ok: false, error: 'path-not-found' }; const dir = ppath.dirname(oldPath); const newPath = ppath.join(dir, newName); fs.renameSync(oldPath, newPath); return { ok: true, newPath }; } catch (e) { console.error('rename-local-item error', e); return { ok: false, error: String(e) }; } }); // Lokal: Neue Datei oder Ordner erstellen ipcMain.handle('create-local-item', async (event, data) => { try { const { parentDir, name, type } = data; const targetPath = ppath.join(parentDir, name); if (type === 'folder') { fs.mkdirSync(targetPath, { recursive: true }); } else { // Sicherstellen dass Elternordner existiert fs.mkdirSync(ppath.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, '', 'utf8'); } return { ok: true, path: targetPath }; } catch (e) { console.error('create-local-item error', e); return { ok: false, error: String(e) }; } }); // Lokal: Verschieben (Cut & Paste) ipcMain.handle('move-local-item', async (event, data) => { try { const { srcPath, destDir } = data; if (!srcPath || !fs.existsSync(srcPath)) return { ok: false, error: 'source-not-found' }; const name = ppath.basename(srcPath); const destPath = ppath.join(destDir, name); fs.mkdirSync(destDir, { recursive: true }); fs.renameSync(srcPath, destPath); return { ok: true, destPath }; } catch (e) { // renameSync kann über Laufwerke nicht funktionieren — dann cpSync + rmSync try { const { srcPath, destDir } = data; const name = ppath.basename(srcPath); const destPath = ppath.join(destDir, name); if (fs.statSync(srcPath).isDirectory()) { fs.cpSync(srcPath, destPath, { recursive: true }); } else { fs.copyFileSync(srcPath, destPath); } fs.rmSync(srcPath, { recursive: true, force: true }); return { ok: true, destPath }; } catch (e2) { console.error('move-local-item error', e2); return { ok: false, error: String(e2) }; } } }); // Lokal: Kopieren ipcMain.handle('copy-local-item', async (event, data) => { try { const { src, destDir } = data; if (!src || !fs.existsSync(src)) return { ok: false, error: 'source-not-found' }; const name = ppath.basename(src); const dest = ppath.join(destDir, name); fs.mkdirSync(destDir, { recursive: true }); if (fs.statSync(src).isDirectory()) { fs.cpSync(src, dest, { recursive: true }); } else { fs.copyFileSync(src, dest); } return { ok: true, dest }; } catch (e) { console.error('copy-local-item error', e); return { ok: false, error: String(e) }; } }); /* ================================ RELEASE MANAGEMENT IPC HANDLERS ================================ */ // List all releases for a repository ipcMain.handle('list-releases', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; if (data.platform === 'github') { const githubToken = (data.token) || credentials.githubToken; if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const releases = await listGithubReleases({ token: githubToken, owner: data.owner, repo: data.repo }); return { ok: true, releases }; } const releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo }); return { ok: true, releases }; } catch (error) { console.error('list-releases error:', error); return { ok: false, error: String(error) }; } }); // Get a specific release by tag ipcMain.handle('get-release', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; const release = await getGiteaRelease({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, tag: data.tag }); return { ok: true, release }; } catch (error) { console.error('get-release error:', error); return { ok: false, error: String(error) }; } }); // Create a new release ipcMain.handle('create-release', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; const releaseData = { tag_name: data.tag_name, name: data.name, body: data.body, draft: data.draft, prerelease: data.prerelease, target_commitish: data.target_commitish }; if (data.platform === 'github') { const githubToken = (data.token) || credentials.githubToken; if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const release = await createGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, data: releaseData }); return { ok: true, release }; } const release = await createGiteaRelease({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, data: releaseData }); return { ok: true, release }; } catch (error) { console.error('create-release error:', error); const errorMsg = error.message || String(error); return { ok: false, error: errorMsg }; } }); // Edit/update a release ipcMain.handle('edit-release', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; const updateData = {}; if (data.name !== undefined) updateData.name = data.name; if (data.body !== undefined) updateData.body = data.body; if (data.draft !== undefined) updateData.draft = data.draft; if (data.prerelease !== undefined) updateData.prerelease = data.prerelease; if (data.platform === 'github') { const githubToken = (data.token) || credentials.githubToken; if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const release = await editGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId, data: updateData }); return { ok: true, release }; } const release = await editGiteaRelease({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, releaseId: data.releaseId, data: updateData }); return { ok: true, release }; } catch (error) { console.error('edit-release error:', error); return { ok: false, error: String(error) }; } }); // Delete a release ipcMain.handle('delete-release', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; if (data.platform === 'github') { const githubToken = (data.token) || credentials.githubToken; if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; await deleteGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId }); return { ok: true }; } await deleteGiteaRelease({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, releaseId: data.releaseId }); return { ok: true }; } catch (error) { console.error('delete-release error:', error); return { ok: false, error: String(error) }; } }); // Upload a release asset ipcMain.handle('upload-release-asset', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; const asset = await uploadReleaseAsset({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, releaseId: data.releaseId, filePath: data.filePath, fileName: data.fileName }); return { ok: true, asset }; } catch (error) { console.error('upload-release-asset error:', error); return { ok: false, error: String(error) }; } }); // Delete a release asset ipcMain.handle('delete-release-asset', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; await deleteReleaseAsset({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo, assetId: data.assetId }); return { ok: true }; } catch (error) { console.error('delete-release-asset error:', error); return { ok: false, error: String(error) }; } }); // Download release archive (ZIP/TAR) ipcMain.handle('download-release-archive', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; const base = credentials.giteaURL.replace(/\/$/, ''); const archiveUrl = `${base}/${data.owner}/${data.repo}/archive/${data.tag}.zip`; // Ask user where to save const result = await dialog.showSaveDialog({ defaultPath: `${data.repo}-${data.tag}.zip`, filters: [{ name: 'ZIP Archive', extensions: ['zip'] }] }); if (result.canceled) return { ok: false, canceled: true }; const savePath = result.filePath; // Download archive const axios = require('axios'); const fs = require('fs'); const writer = fs.createWriteStream(savePath); const response = await axios({ url: archiveUrl, method: 'GET', responseType: 'stream', headers: { Authorization: `token ${credentials.giteaToken}` } }); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => resolve({ ok: true, savedTo: savePath })); writer.on('error', (err) => reject(err)); }); } catch (error) { console.error('download-release-archive error:', error); return { ok: false, error: String(error) }; } }); /* ======================== LOCAL COMMIT HANDLERS ======================== */ ipcMain.handle('get-local-commits', async (event, data) => { try { const { getCommitLogs } = require('./src/git/gitHandler.js'); const commits = await getCommitLogs(data.folderPath, data.branch || 'HEAD'); return { ok: true, commits }; } catch (error) { console.error('get-local-commits error:', error); return { ok: false, error: String(error) }; } }); ipcMain.handle('get-local-commit-details', async (event, data) => { try { const { getCommitDetails } = require('./src/git/gitHandler.js'); const details = await getCommitDetails(data.folderPath, data.sha); return { ok: true, ...details }; } catch (error) { console.error('get-local-commit-details error:', error); return { ok: false, error: String(error) }; } }); ipcMain.handle('get-local-commit-diff', async (event, data) => { try { const { getCommitDiff } = require('./src/git/gitHandler.js'); const diff = await getCommitDiff(data.folderPath, data.sha); return { ok: true, diff }; } catch (error) { console.error('get-local-commit-diff error:', error); return { ok: false, error: String(error) }; } }); ipcMain.handle('get-local-commit-files', async (event, data) => { try { const { getCommitDetails } = require('./src/git/gitHandler.js'); const details = await getCommitDetails(data.folderPath, data.sha); return { ok: true, files: details.files || [], stats: { additions: 0, deletions: 0 } }; } catch (error) { console.error('get-local-commit-files error:', error); return { ok: false, error: String(error) }; } }); /* ======================== GITEA COMMIT HANDLERS ======================== */ ipcMain.handle('get-commit-diff', async (event, data) => { try { const credentials = readCredentials(); if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const diff = await getGithubCommitDiff({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, diff }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const diff = await getGiteaCommitDiff({ token, url, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, diff }; } catch (error) { console.error('get-commit-diff error:', error); return { ok: false, error: String(error) }; } }); ipcMain.handle('get-commit-files', async (event, data) => { try { const credentials = readCredentials(); if (data.platform === 'github') { const githubToken = (data.token) || (credentials && credentials.githubToken); if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; const result = await getGithubCommitFiles({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, files: result.files, stats: result.stats }; } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const result = await getGiteaCommitFiles({ token, url, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, files: result.files, stats: result.stats }; } catch (error) { console.error('get-commit-files error:', error); return { ok: false, error: String(error) }; } }); /* ============================================ FAVORITEN & ZULETZT GEÖFFNET - Persistenz ============================================ */ function getFavoritesFilePath() { return ppath.join(app.getPath('userData'), 'favorites.json'); } function getRecentFilePath() { return ppath.join(app.getPath('userData'), 'recent.json'); } ipcMain.handle('load-favorites', async () => { try { const p = getFavoritesFilePath(); if (!fs.existsSync(p)) return { ok: true, favorites: [] }; return { ok: true, favorites: JSON.parse(fs.readFileSync(p, 'utf8')) || [] }; } catch (e) { return { ok: true, favorites: [] }; } }); ipcMain.handle('save-favorites', async (event, favorites) => { try { fs.writeFileSync(getFavoritesFilePath(), JSON.stringify(favorites || [], null, 2), 'utf8'); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('load-recent', async () => { try { const p = getRecentFilePath(); if (!fs.existsSync(p)) return { ok: true, recent: [] }; return { ok: true, recent: JSON.parse(fs.readFileSync(p, 'utf8')) || [] }; } catch (e) { return { ok: true, recent: [] }; } }); ipcMain.handle('save-recent', async (event, recent) => { try { const trimmed = (recent || []).slice(0, 20); fs.writeFileSync(getRecentFilePath(), JSON.stringify(trimmed, null, 2), 'utf8'); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } }); // main.js - Updater IPC Handlers // Window Controls ipcMain.on('window-minimize', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) win.minimize(); }); ipcMain.on('window-maximize', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.isMaximized() ? win.unmaximize() : win.maximize(); } }); ipcMain.on('window-close', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) win.close(); }); // Autostart ipcMain.handle('set-autostart', (event, enable) => { app.setLoginItemSettings({ openAtLogin: enable, openAsHidden: true, args: ['--hidden'] }); return { ok: true }; }); ipcMain.handle('get-autostart', () => { const settings = app.getLoginItemSettings({ args: ['--hidden'] }); return { ok: true, enabled: settings.openAtLogin }; }); ipcMain.handle('copy-to-clipboard', async (_event, text) => { try { clipboard.writeText(String(text || '')); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } }); function isAllowedExternalUrl(rawUrl) { try { const parsed = new URL(String(rawUrl || '').trim()); const protocol = parsed.protocol.toLowerCase(); if (protocol === 'https:' || protocol === 'mailto:') return true; if (protocol === 'http:') { const host = String(parsed.hostname || '').toLowerCase(); return host === 'localhost' || host === '127.0.0.1' || host === '::1'; } return false; } catch (_) { return false; } } ipcMain.handle('open-external-url', async (_event, rawUrl) => { try { const url = String(rawUrl || '').trim(); if (!url) return { ok: false, error: 'Leere URL' }; if (!isAllowedExternalUrl(url)) { return { ok: false, error: 'Nicht erlaubte URL.' }; } await shell.openExternal(url); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } }); // 1. Version abfragen ipcMain.handle('get-app-version', async () => { return { ok: true, version: app.getVersion() }; }); // 2. Suche nach Updates (Manuell oder Automatisch) ipcMain.handle('check-for-updates', async (event, options = {}) => { console.log("[Main] Update-Check angefordert..."); try { const silent = Boolean(options && options.silent); if (!updater) { const win = BrowserWindow.fromWebContents(event.sender); if (win) updater = new Updater(win); } if (updater) await updater.checkForUpdates(silent); return { ok: true }; } catch (error) { console.error('[Main] Fehler beim Update-Check:', error); return { ok: false, error: String(error) }; } }); // 3. Download starten (wird vom "Jetzt installieren" Button gerufen) ipcMain.handle('start-update-download', async (event, asset) => { console.log("[Main] Download-Signal erhalten für:", asset ? asset.name : "Unbekannt"); try { if (!updater) { const win = BrowserWindow.fromWebContents(event.sender); updater = new Updater(win); } if (asset && asset.browser_download_url) { await updater.startDownload(asset); return { ok: true }; } return { ok: false, error: 'Ungültiges Asset' }; } catch (error) { console.error('[Main] Download-Fehler:', error); return { ok: false, error: String(error) }; } }); ipcMain.handle('test-gitea-connection', async (event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!url) return { ok: false, error: 'Gitea URL fehlt.' }; const result = await checkGiteaConnection({ token, url, timeout: (data && data.timeout) || 8000 }); return { ok: result.ok, result }; } catch (e) { console.error('test-gitea-connection error', e); return { ok: false, error: mapIpcError(e) }; } }); ipcMain.handle('validate-repo-name', async (_event, data) => { try { const name = String(data && data.name || '').trim(); const platform = String(data && data.platform || 'gitea').trim().toLowerCase(); const validPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,98}[a-zA-Z0-9])?$/; const validFormat = validPattern.test(name); if (!name) { return { ok: true, validFormat: false, existsExact: false, similar: [], checked: false, reason: 'empty-name' }; } if (!validFormat) { return { ok: true, validFormat: false, existsExact: false, similar: [], checked: false, reason: 'invalid-format' }; } if (platform !== 'gitea') { return { ok: true, validFormat: true, existsExact: false, similar: [], checked: false, reason: 'platform-not-supported' }; } const credentials = readCredentials(); if (!credentials || !credentials.giteaToken || !credentials.giteaURL) { return { ok: true, validFormat: true, existsExact: false, similar: [], checked: false, reason: 'missing-credentials' }; } const list = await listGiteaRepos({ token: credentials.giteaToken, url: credentials.giteaURL }); const repos = Array.isArray(list) ? list : []; const allNames = repos .map(r => String(r && r.name || '').trim()) .filter(Boolean); const lower = name.toLowerCase(); const normalized = lower.replace(/[\s._-]+/g, ''); const existsExact = allNames.some(n => n.toLowerCase() === lower); const similar = allNames .filter(n => { const nLower = n.toLowerCase(); const nNorm = nLower.replace(/[\s._-]+/g, ''); return nLower.includes(lower) || lower.includes(nLower) || nNorm.includes(normalized) || normalized.includes(nNorm); }) .filter(n => n.toLowerCase() !== lower) .slice(0, 8); return { ok: true, validFormat: true, existsExact, similar, checked: true, totalKnown: allNames.length }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('check-clone-target-collisions', async (_event, data) => { try { const targetDir = String(data && data.targetDir || '').trim(); const rawRepos = Array.isArray(data && data.repos) ? data.repos : []; const repos = rawRepos .map(v => String(v || '').trim()) .filter(Boolean) .map(v => { const parts = v.split('/'); const repo = (parts[1] || '').trim(); return { input: v, repo }; }) .filter(r => r.repo); const duplicateRepoNames = []; const seen = new Map(); for (const item of repos) { const key = item.repo.toLowerCase(); const count = (seen.get(key) || 0) + 1; seen.set(key, count); } for (const [name, count] of seen.entries()) { if (count > 1) duplicateRepoNames.push(name); } const existingTargets = []; if (targetDir) { for (const item of repos) { const repoDir = ppath.join(targetDir, item.repo); if (fs.existsSync(repoDir)) { existingTargets.push(repoDir); } } } return { ok: true, duplicateRepoNames, existingTargets, hasCollisions: duplicateRepoNames.length > 0 || existingTargets.length > 0 }; } catch (e) { return { ok: false, error: String(e) }; } }); ipcMain.handle('sync-repo-to-github', async (event, data) => { let mirrorDir = null; let sourceDefaultBranch = ''; try { const creds = readCredentials(); if (!creds?.githubToken) { return { ok: false, error: 'GitHub Token fehlt. Bitte in den Einstellungen eintragen.' }; } const owner = String(data?.owner || '').trim(); const repo = String(data?.repo || '').trim(); if (!owner || !repo) { return { ok: false, error: 'Owner oder Repository fehlt.' }; } let targetOwner = String(data?.targetOwner || '').trim(); if (!targetOwner) { const me = await getGithubCurrentUser({ token: creds.githubToken }); targetOwner = me?.login || ''; } if (!targetOwner) { return { ok: false, error: 'GitHub Benutzer konnte nicht ermittelt werden.' }; } // GitHub repo erstellen (falls noch nicht vorhanden) let repoCreated = false; try { await createRepoGitHub({ name: repo, token: creds.githubToken, auto_init: false, private: !!data?.isPrivate, description: String(data?.description || ''), homepage: String(data?.homepage || '') }); repoCreated = true; } catch (e) { const msg = String(e?.message || e || ''); if (!/already exists|already_exists|existiert bereits|name already exists/i.test(msg)) { // Falls GitHub mit einer unscharfen Meldung antwortet, explizit auf Existenz pruefen. const exists = await githubRepoExists({ token: creds.githubToken, owner: targetOwner, repo }).catch(() => false); if (!exists) { throw e; } } } let sourceCloneUrl = String(data?.cloneUrl || '').trim(); if (!sourceCloneUrl) { if (!creds?.giteaURL) return { ok: false, error: 'Clone-URL fehlt und Gitea URL ist nicht gesetzt.' }; sourceCloneUrl = `${String(creds.giteaURL).replace(/\/$/, '')}/${owner}/${repo}.git`; } // Quelle auf Gitea: URL ohne Userinfo verwenden und Auth nur über temporären Git-Header übergeben. let cloneNeedsGiteaHeader = false; let sourceOrigin = ''; try { const sourceUrlObj = new URL(sourceCloneUrl); const giteaHost = creds?.giteaURL ? new URL(creds.giteaURL).host : null; const isGiteaSource = giteaHost && sourceUrlObj.host === giteaHost; // Niemals eingebettete Zugangsdaten in URLs behalten. if (sourceUrlObj.username || sourceUrlObj.password) { sourceUrlObj.username = ''; sourceUrlObj.password = ''; } sourceCloneUrl = sourceUrlObj.toString(); if (isGiteaSource && creds?.giteaToken) { sourceOrigin = `${sourceUrlObj.origin}/`; cloneNeedsGiteaHeader = true; } } catch (_) {} mirrorDir = fs.mkdtempSync(ppath.join(os.tmpdir(), 'git-manager-sync-')); // --bare statt --mirror: lädt alle Branches/Tags ohne mirror-Flag zu setzen, // damit anschließende Refspec-Pushes (Force) funktionieren. const cloneArgs = cloneNeedsGiteaHeader ? ['-c', `http.${sourceOrigin}.extraheader=AUTHORIZATION: token ${creds.giteaToken}`, 'clone', '--bare', sourceCloneUrl, mirrorDir] : ['clone', '--bare', sourceCloneUrl, mirrorDir]; // runGit: gibt stdout zurück, loggt stderr immer (git push schreibt Status auf stderr) const runGit = (args, cwd) => { const res = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true }); const out = (res.stdout || '').trim(); const err = (res.stderr || '').trim(); if (err) console.log(`[sync-repo-to-github][git] ${err}`); if (res.status !== 0) { throw new Error((err || out || 'Git-Fehler').trim()); } return out; }; console.log(`[sync-repo-to-github] Klone Quell-Repo (bare): ${sourceCloneUrl}`); runGit(cloneArgs, process.cwd()); console.log('[sync-repo-to-github] Bare-Clone abgeschlossen'); // Alle lokalen Branches auflisten const allBranchesRaw = runGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], mirrorDir); const allBranches = (allBranchesRaw || '').split('\n').map(s => s.trim()).filter(s => s && isSafeGitRef(s)); console.log(`[sync-repo-to-github] Lokale Branches: ${allBranches.join(', ') || '(keine)'}`); // Default-Branch ermitteln try { const headRef = runGit(['symbolic-ref', '--short', 'HEAD'], mirrorDir); sourceDefaultBranch = String(headRef || '').replace(/^refs\/heads\//, '').trim(); if (!isSafeGitRef(sourceDefaultBranch)) sourceDefaultBranch = ''; } catch (_) { sourceDefaultBranch = allBranches[0] || ''; } if (!sourceDefaultBranch && allBranches.length > 0) sourceDefaultBranch = allBranches[0]; console.log(`[sync-repo-to-github] Quell-Default-Branch: "${sourceDefaultBranch || '(unbekannt)'}"`); const githubRemoteUrl = `https://github.com/${targetOwner}/${repo}.git`; const githubAuthHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`; const ghAuthArgs = ['-c', 'credential.helper=', '-c', `http.https://github.com/.extraheader=${githubAuthHeader}`]; // GitHub als Push-Remote setzen runGit(['remote', 'set-url', 'origin', githubRemoteUrl], mirrorDir); // Lokale Commit-SHAs loggen (= Gitea-Stand) for (const branch of allBranches) { try { const localSha = runGit(['rev-parse', `refs/heads/${branch}`], mirrorDir); console.log(`[sync-repo-to-github] Gitea ${branch}: ${localSha}`); } catch (_) {} } // Remote (GitHub) Commit-SHAs VOR dem Push ermitteln und vergleichen try { const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir); const remoteMap = {}; (remoteRefsRaw || '').split('\n').forEach(line => { const parts = line.trim().split(/\s+/); if (parts.length === 2) { const branch = parts[1].replace('refs/heads/', ''); remoteMap[branch] = parts[0]; } }); for (const branch of allBranches) { const remoteSha = remoteMap[branch] || '(nicht vorhanden)'; console.log(`[sync-repo-to-github] GitHub ${branch}: ${remoteSha}`); } } catch (lsErr) { console.warn('[sync-repo-to-github] ls-remote Warnung:', lsErr?.message || lsErr); } // Jeden Branch einzeln force-pushen für maximale Zuverlässigkeit console.log(`[sync-repo-to-github] Force-Push ${allBranches.length} Branch(es) nach GitHub: ${githubRemoteUrl}`); let pushedCount = 0; for (const branch of allBranches) { console.log(`[sync-repo-to-github] Pushe Branch: ${branch}`); runGit([...ghAuthArgs, 'push', '--force', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], mirrorDir); pushedCount++; } console.log(`[sync-repo-to-github] ${pushedCount} Branch(es) erfolgreich gepusht`); // Auf GitHub nicht mehr vorhandene Branches aufräumen (prune via API, non-fatal) try { const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir); const remoteHeads = (remoteRefsRaw || '').split('\n') .map(l => { const m = l.match(/refs\/heads\/(.+)$/); return m ? m[1].trim() : null; }) .filter(Boolean); const localSet = new Set(allBranches); for (const remoteBranch of remoteHeads) { if (!localSet.has(remoteBranch) && isSafeGitRef(remoteBranch)) { console.log(`[sync-repo-to-github] Lösche veralteten Remote-Branch: ${remoteBranch}`); runGit([...ghAuthArgs, 'push', 'origin', `--delete`, remoteBranch], mirrorDir); } } } catch (pruneErr) { console.warn('[sync-repo-to-github] Prune-Warnung:', pruneErr?.message || pruneErr); } // Tags mit Force-Push übertragen (non-fatal) try { runGit([...ghAuthArgs, 'push', 'origin', '--tags', '--force'], mirrorDir); console.log('[sync-repo-to-github] Tags erfolgreich gepusht'); } catch (tagsErr) { console.warn('[sync-repo-to-github] Tags-Push Warnung:', tagsErr?.message || tagsErr); } // Default-Branch auf GitHub per API angleichen if (sourceDefaultBranch) { console.log(`[sync-repo-to-github] Setze GitHub Default-Branch auf: ${sourceDefaultBranch}`); try { await updateGithubRepoDefaultBranch({ token: creds.githubToken, owner: targetOwner, repo, defaultBranch: sourceDefaultBranch }); console.log('[sync-repo-to-github] Default-Branch aktualisiert'); } catch (defaultBranchErr) { console.warn('sync-repo-to-github: default-branch update warning', defaultBranchErr?.message || defaultBranchErr); } } // Optional: Topics auf GitHub angleichen (nur falls übergeben) const topics = Array.isArray(data?.topics) ? data.topics.filter(Boolean) : []; if (topics.length > 0) { try { await updateGithubRepoTopics({ token: creds.githubToken, owner: targetOwner, repo, topics }); } catch (topicErr) { console.warn('sync-repo-to-github: topics sync warning', topicErr?.message || topicErr); } } return { ok: true, repoCreated, githubRepo: `${targetOwner}/${repo}`, sourceDefaultBranch: sourceDefaultBranch || null }; } catch (e) { console.error('sync-repo-to-github error', e); return { ok: false, error: String(e?.message || e) }; } finally { if (mirrorDir) { try { fs.rmSync(mirrorDir, { recursive: true, force: true }); } catch (_) {} } } }); ipcMain.handle('test-github-connection', async (_event, data) => { try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.githubToken); if (!token) return { ok: false, error: 'GitHub Token fehlt.' }; const user = await getGithubCurrentUser({ token }); return { ok: true, result: { ok: true, checks: { authProvided: true, authOk: true }, user: { login: user && user.login ? user.login : '', id: user && user.id ? user.id : null } } }; } catch (e) { console.error('test-github-connection error', e); return { ok: false, error: mapIpcError(e) }; } });