Upload via GUI (85 Dateien)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user