Upload via GUI (85 Dateien)
This commit is contained in:
@@ -12,18 +12,22 @@ const DEFAULTS = {
|
||||
// Uploads
|
||||
uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster
|
||||
uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen
|
||||
uploadBlockSecrets: true, // Dateien mit Zugangsdaten nie hochladen (auf allen Upload-Wegen)
|
||||
uploadPreview: 'always', // 'always' | 'warnings' (nur bei Auffälligkeiten anzeigen)
|
||||
notifyTransfers: 'hidden', // Windows-Meldung bei fertigem/fehlgeschlagenem Upload: 'hidden' (nur wenn die App nicht vorn ist) | 'never'
|
||||
// Darstellung
|
||||
theme: 'dark', // 'dark' | 'light' | 'system'
|
||||
zoom: 100, // Prozent
|
||||
// System
|
||||
autoUpdateCheck: true,
|
||||
showSplash: true, // Startbildschirm beim Öffnen (nie beim versteckten Start mit Windows)
|
||||
closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit'
|
||||
defaultCloneDir: ''
|
||||
};
|
||||
|
||||
const CHOICES = {
|
||||
uploadPreview: ['always', 'warnings'],
|
||||
notifyTransfers: ['hidden', 'never'],
|
||||
theme: ['dark', 'light', 'system'],
|
||||
closeAction: ['auto', 'tray', 'quit']
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ function isPackFailure(stderr) {
|
||||
Upload-Vorschau
|
||||
---------------------------------------------------------------- */
|
||||
async function previewUpload({ paths, destPath }, deps) {
|
||||
const { items, skipped, ignored } = await deps.buildItemsFromPaths(paths, destPath);
|
||||
const { items, skipped, ignored, blocked } = await deps.buildItemsFromPaths(paths, destPath);
|
||||
const files = [];
|
||||
let totalBytes = 0;
|
||||
for (const item of items) {
|
||||
@@ -109,7 +109,7 @@ async function previewUpload({ paths, destPath }, deps) {
|
||||
...(ignored || [])
|
||||
];
|
||||
return {
|
||||
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size,
|
||||
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size, blocked: blocked || [],
|
||||
previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,55 @@ release/
|
||||
*.exe
|
||||
*.blockmap
|
||||
*.sha256
|
||||
`
|
||||
},
|
||||
python: {
|
||||
label: 'Python',
|
||||
content: `# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
build/
|
||||
dist/
|
||||
`
|
||||
},
|
||||
java: {
|
||||
label: 'Java (Maven/Gradle)',
|
||||
content: `# Java
|
||||
target/
|
||||
build/
|
||||
.gradle/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.iml
|
||||
out/
|
||||
`
|
||||
},
|
||||
php: {
|
||||
label: 'PHP',
|
||||
content: `# PHP
|
||||
vendor/
|
||||
composer.phar
|
||||
.phpunit.result.cache
|
||||
`
|
||||
},
|
||||
unity: {
|
||||
label: 'Unity',
|
||||
content: `# Unity
|
||||
[Ll]ibrary/
|
||||
[Tt]emp/
|
||||
[Oo]bj/
|
||||
[Bb]uild/
|
||||
[Bb]uilds/
|
||||
[Ll]ogs/
|
||||
[Uu]ser[Ss]ettings/
|
||||
*.csproj
|
||||
*.sln
|
||||
`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Pause-Schranke für Übertragungen: Arbeitsschritte rufen vor jedem Schritt wait() auf und bleiben
|
||||
// dort stehen, solange pausiert ist. Ein Abbruch (AbortSignal) löst wartende Schritte sofort mit
|
||||
// dem Abbruchfehler (code UPLOAD_CANCELLED) aus.
|
||||
//
|
||||
// Pausiert wird zwischen den Schritten, nicht mittendrin: Ein laufender Clone, Push oder
|
||||
// Paket-Upload wird erst fertig, danach hält die Übertragung an.
|
||||
|
||||
function cancelledError() {
|
||||
const err = new Error('Upload abgebrochen');
|
||||
err.code = 'UPLOAD_CANCELLED';
|
||||
return err;
|
||||
}
|
||||
|
||||
function createPauseGate({ onChange } = {}) {
|
||||
let paused = false;
|
||||
let waiters = [];
|
||||
|
||||
function setPaused(value) {
|
||||
if (paused === value) return false;
|
||||
paused = value;
|
||||
if (!value) {
|
||||
const release = waiters;
|
||||
waiters = [];
|
||||
for (const w of release) w.resolve();
|
||||
}
|
||||
if (onChange) { try { onChange(paused); } catch (_) {} }
|
||||
return true;
|
||||
}
|
||||
|
||||
function wait(signal) {
|
||||
if (signal && signal.aborted) return Promise.reject(cancelledError());
|
||||
if (!paused) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { resolve: null };
|
||||
const onAbort = () => {
|
||||
waiters = waiters.filter((w) => w !== waiter);
|
||||
reject(cancelledError());
|
||||
};
|
||||
waiter.resolve = () => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
};
|
||||
waiters.push(waiter);
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
pause: () => setPaused(true),
|
||||
resume: () => setPaused(false),
|
||||
isPaused: () => paused,
|
||||
waitingCount: () => waiters.length,
|
||||
wait
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPauseGate };
|
||||
@@ -0,0 +1,64 @@
|
||||
// Hilfen für „Release für den Updater veröffentlichen“.
|
||||
// Der Updater (updater.js) vergleicht den Tag des neuesten Releases mit der lokalen Version, sucht das
|
||||
// Installer-Asset und liest die SHA-256-Prüfsumme aus einem Asset „<datei>.sha256“ oder aus einer Zeile
|
||||
// im Release-Text, die Dateiname und 64 Hex-Zeichen enthält. Dieses Modul stellt sicher, dass beides stimmt.
|
||||
|
||||
const SHA256_RE = /\b([a-fA-F0-9]{64})\b/;
|
||||
|
||||
// '2.2.1' aus 'Git-Manager-GUI-Setup-2.2.1.exe' (oder null)
|
||||
function parseVersionFromFileName(name) {
|
||||
const m = String(name || '').match(/(\d+\.\d+\.\d+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Zerlegt 'v2.10.3' in [2, 10, 3]; ungültige Teile zählen als 0.
|
||||
function versionParts(v) {
|
||||
return String(v || '').replace(/^[^\d]*/, '').split('.').map((p) => parseInt(p, 10) || 0);
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = versionParts(a);
|
||||
const pb = versionParts(b);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d !== 0) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Inhalt einer .sha256-Datei ("<hash> <name>") → Hash in Kleinbuchstaben oder null
|
||||
function parseSidecar(text) {
|
||||
const m = String(text || '').match(SHA256_RE);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Prüft, ob ein neuer Tag zum Updater passt.
|
||||
// releases: vorhandene Releases ({ tag_name }), neueste zuerst oder beliebig.
|
||||
function checkNewTag(tag, releases) {
|
||||
const wanted = String(tag || '').trim();
|
||||
if (!/^v?\d+\.\d+\.\d+$/i.test(wanted)) {
|
||||
return { ok: false, reason: 'Der Tag muss eine Version wie v2.2.1 sein, sonst erkennt der Updater das Release nicht.' };
|
||||
}
|
||||
const tags = (releases || []).map((r) => String(r.tag_name || '')).filter(Boolean);
|
||||
if (tags.some((t) => t.toLowerCase() === wanted.toLowerCase())) {
|
||||
return { ok: false, reason: `Den Tag „${wanted}“ gibt es schon.` };
|
||||
}
|
||||
const newest = tags.filter((t) => /\d+\.\d+\.\d+/.test(t)).sort(compareVersions).pop();
|
||||
if (newest && compareVersions(wanted, newest) <= 0) {
|
||||
return { ok: false, reason: `Die Version ${wanted} ist nicht neuer als das neueste Release ${newest}. Der Updater würde sie nicht anbieten.` };
|
||||
}
|
||||
return { ok: true, newest: newest || null };
|
||||
}
|
||||
|
||||
// Hängt die Prüfsummen an die Release-Notizen, in dem Format, das der Updater liest.
|
||||
function buildReleaseBody(notes, assets) {
|
||||
const base = String(notes || '').replace(/\s+$/, '');
|
||||
const lines = (assets || [])
|
||||
.filter((a) => a && a.name && /^[a-fA-F0-9]{64}$/.test(String(a.sha256 || '')))
|
||||
.map((a) => `- ${a.name}: ${String(a.sha256).toLowerCase()}`);
|
||||
if (lines.length === 0) return base;
|
||||
const block = ['### Prüfsummen (SHA-256)', ...lines].join('\n');
|
||||
return base ? `${base}\n\n${block}\n` : `${block}\n`;
|
||||
}
|
||||
|
||||
module.exports = { parseVersionFromFileName, compareVersions, parseSidecar, checkNewTag, buildReleaseBody };
|
||||
@@ -0,0 +1,116 @@
|
||||
// Startanimation des Hauptfensters (abschaltbar unter Einstellungen → Darstellung).
|
||||
//
|
||||
// Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo das Programm erscheinen wird,
|
||||
// und zeigt das sich drehende Logo samt Schriftzug. Ist die Oberfläche geladen, wird das noch
|
||||
// unsichtbare Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte nach außen
|
||||
// aufgezogen. Am Ende übernimmt das echte Fenster an exakt derselben Stelle, so wirkt es, als baue
|
||||
// sich das Programm auf.
|
||||
//
|
||||
// Das Startfenster wird VOR dem Hauptfenster angelegt (begin), damit das Logo so früh wie möglich
|
||||
// erscheint. Danach lädt das Hauptfenster, und play() übernimmt den Rest.
|
||||
|
||||
const { BrowserWindow } = require('electron');
|
||||
|
||||
const MIN_SPIN_MS = 1600; // so lange dreht sich das Logo mindestens
|
||||
const MAX_WAIT_MS = 12000; // spätestens dann wird das Hauptfenster gezeigt
|
||||
const MAX_GROW_MS = 2500; // der Aufbau darf nie länger dauern, sonst bliebe das Fenster unsichtbar
|
||||
const MAX_PAINT_WAIT_MS = 1500;
|
||||
const IGNORE_MOUSE_DELAY_MS = 600; // Mausereignisse erst nach dem ersten Zeichnen ignorieren, sonst wird das Fenster weiß
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Wie lange noch gewartet werden muss, damit das Logo mindestens minMs lang zu sehen war.
|
||||
function remainingMs(shownAt, now, minMs) {
|
||||
if (!shownAt) return 0;
|
||||
return Math.max(0, minMs - (now - shownAt));
|
||||
}
|
||||
|
||||
// Position und Größe des Hauptfensters, zentriert im Arbeitsbereich. Wird für Start- und Hauptfenster
|
||||
// gleich berechnet, damit beide exakt übereinander liegen.
|
||||
function centeredBounds(workArea, width, height) {
|
||||
const w = Math.min(width, workArea.width);
|
||||
const h = Math.min(height, workArea.height);
|
||||
return {
|
||||
x: Math.round(workArea.x + (workArea.width - w) / 2),
|
||||
y: Math.round(workArea.y + (workArea.height - h) / 2),
|
||||
width: w,
|
||||
height: h
|
||||
};
|
||||
}
|
||||
|
||||
// Legt das Startfenster an und zeigt es, sobald es geladen ist.
|
||||
// Liefert { window, shownAt }: shownAt ist ein Promise auf den Zeitpunkt, an dem das Logo sichtbar wurde.
|
||||
function begin({ htmlPath, bounds }) {
|
||||
const window = new BrowserWindow({
|
||||
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
|
||||
frame: false, transparent: true, resizable: false, movable: false,
|
||||
minimizable: false, maximizable: false, fullscreenable: false,
|
||||
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
|
||||
backgroundColor: '#00000000', title: 'Git Manager',
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, spellcheck: false }
|
||||
});
|
||||
const shownAt = window.loadFile(htmlPath).then(() => {
|
||||
if (window.isDestroyed()) return 0;
|
||||
window.showInactive();
|
||||
// Das Startfenster soll keine Mausklicks schlucken. WICHTIG: Der Aufruf muss NACH dem Zeigen und etwas
|
||||
// verzögert erfolgen. Davor aufgerufen wird das durchsichtige Fenster unter Windows weiß (getestet).
|
||||
setTimeout(() => { if (!window.isDestroyed()) window.setIgnoreMouseEvents(true); }, IGNORE_MOUSE_DELAY_MS);
|
||||
return Date.now();
|
||||
}).catch(() => 0);
|
||||
return { window, shownAt };
|
||||
}
|
||||
|
||||
// Wartet, bis die Oberfläche gezeichnet ist (Liste da oder nach höchstens MAX_PAINT_WAIT_MS),
|
||||
// sonst wüchse ein halbleeres Fenster auf.
|
||||
async function waitForPaintedUi(webContents) {
|
||||
const check = (expression) => webContents.executeJavaScript(expression, true).catch(() => true);
|
||||
const until = Date.now() + MAX_PAINT_WAIT_MS;
|
||||
while (Date.now() < until) {
|
||||
if (await check("document.readyState === 'complete' && (document.querySelectorAll('#explorerGrid > *').length > 0)")) break;
|
||||
await wait(100);
|
||||
}
|
||||
await wait(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt die Startanimation und zeigt am Ende das Hauptfenster.
|
||||
* @param {BrowserWindow} mainWin Hauptfenster, noch nicht angezeigt, an denselben Bounds wie das Startfenster
|
||||
* @param {Promise<void>} uiReady wird erfüllt, sobald die Oberfläche geladen ist
|
||||
* @param {{window: BrowserWindow, shownAt: Promise<number>}} startHandle Ergebnis von begin()
|
||||
*/
|
||||
async function play(mainWin, uiReady, startHandle) {
|
||||
const start = startHandle.window;
|
||||
|
||||
const showMain = () => {
|
||||
if (mainWin.isDestroyed()) return;
|
||||
mainWin.setOpacity(1);
|
||||
mainWin.setIgnoreMouseEvents(false);
|
||||
if (!mainWin.isVisible()) mainWin.show();
|
||||
mainWin.focus();
|
||||
};
|
||||
|
||||
try {
|
||||
const shownAt = await startHandle.shownAt;
|
||||
await Promise.race([uiReady, wait(MAX_WAIT_MS)]);
|
||||
await wait(remainingMs(shownAt, Date.now(), MIN_SPIN_MS));
|
||||
if (mainWin.isDestroyed()) return;
|
||||
|
||||
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
|
||||
mainWin.setOpacity(0);
|
||||
mainWin.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
|
||||
mainWin.show();
|
||||
await waitForPaintedUi(mainWin.webContents);
|
||||
if (mainWin.isDestroyed()) return;
|
||||
const image = await mainWin.webContents.capturePage();
|
||||
if (!image.isEmpty() && !start.isDestroyed()) {
|
||||
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(image.toDataURL())})`), wait(MAX_GROW_MS)]);
|
||||
}
|
||||
} catch (_) {
|
||||
// Die Animation ist nur Zierde: im Fehlerfall einfach das Hauptfenster zeigen
|
||||
} finally {
|
||||
showMain();
|
||||
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { begin, play, remainingMs, centeredBounds, MIN_SPIN_MS };
|
||||
@@ -0,0 +1,22 @@
|
||||
// Text der Windows-Benachrichtigung zu einem beendeten Upload (aus dem Verlaufseintrag).
|
||||
// Abgebrochene Uploads melden nichts: Der Nutzer hat sie selbst gestoppt.
|
||||
|
||||
function pluralFiles(n) {
|
||||
return `${n} Datei${n === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
function describeTransfer(entry) {
|
||||
if (!entry || entry.status === 'abgebrochen') return null;
|
||||
const repo = entry.repo || 'Repository';
|
||||
|
||||
if (entry.status === 'ok') {
|
||||
const files = typeof entry.files === 'number' ? pluralFiles(entry.files) : 'Upload';
|
||||
return { title: 'Upload fertig', body: `${repo}: ${files} hochgeladen.` };
|
||||
}
|
||||
|
||||
const detail = entry.error ? ` ${String(entry.error).slice(0, 140)}` : '';
|
||||
const failed = entry.failed ? ` ${pluralFiles(entry.failed)} fehlgeschlagen.` : '';
|
||||
return { title: 'Upload fehlgeschlagen', body: `${repo}:${failed || detail || ' Unbekannter Fehler.'}`.trim() };
|
||||
}
|
||||
|
||||
module.exports = { describeTransfer };
|
||||
@@ -0,0 +1,58 @@
|
||||
// Inhalt des Tray-Menüs und -Tooltips, getrennt von Electron, damit er testbar bleibt.
|
||||
|
||||
function trayTooltip(version, active, paused = false) {
|
||||
const state = active > 0 ? (paused ? ' · Übertragung pausiert' : ' · Übertragung läuft') : '';
|
||||
return `Git Manager Explorer Pro v${version}${state}`;
|
||||
}
|
||||
|
||||
// Liefert die Menüvorlage (Einträge mit id, label, click ...).
|
||||
// active: Zahl laufender Übertragungen, queued: Einträge in der Retry-Queue,
|
||||
// paused: ob die Übertragung pausiert ist, autostart: ob die App mit Windows startet;
|
||||
// on*: Aktionen des jeweiligen Eintrags.
|
||||
function buildTrayTemplate({ version, active = 0, queued = 0, paused = false, autostart = false, onOpen, onHistory, onPause, onCancel, onAutostart, onQuit }) {
|
||||
const status = [{
|
||||
id: 'status',
|
||||
label: active === 0 ? 'Keine laufende Übertragung'
|
||||
: active === 1 ? `1 Übertragung ${paused ? 'pausiert' : 'läuft'}`
|
||||
: `${active} Übertragungen ${paused ? 'pausiert' : 'laufen'}`,
|
||||
enabled: false
|
||||
}];
|
||||
if (queued > 0) status.push({ id: 'queue', label: `Retry-Queue: ${queued} wartend`, enabled: false });
|
||||
|
||||
return [
|
||||
{ id: 'header', label: `Git Manager Explorer Pro v${version}`, enabled: false },
|
||||
...status,
|
||||
{ type: 'separator' },
|
||||
{ id: 'open', label: 'Fenster öffnen', click: onOpen },
|
||||
{ id: 'history', label: 'Upload-Verlauf', click: onHistory },
|
||||
{ id: 'pause', label: paused ? 'Übertragung fortsetzen' : 'Übertragung pausieren', enabled: active > 0, click: onPause },
|
||||
{ id: 'cancel', label: 'Laufende Übertragung abbrechen', enabled: active > 0, click: onCancel },
|
||||
{ type: 'separator' },
|
||||
{ id: 'autostart', label: 'Mit Windows starten', type: 'checkbox', checked: !!autostart, click: onAutostart },
|
||||
{ type: 'separator' },
|
||||
{ id: 'quit', label: 'Beenden', click: onQuit }
|
||||
];
|
||||
}
|
||||
|
||||
// Reine Daten für das Menüfenster (ohne Funktionen, damit sie per IPC übertragen werden können).
|
||||
function serializeTemplate(template) {
|
||||
return template.map((item) => (item.type === 'separator'
|
||||
? { type: 'separator' }
|
||||
: {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
type: item.type || 'normal',
|
||||
enabled: item.enabled !== false,
|
||||
checked: !!item.checked
|
||||
}));
|
||||
}
|
||||
|
||||
// Führt die Aktion eines Eintrags aus. Gibt false zurück, wenn es ihn nicht gibt oder er gesperrt ist.
|
||||
function runAction(template, id) {
|
||||
const item = template.find((i) => i.id === id);
|
||||
if (!item || item.enabled === false || typeof item.click !== 'function') return false;
|
||||
item.click(item.type === 'checkbox' ? { checked: !item.checked } : undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { buildTrayTemplate, serializeTemplate, runAction, trayTooltip };
|
||||
@@ -0,0 +1,140 @@
|
||||
// Eigenes Menüfenster für das Tray-Symbol.
|
||||
// Das native Electron-Menü zeichnet unter Windows keine Hover-Markierung; dieses kleine Fenster
|
||||
// (renderer/tray-menu.html) übernimmt Aussehen und Markierung selbst.
|
||||
|
||||
const { BrowserWindow, screen, nativeTheme, ipcMain } = require('electron');
|
||||
|
||||
const WIDTH = 286; // Fensterbreite inkl. 8 px Rand für den Schatten
|
||||
const DEFAULT_HEIGHT = 320;
|
||||
const MARGIN = 8;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(value, Math.max(min, max)));
|
||||
}
|
||||
|
||||
// Position des Menüs: am Mauszeiger, innerhalb des sichtbaren Bereichs. Liegt der Zeiger in der
|
||||
// Taskleiste (unter/über dem Arbeitsbereich), sitzt das Menü direkt daneben.
|
||||
function computePosition(point, workArea, width, height) {
|
||||
const x = clamp(point.x - Math.round(width / 2), workArea.x + MARGIN, workArea.x + workArea.width - width - MARGIN);
|
||||
let y;
|
||||
if (point.y >= workArea.y + workArea.height) y = workArea.y + workArea.height - height - MARGIN;
|
||||
else if (point.y <= workArea.y) y = workArea.y + MARGIN;
|
||||
else y = clamp(point.y - Math.round(height / 2), workArea.y + MARGIN, workArea.y + workArea.height - height - MARGIN);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function createTrayPopup({ htmlPath, preloadPath, getModel, onAction }) {
|
||||
let win = null;
|
||||
let height = DEFAULT_HEIGHT;
|
||||
let shownAt = 0;
|
||||
let handlersRegistered = false;
|
||||
|
||||
const own = (event) => win && !win.isDestroyed() && event.sender === win.webContents;
|
||||
|
||||
function registerHandlers() {
|
||||
if (handlersRegistered) return;
|
||||
handlersRegistered = true;
|
||||
ipcMain.on('tray-menu:action', (event, id) => {
|
||||
if (!own(event)) return;
|
||||
hide();
|
||||
try { onAction(String(id)); } catch (e) { console.warn('[Tray] Aktion fehlgeschlagen:', e.message); }
|
||||
});
|
||||
ipcMain.on('tray-menu:close', (event) => { if (own(event)) hide(); });
|
||||
ipcMain.on('tray-menu:resize', (event, contentHeight) => {
|
||||
if (!own(event)) return;
|
||||
const h = Math.round(Number(contentHeight));
|
||||
if (!(h > 40 && h < 900)) return;
|
||||
height = h;
|
||||
if (win.isVisible()) {
|
||||
const { x, y } = computePosition(screen.getCursorScreenPoint(), screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea, WIDTH, height);
|
||||
win.setBounds({ x, y, width: WIDTH, height });
|
||||
} else {
|
||||
win.setContentSize(WIDTH, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWindow() {
|
||||
if (win && !win.isDestroyed()) return win;
|
||||
registerHandlers();
|
||||
win = new BrowserWindow({
|
||||
width: WIDTH,
|
||||
height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
focusable: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false
|
||||
}
|
||||
});
|
||||
win.setAlwaysOnTop(true, 'pop-up-menu');
|
||||
win.on('blur', () => { if (Date.now() - shownAt > 250) hide(); });
|
||||
win.on('closed', () => { win = null; });
|
||||
win.loadFile(htmlPath);
|
||||
return win;
|
||||
}
|
||||
|
||||
function push() {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.webContents.send('tray-menu-state', { items: getModel(), dark: nativeTheme.shouldUseDarkColors });
|
||||
}
|
||||
|
||||
function show() {
|
||||
const w = ensureWindow();
|
||||
const send = () => {
|
||||
push();
|
||||
const point = screen.getCursorScreenPoint();
|
||||
const { x, y } = computePosition(point, screen.getDisplayNearestPoint(point).workArea, WIDTH, height);
|
||||
w.setBounds({ x, y, width: WIDTH, height });
|
||||
shownAt = Date.now();
|
||||
w.show();
|
||||
w.focus();
|
||||
};
|
||||
if (w.webContents.isLoading()) w.webContents.once('did-finish-load', send);
|
||||
else send();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (win && !win.isDestroyed() && win.isVisible()) win.hide();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (win && !win.isDestroyed() && win.isVisible()) push();
|
||||
}
|
||||
|
||||
// Lädt das versteckte Fenster im Voraus. Der erste Start des Renderers dauert mehrere Sekunden;
|
||||
// ohne Vorladen würde der erste Rechtsklick auf das Tray-Symbol entsprechend hängen.
|
||||
function warmUp() {
|
||||
ensureWindow();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (win && !win.isDestroyed()) win.destroy();
|
||||
win = null;
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
warmUp,
|
||||
hide,
|
||||
refresh,
|
||||
destroy,
|
||||
isVisible: () => !!win && !win.isDestroyed() && win.isVisible(),
|
||||
getWindow: () => (win && !win.isDestroyed() ? win : null)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createTrayPopup, computePosition };
|
||||
@@ -0,0 +1,26 @@
|
||||
// Feste Sperre für Dateien mit Zugangsdaten: Sie werden nie hochgeladen, egal über welchen Weg.
|
||||
// Grundlage sind die Dateinamen-Regeln des Geheimnis-Scans (.env, Schlüssel, credentials.json ...).
|
||||
// Die Sperre lässt sich in den Einstellungen abschalten (uploadBlockSecrets).
|
||||
|
||||
const path = require('path');
|
||||
const { checkName } = require('./secretScan.js');
|
||||
|
||||
// Grund für die Sperre oder null, wenn die Datei hochgeladen werden darf.
|
||||
function blockedReason(filePath, prefs) {
|
||||
if (prefs && prefs.uploadBlockSecrets === false) return null;
|
||||
return checkName(path.basename(String(filePath || '')));
|
||||
}
|
||||
|
||||
// Teilt Einträge ({ localFile, ... }) in erlaubte und gesperrte auf.
|
||||
function partitionBlocked(items, prefs) {
|
||||
const allowed = [];
|
||||
const blocked = [];
|
||||
for (const item of items) {
|
||||
const reason = blockedReason(item.localFile, prefs);
|
||||
if (reason) blocked.push({ path: item.localFile, targetPath: item.targetPath, reason });
|
||||
else allowed.push(item);
|
||||
}
|
||||
return { allowed, blocked };
|
||||
}
|
||||
|
||||
module.exports = { blockedReason, partitionBlocked };
|
||||
@@ -0,0 +1,97 @@
|
||||
// Upload-Verlauf: die letzten Uploads (Datum, Repo, Dateizahl, Ergebnis) in einer JSON-Datei.
|
||||
|
||||
const nodeFs = require('fs');
|
||||
|
||||
// Baut aus dem Ergebnis eines Upload-Handlers einen Verlaufseintrag.
|
||||
// `thrown` ist der Fehler, falls der Handler geworfen hat.
|
||||
function buildEntry(kind, data, res, startedAt, thrown, now = Date.now()) {
|
||||
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(now).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: now - startedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Filtert Einträge nach Status ('alle' | 'ok' | 'abgebrochen' | 'fehler') und Suchtext im Repo-Namen.
|
||||
function filterEntries(entries, { status = 'alle', query = '' } = {}) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
if (status !== 'alle' && e.status !== status) return false;
|
||||
if (q && !String(e.repo || '').toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
|
||||
// Datum für Menschen: TT.MM.JJJJ HH:MM:SS (Ortszeit)
|
||||
function formatDate(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { ok: 'Erfolgreich', abgebrochen: 'Abgebrochen', fehler: 'Fehler' };
|
||||
|
||||
function csvCell(value) {
|
||||
let s = value == null ? '' : String(value);
|
||||
// Schutz vor Formel-Einschleusung in Excel: Zellen, die mit = + - @ beginnen, erhalten ein ' davor.
|
||||
if (/^[=+\-@]/.test(s)) s = `'${s}`;
|
||||
return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
// CSV für deutsches Excel: Semikolon als Trenner, UTF-8 mit BOM, Windows-Zeilenenden.
|
||||
function toCsv(entries) {
|
||||
const header = ['Datum', 'Repo', 'Art', 'Dateien', 'Fehlgeschlagen', 'Ergebnis', 'Dauer (s)', 'Fehler'];
|
||||
const rows = entries.map((e) => [
|
||||
formatDate(e.at),
|
||||
e.repo || '',
|
||||
e.kind || '',
|
||||
e.files == null ? '' : e.files,
|
||||
e.failed || 0,
|
||||
STATUS_LABEL[e.status] || e.status || '',
|
||||
typeof e.durationMs === 'number' ? Math.round(e.durationMs / 100) / 10 : '',
|
||||
e.error || ''
|
||||
]);
|
||||
return '' + [header, ...rows].map((r) => r.map(csvCell).join(';')).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function createUploadHistory({ file, max = 100, fs = nodeFs }) {
|
||||
function read() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function record(entry) {
|
||||
try {
|
||||
fs.writeFileSync(file, JSON.stringify([entry, ...read()].slice(0, max), null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
fs.writeFileSync(file, '[]', 'utf8');
|
||||
}
|
||||
|
||||
return { read, record, clear };
|
||||
}
|
||||
|
||||
module.exports = { buildEntry, filterEntries, createUploadHistory, toCsv, formatDate };
|
||||
@@ -2190,7 +2190,7 @@ function isPayloadTooLargeError(err) {
|
||||
* @param {function(number):void} [onProgress] Fortschritt in Prozent
|
||||
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
|
||||
*/
|
||||
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal }) {
|
||||
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal, waitIfPaused }) {
|
||||
const base = normalizeBase(url);
|
||||
if (!base) throw new Error('Invalid Gitea base URL');
|
||||
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
|
||||
@@ -2241,6 +2241,8 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
|
||||
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
|
||||
const sendBatch = async (batch, retried = false) => {
|
||||
throwIfUploadCancelled(signal);
|
||||
// Pause: zwischen den Paketen anhalten (ein laufendes Paket wird noch fertig gesendet)
|
||||
if (waitIfPaused) await waitIfPaused(signal);
|
||||
const files = batch.map(item => {
|
||||
const existingSha = shaByPath.get(item.targetPath);
|
||||
const entry = {
|
||||
|
||||
Reference in New Issue
Block a user