Files
Git-Manager-Gui/main.js
T
2026-10-22 09:13:16 +00:00

5858 lines
226 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, Notification, screen } = require('electron');
// Das Setup startet die frisch installierte EXE einmal mit diesem Schalter, damit Windows Defender
// sie schon während der Installation prüft (installer/Installer.cs → Vorwaermen). Sofort beenden.
if (process.argv.includes('--gm-vorwaermen')) process.exit(0);
// Eingebautes Git aktivieren, bevor irgendein Git-Prozess startet.
require('./src/git/gitBinary.js').useBundledGit();
const ppath = require('path');
const fs = require('fs');
// Allgemeine App-Einstellungen (ohne Geheimnisse) und was sofort davon abhängt
const { applyGitIdentity, commitIdentity } = require('./src/git/gitBinary.js');
const appPrefs = require('./src/features/appPrefs.js').createPrefs(ppath.join(app.getPath('userData'), 'app-settings.json'));
const UPLOAD_EXCLUDES_FILE = ppath.join(app.getPath('userData'), 'upload-excludes.txt');
function applyPrefsToProcess(p) {
try { applyGitIdentity({ name: p.gitName, email: p.gitEmail }, app.getPath('userData')); } catch (e) { console.warn('Git-Identität:', e.message); }
try {
if (p.uploadExtraExcludes.trim()) fs.writeFileSync(UPLOAD_EXCLUDES_FILE, p.uploadExtraExcludes + '\n', 'utf8');
else if (fs.existsSync(UPLOAD_EXCLUDES_FILE)) fs.rmSync(UPLOAD_EXCLUDES_FILE, { force: true });
} catch (e) { console.warn('Upload-Ausschlüsse:', e.message); }
}
applyPrefsToProcess(appPrefs.get());
appPrefs.onChange((p) => {
applyPrefsToProcess(p);
for (const w of BrowserWindow.getAllWindows()) {
if (trayPopup && w === trayPopup.getWindow()) continue;
try { w.webContents.setZoomFactor(p.zoom / 100); } catch (_) {}
}
});
// Name/E-Mail für Commits in den temporären Upload-Klonen
function gitIdentityConfig() {
const id = commitIdentity(appPrefs.get());
return [`user.email=${id.email}`, `user.name=${id.name}`];
}
// Wird beendet (Tray-Menü, Updater, Herunterfahren)? Dann das Fenster nicht in den Tray schicken.
let quittingForReal = false;
app.on('before-quit', () => { quittingForReal = true; });
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync, spawn } = require('child_process');
const http = require('http');
const https = require('https');
// IMPORTS: Zentrale Utilities
const {
logger,
normalizeBranch,
parseApiError,
formatErrorForUser,
caches,
runParallel,
retryWithBackoff,
bareHostname
} = require('./src/utils/helpers.js');
// OPTIMIERUNG: Updater wird nicht sofort geladen, sondern verzögert nach Startup
let Updater = null;
let updater = null;
// Updater lazy-loaded nach 2 Sekunden
function initUpdaterAsync() {
setTimeout(() => {
try {
if (!Updater) Updater = require('./updater.js');
} catch (e) {
console.warn('Updater init deferred error:', e.message);
}
}, 2000);
}
const {
createRepoGitHub,
createRepoGitea,
checkGiteaConnection,
listGiteaRepos,
getGiteaUserHeatmap,
getGiteaRepoContents,
getGiteaFileContent,
uploadGiteaFile,
uploadItemsViaGiteaApi,
createUploadCancelledError,
isUploadCancelledError,
throwIfUploadCancelled,
listGiteaRepoFiles,
deleteGiteaPathsBatched,
getGiteaCurrentUser,
getGiteaCommits,
getGiteaCommit,
getGiteaCommitDiff,
getGiteaCommitFiles,
searchGiteaCommits,
getGiteaBranches,
listGiteaReleases,
getGiteaRelease,
createGiteaRelease,
editGiteaRelease,
deleteGiteaRelease,
uploadReleaseAsset,
deleteReleaseAsset,
updateGiteaAvatar,
updateGiteaRepoAvatar,
updateGiteaRepoVisibility,
updateGiteaRepoTopics,
updateGiteaRepoArchived,
migrateRepoToGitea
,listGiteaTopicsCatalog,
// GitHub
listGithubRepos,
getGithubCurrentUser,
githubRepoExists,
getGithubUserHeatmap,
getGithubRepoContents,
getGithubFileContent,
uploadGithubFile,
deleteGithubFile,
getGithubCommits,
getGithubCommitDiff,
getGithubCommitFiles,
searchGithubCommits,
getGithubBranches,
listGithubReleases,
createGithubRelease,
editGithubRelease,
deleteGithubRelease,
updateGithubRepoVisibility,
updateGithubRepoDefaultBranch,
updateGithubRepoTopics,
updateGithubRepoArchived,
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;
// Git-Konfiguration gegen abbrechende HTTP-Transfers bei grossen Repos.
// HTTP/1.1 statt HTTP/2 verhindert die haeufigen "early EOF"/"invalid index-pack output"-Abbrueche
// hinter Reverse-Proxies; die lowSpeed-Werte verhindern, dass der Client langsame Packs abbricht.
const GIT_TRANSFER_CONFIG = [
'http.version=HTTP/1.1',
'http.postBuffer=524288000',
'http.lowSpeedLimit=0',
'http.lowSpeedTime=600',
'core.longpaths=true'
];
// Anzahl der Clone-Versuche bei abgebrochenen Uebertragungen.
const GIT_CLONE_MAX_ATTEMPTS = 3;
// Server, deren Git-Transport sich als unbrauchbar erwiesen hat (z. B. weil upload-pack
// keine Packfiles erzeugen kann). Für diese wird der Clone übersprungen und sofort der
// API-Weg genommen — sonst kostet jeder Upload erneut sechs vergebliche Versuche.
//
// Der Merker liegt auf der Platte, damit auch der erste Upload nach einem Neustart
// sofort den funktionierenden Weg nimmt. Die Ablaufzeit sorgt dafür, dass ein wieder
// reparierter Server von selbst erneut probiert wird — ohne Eingriff im Code.
const brokenGitTransports = new Map();
const GIT_TRANSPORT_BLOCK_MS = 24 * 60 * 60 * 1000;
let brokenGitTransportsLoaded = false;
function getGitTransportStateFilePath() {
return ppath.join(app.getPath('userData'), 'git-transport-state.json');
}
function loadBrokenGitTransports() {
if (brokenGitTransportsLoaded) return;
brokenGitTransportsLoaded = true;
try {
const file = getGitTransportStateFilePath();
if (!fs.existsSync(file)) return;
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const [host, since] of Object.entries(parsed || {})) {
if (typeof since === 'number') brokenGitTransports.set(host, since);
}
} catch (e) {
console.warn('loadBrokenGitTransports:', e.message || e);
}
}
function saveBrokenGitTransports() {
try {
const file = getGitTransportStateFilePath();
const dir = app.getPath('userData');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(brokenGitTransports), null, 2), 'utf8');
} catch (e) {
console.warn('saveBrokenGitTransports:', e.message || e);
}
}
function gitTransportKey(authUrl) {
try {
return new URL(authUrl).host;
} catch (_) {
return String(authUrl || '');
}
}
function markGitTransportBroken(authUrl) {
loadBrokenGitTransports();
brokenGitTransports.set(gitTransportKey(authUrl), Date.now());
saveBrokenGitTransports();
}
function markGitTransportWorking(authUrl) {
loadBrokenGitTransports();
if (brokenGitTransports.delete(gitTransportKey(authUrl))) saveBrokenGitTransports();
}
// Hebt die Sperre für einen Host auf, z. B. wenn die Diagnose den Git-Weg als funktionierend erkannt hat.
function clearGitTransportBlock(host) {
loadBrokenGitTransports();
if (!brokenGitTransports.delete(host)) return false;
saveBrokenGitTransports();
return true;
}
function isGitTransportKnownBroken(authUrl) {
loadBrokenGitTransports();
const key = gitTransportKey(authUrl);
const since = brokenGitTransports.get(key);
if (!since) return false;
if (Date.now() - since > GIT_TRANSPORT_BLOCK_MS) {
brokenGitTransports.delete(key);
saveBrokenGitTransports();
return false;
}
return true;
}
// Fehler, die auf einen abgebrochenen Transfer hindeuten (nicht auf ein leeres/fehlendes Repo).
function isTransientGitTransferError(message) {
const raw = String(message || '').toLowerCase();
return raw.includes('early eof')
|| raw.includes('index-pack')
|| raw.includes('rpc failed')
|| raw.includes('remote end hung up')
|| raw.includes('unexpected disconnect')
|| raw.includes('transfer closed')
|| raw.includes('connection reset')
|| raw.includes('curl 92')
|| raw.includes('curl 56')
|| raw.includes('curl 18');
}
// Leert das Zielverzeichnis, damit ein erneuter Clone-Versuch nicht an Resten scheitert.
function clearDirContents(dirPath) {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
return;
}
for (const entry of fs.readdirSync(dirPath)) {
fs.rmSync(ppath.join(dirPath, entry), { recursive: true, force: true });
}
} catch (_) {}
}
// Clone-Strategien, absteigend nach Sparsamkeit.
// "blobless" laedt nur Commits und Trees, keine Dateiinhalte: Der Server muss keine Blobs packen.
// Das ist bei Repos mit grossen Binaerdateien der entscheidende Unterschied und umgeht
// serverseitige Abbrueche beim Packen. --no-checkout haelt den Arbeitsbaum leer, der Index
// wird trotzdem aus HEAD befuellt, sodass Commit und Push den bestehenden Stand erhalten.
// "shallow" ist der klassische Fallback fuer Server ohne Partial-Clone-Unterstuetzung.
const GIT_CLONE_STRATEGIES = [
{ name: 'blobless', args: ['--filter=blob:none', '--no-checkout'], checkout: false },
{ name: 'shallow', args: [], checkout: true }
];
/**
* Shallow-Clone fuer die Upload-Flows, mit Strategie- und Wiederholungs-Fallback.
* Nutzt --single-branch: es wird nur der Branch geholt, in den anschliessend gepusht wird.
* Bei abgebrochenen Transfers ("early EOF", "invalid index-pack output") wird erneut versucht,
* danach mit der naechsten Strategie. Erkennt leere Repos und initialisiert sie lokal.
*
* @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>}
*/
async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone', signal }) {
const simpleGit = require('simple-git');
const config = [...gitConfig, ...GIT_TRANSFER_CONFIG];
throwIfUploadCancelled(signal);
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
// Bekannt defekter Server: gar nicht erst versuchen.
if (isGitTransportKnownBroken(authUrl)) {
console.log(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} ist als unbrauchbar bekannt — überspringe Clone`);
throw new Error('Git-Transport für diesen Server ist als unbrauchbar bekannt (übersprungen).');
}
let lastErr = null;
for (const strategy of GIT_CLONE_STRATEGIES) {
const cloneArgs = ['--depth', '1', '--single-branch', ...strategy.args];
if (safeBranch !== 'HEAD') cloneArgs.push('--branch', safeBranch);
let previousMsg = null;
for (let attempt = 1; attempt <= GIT_CLONE_MAX_ATTEMPTS; attempt++) {
try {
if (attempt > 1) clearDirContents(tmpDir);
console.log(`[${label}] Clone-Versuch ${attempt}/${GIT_CLONE_MAX_ATTEMPTS} (${strategy.name}):`, { cloneArgs, tmpDir });
await simpleGit({ config, abort: signal }).clone(authUrl, tmpDir, cloneArgs);
const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal });
// Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der
// folgende Commit den kompletten Bestand des Branches als geloescht markieren.
if (!strategy.checkout) {
await repoGit.raw(['read-tree', 'HEAD']);
}
markGitTransportWorking(authUrl);
return { repoGit, isEmptyRepo: false, strategy: strategy.name };
} catch (cloneErr) {
// Abbruch durch den Nutzer: weder wiederholen noch den Server als defekt markieren.
if (signal && signal.aborted) {
clearDirContents(tmpDir);
throw createUploadCancelledError();
}
const cloneMsg = String(cloneErr);
// Leeres Repo ohne Commits: lokal initialisieren statt klonen.
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
console.log(`[${label}] Leeres Repo erkannt — initialisiere lokal als "${safeBranch}"`);
clearDirContents(tmpDir);
const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal });
await repoGit.init();
// git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet
// der Upload auf einem anderen Branch als dem angeforderten und ist im Gitea-Web
// nicht sichtbar, weil dort "main" der Standard ist.
if (safeBranch !== 'HEAD') {
await repoGit.raw(['symbolic-ref', 'HEAD', `refs/heads/${safeBranch}`]);
}
await repoGit.addRemote('origin', authUrl);
return { repoGit, isEmptyRepo: true, strategy: 'init' };
}
lastErr = cloneErr;
clearDirContents(tmpDir);
// Nicht-transiente Fehler (z. B. fehlende Partial-Clone-Unterstuetzung)
// sofort mit der naechsten Strategie beantworten statt zu wiederholen.
if (!isTransientGitTransferError(cloneMsg)) {
console.warn(`[${label}] Strategie "${strategy.name}" nicht nutzbar:`, cloneMsg);
break;
}
if (attempt === GIT_CLONE_MAX_ATTEMPTS) {
console.warn(`[${label}] Strategie "${strategy.name}" nach ${attempt} Versuchen aufgegeben`);
break;
}
// Zweimal exakt derselbe Fehler heißt: Das ist kein Aussetzer, sondern ein
// dauerhafter Defekt. Weitere Versuche kosten nur Zeit.
if (cloneMsg === previousMsg) {
console.warn(`[${label}] Strategie "${strategy.name}" scheitert reproduzierbar — keine weiteren Versuche`);
break;
}
previousMsg = cloneMsg;
console.warn(`[${label}] Transfer abgebrochen, neuer Versuch:`, cloneMsg);
await new Promise(resolve => setTimeout(resolve, attempt * 1500));
}
}
}
// Alle Strategien erschöpft: Server für die nächsten Uploads überspringen.
markGitTransportBroken(authUrl);
console.warn(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} wird künftig übersprungen — Uploads laufen über die API`);
throw lastErr || new Error('Clone fehlgeschlagen');
}
/**
* Staged die hochgeladenen Dateien.
* --ignore-removal: Bei der blobless-Strategie steht der Bestand nur im Index (siehe read-tree
* in cloneForUpload), nicht im Arbeitsbaum. Ohne dieses Flag wuerde git add die fehlenden
* Dateien als Loeschungen stagen. Uploads loeschen ohnehin nie etwas.
*/
async function stageUploadedFiles(repoGit) {
await repoGit.raw(['add', '--ignore-removal', '.']);
}
/**
* Ausweichweg, wenn der Git-Transport nicht funktioniert.
*
* Nötig, wenn git-upload-pack auf dem Server keine Packfiles liefert ("early EOF",
* "invalid index-pack output"). Der Upload läuft dann rein über die REST-API und
* braucht weder Clone noch Push.
*
* @returns {Promise<{ok: boolean, uploaded: string[], failed: Array<{path: string, error: string}>, commits: number, branch: string}>}
*/
async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload', signal }) {
console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`);
const result = await uploadItemsViaGiteaApi({
token, url, owner, repo, branch, items, message, onProgress, signal,
waitIfPaused: (s) => transferGate.wait(s)
});
console.log(`[${label}] API-Upload fertig:`, {
commits: result.commits,
uploaded: result.uploaded.length,
failed: result.failed.length,
branch: result.branch
});
return { ok: result.failed.length === 0, ...result };
}
// Fehler für einen teilweise fehlgeschlagenen API-Upload. Trägt die betroffenen Einträge,
// damit der Renderer nur diese wiederholen kann.
function buildPartialUploadError(gitErr, apiResult, items) {
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
err.uploadedCount = (apiResult.uploaded || []).length;
return err;
}
// Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen.
function describeApiFallbackFailure(gitErr, apiResult) {
const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`;
if (!apiResult) return gitPart;
const firstErrors = apiResult.failed.slice(0, 3).map(f => `${f.path}: ${f.error}`).join('; ');
const more = apiResult.failed.length > 3 ? ` (+${apiResult.failed.length - 3} weitere)` : '';
return `${gitPart} — auch der API-Upload schlug fehl: ${firstErrors}${more}`;
}
let retryQueue = [];
let retryQueueRunning = false;
let retryQueueTimer = null;
const DIAGNOSTIC_EVENT_LIMIT = 1000;
let diagnosticsEvents = [];
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 normalizeDiagnosticDetails(details) {
if (details == null) return null;
if (details instanceof Error) {
return {
name: details.name,
message: details.message,
stack: details.stack
};
}
try {
return JSON.parse(JSON.stringify(details));
} catch (_) {
return { value: String(details) };
}
}
function recordDiagnosticEvent(level, source, message, details = null) {
const event = {
ts: new Date().toISOString(),
level: String(level || 'INFO').toUpperCase(),
source: String(source || 'main'),
message: String(message || ''),
details: normalizeDiagnosticDetails(details)
};
diagnosticsEvents.push(event);
if (diagnosticsEvents.length > DIAGNOSTIC_EVENT_LIMIT) {
diagnosticsEvents.splice(0, diagnosticsEvents.length - DIAGNOSTIC_EVENT_LIMIT);
}
}
process.on('warning', (warning) => {
recordDiagnosticEvent('WARN', 'process.warning', warning?.message || 'process warning', warning);
});
process.on('unhandledRejection', (reason) => {
recordDiagnosticEvent('ERROR', 'process.unhandledRejection', 'Unhandled promise rejection', reason);
});
process.on('uncaughtExceptionMonitor', (error) => {
recordDiagnosticEvent('ERROR', 'process.uncaughtExceptionMonitor', error?.message || 'Uncaught exception', error);
});
function quarantineCredentialsFile(filePath, reason = 'invalid-format') {
try {
if (!fs.existsSync(filePath)) return null;
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 können 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 entschlüsselt 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 zurückgesetzt. 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 (_) {}
}
try { refreshTray(); } 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;
}
/* -----------------------------
Startup-Optimierung
----------------------------- */
// OPTIMIERUNG: V8 Code Caching aktivieren
app.commandLine.appendSwitch('enable-v8-code-caching');
// OPTIMIERUNG: GPU-Beschleunigung aktivieren (für bessere Performance)
if (os.platform() !== 'linux') {
app.commandLine.appendSwitch('enable-gpu-acceleration');
app.commandLine.appendSwitch('enable-gpu-compositing');
}
// OPTIMIERUNG: Native Binaries mit Memory-Mapping
app.commandLine.appendSwitch('v8-cache-options', 'code');
// OPTIMIERUNG: Speicher-Handling optimieren
app.commandLine.appendSwitch('enable-memory-coordination');
/* -----------------------------
app / window
----------------------------- */
let tray = null;
let trayPopup = 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();
const trayMenu = require('./src/features/trayMenu.js');
const { createTrayPopup } = require('./src/features/trayPopup.js');
const startAnimation = require('./src/features/splash.js');
function showMainWindow(win) {
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
// Tooltip des Tray-Symbols und offenes Menü: zeigen, ob gerade etwas übertragen wird.
// Wird beim Start und Ende jeder abbrechbaren Übertragung und bei Änderungen der Retry-Queue aufgerufen.
function refreshTray() {
if (!tray || tray.isDestroyed()) return;
tray.setToolTip(trayMenu.trayTooltip(app.getVersion(), activeTransferAborts.size, transferGate.isPaused()));
if (trayPopup) trayPopup.refresh();
}
// Beenden über das Tray-Menü: läuft noch ein Upload oder Download, vorher nachfragen.
async function quitFromTray(win) {
if (activeTransferAborts.size > 0) {
showMainWindow(win);
const { response } = await dialog.showMessageBox(win, {
type: 'warning',
buttons: ['Beenden und abbrechen', 'Weiter laufen lassen'],
defaultId: 1,
cancelId: 1,
title: 'Übertragung läuft',
message: 'Es läuft noch ein Upload oder Download.',
detail: 'Wenn du jetzt beendest, wird er abgebrochen.'
});
if (response !== 0) return;
}
app.quit();
}
// Menüvorlage mit dem aktuellen Status; wird bei jedem Öffnen und Aktualisieren neu gebaut.
function trayTemplate(win) {
return trayMenu.buildTrayTemplate({
version: app.getVersion(),
active: activeTransferAborts.size,
queued: Array.isArray(retryQueue) ? retryQueue.length : 0,
paused: transferGate.isPaused(),
autostart: app.getLoginItemSettings({ args: ['--hidden'] }).openAtLogin,
onPause: () => { if (transferGate.isPaused()) transferGate.resume(); else transferGate.pause(); },
onOpen: () => showMainWindow(win),
onHistory: () => {
showMainWindow(win);
try { win.webContents.send('open-upload-history'); } catch (_) {}
},
onCancel: () => {
for (const controller of activeTransferAborts) controller.abort();
refreshTray();
},
onAutostart: (item) => app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true, args: ['--hidden'] }),
onQuit: () => { quitFromTray(win); }
});
}
function createTray(win) {
const iconPath = ppath.join(__dirname, 'renderer', 'icon.png');
tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }));
// Eigenes Menüfenster statt des nativen Menüs: Letzteres zeichnet unter Windows keine Hover-Markierung.
trayPopup = createTrayPopup({
htmlPath: ppath.join(__dirname, 'renderer', 'tray-menu.html'),
preloadPath: ppath.join(__dirname, 'tray-menu-preload.js'),
getModel: () => trayMenu.serializeTemplate(trayTemplate(win)),
onAction: (id) => trayMenu.runAction(trayTemplate(win), id)
});
refreshTray();
// Menüfenster kurz nach dem Start im Hintergrund vorladen, damit der erste Rechtsklick sofort reagiert.
setTimeout(() => { try { trayPopup.warmUp(); } catch (e) { console.warn('[Tray] Vorladen fehlgeschlagen:', e.message); } }, 2000);
tray.on('right-click', () => trayPopup.show());
tray.on('click', () => showMainWindow(win));
tray.on('double-click', () => showMainWindow(win));
}
function createWindow() {
// Entfernt das Menü (File, Edit, View...) komplett
Menu.setApplicationMenu(null);
const startHidden = process.argv.includes('--hidden');
// Startanimation: drehendes Logo, danach baut sich das Fenster von der Mitte aus auf. Das Hauptfenster
// bleibt bis dahin verborgen. Beim versteckten Start (Windows-Autostart im Tray) gibt es sie nie.
const useSplash = !startHidden && appPrefs.get().showSplash;
// Start- und Hauptfenster bekommen dieselbe Position und Größe. Das Startfenster entsteht zuerst,
// damit das Logo so früh wie möglich erscheint.
let splashHandle = null;
let windowBounds = { width: 1200, height: 820 };
if (useSplash) {
windowBounds = startAnimation.centeredBounds(screen.getPrimaryDisplay().workArea, 1200, 820);
splashHandle = startAnimation.begin({ htmlPath: ppath.join(__dirname, 'renderer', 'splash.html'), bounds: windowBounds });
}
const win = new BrowserWindow({
...windowBounds,
frame: false,
show: !startHidden && !useSplash,
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 öffnen.
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'));
if (splashHandle) {
// Die Animation zeigt das Hauptfenster selbst, sobald die Oberfläche geladen ist (oder das Laden scheitert).
const uiReady = new Promise((resolve) => {
win.webContents.once('did-finish-load', resolve);
win.webContents.once('did-fail-load', resolve);
});
startAnimation.play(win, uiReady, splashHandle);
}
win.webContents.on('did-finish-load', () => {
try { win.webContents.setZoomFactor(appPrefs.get().zoom / 100); } catch (_) {}
});
// win.webContents.openDevTools();
// OPTIMIERUNG: Tray wird verzögert hergestellt (nicht beim Fenster-Create)
setImmediate(() => {
createTray(win);
});
// Schließen-Button -> Tray statt Beenden. Einstellung "Beim Schließen": immer Tray, immer beenden
// oder automatisch (Tray nur, wenn die App mit Windows startet).
win.on('close', (e) => {
const action = appPrefs.get().closeAction;
const toTray = action === 'tray' || (action === 'auto' && app.getLoginItemSettings().enabled);
if (toTray && !quittingForReal) {
e.preventDefault();
win.hide();
}
});
mainWin = win;
// Echtes Schließen: das versteckte Tray-Menüfenster mitschließen, sonst bliebe die App im Hintergrund.
win.on('closed', () => { try { if (trayPopup) trayPopup.destroy(); } catch (_) {} });
}
app.whenReady().then(() => {
// OPTIMIERUNG: Fenster wird schnell erstellt
createWindow();
// OPTIMIERUNG: RetryQueue asynchron laden (nicht blockierend)
setImmediate(() => {
retryQueue = readRetryQueueFromDisk();
broadcastRetryQueueUpdate({ event: 'startup' });
retryQueueTimer = setInterval(() => {
processRetryQueueOnce().catch(e => console.error('processRetryQueueOnce timer error', e));
}, RETRY_QUEUE_INTERVAL_MS);
});
// OPTIMIERUNG: Updater wird verzögert geladen (nach Fenster erstellt)
initUpdaterAsync();
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('before-quit', () => {
try { if (trayPopup) trayPopup.destroy(); } catch (_) {}
if (retryQueueTimer) {
clearInterval(retryQueueTimer);
retryQueueTimer = null;
}
});
/* -----------------------------
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.';
// Zuerst pruefen: abgebrochene Uebertragungen sind spezifischer als die Status-Code-Heuristik unten.
if (isTransientGitTransferError(raw)) {
return 'Übertragung vom Server abgebrochen (early EOF / index-pack). Repository zu groß oder Timeout beim Gitea-Server bzw. Reverse-Proxy. Bitte erneut versuchen.';
}
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, Server-IP 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('ungültige url') ||
raw.includes('ungueltige url') ||
raw.includes('ungültige gitea url') ||
raw.includes('ungueltige gitea url') ||
raw.includes('invalid url') ||
raw.includes('err_invalid_url') ||
raw.includes('invalid uri') ||
raw.includes('unsupported protocol') ||
raw.includes('only absolute urls')
) {
return 'Ungültige URL. Beispiel: https://git.example.net oder http://192.168.1.10: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') {
// DEPRECATED: Use normalizeBranch() from helpers instead
return normalizeBranch(ref, 'gitea');
}
// Startet Git ohne den Hauptprozess zu blockieren. Synchrone Aufrufe (spawnSync) froren
// die Oberfläche ein, bis Windows "Anwendung reagiert nicht" meldete.
function execGit(args, { cwd, input, env, signal } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true, signal });
} catch (e) {
resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) });
return;
}
const out = [];
const err = [];
child.stdout.on('data', c => out.push(c));
child.stderr.on('data', c => err.push(c));
child.on('error', e => err.push(Buffer.from(String(e && e.message || e))));
// Abbruch: unter Windows beendet spawn(signal) nur git.exe, der Netzwerk-Kindprozess
// (git-remote-https) läuft weiter und hält die Pipes offen. Deshalb den ganzen
// Prozessbaum beenden und sofort zurückkehren, statt auf 'close' zu warten.
if (signal) {
const onAbort = () => {
try {
if (process.platform === 'win32' && child.pid) {
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
} else {
child.kill('SIGKILL');
}
} catch (_) {}
resolve({ status: -1, stdout: '', stderr: 'aborted' });
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
child.on('close', (code) => resolve({
status: code,
stdout: Buffer.concat(out).toString('utf8'),
stderr: Buffer.concat(err).toString('utf8')
}));
if (input != null) child.stdin.end(input);
else child.stdin.end();
});
}
async function runGitAsync(args, cwd, options = {}) {
throwIfUploadCancelled(options.signal);
const res = await execGit(args, { cwd, env: options.env, signal: options.signal });
throwIfUploadCancelled(options.signal);
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, signal = null) {
const results = new Array(tasks.length);
let index = 0;
let processed = 0;
const total = tasks.length;
async function worker() {
while (true) {
if (signal && signal.aborted) return;
try { await transferGate.wait(signal); } catch (_) { return; }
const i = index++;
if (i >= tasks.length) return;
try {
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);
throwIfUploadCancelled(signal);
return results;
}
/* -----------------------------
Upload-Verlauf
Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo,
Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge).
----------------------------- */
const uploadHistory = require('./src/features/uploadHistory.js').createUploadHistory({
file: ppath.join(app.getPath('userData'), 'upload-history.json'),
max: 100
});
const { buildEntry: buildUploadHistoryEntry, filterEntries: filterUploadHistory, toCsv: uploadHistoryToCsv } = require('./src/features/uploadHistory.js');
const UPLOAD_HISTORY_CHANNELS = {
'upload-paths-to-gitea': 'Drag & Drop',
'upload-local-folder-to-gitea': 'Ordner',
'upload-gitea-file': 'Dateien',
'upload-and-push': 'Ordner/Datei'
};
// Windows-Meldung zu einem beendeten Upload, wenn die App nicht im Vordergrund ist
// (im Tray versteckt, minimiert oder von einem anderen Fenster verdeckt).
const transferNotice = require('./src/features/transferNotice.js');
let mainWin = null;
function maybeNotifyTransfer(entry) {
try {
if (appPrefs.get().notifyTransfers === 'never') return;
const win = mainWin;
const inFront = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused();
if (inFront) return;
const notice = transferNotice.describeTransfer(entry);
if (!notice || !Notification.isSupported()) return;
const n = new Notification({ title: notice.title, body: notice.body, icon: ppath.join(__dirname, 'assets', 'icon.png') });
n.on('click', () => showMainWindow(win));
n.show();
} catch (e) {
console.warn('Benachrichtigung:', e.message);
}
}
const rawIpcHandle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, handler) => {
const kind = UPLOAD_HISTORY_CHANNELS[channel];
if (!kind) return rawIpcHandle(channel, handler);
return rawIpcHandle(channel, async (event, data) => {
const startedAt = Date.now();
let res;
try {
res = await handler(event, data);
} catch (e) {
const failedEntry = buildUploadHistoryEntry(kind, data, null, startedAt, e);
uploadHistory.record(failedEntry);
maybeNotifyTransfer(failedEntry);
throw e;
}
const entry = buildUploadHistoryEntry(kind, data, res, startedAt, null);
uploadHistory.record(entry);
maybeNotifyTransfer(entry);
return res;
});
};
ipcMain.handle('get-upload-history', (_event, data) => ({ ok: true, history: filterUploadHistory(uploadHistory.read(), data || {}) }));
// CSV-Export des (gefilterten) Verlaufs, für deutsches Excel (Semikolon, UTF-8 mit BOM).
ipcMain.handle('export-upload-history-csv', async (event, data) => {
try {
const entries = filterUploadHistory(uploadHistory.read(), data || {});
if (entries.length === 0) return { ok: false, error: 'Keine Einträge zum Exportieren.' };
const win = BrowserWindow.fromWebContents(event.sender);
const stamp = new Date().toISOString().slice(0, 10);
const save = await dialog.showSaveDialog(win, {
defaultPath: ppath.join(app.getPath('documents'), `upload-verlauf-${stamp}.csv`),
filters: [{ name: 'CSV-Datei', extensions: ['csv'] }]
});
if (save.canceled || !save.filePath) return { ok: false, canceled: true };
fs.writeFileSync(save.filePath, uploadHistoryToCsv(entries), 'utf8');
return { ok: true, savedTo: save.filePath, count: entries.length };
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
ipcMain.handle('clear-upload-history', () => {
try { uploadHistory.clear(); return { ok: true }; }
catch (e) { return { ok: false, error: String(e && e.message || e) }; }
});
const gitignoreTemplates = require('./src/features/gitignoreTemplates.js');
ipcMain.handle('get-gitignore-templates', () => ({ ok: true, templates: gitignoreTemplates.listTemplates() }));
ipcMain.handle('build-gitignore', (_event, data) => ({
ok: true,
content: gitignoreTemplates.buildGitignore(Array.isArray(data && data.keys) ? data.keys : [])
}));
/* -----------------------------
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);
logger.info('save-credentials', 'Credentials saved successfully', { file: CREDENTIALS_FILE });
return { ok: true };
} catch (e) {
const errInfo = formatErrorForUser(e, 'save-credentials');
logger.error('save-credentials', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
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;
logger[level === 'error' ? 'error' : (level === 'warn' ? 'warn' : 'info')]('renderer', message, details);
if (level === 'error' || level === 'warn') {
recordDiagnosticEvent(level.toUpperCase(), 'renderer', message, details);
}
} catch (e) {
logger.warn('renderer-debug-log', 'Logging failed', { error: e.message });
recordDiagnosticEvent('WARN', 'renderer-debug-log', 'Logging failed', e);
}
});
ipcMain.handle('load-credentials', async () => {
try {
return readCredentials();
} catch (e) {
logger.error('load-credentials', 'Failed to load credentials', { error: e.message });
return null;
}
});
ipcMain.handle('get-credentials-status', async () => {
try {
readCredentials();
} catch (_) {}
return getCredentialReadStatus();
});
// Debug-API für die Anwendung
ipcMain.handle('get-debug-info', async () => {
return {
version: app.getVersion(),
logs: logger.getRecent(30),
cacheStats: {
repos: caches.repos.size(),
fileTree: caches.fileTree.size(),
api: caches.api.size()
}
};
});
ipcMain.handle('clear-cache', async (event, type = 'all') => {
try {
if (type === 'all' || type === 'repos') caches.repos.clear();
if (type === 'all' || type === 'fileTree') caches.fileTree.clear();
if (type === 'all' || type === 'api') caches.api.clear();
logger.info('clear-cache', `Cache cleared: ${type}`);
return { ok: true, message: `Cache gelöscht: ${type}` };
} catch (e) {
logger.error('clear-cache', 'Failed to clear cache', { error: e.message });
return { ok: false, error: String(e) };
}
});
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 });
logger.info('get-gitea-current-user', 'User loaded', { user: user?.login });
return { ok: true, user };
} catch (e) {
const errInfo = formatErrorForUser(e, 'get-gitea-current-user');
logger.error('get-gitea-current-user', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
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('update-repo-archived', async (event, data) => {
try {
const { owner, repo, archived, platform } = data || {};
const creds = readCredentials();
if (platform === 'github') {
const githubToken = (data && data.token) || (creds && creds.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const updated = await updateGithubRepoArchived({
token: githubToken,
owner,
repo,
archived: !!archived
});
return { ok: true, repo: updated };
}
if (!creds?.giteaToken || !creds?.giteaURL) {
return { ok: false, error: 'Gitea Token oder URL fehlt.' };
}
const updated = await updateGiteaRepoArchived({
token: creds.giteaToken,
url: creds.giteaURL,
owner,
repo,
archived: !!archived
});
return { ok: true, repo: updated };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-repo-archived error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('get-gitea-topics-catalog', async () => {
try {
const creds = readCredentials();
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: 'Keine Zugangsdaten gespeichert' };
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
});
logger.info('create-repo', `GitHub repo created: ${data.name}`);
return { ok: true, repo };
} else if (data.platform === 'gitea') {
// Cache invalidieren nach Repo-Erstellung
caches.repos.clear();
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
});
logger.info('create-repo', `Gitea repo created: ${data.name}`);
return { ok: true, repo };
} else {
return { ok: false, error: 'Plattform nicht unterstützt' };
}
} catch (e) {
const errInfo = formatErrorForUser(e, 'create-repo');
logger.error('create-repo', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
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 = await runGitAsync(['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 = await runGitAsync(['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();
await runGitAsync(['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 {
await runGitAsync(['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) {
await runGitAsync(['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 = await runGitAsync(['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;
}
function invalidateLocalFileTreeCacheForPath(targetPath) {
try {
if (!targetPath) {
caches.fileTree.invalidate('fileTree:');
return;
}
let current = ppath.resolve(targetPath);
while (true) {
caches.fileTree.invalidate(`fileTree:${current}:`);
const parent = ppath.dirname(current);
if (parent === current) break;
current = parent;
}
// Safety net: local trees are small enough, ensure no stale entries survive.
caches.fileTree.invalidate('fileTree:');
} catch (_) {}
}
function invalidateRepoContentsCache(owner, repo) {
try {
if (owner && repo) {
caches.repos.invalidate(`gitea:${owner}/${repo}:`);
caches.repos.invalidate(`${owner}/${repo}`);
return;
}
caches.repos.invalidate('gitea:');
} catch (_) {}
}
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 };
// OPTIMIERUNG: Cache für File-Trees (5 min)
const cacheKey = `fileTree:${folder}:${JSON.stringify(opts)}`;
const cached = caches.fileTree.get(cacheKey);
if (cached) {
logger.debug('getFileTree', 'Cache hit', { folder });
return { ok: true, tree: cached, cached: true };
}
const tree = buildTree(folder, opts);
caches.fileTree.set(cacheKey, tree);
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');
invalidateLocalFileTreeCacheForPath(data.path);
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');
const items = Array.isArray(contents.items) ? contents.items : [];
if (items.length > 0) {
// A single file lookup can come back as one item with the same path.
const isSingleFile = items.length === 1 && items[0].type !== 'dir' && items[0].path === entryPath;
if (isSingleFile) {
await deleteGithubFile({ token: githubToken, owner, repo, path: items[0].path, sha: items[0].sha, branch: useBranch });
return;
}
// Directory listing (also when it has only one child): recurse into dirs, delete files.
for (const item of items) {
if (item.type === 'dir') {
await deleteGithubEntry(item.path);
} else {
await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, sha: item.sha, branch: useBranch });
}
}
return;
}
// Fallback for APIs that return no items payload for direct file paths.
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);
// Ohne explizite Ref die Default-Branch des Repos verwenden (kein harter "main"-Fallback).
const useBranch = (data.branch && data.branch !== 'HEAD')
? data.branch
: ((data.ref && data.ref !== 'HEAD') ? data.ref : null);
// Helper: GET contents from Gitea API
function giteaGet(path) {
return new Promise((resolve, reject) => {
const refQuery = useBranch ? `?ref=${encodeURIComponent(useBranch)}` : '';
const req = protocol.request({
hostname: bareHostname(urlObj.hostname), port,
path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`,
method: 'GET',
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.end();
});
}
// Helper: DELETE a single file by path + sha
async function giteaDeleteFile(filePath, sha, retryCount = 0) {
return new Promise((resolve) => {
const payload = {
message: `Delete ${filePath} via Git Manager GUI`,
sha
};
if (useBranch) payload.branch = useBranch;
const body = JSON.stringify(payload);
const req = protocol.request({
hostname: bareHostname(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)
}
}, async (res) => {
let respBody = '';
res.on('data', chunk => respBody += chunk);
res.on('end', async () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ ok: true });
} else if (res.statusCode === 409 && retryCount < 2) {
// Conflict: retry with backoff
await new Promise(r => setTimeout(r, 100 + retryCount * 100));
const retry = await giteaDeleteFile(filePath, sha, retryCount + 1);
resolve(retry);
} else {
resolve({ ok: false, error: `HTTP ${res.statusCode}: ${respBody}` });
}
});
});
req.on('error', (e) => resolve({ ok: false, error: String(e) }));
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 (contents && Array.isArray(contents.errors)) {
const notFound = contents.errors.some(err => String(err).toLowerCase().includes('does not exist'));
if (notFound) return files;
}
// Gitea meldet einen nicht (mehr) vorhandenen Pfad als {"message":"object does not exist ..."}.
if (contents && !Array.isArray(contents) && !contents.sha && typeof contents.message === 'string' &&
/does not exist|not found/i.test(contents.message)) {
return 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.sha) {
// Treat files, symlinks, submodules etc. all as deletable entries with a SHA
files.push({ path: item.path, sha: item.sha });
}
// Items without SHA (e.g. empty placeholders) are silently skipped
}
} else if (contents && contents.type === 'file' && contents.sha) {
// It's a single file
files.push({ path: contents.path, sha: contents.sha });
} else if (contents && contents.sha) {
// Symlink or other single entry with a SHA
files.push({ path: contents.path, sha: contents.sha });
} else if (contents && contents.type === 'dir' && contents.path) {
// Defensive fallback: some endpoints can return dir objects.
const sub = await collectAllFiles(contents.path);
files.push(...sub);
} else {
const preview = JSON.stringify(contents).slice(0, 200);
throw new Error(`Unbekannte Antwort: ${preview}`);
}
return files;
}
async function executeDeleteBatch(files, concurrency = 2) {
const deleteOps = files.map(f =>
async () => {
const res = await giteaDeleteFile(f.path, f.sha);
return { path: f.path, ...res };
}
);
// Concurrency kann dynamisch reduziert werden, um API-Konflikte bei Nachloeschungen zu vermeiden
const deleteResults = await runParallel(deleteOps, concurrency);
const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok));
return { deleteResults, failedEntries };
}
async function pathStillExists(path) {
const remaining = await collectAllFiles(path);
return remaining.length > 0;
}
// Schnellweg: paketweise löschen (ein Commit pro bis zu 100 Dateien).
// Nur wenn die Trees-API nicht nutzbar ist, läuft der alte Einzelweg weiter unten.
try {
const r = await deleteGiteaPathsBatched({
token, url: giteaUrl, owner, repo, branch: useBranch || 'HEAD', prefix: filePath
});
invalidateRepoContentsCache(owner, repo);
if (r.failed.length === 0) {
logger.info('deleteFile', `Deleted ${r.deleted} files in ${r.commits} commits`);
return { ok: true, deleted: r.deleted };
}
const preview = r.failed.slice(0, 5).map(f => `${f.path}: ${f.error}`).join('; ');
return { ok: false, error: `Löschen unvollständig: ${r.failed.length} von ${r.total} Dateien nicht gelöscht (${preview}${r.failed.length > 5 ? ', ...' : ''}).` };
} catch (batchErr) {
logger.warn('deleteFile', 'Paket-Löschen nicht möglich, nutze Einzelweg', { error: String(batchErr && batchErr.message || batchErr) });
}
// Collect all files to delete (handles both file and folder)
const filesToDelete = await collectAllFiles(filePath);
if (filesToDelete.length === 0) {
invalidateRepoContentsCache(owner, repo);
return { ok: true, deleted: 0 };
}
// Delete attempt (schnell) + sequenzieller Fallback bei Konflikten
let { failedEntries } = await executeDeleteBatch(filesToDelete, 2);
// Bei Teilfehlern sofort sequenziellen Retry fahren (stabiler bei parallelen Commits)
if (failedEntries.length > 0) {
const failedFiles = failedEntries
.map(r => (r.result && r.result.path) ? r.result.path : 'unknown')
.join(', ');
logger.warn('deleteFile', `Initial delete had ${failedEntries.length} failures, retrying sequentially`, { files: failedFiles });
}
// Verification + bis zu zwei sequenzielle Nachlöschversuche auf verbleibende Dateien
for (let attempt = 0; attempt < 2; attempt++) {
if (!(await pathStillExists(filePath))) break;
const remaining = await collectAllFiles(filePath);
if (remaining.length === 0) break;
({ failedEntries } = await executeDeleteBatch(remaining, 1));
}
if (await pathStillExists(filePath)) {
const remaining = await collectAllFiles(filePath);
const remainingPreview = remaining.slice(0, 10).map(f => f.path).join(', ');
return {
ok: false,
error: `Löschen unvollständig: ${remaining.length} Dateien sind noch vorhanden (${remainingPreview}${remaining.length > 10 ? ', ...' : ''}).`
};
}
logger.info('deleteFile', `Deleted ${filesToDelete.length} files successfully`);
invalidateRepoContentsCache(owner, repo);
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 });
invalidateLocalFileTreeCacheForPath(data.path);
return { ok: true };
} catch (e) {
console.error('deleteFile error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------
Gitea: Dateien finden, die laut lokaler .gitignore nicht ins Repo gehören
----------------------------- */
ipcMain.handle('find-gitignored-in-repo', async (_event, data) => {
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' };
const { owner, repo, localFolder } = data || {};
const repoPath = String((data && data.repoPath) || '').replace(/^\/+|\/+$/g, '');
if (!owner || !repo || !localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'Lokaler Ordner nicht gefunden' };
const { branch, files } = await listGiteaRepoFiles({ token, url, owner, repo, branch: data.branch || 'HEAD' });
const prefix = repoPath ? `${repoPath}/` : '';
const remote = [...files.keys()].filter(p => p.startsWith(prefix));
if (remote.length === 0) return { ok: true, branch, files: [], scanned: 0 };
// Die Regeln des lokalen Ordners auf die Server-Pfade anwenden. check-ignore prüft rein
// nach Muster, die Dateien müssen lokal nicht existieren.
const tmp = fs.mkdtempSync(ppath.join(os.tmpdir(), 'gm-ignore-'));
let ignoredRel = [];
try {
await execGit(['init', '-q', '--bare', tmp]);
const r = await execGit([`--git-dir=${tmp}`, `--work-tree=${localFolder}`, 'check-ignore', '--no-index', '--stdin', '-z'], {
cwd: localFolder,
input: remote.map(p => p.slice(prefix.length)).join('\0') + '\0'
});
// Exit-Code 1 heißt: nichts ignoriert.
if (r.status !== 0 && r.status !== 1) throw new Error((r.stderr || 'git check-ignore fehlgeschlagen').trim());
ignoredRel = r.stdout.split('\0').filter(Boolean);
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
}
return { ok: true, branch, files: ignoredRel.map(rel => prefix + rel), scanned: remote.length };
} catch (e) {
console.error('find-gitignored-in-repo error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('delete-gitea-paths', async (event, data) => {
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' };
const { owner, repo, paths } = data || {};
if (!owner || !repo || !Array.isArray(paths) || paths.length === 0) return { ok: false, error: 'Keine Dateien angegeben' };
const r = await deleteGiteaPathsBatched({
token, url, owner, repo, branch: data.branch || 'HEAD', paths,
message: data.message,
onProgress: (percent) => { try { event.sender.send('folder-upload-progress', { percent }); } catch (_) {} }
});
invalidateRepoContentsCache(owner, repo);
return { ok: r.failed.length === 0, ...r };
} catch (e) {
console.error('delete-gitea-paths error', e);
return { ok: false, error: mapIpcError(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 skipCache = data && data.noCache === true;
// OPTIMIERUNG: Cache für Repo-Inhalte (5 min)
const cacheKey = `gitea:${owner}/${repo}:${p}:${ref || 'HEAD'}`;
if (!skipCache) {
const cached = caches.repos.get(cacheKey);
if (cached) {
logger.debug('ipc-get-repo-contents', 'Cache hit', { cacheKey });
return { ok: true, ...cached };
}
}
const result = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref });
const response = { items: result.items || result, empty: result.empty || false };
if (!skipCache) caches.repos.set(cacheKey, response, 4000);
return { ok: true, ...response };
} catch (e) {
const errInfo = formatErrorForUser(e, 'get-gitea-repo-contents');
logger.error('get-gitea-repo-contents', errInfo.technicalMessage, errInfo.details);
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 uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
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;
logger.info('upload-gitea-file', 'Upload started', {
uploadDebugId,
owner,
repo,
platform: data.platform || 'gitea',
files: 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) {
logger.warn('upload-gitea-file', 'GitHub token missing');
return { ok: false, error: `GitHub Token fehlt.` };
}
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
const branch = normalizeBranch(data.branch, 'github');
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 });
logger.debug('upload-gitea-file', `File uploaded: ${fileName}`);
} catch (e) {
const errInfo = formatErrorForUser(e, 'File upload');
results.push({ file: localFile, ok: false, error: errInfo.userMessage });
logger.error('upload-gitea-file', `Upload failed for ${fileName}`, errInfo.details);
}
}
const failedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', `GitHub upload done`, { failedCount, total: results.length, uploadDebugId });
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) {
logger.warn('upload-gitea-file', 'Missing token or URL');
return { ok: false, error: `Zugangsdaten fehlen` };
}
// Invalid cache for repo after upload
caches.repos.invalidate(`${owner}/${repo}`);
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
let branch = normalizeBranch(data.branch || 'HEAD', 'gitea');
const message = data.message || 'Upload via Git Manager GUI';
const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []);
// Validierung: nur existierende Dateien (keine Ordner)
const validFiles = [];
const results = [];
for (const localFile of localFiles) {
if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; }
let stat;
try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; }
if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; }
const blockedReason = blockedSecretReason(localFile);
if (blockedReason) {
results.push({ file: localFile, ok: false, blocked: true, error: `Gesperrt (Zugangsdaten): ${blockedReason}` });
continue;
}
validFiles.push(localFile);
}
if (validFiles.length === 0) {
const failedCount = results.filter(r => !r.ok).length;
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
}
// Git-basierter Upload via cloneForUpload() — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
let authUrl;
try {
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
} catch (urlErr) {
return { ok: false, error: `Ungültige Gitea-URL: ${url}`, debugId: uploadDebugId };
}
const tmpDir = getSafeTmpDir(`git-file-${owner}-${repo}`);
const gitConfig = gitIdentityConfig();
try {
const { repoGit, isEmptyRepo } = await cloneForUpload({
authUrl,
tmpDir,
branch,
gitConfig,
label: 'FileUpload',
signal
});
// Dateien in den Zielordner kopieren
for (const localFile of validFiles) {
throwIfUploadCancelled(signal);
const fileName = ppath.basename(localFile);
const targetPath = destPath ? `${destPath}/${fileName}` : fileName;
const destFile = ppath.join(tmpDir, ...targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(localFile, destFile);
results.push({ file: localFile, ok: true, targetPath });
}
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
try {
await repoGit.commit(message);
} catch (commitErr) {
if (String(commitErr).includes('nothing to commit')) {
hasChanges = false;
} else {
throw commitErr;
}
}
if (hasChanges) {
let pushBranch = branch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try {
const branchSummary = await repoGit.branch();
pushBranch = branchSummary.current || 'main';
} catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) {
await repoGit.push(['-u', 'origin', pushBranch]);
} else {
await repoGit.push('origin', pushBranch);
}
}
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId });
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
try {
const apiItems = validFiles.map(localFile => {
const fileName = ppath.basename(localFile);
return { localFile, targetPath: destPath ? `${destPath}/${fileName}` : fileName };
});
const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch,
items: apiItems,
message,
label: 'FileUpload',
signal
});
if (apiResult.ok) {
// Erfolgsmeldungen des gescheiterten Git-Versuchs verwerfen, damit keine
// Dubletten entstehen. Die Fehler zu ungültigen Pfaden bleiben erhalten.
for (let i = results.length - 1; i >= 0; i--) {
if (results[i].ok) results.splice(i, 1);
}
for (const item of apiItems) {
results.push({ file: item.localFile, ok: true, targetPath: item.targetPath });
}
const apiFailedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', 'Upload über API-Fallback erfolgreich', {
commits: apiResult.commits,
failedCount: apiFailedCount,
uploadDebugId
});
return { ok: apiFailedCount === 0, results, failedCount: apiFailedCount, viaApi: true, debugId: uploadDebugId };
}
return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId };
} catch (apiErr) {
if (isUploadCancelledError(apiErr)) throw apiErr;
logger.error('upload-gitea-file', 'API-Fallback fehlgeschlagen', { error: String(apiErr), uploadDebugId });
return { ok: false, error: describeApiFallbackFailure(gitErr, null) + ` — API-Fallback: ${apiErr.message || String(apiErr)}`, debugId: uploadDebugId };
}
}
const failedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId });
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
} finally {
endCancellableUpload(uploadAbort);
}
});
// 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
});
invalidateRepoContentsCache(owner, repo);
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) };
}
});
// Gesamtgröße der Quelldateien in Bytes (für die Fortschrittsanzeige).
function sumFileSizes(items) {
let bytes = 0;
for (const it of items) {
try { bytes += fs.statSync(it.localFile).size; } catch (_) {}
}
return bytes;
}
// Abbruch laufender Uploads und Downloads: der Renderer bricht per IPC alle laufenden ab
// (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf).
const activeTransferAborts = new Set();
// Pause/Fortsetzen: Übertragungen halten an den Kontrollpunkten an (zwischen Paketen, Dateien und
// Git-Schritten), solange pausiert ist. Ein laufender Clone oder Push wird erst fertig.
const transferGate = require('./src/features/pauseGate.js').createPauseGate({
onChange: (paused) => {
for (const w of BrowserWindow.getAllWindows()) {
try { if (!w.isDestroyed()) w.webContents.send('transfer-pause-state', { paused }); } catch (_) {}
}
refreshTray();
}
});
function beginCancellableUpload() {
// Eine vergessene Pause eines früheren Vorgangs darf den neuen nicht blockieren.
if (activeTransferAborts.size === 0) transferGate.resume();
const controller = new AbortController();
activeTransferAborts.add(controller);
refreshTray();
return controller;
}
function endCancellableUpload(controller) {
activeTransferAborts.delete(controller);
if (activeTransferAborts.size === 0) transferGate.resume();
refreshTray();
}
ipcMain.handle('pause-upload', () => {
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
transferGate.pause();
return { ok: true, paused: true };
});
ipcMain.handle('resume-upload', () => {
transferGate.resume();
return { ok: true, paused: false };
});
ipcMain.handle('get-pause-state', () => ({ ok: true, paused: transferGate.isPaused() }));
ipcMain.handle('cancel-upload', () => {
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
for (const controller of activeTransferAborts) controller.abort();
return { ok: true };
});
ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
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';
if (!localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'local-folder-not-found' };
const items = [];
const folderName = ppath.basename(localFolder);
// Exclude-Liste: .git, node_modules etc. überspringen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt den Ordner neu einzulesen.
const retryItems = Array.isArray(data.items)
? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile))
: null;
const isIgnored = await createUploadIgnore(localFolder);
if (retryItems && retryItems.length) items.push(...retryItems);
else (function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (excludeList.includes(entry)) continue;
const full = ppath.join(dir, entry);
let stat;
try { stat = fs.statSync(full); } catch(e) { continue; }
if (isIgnored(full, stat.isDirectory())) continue;
if (stat.isDirectory()) {
walk(full);
} else if (stat.isFile()) {
const rel = ppath.relative(localFolder, full).split(ppath.sep).join('/');
let targetPath;
if (destPath && destPath.length > 0) {
targetPath = `${destPath}/${folderName}/${rel}`;
} else {
targetPath = `${folderName}/${rel}`;
}
items.push({ localFile: full, targetPath });
}
}
})(localFolder);
// Dateien mit Zugangsdaten bleiben außen vor.
const blockedFiles = removeBlockedInPlace(items);
const total = items.length;
if (total === 0) return { ok: true, results: [], blockedFiles };
const totalBytes = sumFileSizes(items);
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
// Auth-URL mit Token für HTTPS-Auth bauen
let authUrl;
try {
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
} catch (urlErr) {
throw new Error(`Ungültige Gitea-URL: ${url}`);
}
const tmpDir = getSafeTmpDir(`git-folder-${owner}-${repo}`);
const gitConfig = gitIdentityConfig();
// Fortschritt: Start
try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0, totalBytes }); } catch (_) {}
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
try {
const { repoGit, isEmptyRepo } = await cloneForUpload({
authUrl,
tmpDir,
branch,
gitConfig,
label: 'FolderUpload',
signal
});
// Fortschritt: 30% (nach Clone)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30, totalBytes }); } catch (_) {}
// Dateien in den Zielordner im geklonten Repo kopieren
for (const item of items) {
throwIfUploadCancelled(signal);
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(item.localFile, destFile);
}
// Fortschritt: 60% (nach Kopieren)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60, totalBytes }); } catch (_) {}
// git add + commit + push
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
try {
await repoGit.commit(`${messagePrefix} - ${folderName}`);
} catch (commitErr) {
if (String(commitErr).includes('nothing to commit')) {
hasChanges = false; // Keine Änderungen — bereits aktuell
} else {
throw commitErr;
}
}
if (hasChanges) {
let pushBranch = branch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try {
const branchSummary = await repoGit.branch();
pushBranch = branchSummary.current || 'main';
} catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) {
await repoGit.push(['-u', 'origin', pushBranch]);
} else {
await repoGit.push('origin', pushBranch);
}
}
// Fortschritt: 100%
try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100, totalBytes }); } catch (_) {}
// Temp-Ordner nach kurzer Verzögerung bereinigen
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0, blockedFiles };
} catch (gitErr) {
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch, items,
message: `${messagePrefix} - ${folderName}`,
label: 'FolderUpload',
signal,
onProgress: percent => {
try {
event.sender.send('folder-upload-progress', {
processed: Math.floor(total * (percent / 100)),
total,
percent,
totalBytes
});
} catch (_) {}
}
});
if (!apiResult.ok) {
throw buildPartialUploadError(gitErr, apiResult, items);
}
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0, viaApi: true, blockedFiles };
}
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
console.error('upload-local-folder-to-gitea error', e);
return {
ok: false,
error: String(e),
retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined,
uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined
};
} finally {
endCancellableUpload(uploadAbort);
}
});
/* -----------------------------------------------------------------
Gemeinsamer Git-Upload für einen kompletten Drop (Dateien + Ordner).
Erzeugt EINEN Clone + EINEN Commit + EINEN Push für ALLE Pfade
zusammen – egal wie viele Dateien/Ordner gedroppt wurden.
----------------------------------------------------------------- */
// Liest die .gitignore-Regeln (auch verschachtelte und globale) für einen Upload-Pfad aus
// und liefert eine Prüffunktion. Build-Ausgaben wie build/ oder .gradle/ werden so nicht
// hochgeladen. Maßgeblich ist das Repo, in dem der Pfad liegt; ohne Repo der Elternordner.
// So greift die .gitignore auch, wenn nur einzelne Einträge eines Ordners gezogen werden.
// Git bekommt ein leeres Dummy-Repo und den Ordner nur als Arbeitsverzeichnis.
//
// Läuft asynchron; das Ergebnis pro Arbeitsordner wird im übergebenen Cache gehalten,
// damit 19 gezogene Einträge eines Ordners nur eine Git-Abfrage auslösen.
async function createUploadIgnore(target, cache = new Map()) {
const parent = ppath.dirname(target);
let workTree = parent;
if (cache.has(`top:${parent}`)) {
workTree = cache.get(`top:${parent}`);
} else {
const top = await execGit(['-C', parent, 'rev-parse', '--show-toplevel']);
if (top.status === 0 && top.stdout.trim()) workTree = ppath.resolve(top.stdout.trim());
cache.set(`top:${parent}`, workTree);
}
let ignored = cache.get(`ign:${workTree}`);
if (!ignored) {
ignored = new Set();
let tmp = null;
try {
tmp = fs.mkdtempSync(ppath.join(os.tmpdir(), 'gm-ignore-'));
const init = await execGit(['init', '-q', '--bare', tmp]);
if (init.status === 0) {
const r = await execGit([`--git-dir=${tmp}`, `--work-tree=${workTree}`, 'ls-files', '-z',
'--others', '--ignored', '--exclude-standard', '--directory',
// zusätzliche Muster aus Einstellungen → Uploads
...(fs.existsSync(UPLOAD_EXCLUDES_FILE) ? [`--exclude-from=${UPLOAD_EXCLUDES_FILE}`] : [])], { cwd: workTree });
if (r.status === 0) {
for (const entry of r.stdout.split('\0')) if (entry) ignored.add(entry);
}
}
} catch (e) {
console.warn('createUploadIgnore:', e.message || e);
} finally {
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} }
}
cache.set(`ign:${workTree}`, ignored);
if (ignored.size) console.log(`[Upload] .gitignore: ${ignored.size} Einträge unter ${workTree} ausgeschlossen`);
}
return (fullPath, isDir) => {
if (!ignored.size) return false;
const rel = ppath.relative(workTree, fullPath).split(ppath.sep).join('/');
if (ignored.has(isDir ? `${rel}/` : rel)) return true;
// Liegt der Pfad in einem ausgeschlossenen Ordner?
for (let i = rel.lastIndexOf('/'); i > 0; i = rel.lastIndexOf('/', i - 1)) {
if (ignored.has(rel.slice(0, i + 1))) return true;
}
return false;
};
}
const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Feste Sperre für Dateien mit Zugangsdaten (Einstellung "Zugangsdaten nie hochladen").
const uploadBlocklist = require('./src/features/uploadBlocklist.js');
// Grund, warum eine Datei nicht hochgeladen werden darf, oder null.
function blockedSecretReason(filePath) {
return uploadBlocklist.blockedReason(filePath, appPrefs.get());
}
// Entfernt gesperrte Dateien aus der Liste (an Ort und Stelle) und liefert sie zurück.
function removeBlockedInPlace(items) {
const { allowed, blocked } = uploadBlocklist.partitionBlocked(items, appPrefs.get());
items.splice(0, items.length, ...allowed);
return blocked;
}
// Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste
// { localFile, targetPath } um. Dateien -> destPath/<name>, Ordner ->
// destPath/<ordnername>/<rel> (identisch zum bisherigen Verhalten).
async function buildItemsFromPaths(paths, destPath) {
const items = [];
const ignoreCache = new Map();
const skipped = [];
const ignored = [];
for (const p of paths) {
let stat;
try { stat = fs.statSync(p); } catch (e) { skipped.push({ path: p, error: 'not-found' }); continue; }
const baseName = ppath.basename(p);
const isIgnored = await createUploadIgnore(p, ignoreCache);
if (isIgnored(p, stat.isDirectory())) { skipped.push({ path: p, error: 'gitignore' }); continue; }
if (stat.isFile()) {
const targetPath = destPath ? `${destPath}/${baseName}` : baseName;
items.push({ localFile: p, targetPath });
} else if (stat.isDirectory()) {
const folderName = baseName;
(function walk(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch (e) { return; }
for (const entry of entries) {
if (GIT_UPLOAD_EXCLUDE.includes(entry)) continue;
const full = ppath.join(dir, entry);
let st;
try { st = fs.statSync(full); } catch (e) { continue; }
if (isIgnored(full, st.isDirectory())) { ignored.push({ path: full, isDir: st.isDirectory() }); continue; }
if (st.isDirectory()) {
walk(full);
} else if (st.isFile()) {
const rel = ppath.relative(p, full).split(ppath.sep).join('/');
const targetPath = destPath ? `${destPath}/${folderName}/${rel}` : `${folderName}/${rel}`;
items.push({ localFile: full, targetPath });
}
}
})(p);
} else {
skipped.push({ path: p, error: 'unsupported' });
}
}
const blocked = removeBlockedInPlace(items);
return { items, skipped, ignored, blocked };
}
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress, signal }) {
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
const tmpDir = getSafeTmpDir(`git-paths-${owner}-${repo}`);
const gitConfig = gitIdentityConfig();
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
try {
const { repoGit, isEmptyRepo } = await cloneForUpload({
authUrl,
tmpDir,
branch: safeBranch,
gitConfig,
label: 'UploadPaths',
signal
});
if (onProgress) onProgress(30);
for (const item of items) {
throwIfUploadCancelled(signal);
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(item.localFile, destFile);
}
if (onProgress) onProgress(60);
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
try {
await repoGit.commit(message);
} catch (commitErr) {
if (String(commitErr).includes('nothing to commit')) hasChanges = false;
else throw commitErr;
}
if (hasChanges) {
let pushBranch = safeBranch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try { const bs = await repoGit.branch(); pushBranch = bs.current || 'main'; } catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) await repoGit.push(['-u', 'origin', pushBranch]);
else await repoGit.push('origin', pushBranch);
}
if (onProgress) onProgress(100);
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
return { hasChanges };
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || (signal && signal.aborted)) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch: safeBranch, items, message, onProgress,
label: 'UploadPaths',
signal
});
if (!apiResult.ok) {
throw buildPartialUploadError(gitErr, apiResult, items);
}
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
}
}
ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
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: 'Zugangsdaten fehlen', debugId: uploadDebugId };
const owner = data.owner;
const repo = data.repo;
if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo', debugId: uploadDebugId };
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
const branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD');
const messagePrefix = data.messagePrefix || 'Upload via GUI';
const paths = Array.isArray(data.paths) ? data.paths.filter(Boolean) : [];
// Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt neuer Pfade.
const retryItems = Array.isArray(data.items)
? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile))
: null;
if (paths.length === 0 && !(retryItems && retryItems.length)) {
return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId };
}
try { invalidateRepoContentsCache(owner, repo); } catch (_) {}
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
const built = retryItems && retryItems.length
? { items: retryItems, skipped: [], ignored: [], blocked: removeBlockedInPlace(retryItems) }
: await buildItemsFromPaths(paths, destPath);
const skipped = built.skipped;
const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []);
const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items;
if (items.length === 0) {
const onlyBlocked = Array.isArray(built.blocked) && built.blocked.length > 0;
return {
ok: false,
error: onlyBlocked
? 'Es blieb nichts zum Hochladen übrig: Alle Dateien enthalten vermutlich Zugangsdaten und sind gesperrt.'
: 'Keine Dateien zum Hochladen gefunden',
skipped,
blockedFiles: built.blocked,
debugId: uploadDebugId
};
}
const total = items.length;
const totalBytes = sumFileSizes(items);
const onProgress = (percent) => {
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent, totalBytes }); } catch (_) {}
};
onProgress(0);
console.log('[UploadPaths] start', { uploadDebugId, owner, repo, branch, total, destPath, pathCount: paths.length });
await gitPushItemsToGitea({
token, url, owner, repo, branch, items,
message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`,
onProgress,
signal
});
console.log('[UploadPaths] done', { uploadDebugId, total });
const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath }));
return { ok: true, results, failedCount: 0, skipped, blockedFiles: built.blocked, debugId: uploadDebugId };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
return {
ok: false,
error: String(e && e.message ? e.message : e),
retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined,
uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined,
debugId: uploadDebugId
};
} finally {
endCancellableUpload(uploadAbort);
}
});
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) => {
const downloadAbort = beginCancellableUpload();
const signal = downloadAbort.signal;
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) {
throwIfUploadCancelled(signal);
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), signal);
const savedFiles = results.filter(r => r.ok).map(r => r.result);
return { ok: true, savedTo: destBase, files: savedFiles };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Download abgebrochen' };
console.error('download-gitea-folder error', e);
return { ok: false, error: String(e) };
} finally {
endCancellableUpload(downloadAbort);
}
});
/* -----------------------------
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: bareHostname(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 uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
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})` };
invalidateRepoContentsCache(owner, repo);
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,
signal
};
// 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) {
const singleBlocked = blockedSecretReason(data.localFolder);
if (singleBlocked) {
return {
ok: false,
error: `Gesperrt: „${ppath.basename(data.localFolder)}“ enthält vermutlich Zugangsdaten (${singleBlocked}). Die Sperre lässt sich unter Einstellungen → Uploads abschalten.`,
blockedFiles: [{ path: data.localFolder, reason: singleBlocked }],
debugId: uploadDebugId
};
}
console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId });
const raw = fs.readFileSync(data.localFolder);
const base64 = raw.toString('base64');
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) {
throwIfUploadCancelled(signal);
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);
await runGitAsync(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
await runGitAsync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try { await runGitAsync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {}
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = await runGitAsync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions);
} catch (_) {
pushBranch = 'main';
}
}
await runGitAsync(['-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 });
if (isUploadCancelledError(gitErr)) throw gitErr;
return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` };
}
}
}
// --- FALL 2: Ordner ---
if (!isDirectory) {
return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` };
}
const folderName = ppath.basename(data.localFolder);
// Dateien/Ordner, die niemals hochgeladen werden sollen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Dateien mit Zugangsdaten, die der Git-Weg beim Kopieren ausgelassen hat
const blockedInCopy = [];
// === PRIMÄRWEG: git push =========================================
// Ein einziger komprimierter Transfer + genau EIN Commit statt
// hunderter API-Requests. Das behebt sowohl die Langsamkeit als auch
// die "cannot lock ref"/Timeout-Fehler durch parallele Einzel-Commits.
// Bei Fehler (z.B. git nicht installiert) fällt der Code auf den
// API-Upload weiter unten zurück.
try {
let finalCloneUrl = cloneUrl;
if (!finalCloneUrl && giteaUrl) {
const base = giteaUrl.replace(/\/$/, '');
const urlObj = new URL(base);
finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`;
}
if (!finalCloneUrl) throw new Error('no-clone-url');
// Token in die Clone-URL einbetten (für Authentifizierung beim Push)
let authClone = finalCloneUrl;
try {
const urlObj = new URL(finalCloneUrl);
if (token && urlObj.protocol.startsWith('http')) {
urlObj.username = encodeURIComponent(token);
authClone = urlObj.toString();
}
} catch (_) {}
const tmpDir = getSafeTmpDir(`git-push-folder-${owner}-${repo}`);
try {
console.log('[UPLOAD_DEBUG][main] directory-upload:git-clone', { uploadDebugId, branch, destPath });
const cloneArgs = ['clone', '--depth', '1'];
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
cloneArgs.push(authClone, tmpDir);
await runGitAsync(cloneArgs, process.cwd(), gitExecOptions);
// Zielverzeichnis im Repo: <destPath>/<folderName>/ (wie im API-Weg)
let destDirInRepo = tmpDir;
if (destPath) destDirInRepo = ppath.join(tmpDir, destPath.split('/').join(ppath.sep));
destDirInRepo = ppath.join(destDirInRepo, folderName);
ensureDir(destDirInRepo);
await transferGate.wait(signal);
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
const isIgnoredLocal = await createUploadIgnore(data.localFolder);
fs.cpSync(data.localFolder, destDirInRepo, {
recursive: true,
filter: (src) => {
if (excludeList.includes(ppath.basename(src))) return false;
let isDir = false;
try { isDir = fs.statSync(src).isDirectory(); } catch (_) {}
if (!isDir) {
const reason = blockedSecretReason(src);
if (reason) { blockedInCopy.push({ path: src, reason }); return false; }
}
return !isIgnoredLocal(src, isDir);
}
});
await runGitAsync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try {
await runGitAsync(['-C', tmpDir, 'commit', '-m', `Upload ${folderName} via GUI`], process.cwd(), gitExecOptions);
} catch (commitErr) {
// "nothing to commit" ist kein echter Fehler
if (!/nothing to commit/i.test(String(commitErr))) throw commitErr;
console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
}
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = await runGitAsync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions);
} catch (_) { pushBranch = 'main'; }
if (!pushBranch || pushBranch === 'HEAD') pushBranch = 'main';
}
await runGitAsync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId };
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
throw gitErr;
}
} catch (gitErr) {
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
console.warn('[UPLOAD_DEBUG][main] directory-upload:git-failed-fallback-to-api', { uploadDebugId, error: String(gitErr && gitErr.message ? gitErr.message : gitErr) });
// -> weiter zum API-Fallback unten
}
console.log('[UPLOAD_DEBUG][main] directory-upload:using-api-path', { uploadDebugId, branch, destPath });
// Fallback: API Upload (paralleler Upload)
const items = [];
// excludeList und folderName sind oben (FALL 2) bereits definiert
const isIgnoredApi = await createUploadIgnore(data.localFolder);
(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 (isIgnoredApi(full, stat.isDirectory())) 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);
// Dateien mit Zugangsdaten bleiben außen vor.
const blockedFiles = removeBlockedInPlace(items);
const total = items.length;
const concurrency = data.concurrency || DEFAULT_CONCURRENCY;
if (total === 0) return { ok: true, usedGit: false, results: [], blockedFiles, 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), signal);
const failedCount = uploadResults.filter(r => !r.ok).length;
console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length });
return { ok: failedCount === 0, usedGit: false, results: uploadResults, blockedFiles, debugId: uploadDebugId, failedCount };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
} finally {
endCancellableUpload(uploadAbort);
}
});
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) => {
const batchAbort = beginCancellableUpload();
const signal = batchAbort.signal;
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 = [];
let cancelled = false;
for (let i = 0; i < repos.length; i++) {
if (signal.aborted) { cancelled = true; break; }
try { await transferGate.wait(signal); } catch (e) { if (isUploadCancelledError(e)) { cancelled = true; break; } throw e; }
const item = repos[i];
const progressBase = { index: i + 1, total: repos.length, repo: item.id, action };
sendProgress({ ...progressBase, status: 'running' });
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 (_) {}
try {
await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore', signal });
} catch (cloneErr) {
// Abgebrochener Clone: den angelegten Teilordner entfernen (existierte vorher nicht).
if (isUploadCancelledError(cloneErr)) {
try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch (_) {}
}
throw cloneErr;
}
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) {
if (isUploadCancelledError(e)) { cancelled = true; break; }
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,
cancelled,
summary: { total: repos.length, done: results.length, success, failed },
results
};
} catch (e) {
console.error('run-batch-repo-action error', e);
return { ok: false, error: String(e) };
} finally {
endCancellableUpload(batchAbort);
}
});
/* ================================
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: bareHostname(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=main`, 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=main`, 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=main`, null);
const body = { message, content: contentBase64, branch: 'main' };
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: 'main'
});
}
// 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);
}
invalidateRepoContentsCache(owner, repo);
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);
function parseBody(raw) {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function isAlreadyExistsResponse(statusCode, rawBody) {
if (statusCode !== 409 && statusCode !== 422) return false;
const parsed = parseBody(rawBody);
const message = typeof parsed?.message === 'string' ? parsed.message : String(rawBody || '');
return /already exists/i.test(message);
}
function requestGitea(method, apiPath, body = null) {
return new Promise((resolve) => {
const bodyStr = body ? JSON.stringify(body) : null;
const req = protocol.request({
hostname: bareHostname(urlObj.hostname),
port,
path: apiPath,
method,
headers: {
'Authorization': `token ${token}`,
...(bodyStr ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) } : {})
}
}, (res) => {
let b = '';
res.on('data', c => b += c);
res.on('end', () => resolve({ statusCode: res.statusCode, rawBody: b, body: parseBody(b) }));
});
req.on('error', e => resolve({ statusCode: 0, rawBody: String(e), body: null, error: String(e) }));
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Für Ordner: .gitkeep Datei anlegen
const targetPath = type === 'folder' ? `${itemPath}/.gitkeep` : itemPath;
const content = Buffer.from('').toString('base64');
// Branch-Auflösung: HEAD zu 'main' konvertieren
const branch = (data.branch && data.branch !== 'HEAD') ? data.branch : 'main';
const encodedTargetPath = targetPath.split('/').map(encodeURIComponent).join('/');
const encodedItemPath = itemPath.split('/').map(encodeURIComponent).join('/');
const baseContentsPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
if (type === 'folder') {
const existing = await requestGitea('GET', `${baseContentsPath}/${encodedTargetPath}?ref=${encodeURIComponent(branch)}`);
if (existing.statusCode >= 200 && existing.statusCode < 300) {
return { ok: true, exists: true };
}
const existingDir = await requestGitea('GET', `${baseContentsPath}/${encodedItemPath}?ref=${encodeURIComponent(branch)}`);
if (existingDir.statusCode >= 200 && existingDir.statusCode < 300 && Array.isArray(existingDir.body)) {
const hasGitkeep = existingDir.body.some(entry => entry?.name === '.gitkeep');
if (hasGitkeep) {
return { ok: true, exists: true };
}
}
}
const response = await requestGitea('POST', `${baseContentsPath}/${encodedTargetPath}`, { message: `Create ${itemPath}`, content, branch });
if (response.statusCode >= 200 && response.statusCode < 300) {
invalidateRepoContentsCache(owner, repo);
return { ok: true };
}
if (type === 'folder' && isAlreadyExistsResponse(response.statusCode, response.rawBody)) {
invalidateRepoContentsCache(owner, repo);
return { ok: true, exists: true };
}
return { ok: false, error: `HTTP ${response.statusCode}: ${response.rawBody}` };
} 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);
invalidateLocalFileTreeCacheForPath(dir);
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');
}
invalidateLocalFileTreeCacheForPath(parentDir);
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);
invalidateLocalFileTreeCacheForPath(srcPath);
invalidateLocalFileTreeCacheForPath(destDir);
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 });
invalidateLocalFileTreeCacheForPath(srcPath);
invalidateLocalFileTreeCacheForPath(destDir);
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);
}
invalidateLocalFileTreeCacheForPath(destDir);
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
// Release für den Updater: Datei prüfen und Release vorbereiten (Tag, Prüfsummen im Text)
const releaseAssist = require('./src/features/releaseAssist.js');
function sha256OfFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
// Liefert Name, Größe, SHA-256 und Version einer Release-Datei und gleicht sie mit "<datei>.sha256" ab.
ipcMain.handle('inspect-release-file', async (_event, data) => {
try {
const filePath = String((data && data.filePath) || '');
const stat = fs.statSync(filePath);
if (!stat.isFile()) return { ok: false, error: 'Das ist keine Datei.' };
const name = ppath.basename(filePath);
const sha256 = await sha256OfFile(filePath);
const sidecarPath = `${filePath}.sha256`;
const hasSidecar = fs.existsSync(sidecarPath);
const sidecarSha256 = hasSidecar ? releaseAssist.parseSidecar(fs.readFileSync(sidecarPath, 'utf8')) : null;
return {
ok: true,
name,
size: stat.size,
sha256,
version: releaseAssist.parseVersionFromFileName(name),
sidecarPath: hasSidecar ? sidecarPath : null,
sidecarSha256,
sidecarMatches: sidecarSha256 ? sidecarSha256 === sha256 : null
};
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
// Prüft den Tag gegen die vorhandenen Releases und baut den Release-Text mit den Prüfsummen.
ipcMain.handle('prepare-release', async (_event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
let releases;
try {
if (data.platform === 'github') {
if (!credentials.githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
releases = await listGithubReleases({ token: credentials.githubToken, owner: data.owner, repo: data.repo });
} else {
releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo });
}
} catch (e) {
return { ok: false, error: `Vorhandene Releases konnten nicht geprüft werden: ${String(e && e.message || e)}` };
}
const check = releaseAssist.checkNewTag(data.tag, releases);
if (!check.ok) return { ok: false, error: check.reason };
return { ok: true, body: releaseAssist.buildReleaseBody(data.notes, data.assets), newest: check.newest };
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
ipcMain.handle('create-release', async (event, data) => {
try {
const credentials = readCredentials();
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');
}
function safeReadJsonFile(filePath, fallbackValue) {
try {
if (!fs.existsSync(filePath)) return fallbackValue;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (_) {
return fallbackValue;
}
}
function createStampForFileName(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function getFileMeta(filePath) {
try {
if (!filePath || !fs.existsSync(filePath)) {
return { path: filePath || '', exists: false };
}
const stat = fs.statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mtime: stat.mtime ? stat.mtime.toISOString() : null,
ctime: stat.ctime ? stat.ctime.toISOString() : null
};
} catch (e) {
return {
path: filePath || '',
exists: false,
error: String(e?.message || e)
};
}
}
ipcMain.handle('load-favorites', async () => {
try {
const p = getFavoritesFilePath();
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) };
}
});
ipcMain.handle('export-settings-bundle', async () => {
try {
const credentials = readCredentials() || {};
const favorites = safeReadJsonFile(getFavoritesFilePath(), []);
const recent = safeReadJsonFile(getRecentFilePath(), []);
const settingsWithoutSecrets = {
giteaURL: credentials.giteaURL || '',
avatarB64: credentials.avatarB64 || null,
featureFavorites: !!credentials.featureFavorites,
featureRecent: !!credentials.featureRecent,
compactMode: !!credentials.compactMode,
featureColoredIcons: credentials.featureColoredIcons !== false,
favCollapsedFavorites: !!credentials.favCollapsedFavorites,
favCollapsedRecent: !!credentials.favCollapsedRecent
};
const encryptedSecretsPayload = encryptLegacyCredentials(JSON.stringify({
githubToken: credentials.githubToken || '',
giteaToken: credentials.giteaToken || ''
})).toString('base64');
const bundle = {
bundleType: 'git-manager-settings-backup',
schemaVersion: 1,
exportedAt: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
settings: settingsWithoutSecrets,
encryptedSecrets: {
algorithm: 'legacyAesCbc',
payloadBase64: encryptedSecretsPayload
},
favorites: Array.isArray(favorites) ? favorites : [],
recent: Array.isArray(recent) ? recent : []
};
const fileName = `git-manager-backup-${createStampForFileName()}.json`;
const result = await dialog.showSaveDialog({
title: 'Backup exportieren',
defaultPath: ppath.join(app.getPath('documents'), fileName),
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
fs.writeFileSync(result.filePath, JSON.stringify(bundle, null, 2), 'utf8');
logger.info('settings-backup', 'Backup exported', { filePath: result.filePath });
return { ok: true, filePath: result.filePath };
} catch (e) {
logger.error('settings-backup', 'Backup export failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
ipcMain.handle('import-settings-bundle', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Backup importieren',
properties: ['openFile'],
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePaths || !result.filePaths[0]) return { ok: false, canceled: true };
const filePath = result.filePaths[0];
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || parsed.bundleType !== 'git-manager-settings-backup') {
return { ok: false, error: 'Ungültige Backup-Datei.' };
}
const existing = readCredentials() || {};
const settings = parsed.settings && typeof parsed.settings === 'object' ? parsed.settings : {};
let importedSecrets = { githubToken: '', giteaToken: '' };
const encryptedPayload = parsed?.encryptedSecrets?.payloadBase64;
if (encryptedPayload) {
try {
importedSecrets = decryptLegacyCredentials(Buffer.from(String(encryptedPayload), 'base64'));
} catch (_) {
return { ok: false, error: 'Backup konnte nicht entschluesselt werden.' };
}
}
const mergedCredentials = {
...existing,
...settings,
githubToken: String(importedSecrets.githubToken || existing.githubToken || ''),
giteaToken: String(importedSecrets.giteaToken || existing.giteaToken || '')
};
persistCredentials(mergedCredentials);
if (Array.isArray(parsed.favorites)) {
fs.writeFileSync(getFavoritesFilePath(), JSON.stringify(parsed.favorites, null, 2), 'utf8');
}
if (Array.isArray(parsed.recent)) {
fs.writeFileSync(getRecentFilePath(), JSON.stringify(parsed.recent.slice(0, 20), null, 2), 'utf8');
}
logger.info('settings-backup', 'Backup imported', {
filePath,
favorites: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0,
recent: Array.isArray(parsed.recent) ? parsed.recent.length : 0
});
return {
ok: true,
filePath,
favoritesImported: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0,
recentImported: Array.isArray(parsed.recent) ? parsed.recent.length : 0
};
} catch (e) {
logger.error('settings-backup', 'Backup import failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
ipcMain.handle('create-diagnostics-package', async () => {
try {
const recentLogs = logger.getRecent(1200);
const capturedEvents = diagnosticsEvents.slice(-600);
const credentials = readCredentials() || {};
const favorites = safeReadJsonFile(getFavoritesFilePath(), []);
const recent = safeReadJsonFile(getRecentFilePath(), []);
const userDataPath = app.getPath('userData');
const appPaths = {
userData: userDataPath,
sessionData: app.getPath('sessionData'),
cache: app.getPath('cache'),
temp: app.getPath('temp'),
logs: app.getPath('logs'),
documents: app.getPath('documents')
};
const windows = BrowserWindow.getAllWindows().map((w, idx) => {
let bounds = null;
try { bounds = w.getBounds(); } catch (_) {}
return {
index: idx,
visible: w.isVisible(),
focused: w.isFocused(),
minimized: w.isMinimized(),
maximized: w.isMaximized(),
bounds
};
});
const settingsSnapshot = {
featureFavorites: !!credentials.featureFavorites,
featureRecent: !!credentials.featureRecent,
compactMode: !!credentials.compactMode,
featureColoredIcons: credentials.featureColoredIcons !== false,
favCollapsedFavorites: !!credentials.favCollapsedFavorites,
favCollapsedRecent: !!credentials.favCollapsedRecent,
giteaUrlConfigured: !!String(credentials.giteaURL || '').trim(),
githubTokenConfigured: !!String(credentials.githubToken || '').trim(),
giteaTokenConfigured: !!String(credentials.giteaToken || '').trim()
};
const resourceUsage = {
processUptimeSec: Number(process.uptime().toFixed(3)),
rss: process.memoryUsage().rss,
heapTotal: process.memoryUsage().heapTotal,
heapUsed: process.memoryUsage().heapUsed,
external: process.memoryUsage().external,
arrayBuffers: process.memoryUsage().arrayBuffers,
cpuUsage: process.cpuUsage()
};
const errorBySource = {};
const errorByLevel = {};
for (const item of [...(Array.isArray(recentLogs) ? recentLogs : []), ...(Array.isArray(capturedEvents) ? capturedEvents : [])]) {
const level = String(item?.level || 'INFO').toUpperCase();
const source = String(item?.source || item?.context || 'unknown');
errorByLevel[level] = (errorByLevel[level] || 0) + 1;
errorBySource[source] = (errorBySource[source] || 0) + 1;
}
const debug = {
generatedAt: new Date().toISOString(),
appVersion: app.getVersion(),
appName: app.getName(),
platform: process.platform,
arch: process.arch,
os: {
release: os.release(),
type: os.type(),
version: os.version ? os.version() : null,
hostname: os.hostname(),
uptimeSec: os.uptime()
},
runtime: {
node: process.versions.node,
electron: process.versions.electron,
chrome: process.versions.chrome,
v8: process.versions.v8
},
appPaths,
storageFiles: {
credentialsPrimary: getFileMeta(getCredentialsFilePath()),
credentialsLegacy: getFileMeta(getLegacyWorkspaceCredentialsFilePath()),
favorites: getFileMeta(getFavoritesFilePath()),
recent: getFileMeta(getRecentFilePath())
},
windowState: windows,
settingsSnapshot,
collections: {
favoritesCount: Array.isArray(favorites) ? favorites.length : 0,
recentCount: Array.isArray(recent) ? recent.length : 0
},
resourceUsage,
autostart: app.getLoginItemSettings({ args: ['--hidden'] }),
processInfo: {
pid: process.pid,
ppid: process.ppid,
argv: process.argv,
execPath: process.execPath,
cwd: process.cwd(),
env: {
NODE_ENV: process.env.NODE_ENV || null,
APPDATA: process.env.APPDATA || null,
LOCALAPPDATA: process.env.LOCALAPPDATA || null,
TEMP: process.env.TEMP || null,
TMP: process.env.TMP || null
}
},
credentialStatus: getCredentialReadStatus(),
cacheStats: {
repos: caches.repos.size(),
fileTree: caches.fileTree.size(),
api: caches.api.size()
},
retryQueue: {
size: Array.isArray(retryQueue) ? retryQueue.length : 0,
sample: Array.isArray(retryQueue) ? retryQueue.slice(0, 10) : []
},
logs: recentLogs,
capturedEvents,
counts: {
logs: Array.isArray(recentLogs) ? recentLogs.length : 0,
capturedEvents: Array.isArray(capturedEvents) ? capturedEvents.length : 0,
errorsByLevel: errorByLevel,
errorsBySource: errorBySource
},
guidance: {
note: 'Diese Datei kann an den Support weitergegeben werden. Tokens werden nicht im Klartext exportiert.',
recommendation: 'Reproduziere den Fehler direkt vor der Diagnose-Erstellung für maximale Aussagekraft.'
}
};
const logErrors = (Array.isArray(debug.logs) ? debug.logs : [])
.filter(l => ['ERROR', 'WARN'].includes(String(l?.level || '').toUpperCase()))
.slice(-80);
const eventErrors = (Array.isArray(capturedEvents) ? capturedEvents : [])
.filter(e => ['ERROR', 'WARN'].includes(String(e?.level || '').toUpperCase()))
.slice(-80);
debug.lastErrors = [...logErrors, ...eventErrors].slice(-120);
const fileName = `git-manager-diagnostics-${createStampForFileName()}.json`;
const result = await dialog.showSaveDialog({
title: 'Diagnosepaket speichern',
defaultPath: ppath.join(app.getPath('documents'), fileName),
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
fs.writeFileSync(result.filePath, JSON.stringify(debug, null, 2), 'utf8');
logger.info('diagnostics', 'Diagnostics package created', { filePath: result.filePath });
return { ok: true, filePath: result.filePath };
} catch (e) {
logger.error('diagnostics', 'Diagnostics package failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
// main.js - Updater IPC Handlers
// Window Controls
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]' || 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) {
Updater = require('./updater.js');
}
const win = BrowserWindow.fromWebContents(event.sender);
if (!updater) {
if (win && Updater) updater = new Updater(win);
} else if (win) {
updater.mainWindow = win;
}
if (updater) await updater.checkForUpdates(silent);
else return { ok: false, error: 'Updater konnte nicht initialisiert werden.' };
return { ok: true };
} catch (error) {
console.error('[Main] Fehler beim Update-Check:', error);
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) {
Updater = require('./updater.js');
}
const win = BrowserWindow.fromWebContents(event.sender);
if (!updater) {
updater = new Updater(win);
} else if (win) {
updater.mainWindow = 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 = async (args, cwd) => {
const res = await execGit(args, { cwd });
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}`);
await runGit(cloneArgs, process.cwd());
console.log('[sync-repo-to-github] Bare-Clone abgeschlossen');
// Alle lokalen Branches auflisten
const allBranchesRaw = await 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 = await 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
await runGit(['remote', 'set-url', 'origin', githubRemoteUrl], mirrorDir);
// Lokale Commit-SHAs loggen (= Gitea-Stand)
for (const branch of allBranches) {
try {
const localSha = await 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 = await 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}`);
await 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 = await 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}`);
await 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 {
await 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) };
}
});
/* -----------------------------
Neue Funktionen: Upload-Vorschau, Diagnose, lokale Änderungen, PRs/Issues, Repo-Backup
----------------------------- */
require('./src/features/featuresIpc.js').registerFeatureIpc({
ipcMain,
app,
readCredentials,
buildItemsFromPaths,
clearGitTransportBlock,
getGitTransportBlocks: () => { loadBrokenGitTransports(); return Object.fromEntries(brokenGitTransports); },
prefs: appPrefs,
openPath: (dir) => shell.openPath(dir),
notify: (title, body) => {
try {
const { Notification } = require('electron');
if (Notification.isSupported()) new Notification({ title, body, icon: ppath.join(__dirname, 'assets', 'icon.png') }).show();
} catch (e) { console.warn('Benachrichtigung:', e.message); }
},
broadcast: (channel, payload) => {
for (const win of BrowserWindow.getAllWindows()) {
try { if (!win.isDestroyed()) win.webContents.send(channel, payload); } catch (_) {}
}
}
});