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