Upload via GUI (85 Dateien)

This commit is contained in:
2026-10-22 09:13:16 +00:00
parent ce5a655758
commit 2ad8777311
19 changed files with 1429 additions and 135 deletions
+337 -68
View File
@@ -1,5 +1,5 @@
// 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 { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage, Notification, screen } = require('electron');
// Das Setup startet die frisch installierte EXE einmal mit diesem Schalter, damit Windows Defender
// sie schon während der Installation prüft (installer/Installer.cs → Vorwaermen). Sofort beenden.
if (process.argv.includes('--gm-vorwaermen')) process.exit(0);
@@ -23,6 +23,7 @@ applyPrefsToProcess(appPrefs.get());
appPrefs.onChange((p) => {
applyPrefsToProcess(p);
for (const w of BrowserWindow.getAllWindows()) {
if (trayPopup && w === trayPopup.getWindow()) continue;
try { w.webContents.setZoomFactor(p.zoom / 100); } catch (_) {}
}
});
@@ -414,7 +415,8 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item
console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`);
const result = await uploadItemsViaGiteaApi({
token, url, owner, repo, branch, items, message, onProgress, signal
token, url, owner, repo, branch, items, message, onProgress, signal,
waitIfPaused: (s) => transferGate.wait(s)
});
console.log(`[${label}] API-Upload fertig:`, {
@@ -427,6 +429,16 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item
return { ok: result.failed.length === 0, ...result };
}
// Fehler für einen teilweise fehlgeschlagenen API-Upload. Trägt die betroffenen Einträge,
// damit der Renderer nur diese wiederholen kann.
function buildPartialUploadError(gitErr, apiResult, items) {
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
err.uploadedCount = (apiResult.uploaded || []).length;
return err;
}
// Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen.
function describeApiFallbackFailure(gitErr, apiResult) {
const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`;
@@ -716,6 +728,7 @@ function broadcastRetryQueueUpdate(extra = {}) {
for (const win of BrowserWindow.getAllWindows()) {
try { win.webContents.send('retry-queue-updated', payload); } catch (_) {}
}
try { refreshTray(); } catch (_) {}
}
function isRetryableNetworkError(errorLike) {
@@ -899,6 +912,7 @@ app.commandLine.appendSwitch('enable-memory-coordination');
app / window
----------------------------- */
let tray = null;
let trayPopup = null;
function ensureUsableDirectory(dirPath) {
try {
@@ -969,17 +983,82 @@ function initializeAppStoragePaths() {
initializeAppStoragePaths();
const trayMenu = require('./src/features/trayMenu.js');
const { createTrayPopup } = require('./src/features/trayPopup.js');
const startAnimation = require('./src/features/splash.js');
function showMainWindow(win) {
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
// Tooltip des Tray-Symbols und offenes Menü: zeigen, ob gerade etwas übertragen wird.
// Wird beim Start und Ende jeder abbrechbaren Übertragung und bei Änderungen der Retry-Queue aufgerufen.
function refreshTray() {
if (!tray || tray.isDestroyed()) return;
tray.setToolTip(trayMenu.trayTooltip(app.getVersion(), activeTransferAborts.size, transferGate.isPaused()));
if (trayPopup) trayPopup.refresh();
}
// Beenden über das Tray-Menü: läuft noch ein Upload oder Download, vorher nachfragen.
async function quitFromTray(win) {
if (activeTransferAborts.size > 0) {
showMainWindow(win);
const { response } = await dialog.showMessageBox(win, {
type: 'warning',
buttons: ['Beenden und abbrechen', 'Weiter laufen lassen'],
defaultId: 1,
cancelId: 1,
title: 'Übertragung läuft',
message: 'Es läuft noch ein Upload oder Download.',
detail: 'Wenn du jetzt beendest, wird er abgebrochen.'
});
if (response !== 0) return;
}
app.quit();
}
// Menüvorlage mit dem aktuellen Status; wird bei jedem Öffnen und Aktualisieren neu gebaut.
function trayTemplate(win) {
return trayMenu.buildTrayTemplate({
version: app.getVersion(),
active: activeTransferAborts.size,
queued: Array.isArray(retryQueue) ? retryQueue.length : 0,
paused: transferGate.isPaused(),
autostart: app.getLoginItemSettings({ args: ['--hidden'] }).openAtLogin,
onPause: () => { if (transferGate.isPaused()) transferGate.resume(); else transferGate.pause(); },
onOpen: () => showMainWindow(win),
onHistory: () => {
showMainWindow(win);
try { win.webContents.send('open-upload-history'); } catch (_) {}
},
onCancel: () => {
for (const controller of activeTransferAborts) controller.abort();
refreshTray();
},
onAutostart: (item) => app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true, args: ['--hidden'] }),
onQuit: () => { quitFromTray(win); }
});
}
function createTray(win) {
const iconPath = ppath.join(__dirname, 'renderer', 'icon.png');
tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }));
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(); });
// Eigenes Menüfenster statt des nativen Menüs: Letzteres zeichnet unter Windows keine Hover-Markierung.
trayPopup = createTrayPopup({
htmlPath: ppath.join(__dirname, 'renderer', 'tray-menu.html'),
preloadPath: ppath.join(__dirname, 'tray-menu-preload.js'),
getModel: () => trayMenu.serializeTemplate(trayTemplate(win)),
onAction: (id) => trayMenu.runAction(trayTemplate(win), id)
});
refreshTray();
// Menüfenster kurz nach dem Start im Hintergrund vorladen, damit der erste Rechtsklick sofort reagiert.
setTimeout(() => { try { trayPopup.warmUp(); } catch (e) { console.warn('[Tray] Vorladen fehlgeschlagen:', e.message); } }, 2000);
tray.on('right-click', () => trayPopup.show());
tray.on('click', () => showMainWindow(win));
tray.on('double-click', () => showMainWindow(win));
}
function createWindow() {
@@ -988,11 +1067,23 @@ function createWindow() {
const startHidden = process.argv.includes('--hidden');
// Startanimation: drehendes Logo, danach baut sich das Fenster von der Mitte aus auf. Das Hauptfenster
// bleibt bis dahin verborgen. Beim versteckten Start (Windows-Autostart im Tray) gibt es sie nie.
const useSplash = !startHidden && appPrefs.get().showSplash;
// Start- und Hauptfenster bekommen dieselbe Position und Größe. Das Startfenster entsteht zuerst,
// damit das Logo so früh wie möglich erscheint.
let splashHandle = null;
let windowBounds = { width: 1200, height: 820 };
if (useSplash) {
windowBounds = startAnimation.centeredBounds(screen.getPrimaryDisplay().workArea, 1200, 820);
splashHandle = startAnimation.begin({ htmlPath: ppath.join(__dirname, 'renderer', 'splash.html'), bounds: windowBounds });
}
const win = new BrowserWindow({
width: 1200,
height: 820,
...windowBounds,
frame: false,
show: !startHidden,
show: !startHidden && !useSplash,
webPreferences: {
preload: ppath.join(__dirname, 'preload.js'),
nodeIntegration: false,
@@ -1027,6 +1118,14 @@ function createWindow() {
});
win.loadFile(ppath.join(__dirname, 'renderer', 'index.html'));
if (splashHandle) {
// Die Animation zeigt das Hauptfenster selbst, sobald die Oberfläche geladen ist (oder das Laden scheitert).
const uiReady = new Promise((resolve) => {
win.webContents.once('did-finish-load', resolve);
win.webContents.once('did-fail-load', resolve);
});
startAnimation.play(win, uiReady, splashHandle);
}
win.webContents.on('did-finish-load', () => {
try { win.webContents.setZoomFactor(appPrefs.get().zoom / 100); } catch (_) {}
});
@@ -1047,6 +1146,9 @@ function createWindow() {
win.hide();
}
});
mainWin = win;
// Echtes Schließen: das versteckte Tray-Menüfenster mitschließen, sonst bliebe die App im Hintergrund.
win.on('closed', () => { try { if (trayPopup) trayPopup.destroy(); } catch (_) {} });
}
app.whenReady().then(() => {
@@ -1069,6 +1171,7 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('before-quit', () => {
try { if (trayPopup) trayPopup.destroy(); } catch (_) {}
if (retryQueueTimer) {
clearInterval(retryQueueTimer);
retryQueueTimer = null;
@@ -1264,6 +1367,7 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
async function worker() {
while (true) {
if (signal && signal.aborted) return;
try { await transferGate.wait(signal); } catch (_) { return; }
const i = index++;
if (i >= tasks.length) return;
try {
@@ -1292,8 +1396,11 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo,
Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge).
----------------------------- */
const UPLOAD_HISTORY_FILE = ppath.join(app.getPath('userData'), 'upload-history.json');
const UPLOAD_HISTORY_MAX = 100;
const uploadHistory = require('./src/features/uploadHistory.js').createUploadHistory({
file: ppath.join(app.getPath('userData'), 'upload-history.json'),
max: 100
});
const { buildEntry: buildUploadHistoryEntry, filterEntries: filterUploadHistory, toCsv: uploadHistoryToCsv } = require('./src/features/uploadHistory.js');
const UPLOAD_HISTORY_CHANNELS = {
'upload-paths-to-gitea': 'Drag & Drop',
'upload-local-folder-to-gitea': 'Ordner',
@@ -1301,41 +1408,27 @@ const UPLOAD_HISTORY_CHANNELS = {
'upload-and-push': 'Ordner/Datei'
};
function readUploadHistory() {
try {
const parsed = JSON.parse(fs.readFileSync(UPLOAD_HISTORY_FILE, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
// Windows-Meldung zu einem beendeten Upload, wenn die App nicht im Vordergrund ist
// (im Tray versteckt, minimiert oder von einem anderen Fenster verdeckt).
const transferNotice = require('./src/features/transferNotice.js');
let mainWin = null;
function recordUploadHistory(entry) {
function maybeNotifyTransfer(entry) {
try {
const history = [entry, ...readUploadHistory()].slice(0, UPLOAD_HISTORY_MAX);
fs.writeFileSync(UPLOAD_HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8');
if (appPrefs.get().notifyTransfers === 'never') return;
const win = mainWin;
const inFront = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused();
if (inFront) return;
const notice = transferNotice.describeTransfer(entry);
if (!notice || !Notification.isSupported()) return;
const n = new Notification({ title: notice.title, body: notice.body, icon: ppath.join(__dirname, 'assets', 'icon.png') });
n.on('click', () => showMainWindow(win));
n.show();
} catch (e) {
console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message);
console.warn('Benachrichtigung:', e.message);
}
}
function buildUploadHistoryEntry(kind, data, res, startedAt, thrown) {
const results = res && Array.isArray(res.results) ? res.results : null;
let status = 'ok';
if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler';
const error = thrown ? String(thrown.message || thrown) : (res && !res.ok && !res.cancelled ? String(res.error || '') : '');
return {
at: new Date().toISOString(),
kind,
repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '',
files: results ? results.length : (res && res.singleFile ? 1 : null),
failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0,
status,
error: error.slice(0, 300),
durationMs: Date.now() - startedAt
};
}
const rawIpcHandle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, handler) => {
const kind = UPLOAD_HISTORY_CHANNELS[channel];
@@ -1346,17 +1439,40 @@ ipcMain.handle = (channel, handler) => {
try {
res = await handler(event, data);
} catch (e) {
recordUploadHistory(buildUploadHistoryEntry(kind, data, null, startedAt, e));
const failedEntry = buildUploadHistoryEntry(kind, data, null, startedAt, e);
uploadHistory.record(failedEntry);
maybeNotifyTransfer(failedEntry);
throw e;
}
recordUploadHistory(buildUploadHistoryEntry(kind, data, res, startedAt, null));
const entry = buildUploadHistoryEntry(kind, data, res, startedAt, null);
uploadHistory.record(entry);
maybeNotifyTransfer(entry);
return res;
});
};
ipcMain.handle('get-upload-history', () => ({ ok: true, history: readUploadHistory() }));
ipcMain.handle('get-upload-history', (_event, data) => ({ ok: true, history: filterUploadHistory(uploadHistory.read(), data || {}) }));
// CSV-Export des (gefilterten) Verlaufs, für deutsches Excel (Semikolon, UTF-8 mit BOM).
ipcMain.handle('export-upload-history-csv', async (event, data) => {
try {
const entries = filterUploadHistory(uploadHistory.read(), data || {});
if (entries.length === 0) return { ok: false, error: 'Keine Einträge zum Exportieren.' };
const win = BrowserWindow.fromWebContents(event.sender);
const stamp = new Date().toISOString().slice(0, 10);
const save = await dialog.showSaveDialog(win, {
defaultPath: ppath.join(app.getPath('documents'), `upload-verlauf-${stamp}.csv`),
filters: [{ name: 'CSV-Datei', extensions: ['csv'] }]
});
if (save.canceled || !save.filePath) return { ok: false, canceled: true };
fs.writeFileSync(save.filePath, uploadHistoryToCsv(entries), 'utf8');
return { ok: true, savedTo: save.filePath, count: entries.length };
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
ipcMain.handle('clear-upload-history', () => {
try { fs.writeFileSync(UPLOAD_HISTORY_FILE, '[]', 'utf8'); return { ok: true }; }
try { uploadHistory.clear(); return { ok: true }; }
catch (e) { return { ok: false, error: String(e && e.message || e) }; }
});
@@ -2653,6 +2769,11 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
let stat;
try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; }
if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; }
const blockedReason = blockedSecretReason(localFile);
if (blockedReason) {
results.push({ file: localFile, ok: false, blocked: true, error: `Gesperrt (Zugangsdaten): ${blockedReason}` });
continue;
}
validFiles.push(localFile);
}
@@ -2696,6 +2817,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
}
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
@@ -2886,16 +3008,45 @@ function sumFileSizes(items) {
// (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf).
const activeTransferAborts = new Set();
// Pause/Fortsetzen: Übertragungen halten an den Kontrollpunkten an (zwischen Paketen, Dateien und
// Git-Schritten), solange pausiert ist. Ein laufender Clone oder Push wird erst fertig.
const transferGate = require('./src/features/pauseGate.js').createPauseGate({
onChange: (paused) => {
for (const w of BrowserWindow.getAllWindows()) {
try { if (!w.isDestroyed()) w.webContents.send('transfer-pause-state', { paused }); } catch (_) {}
}
refreshTray();
}
});
function beginCancellableUpload() {
// Eine vergessene Pause eines früheren Vorgangs darf den neuen nicht blockieren.
if (activeTransferAborts.size === 0) transferGate.resume();
const controller = new AbortController();
activeTransferAborts.add(controller);
refreshTray();
return controller;
}
function endCancellableUpload(controller) {
activeTransferAborts.delete(controller);
if (activeTransferAborts.size === 0) transferGate.resume();
refreshTray();
}
ipcMain.handle('pause-upload', () => {
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
transferGate.pause();
return { ok: true, paused: true };
});
ipcMain.handle('resume-upload', () => {
transferGate.resume();
return { ok: true, paused: false };
});
ipcMain.handle('get-pause-state', () => ({ ok: true, paused: transferGate.isPaused() }));
ipcMain.handle('cancel-upload', () => {
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
for (const controller of activeTransferAborts) controller.abort();
@@ -2926,8 +3077,13 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
// Exclude-Liste: .git, node_modules etc. überspringen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt den Ordner neu einzulesen.
const retryItems = Array.isArray(data.items)
? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile))
: null;
const isIgnored = await createUploadIgnore(localFolder);
(function walk(dir) {
if (retryItems && retryItems.length) items.push(...retryItems);
else (function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (excludeList.includes(entry)) continue;
@@ -2950,8 +3106,11 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
}
})(localFolder);
// Dateien mit Zugangsdaten bleiben außen vor.
const blockedFiles = removeBlockedInPlace(items);
const total = items.length;
if (total === 0) return { ok: true, results: [] };
if (total === 0) return { ok: true, results: [], blockedFiles };
const totalBytes = sumFileSizes(items);
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
@@ -3001,6 +3160,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
// git add + commit + push
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
@@ -3036,7 +3196,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0 };
return { ok: true, results: successResults, failedCount: 0, blockedFiles };
} catch (gitErr) {
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
@@ -3064,16 +3224,21 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
});
if (!apiResult.ok) {
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
throw buildPartialUploadError(gitErr, apiResult, items);
}
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0, viaApi: true };
return { ok: true, results: successResults, failedCount: 0, viaApi: true, blockedFiles };
}
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
console.error('upload-local-folder-to-gitea error', e);
return { ok: false, error: String(e) };
return {
ok: false,
error: String(e),
retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined,
uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined
};
} finally {
endCancellableUpload(uploadAbort);
}
@@ -3142,6 +3307,21 @@ async function createUploadIgnore(target, cache = new Map()) {
const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Feste Sperre für Dateien mit Zugangsdaten (Einstellung "Zugangsdaten nie hochladen").
const uploadBlocklist = require('./src/features/uploadBlocklist.js');
// Grund, warum eine Datei nicht hochgeladen werden darf, oder null.
function blockedSecretReason(filePath) {
return uploadBlocklist.blockedReason(filePath, appPrefs.get());
}
// Entfernt gesperrte Dateien aus der Liste (an Ort und Stelle) und liefert sie zurück.
function removeBlockedInPlace(items) {
const { allowed, blocked } = uploadBlocklist.partitionBlocked(items, appPrefs.get());
items.splice(0, items.length, ...allowed);
return blocked;
}
// Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste
// { localFile, targetPath } um. Dateien -> destPath/<name>, Ordner ->
// destPath/<ordnername>/<rel> (identisch zum bisherigen Verhalten).
@@ -3183,7 +3363,8 @@ async function buildItemsFromPaths(paths, destPath) {
skipped.push({ path: p, error: 'unsupported' });
}
}
return { items, skipped, ignored };
const blocked = removeBlockedInPlace(items);
return { items, skipped, ignored, blocked };
}
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
@@ -3218,6 +3399,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
if (onProgress) onProgress(60);
throwIfUploadCancelled(signal);
await transferGate.wait(signal);
await stageUploadedFiles(repoGit);
let hasChanges = true;
@@ -3254,12 +3436,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
});
if (!apiResult.ok) {
// Die fehlgeschlagenen Dateien mitgeben, damit der Renderer nur diese wiederholen kann.
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
err.uploadedCount = (apiResult.uploaded || []).length;
throw err;
throw buildPartialUploadError(gitErr, apiResult, items);
}
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
@@ -3296,13 +3473,22 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
const built = retryItems && retryItems.length
? { items: retryItems, skipped: [], ignored: [] }
? { items: retryItems, skipped: [], ignored: [], blocked: removeBlockedInPlace(retryItems) }
: await buildItemsFromPaths(paths, destPath);
const skipped = built.skipped;
const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []);
const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items;
if (items.length === 0) {
return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId };
const onlyBlocked = Array.isArray(built.blocked) && built.blocked.length > 0;
return {
ok: false,
error: onlyBlocked
? 'Es blieb nichts zum Hochladen übrig: Alle Dateien enthalten vermutlich Zugangsdaten und sind gesperrt.'
: 'Keine Dateien zum Hochladen gefunden',
skipped,
blockedFiles: built.blocked,
debugId: uploadDebugId
};
}
const total = items.length;
@@ -3322,7 +3508,7 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
console.log('[UploadPaths] done', { uploadDebugId, total });
const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath }));
return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId };
return { ok: true, results, failedCount: 0, skipped, blockedFiles: built.blocked, debugId: uploadDebugId };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
@@ -3643,6 +3829,15 @@ ipcMain.handle('upload-and-push', async (event, data) => {
// --- FALL 1: Einzelne Datei ---
if (isFile) {
const singleBlocked = blockedSecretReason(data.localFolder);
if (singleBlocked) {
return {
ok: false,
error: `Gesperrt: „${ppath.basename(data.localFolder)}“ enthält vermutlich Zugangsdaten (${singleBlocked}). Die Sperre lässt sich unter Einstellungen → Uploads abschalten.`,
blockedFiles: [{ path: data.localFolder, reason: singleBlocked }],
debugId: uploadDebugId
};
}
console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId });
const raw = fs.readFileSync(data.localFolder);
const base64 = raw.toString('base64');
@@ -3754,6 +3949,8 @@ ipcMain.handle('upload-and-push', async (event, data) => {
const folderName = ppath.basename(data.localFolder);
// Dateien/Ordner, die niemals hochgeladen werden sollen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Dateien mit Zugangsdaten, die der Git-Weg beim Kopieren ausgelassen hat
const blockedInCopy = [];
// === PRIMÄRWEG: git push =========================================
// Ein einziger komprimierter Transfer + genau EIN Commit statt
@@ -3794,6 +3991,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
destDirInRepo = ppath.join(destDirInRepo, folderName);
ensureDir(destDirInRepo);
await transferGate.wait(signal);
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
const isIgnoredLocal = await createUploadIgnore(data.localFolder);
fs.cpSync(data.localFolder, destDirInRepo, {
@@ -3802,6 +4000,10 @@ ipcMain.handle('upload-and-push', async (event, data) => {
if (excludeList.includes(ppath.basename(src))) return false;
let isDir = false;
try { isDir = fs.statSync(src).isDirectory(); } catch (_) {}
if (!isDir) {
const reason = blockedSecretReason(src);
if (reason) { blockedInCopy.push({ path: src, reason }); return false; }
}
return !isIgnoredLocal(src, isDir);
}
});
@@ -3814,7 +4016,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
if (!/nothing to commit/i.test(String(commitErr))) throw commitErr;
console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
}
let pushBranch = branch;
@@ -3828,7 +4030,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId };
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId };
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
throw gitErr;
@@ -3874,9 +4076,12 @@ ipcMain.handle('upload-and-push', async (event, data) => {
}
})(data.localFolder);
// Dateien mit Zugangsdaten bleiben außen vor.
const blockedFiles = removeBlockedInPlace(items);
const total = items.length;
const concurrency = data.concurrency || DEFAULT_CONCURRENCY;
if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId };
if (total === 0) return { ok: true, usedGit: false, results: [], blockedFiles, debugId: uploadDebugId };
console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath });
@@ -3902,7 +4107,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal);
const failedCount = uploadResults.filter(r => !r.ok).length;
console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length });
return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount };
return { ok: failedCount === 0, usedGit: false, results: uploadResults, blockedFiles, debugId: uploadDebugId, failedCount };
} catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) });
@@ -3966,6 +4171,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
let cancelled = false;
for (let i = 0; i < repos.length; i++) {
if (signal.aborted) { cancelled = true; break; }
try { await transferGate.wait(signal); } catch (e) { if (isUploadCancelledError(e)) { cancelled = true; break; } throw e; }
const item = repos[i];
const progressBase = { index: i + 1, total: repos.length, repo: item.id, action };
sendProgress({ ...progressBase, status: 'running' });
@@ -4405,6 +4611,69 @@ ipcMain.handle('get-release', async (event, data) => {
});
// Create a new release
// Release für den Updater: Datei prüfen und Release vorbereiten (Tag, Prüfsummen im Text)
const releaseAssist = require('./src/features/releaseAssist.js');
function sha256OfFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
// Liefert Name, Größe, SHA-256 und Version einer Release-Datei und gleicht sie mit "<datei>.sha256" ab.
ipcMain.handle('inspect-release-file', async (_event, data) => {
try {
const filePath = String((data && data.filePath) || '');
const stat = fs.statSync(filePath);
if (!stat.isFile()) return { ok: false, error: 'Das ist keine Datei.' };
const name = ppath.basename(filePath);
const sha256 = await sha256OfFile(filePath);
const sidecarPath = `${filePath}.sha256`;
const hasSidecar = fs.existsSync(sidecarPath);
const sidecarSha256 = hasSidecar ? releaseAssist.parseSidecar(fs.readFileSync(sidecarPath, 'utf8')) : null;
return {
ok: true,
name,
size: stat.size,
sha256,
version: releaseAssist.parseVersionFromFileName(name),
sidecarPath: hasSidecar ? sidecarPath : null,
sidecarSha256,
sidecarMatches: sidecarSha256 ? sidecarSha256 === sha256 : null
};
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
// Prüft den Tag gegen die vorhandenen Releases und baut den Release-Text mit den Prüfsummen.
ipcMain.handle('prepare-release', async (_event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
let releases;
try {
if (data.platform === 'github') {
if (!credentials.githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
releases = await listGithubReleases({ token: credentials.githubToken, owner: data.owner, repo: data.repo });
} else {
releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo });
}
} catch (e) {
return { ok: false, error: `Vorhandene Releases konnten nicht geprüft werden: ${String(e && e.message || e)}` };
}
const check = releaseAssist.checkNewTag(data.tag, releases);
if (!check.ok) return { ok: false, error: check.reason };
return { ok: true, body: releaseAssist.buildReleaseBody(data.notes, data.assets), newest: check.newest };
} catch (e) {
return { ok: false, error: String(e && e.message || e) };
}
});
ipcMain.handle('create-release', async (event, data) => {
try {
const credentials = readCredentials();