Upload via GUI (85 Dateien)

This commit is contained in:
2026-10-22 09:13:21 +00:00
parent 2ad8777311
commit f4c16b173f
23 changed files with 1982 additions and 539 deletions
+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.png binary
*.ico binary
*.exe binary
*.blockmap binary
+50 -1
View File
@@ -282,9 +282,58 @@ npm run build
# Output: dist/git-manager-gui-Setup-2.x.x.exe # Output: dist/git-manager-gui-Setup-2.x.x.exe
``` ```
### Abbrechbare Vorgänge
Uploads, Ordner-Downloads und Batch-Clones laufen über **ein** gemeinsames Abbruch-System:
- `main.js`: `beginCancellableUpload()` legt einen `AbortController` an, `endCancellableUpload()` räumt auf (immer im `finally`). Der IPC-Kanal `cancel-upload` bricht alle laufenden Vorgänge ab.
- Das `signal` wird durchgereicht an `simple-git` (`abort`), `execGit`/`runGitAsync` (`signal`), `runLimited` und `uploadItemsViaGiteaApi` (axios `signal`).
- Ein Abbruch ist **kein Fehler**: Er läuft als `UPLOAD_CANCELLED` durch die Kette (`createUploadCancelledError`, `isUploadCancelledError`, `throwIfUploadCancelled` in `src/git/apiHandler.js`), löst keinen API-Ausweichweg aus und kommt als `{ ok: false, cancelled: true }` im Renderer an.
- Neuer abbrechbarer Handler: `beginCancellableUpload()` am Anfang, `signal` weiterreichen, im `catch` `isUploadCancelledError(e)` abfangen, im `finally` `endCancellableUpload()`. Im Renderer `showProgress(..., { cancellable: true })`.
- Unter Windows beendet `spawn(signal)` nur `git.exe`; `execGit` beendet deshalb den ganzen Prozessbaum (`taskkill /T /F`) und kehrt sofort zurück.
### Pause, Sperre, Benachrichtigung, Release-Assistent
- **Pause:** `src/features/pauseGate.js` (`createPauseGate`). In `main.js` gibt es genau ein `transferGate`. Kontrollpunkte rufen `await transferGate.wait(signal)` auf (in `runLimited`, vor dem Staging der Git-Wege, zwischen den Paketen von `uploadItemsViaGiteaApi` über `waitIfPaused`, vor jedem Batch-Clone). Ein Abbruch löst Wartende sofort aus. Neue langlaufende Schritte brauchen einen eigenen Kontrollpunkt.
- **Zugangsdaten-Sperre:** `src/features/uploadBlocklist.js` nutzt die Dateinamen-Regeln aus `secretScan.js`. `removeBlockedInPlace()` und `blockedSecretReason()` in `main.js` sind in allen Upload-Handlern eingebaut, die Ergebnisse tragen `blockedFiles`. Neuer Upload-Weg: dort ebenfalls aufrufen. Einstellung `uploadBlockSecrets`.
- **Benachrichtigung:** `src/features/transferNotice.js` baut den Text aus dem Verlaufseintrag, `maybeNotifyTransfer()` in `main.js` zeigt ihn nur, wenn das Hauptfenster nicht vorn ist (Einstellung `notifyTransfers`).
- **Release-Assistent:** `src/features/releaseAssist.js` (Version aus Dateiname, Versionsvergleich, Tag-Prüfung, Release-Text mit Prüfsummen). IPC `inspect-release-file` und `prepare-release`, Oberfläche in `showCreateReleaseModal()`. Das Prüfsummen-Format in `buildReleaseBody` muss zu `extractChecksumFromReleaseBody` in `updater.js` passen.
### Startanimation
Nach dem Vorbild des AeroMC-Launchers (`AeroMC-Launcher/src/startanimation.js`):
1. `createWindow()` berechnet Position und Größe (`centeredBounds`) und legt über `begin()` zuerst ein randloses, durchsichtiges Fenster mit `renderer/splash.html` an: das Logo dreht sich (CSS `rotateY`), darunter der Schriftzug.
2. Das Hauptfenster entsteht danach mit denselben Maßen, verborgen, und lädt.
3. `play()` wartet auf `did-finish-load` (höchstens 12 s) und mindestens 1,6 s Drehzeit. Dann zeigt es das Hauptfenster unsichtbar (Deckkraft 0, Mausereignisse aus), wartet auf die gezeichnete Oberfläche (Repo-Liste oder höchstens 1,5 s), fotografiert es mit `capturePage()` und lässt `aufbauen()` in `splash.js` das Bild von der Mitte aus aufziehen.
4. Am Ende übernimmt das echte Fenster an derselben Stelle. Fehler und Zeitüberschreitungen zeigen das Hauptfenster immer trotzdem.
**Stolperstein:** `setIgnoreMouseEvents(true)` am Startfenster darf erst nach dem Zeigen und leicht verzögert aufgerufen werden (`IGNORE_MOUSE_DELAY_MS`). Davor aufgerufen wird das durchsichtige Fenster unter Windows weiß. `scripts/ui-smoke-splash.js` prüft das über eine Bildschirmaufnahme (Farbe an einer leeren Stelle muss der des Desktops entsprechen) und darf deshalb die Hardware-Beschleunigung nicht abschalten.
Beim Start mit `--hidden` und bei abgeschalteter Einstellung `showSplash` gibt es die Animation nicht. `scripts/ui-smoke-splash.js` prüft sie mit einem Ersatz-Hauptfenster. Im Entwicklungsmodus (`electron .`) startet die App wegen der vielen `node_modules`-Dateien deutlich langsamer als das gepackte Programm; die Animation selbst wird dadurch nicht langsamer.
### Oberflächen-Testlauf
`npm run test:ui` (`scripts/ui-smoke.js`) startet die App mit leerem eigenen `APPDATA`, steuert sie über den Debug-Port und prüft Toolbar-Layout bei drei Breiten, Fortschrittsbalken, Pause, Verlauf, Sperre, Release-Prüfung und Vorlagen. `scripts/ui-smoke-tray.js` prüft das Tray-Menüfenster in echtem Electron. Der Lauf berührt weder echte Zugangsdaten noch einen Server und dauert gut eine Minute.
### Tray-Menü
Das Tray-Symbol öffnet kein natives Menü, sondern ein eigenes kleines Fenster, denn das native Electron-Menü zeichnet unter Windows keine Hover-Markierung:
- `src/features/trayMenu.js`: Inhalt (`buildTrayTemplate`, `serializeTemplate`, `runAction`, `trayTooltip`), ohne Electron und damit testbar. Neuer Eintrag: dort mit `id` ergänzen, die Aktion in `trayTemplate()` in `main.js` verdrahten.
- `src/features/trayPopup.js`: das Fenster (frameless, transparent, schließt bei Fokusverlust und Esc). Es wird 2 Sekunden nach dem Start vorgeladen, weil der erste Renderer-Start mehrere Sekunden dauert.
- `renderer/tray-menu.html`, `renderer/tray-menu.js`, `tray-menu-preload.js`: Darstellung (hell/dunkel nach Windows-Design, Pfeiltasten und Enter) und die vier IPC-Kanäle.
- Das Fenster ist von der Zoom-Einstellung ausgenommen und wird beim Beenden zerstört, sonst hielte das versteckte Fenster die App am Leben.
### Upload-Verlauf und Wiederholen
- `src/features/uploadHistory.js` (`buildEntry`, `filterEntries`, `createUploadHistory`): speichert die letzten 100 Uploads in `upload-history.json` im userData-Ordner. `main.js` umhüllt dafür `ipcMain.handle` für die Kanäle in `UPLOAD_HISTORY_CHANNELS`, neue Upload-Kanäle dort eintragen.
- Teilfehler: `buildPartialUploadError` hängt die fehlgeschlagenen Einträge an den Fehler (`failedItems`). Die Handler geben sie als `retryItems` zurück, der Renderer sendet sie über den Parameter `items` erneut (`retryFailedUploads`).
- `.gitignore`-Vorlagen: `src/features/gitignoreTemplates.js`.
### Version erhöhen ### Version erhöhen
1. Version in `package.json` updaten: `"version": "2.1.4"` 1. Version in `package.json` updaten: `"version": "2.2.1"`
2. Changelog aktualisieren (optional) 2. Changelog aktualisieren (optional)
3. Build ausführen 3. Build ausführen
4. GitHub Release erstellen mit der .exe 4. GitHub Release erstellen mit der .exe
+69 -1
View File
@@ -1250,6 +1250,73 @@ tr:last-child td { border-bottom: none; }
</div> </div>
</div> </div>
<div class="chapter">
<h2 class="chapter-heading"><span class="num">12a</span> Uploads: Abbrechen, Wiederholen &amp; Verlauf</h2>
<div class="feature-grid">
<div class="feature-cell">
<div class="icon">⏹️</div>
<h4>Abbrechen</h4>
<p>Während eines Uploads, Ordner-Downloads oder Batch-Clones erscheint im Fortschrittsbalken oben der Knopf <kbd>Abbrechen (Esc)</kbd>. Du kannst auch einfach <kbd>Esc</kbd> drücken, solange kein Dialog offen ist.</p>
</div>
<div class="feature-cell">
<div class="icon">⏱️</div>
<h4>Fortschritt</h4>
<p>Der Balken zeigt Dateien, übertragene MB und eine geschätzte Restzeit, zum Beispiel <em>Upload: 48/120 Dateien · 19,2 MB / 48,0 MB · noch ca. 5 Sek.</em> Die Restzeit ist eine Hochrechnung und erscheint erst nach einigen Sekunden.</p>
</div>
<div class="feature-cell">
<div class="icon">🔁</div>
<h4>Nur Fehlgeschlagene wiederholen</h4>
<p>Schlagen bei einem Upload einzelne Dateien fehl, fragt die App nach und sendet nur diese erneut (bis zu dreimal). Bereits hochgeladene Dateien bleiben unberührt.</p>
</div>
<div class="feature-cell">
<div class="icon">⏸️</div>
<h4>Pausieren und Fortsetzen</h4>
<p>Neben <kbd>Abbrechen</kbd> im Fortschrittsbalken gibt es <kbd>Pausieren</kbd>. Die Übertragung hält zwischen den Schritten an (ein laufender Clone, Push oder ein Paket wird noch fertig) und läuft mit <kbd>Fortsetzen</kbd> weiter. Das geht auch über das Tray-Menü.</p>
</div>
<div class="feature-cell">
<div class="icon">🕘</div>
<h4>Upload-Verlauf</h4>
<p>Über <kbd>🕘 Verlauf</kbd> in der Toolbar siehst du die letzten 100 Uploads mit Datum, Repo, Dateizahl, Dauer und Ergebnis. Filtere nach Erfolgreich, Abgebrochen oder Fehler und suche nach einem Repo-Namen. Ein Klick auf einen Eintrag öffnet das Repo, <kbd>CSV exportieren</kbd> speichert die gefilterte Liste für Excel.</p>
</div>
<div class="feature-cell">
<div class="icon">🛡️</div>
<h4>Zugangsdaten-Sperre</h4>
<p>Dateien wie <code>credentials.json</code>, <code>.env</code>, private Schlüssel oder Keystores werden auf allen Upload-Wegen ausgelassen, auch wenn du sie in der Vorschau nicht abgewählt hast. Die App meldet, welche Dateien fehlen. Vorlagen wie <code>.env.example</code> bleiben erlaubt. Unter Einstellungen → Uploads lässt sich die Sperre abschalten, bei öffentlichen Repos solltest du sie aktiv lassen.</p>
</div>
<div class="feature-cell">
<div class="icon">🔔</div>
<h4>Windows-Meldung</h4>
<p>Endet ein Upload, während die App im Tray versteckt, minimiert oder nicht im Vordergrund ist, meldet Windows das Ergebnis. Ein Klick auf die Meldung öffnet die App. Abgebrochene Uploads melden nichts. Unter Einstellungen → Uploads lässt es sich abschalten.</p>
</div>
</div>
<div class="warn-box">
<strong>Grenzen des Abbrechens:</strong> Ein Push, der schon auf dem Server angekommen ist, lässt sich nicht zurücknehmen. Beim Upload über die Gitea-API bleiben Pakete, die schon als Commit verbucht sind, im Repo. Eine Migration kann nicht abgebrochen werden, weil der Server sie selbst ausführt. Das Repo-Backup hat seinen eigenen Abbrechen-Knopf.
</div>
<div class="card">
<div class="card-title">📦 Release für den Updater veröffentlichen</div>
<p>Der Updater liest die Releases des Repos <code>M_Viper/Git-Manager-Gui</code> auf Gitea. So veröffentlichst du eine neue Version:</p>
<ol class="steps">
<li>Baue den Installer mit <code>npm run build</code>. Dabei entstehen <code>Git-Manager-GUI-Setup-X.Y.Z.exe</code> und die Prüfsummen-Datei <code>.sha256</code> in <code>dist/</code>.</li>
<li>Öffne in der Repo-Ansicht <strong>🚀 Releases</strong> und lege ein neues Release an. Wähle bei <strong>Release Asset</strong> den Installer.</li>
<li>Die App berechnet die SHA-256-Prüfsumme, gleicht sie mit der <code>.sha256</code>-Datei ab und füllt Tag und Name aus dem Dateinamen vor. Stimmen die Prüfsummen nicht überein, wird nichts veröffentlicht.</li>
<li>Beim Erstellen prüft die App, dass der Tag eine Version wie <code>v2.2.1</code> ist, es ihn noch nicht gibt und er neuer ist als das letzte Release, sonst würde der Updater ihn nicht anbieten.</li>
<li>Die Prüfsumme wird im Format, das der Updater liest, in den Release-Text geschrieben, und Installer und <code>.sha256</code> werden hochgeladen.</li>
</ol>
</div>
<div class="card">
<div class="card-title">📄 .gitignore im Repo anlegen</div>
<ol class="steps">
<li>Öffne ein Repository und klicke in der Repo-Leiste auf <strong>📄 .gitignore</strong>.</li>
<li>Wähle die Vorlagen: Node.js, .NET, Electron, Python, Java, PHP, Unity. Der allgemeine Teil (Betriebssystem, Editor, <code>.env</code>, Logs) ist immer dabei.</li>
<li>Mit <strong>Anlegen</strong> wird die Datei ins Repo geschrieben. Existiert schon eine, fragt die App vor dem Ersetzen nach.</li>
</ol>
</div>
</div>
<!-- ──────────────────────────────────────────── --> <!-- ──────────────────────────────────────────── -->
<div class="part-break"> <div class="part-break">
<div class="part-label">TEIL F</div> <div class="part-label">TEIL F</div>
@@ -1276,6 +1343,7 @@ tr:last-child td { border-bottom: none; }
<tr><td>🕐 Zuletzt geöffnet</td><td>An</td><td>Verlaufs-Sidebar ein-/ausblenden</td></tr> <tr><td>🕐 Zuletzt geöffnet</td><td>An</td><td>Verlaufs-Sidebar ein-/ausblenden</td></tr>
<tr><td>⊞ Kompakt-Modus</td><td>Aus</td><td>Verdichtete Darstellung für kleinere Bildschirme</td></tr> <tr><td>⊞ Kompakt-Modus</td><td>Aus</td><td>Verdichtete Darstellung für kleinere Bildschirme</td></tr>
<tr><td>🎨 Farbige Datei-Icons</td><td>An</td><td>Unterschiedliche Farben je Dateityp im Explorer</td></tr> <tr><td>🎨 Farbige Datei-Icons</td><td>An</td><td>Unterschiedliche Farben je Dateityp im Explorer</td></tr>
<tr><td>✨ Startanimation</td><td>An</td><td>Beim Öffnen dreht sich das Logo, danach baut sich das Fenster von der Mitte aus auf. Beim Start mit Windows im Tray gibt es sie nie</td></tr>
<tr><td>🚀 Mit Windows starten</td><td>Aus</td><td>App miniert im System-Tray beim Windows-Login starten</td></tr> <tr><td>🚀 Mit Windows starten</td><td>Aus</td><td>App miniert im System-Tray beim Windows-Login starten</td></tr>
</table> </table>
@@ -1303,7 +1371,7 @@ tr:last-child td { border-bottom: none; }
<tr><td><kbd>Ctrl</kbd>+<kbd>K</kbd></td><td>Fuzzy-Suche im Repository-Grid fokussieren</td></tr> <tr><td><kbd>Ctrl</kbd>+<kbd>K</kbd></td><td>Fuzzy-Suche im Repository-Grid fokussieren</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>S</kbd></td><td>Aktive Datei im Editor speichern</td></tr> <tr><td><kbd>Ctrl</kbd>+<kbd>S</kbd></td><td>Aktive Datei im Editor speichern</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>F</kbd></td><td>Suchleiste im Editor öffnen</td></tr> <tr><td><kbd>Ctrl</kbd>+<kbd>F</kbd></td><td>Suchleiste im Editor öffnen</td></tr>
<tr><td><kbd>Escape</kbd></td><td>Suchleiste im Editor schließen</td></tr> <tr><td><kbd>Escape</kbd></td><td>Suchleiste im Editor schließen · laufenden Upload/Download abbrechen (wenn kein Dialog offen ist)</td></tr>
<tr><td><kbd>Rechtsklick</kbd></td><td>Kontextmenü auf Repository-Karte oder Sidebar-Eintrag</td></tr> <tr><td><kbd>Rechtsklick</kbd></td><td>Kontextmenü auf Repository-Karte oder Sidebar-Eintrag</td></tr>
<tr><td><kbd>Drag</kbd> raus</td><td>Datei/Ordner/Repo herunterladen</td></tr> <tr><td><kbd>Drag</kbd> raus</td><td>Datei/Ordner/Repo herunterladen</td></tr>
<tr><td><kbd>Drag</kbd> rein</td><td>Datei/Ordner in aktuelles Verzeichnis hochladen</td></tr> <tr><td><kbd>Drag</kbd> rein</td><td>Datei/Ordner in aktuelles Verzeichnis hochladen</td></tr>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'">
<title>Git Manager</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
/* Logo und Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block; object-fit: contain;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em;
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* das fertige Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="./icon.png" alt=""></div>
<div class="name">Git Manager</div>
</div>
<img id="fenster" alt="">
<script src="./splash.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// Vom Hauptprozess aufgerufen: das Bild des fertigen Fensters von der Mitte aus aufziehen.
// Liefert true, wenn die Animation durchgelaufen ist, sonst false.
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 }
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
+4
View File
@@ -4576,3 +4576,7 @@ select option:checked { background: #2d4ea4; color: #ffffff; }
color: #04101f !important; color: #04101f !important;
border-color: transparent !important; border-color: transparent !important;
} }
.upload-history-row { padding: 6px 8px; border-radius: 8px; cursor: pointer; }
.upload-history-row:hover { background: rgba(88, 213, 255, 0.14); }
.upload-history-err { opacity: 0.75; padding-left: 22px; }
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'self'">
<title>Git Manager Menü</title>
<style>
:root {
--bg: #0f1a2e;
--fg: #e8eefc;
--muted: #8fa0c0;
--hover: rgba(88, 213, 255, 0.16);
--border: rgba(88, 213, 255, 0.24);
--sep: rgba(138, 164, 214, 0.22);
--accent: #67e8f9;
}
body.light {
--bg: #f7f9fd;
--fg: #16233d;
--muted: #6a7a99;
--hover: rgba(40, 110, 220, 0.12);
--border: rgba(30, 60, 110, 0.2);
--sep: rgba(30, 60, 110, 0.14);
--accent: #1d6fe0;
}
html, body {
margin: 0;
background: transparent;
color: var(--fg);
font: 13px "Segoe UI Variable Text", "Segoe UI", Arial, sans-serif;
user-select: none;
overflow: hidden;
}
body { padding: 8px; }
#menu {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.header { font-weight: 700; padding: 8px 12px 2px; }
.status { color: var(--muted); font-size: 12px; padding: 2px 12px; }
.sep { height: 1px; background: var(--sep); margin: 6px 4px; }
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 32px;
padding: 0 12px;
box-sizing: border-box;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
outline: none;
}
.item:hover:not(:disabled), .item.active:not(:disabled) { background: var(--hover); }
.item:disabled { color: var(--muted); opacity: 0.6; }
.check { width: 14px; flex: 0 0 14px; text-align: center; color: var(--accent); font-weight: 700; }
</style>
</head>
<body>
<div id="menu" role="menu"></div>
<script src="./tray-menu.js"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
// Zeichnet das Tray-Menü aus dem Zustand, den der Hauptprozess schickt.
// Ohne Electron (Seite direkt im Browser geöffnet) greift ein Stub, damit sich die Optik prüfen lässt.
(function () {
const api = window.trayApi || {
onState: () => {},
action: (id) => console.log('action', id),
resize: () => {},
close: () => {}
};
const menu = document.getElementById('menu');
let buttons = [];
let activeIndex = -1;
function setActive(index) {
activeIndex = index;
buttons.forEach((b, i) => b.classList.toggle('active', i === index));
}
function render(state) {
document.body.classList.toggle('light', state && state.dark === false);
menu.textContent = '';
buttons = [];
activeIndex = -1;
for (const item of (state && state.items) || []) {
if (item.type === 'separator') {
const sep = document.createElement('div');
sep.className = 'sep';
menu.appendChild(sep);
continue;
}
if (item.id === 'header' || item.id === 'status' || item.id === 'queue') {
const line = document.createElement('div');
line.className = item.id === 'header' ? 'header' : 'status';
line.textContent = item.label;
menu.appendChild(line);
continue;
}
const button = document.createElement('button');
button.className = 'item';
button.disabled = item.enabled === false;
button.setAttribute('role', item.type === 'checkbox' ? 'menuitemcheckbox' : 'menuitem');
if (item.type === 'checkbox') button.setAttribute('aria-checked', String(!!item.checked));
const check = document.createElement('span');
check.className = 'check';
check.textContent = item.type === 'checkbox' && item.checked ? '✓' : '';
const label = document.createElement('span');
label.textContent = item.label;
button.append(check, label);
button.addEventListener('click', () => api.action(item.id));
button.addEventListener('mouseenter', () => { if (!button.disabled) setActive(buttons.indexOf(button)); });
button.addEventListener('mouseleave', () => setActive(-1));
menu.appendChild(button);
buttons.push(button);
}
api.resize(Math.ceil(document.body.scrollHeight));
}
function move(step) {
const enabled = buttons.map((b, i) => (b.disabled ? -1 : i)).filter((i) => i >= 0);
if (enabled.length === 0) return;
const pos = enabled.indexOf(activeIndex);
const next = pos < 0 ? (step > 0 ? 0 : enabled.length - 1) : (pos + step + enabled.length) % enabled.length;
setActive(enabled[next]);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') api.close();
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if ((e.key === 'Enter' || e.key === ' ') && buttons[activeIndex]) { e.preventDefault(); buttons[activeIndex].click(); }
});
window.__renderTray = render;
api.onState(render);
})();
+110
View File
@@ -0,0 +1,110 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft die Startanimation in echtem Electron mit einem
// Ersatz-Hauptfenster. Das Startfenster muss genau über dem Hauptfenster liegen, wirklich durchsichtig sein
// (kein weißes Feld), das Hauptfenster bis zum Aufbau verborgen bleiben, das Bild von der Mitte aus
// aufgezogen werden und am Ende das Hauptfenster sichtbar sein. Gibt „✓ …“ und „✗ …“ Zeilen aus.
//
// Die Durchsichtigkeit wird über eine Bildschirmaufnahme geprüft: An einer leeren Stelle des Startfensters
// muss dieselbe Farbe stehen wie vorher auf dem Desktop. Dafür darf die Hardware-Beschleunigung nicht
// abgeschaltet sein, sonst verhalten sich durchsichtige Fenster anders als im echten Programm.
const { app, BrowserWindow, desktopCapturer, screen } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { begin, play, MIN_SPIN_MS } = require(path.join(ROOT, 'src', 'features', 'splash.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.on('window-all-closed', (e) => e.preventDefault());
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// Farbe des Bildschirms an einer Stelle (Koordinaten des Hauptbildschirms) als [r, g, b]
async function pixelAt(x, y) {
const d = screen.getPrimaryDisplay();
const sf = d.scaleFactor;
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: Math.round(d.size.width * sf), height: Math.round(d.size.height * sf) }
});
const src = sources.find((s) => String(s.display_id) === String(d.id)) || sources[0];
const size = src.thumbnail.getSize();
const bmp = src.thumbnail.toBitmap(); // BGRA
const i = (Math.round(y * sf) * size.width + Math.round(x * sf)) * 4;
return [bmp[i + 2], bmp[i + 1], bmp[i]];
}
const close = (a, b) => a.every((v, k) => Math.abs(v - b[k]) <= 6);
app.whenReady().then(async () => {
const page = 'data:text/html;charset=utf-8,' + encodeURIComponent(
'<body style="margin:0;background:#123"><div id="explorerGrid"><div>Repo</div></div></body>');
const main = new BrowserWindow({ width: 900, height: 600, frame: false, show: false, webPreferences: { sandbox: true } });
main.loadURL(page);
const loaded = new Promise((resolve) => main.webContents.once('did-finish-load', resolve));
await loaded;
const mainBounds = main.getBounds();
// Leere Stelle nahe der linken oberen Ecke des Fensters (das Logo sitzt in der Mitte)
const sx = mainBounds.x + 24;
const sy = mainBounds.y + 24;
const desktopColor = await pixelAt(sx, sy);
let sawSplashWindow = false;
let boundsMatch = false;
let hiddenWhileSpinning = true;
let sawGrow = false;
const startedAt = Date.now();
const handle = begin({ htmlPath: path.join(ROOT, 'renderer', 'splash.html'), bounds: mainBounds });
// Die Oberfläche gilt erst später als bereit, damit das Logo lange genug dreht, um es zu messen
const slowReady = wait(3500);
const playing = play(main, slowReady, handle);
// Durchsichtigkeit messen, solange das Logo dreht (nach dem Zeigen und der Verzögerung für die Mausereignisse)
const duringColor = (async () => {
await handle.shownAt;
await wait(1400);
return pixelAt(sx, sy);
})();
// Während der Animation beobachten
for (let i = 0; i < 500; i++) {
const others = BrowserWindow.getAllWindows().filter((w) => w !== main);
if (others.length) {
sawSplashWindow = true;
const b = others[0].getBounds();
if (b.x === mainBounds.x && b.y === mainBounds.y && b.width === mainBounds.width && b.height === mainBounds.height) boundsMatch = true;
if (Date.now() - startedAt < MIN_SPIN_MS - 250 && main.isVisible() && main.getOpacity() > 0) hiddenWhileSpinning = false;
try {
if (await others[0].webContents.executeJavaScript("document.body.classList.contains('aufbau')")) sawGrow = true;
} catch (_) { /* Fenster schon geschlossen */ }
}
if (sawSplashWindow && others.length === 0) break;
await wait(40);
}
await playing;
const duration = Date.now() - startedAt;
const measured = await duringColor;
check('Startfenster erscheint über dem Hauptfenster', sawSplashWindow && boundsMatch);
check('Startfenster ist durchsichtig (kein weißes Feld)', close(measured, desktopColor) && !close(measured, [255, 255, 255]),
`Desktop ${desktopColor.join(',')} / Startfenster ${measured.join(',')}`);
check('Hauptfenster bleibt während des Drehens unsichtbar', hiddenWhileSpinning);
check(`Logo dreht sich mindestens ${MIN_SPIN_MS} ms`, duration >= MIN_SPIN_MS, `${duration} ms`);
check('Animation endet in angemessener Zeit', duration < 12000, `${duration} ms`);
check('Fenster wird von der Mitte aus aufgezogen', sawGrow);
check('Danach ist das Hauptfenster sichtbar und deckend', main.isVisible() && main.getOpacity() === 1);
await wait(300);
check('Startfenster ist danach geschlossen', BrowserWindow.getAllWindows().filter((w) => w !== main).length === 0);
app.exit(failed ? 1 : 0);
}).catch((e) => {
console.log(`✗ Startanimation ohne Ausnahme — ${e.message}`);
app.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft das Tray-Menüfenster in echtem Electron, ohne das
// Tray-Symbol selbst. Zeigt das Fenster, fährt mit der Maus darüber, klickt, drückt Esc.
// Gibt „✓ …“ und „✗ …“ Zeilen aus; Aufruf durch ui-smoke.js.
const { app } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { createTrayPopup } = require(path.join(ROOT, 'src', 'features', 'trayPopup.js'));
const trayMenu = require(path.join(ROOT, 'src', 'features', 'trayMenu.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.disableHardwareAcceleration();
app.on('window-all-closed', (e) => e.preventDefault());
app.whenReady().then(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const log = [];
let active = 1;
const template = () => trayMenu.buildTrayTemplate({
version: '0.0.0', active, queued: 2, paused: false, autostart: true,
onOpen: () => log.push('open'), onHistory: () => log.push('history'), onPause: () => log.push('pause'),
onCancel: () => log.push('cancel'), onAutostart: () => log.push('autostart'), onQuit: () => log.push('quit')
});
const popup = createTrayPopup({
htmlPath: path.join(ROOT, 'renderer', 'tray-menu.html'),
preloadPath: path.join(ROOT, 'tray-menu-preload.js'),
getModel: () => trayMenu.serializeTemplate(template()),
onAction: (id) => trayMenu.runAction(template(), id)
});
const js = (code) => popup.getWindow().webContents.executeJavaScript(code);
const items = async () => JSON.parse(await js(`JSON.stringify([...document.querySelectorAll('.item')].map(b => { const r = b.getBoundingClientRect(); return { text: b.innerText.trim(), disabled: b.disabled, x: r.x, y: r.y, h: r.height }; }))`));
const mid = (i) => ({ x: Math.round(i.x + 40), y: Math.round(i.y + i.h / 2) });
const bg = (label) => js(`(() => { const b = [...document.querySelectorAll('.item')].find(x => x.innerText.includes(${JSON.stringify(label)})); return getComputedStyle(b).backgroundColor; })()`);
const mouse = (type, p, extra = {}) => popup.getWindow().webContents.sendInputEvent({ type, x: p.x, y: p.y, ...extra });
try {
popup.warmUp();
await wait(6000); // erster Renderer-Start dauert mehrere Sekunden
popup.show();
await wait(800);
check('Menüfenster wird sichtbar', popup.isVisible());
let list = await items();
check('Menü zeigt Einträge mit Pausieren und Abbrechen',
['Fenster öffnen', 'Upload-Verlauf', 'Übertragung pausieren', 'Laufende Übertragung abbrechen', 'Beenden'].every((t) => list.some((i) => i.text.includes(t))));
// Hover folgt der Maus
const history = list.find((i) => i.text.includes('Upload-Verlauf'));
// Das frisch geöffnete Fenster braucht einen Moment, bis es sein erstes Bild gezeichnet hat und Hover annimmt.
let hovered = 'rgba(0, 0, 0, 0)';
for (let i = 0; i < 12 && hovered === 'rgba(0, 0, 0, 0)'; i++) {
mouse('mouseMove', { x: mid(history).x + (i % 2), y: mid(history).y });
await wait(250);
hovered = await bg('Upload-Verlauf');
}
const other = await bg('Fenster öffnen');
check('Hover markiert den Eintrag unter der Maus', hovered !== 'rgba(0, 0, 0, 0)' && other === 'rgba(0, 0, 0, 0)', `${hovered} / ${other}`);
// Gesperrter Eintrag bleibt unmarkiert und löst nichts aus
active = 0; popup.refresh(); await wait(400);
list = await items();
const cancel = list.find((i) => i.text.includes('abbrechen'));
mouse('mouseMove', mid(cancel));
await wait(300);
check('Gesperrter Eintrag wird nicht markiert', cancel.disabled && (await bg('abbrechen')) === 'rgba(0, 0, 0, 0)');
mouse('mouseDown', mid(cancel), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(cancel), { button: 'left', clickCount: 1 });
await wait(400);
check('Klick auf gesperrten Eintrag löst keine Aktion aus', log.length === 0 && popup.isVisible());
// Klick führt die Aktion aus und schließt das Fenster
const open = list.find((i) => i.text.includes('Fenster öffnen'));
mouse('mouseMove', mid(open));
mouse('mouseDown', mid(open), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(open), { button: 'left', clickCount: 1 });
await wait(600);
check('Klick führt die Aktion aus und schließt das Menü', log.join() === 'open' && !popup.isVisible());
// Esc schließt
popup.show();
await wait(900);
const reopened = popup.isVisible();
const win = popup.getWindow();
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait(500);
check('Esc schließt das Menü', reopened && !popup.isVisible());
} catch (e) {
check('Menüfenster-Test ohne Ausnahme', false, e.message);
}
popup.destroy();
app.exit(failed ? 1 : 0);
});
+256
View File
@@ -0,0 +1,256 @@
// Oberflächen-Testlauf: startet die App mit einem leeren, eigenen Datenordner (APPDATA), steuert sie
// über das Chrome-DevTools-Protokoll und prüft Layout, Knöpfe und die neuen Funktionen.
// Berührt weder echte Zugangsdaten noch einen Server. Aufruf: npm run test:ui
//
// Prüft nur, was ohne Anmeldung sichtbar ist. Die Repo-Ansicht und echte Uploads sind bewusst nicht dabei.
const { spawn, spawnSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const electronPath = require(path.join(ROOT, 'node_modules', 'electron'));
const PORT = 9300 + Math.floor(Math.random() * 500);
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok });
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function killTree(pid) {
if (!pid) return;
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
else { try { process.kill(-pid, 'SIGKILL'); } catch (_) { try { process.kill(pid, 'SIGKILL'); } catch (__) {} } }
}
async function waitForPage(timeoutMs, state = {}) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
if (targets.some((t) => /renderer[\\/]splash\.html/.test(t.url))) state.sawSplash = true;
const page = targets.find((t) => t.type === 'page' && /renderer[\\/]index\.html/.test(t.url));
if (page) return page;
} catch (_) { /* App startet noch */ }
await sleep(500);
}
throw new Error('Die App hat sich nicht innerhalb von ' + Math.round(timeoutMs / 1000) + ' s gemeldet');
}
async function connect(page) {
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });
let id = 0;
const pending = new Map();
ws.addEventListener('message', (m) => {
const d = JSON.parse(m.data);
if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
});
const send = (method, params = {}) => new Promise((resolve) => {
const i = ++id;
pending.set(i, resolve);
ws.send(JSON.stringify({ id: i, method, params }));
});
const ev = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.result.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text);
return r.result.result.value;
};
return { ev, close: () => ws.close() };
}
async function run() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ui-smoke-'));
const appData = path.join(tmp, 'appdata');
fs.mkdirSync(appData, { recursive: true });
const app = spawn(electronPath, ['.', `--remote-debugging-port=${PORT}`], {
cwd: ROOT,
env: { ...process.env, APPDATA: appData },
stdio: 'ignore',
detached: process.platform !== 'win32'
});
let page;
try {
const startup = { sawSplash: false };
page = await waitForPage(60000, startup);
const cdp = await connect(page);
const { ev } = cdp;
// Warten, bis die Seite fertig geladen ist und die Renderer-Skripte laufen (im frischen Datenordner dauert das länger)
const splashOpen = async () => {
try {
const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
return targets.some((t) => /renderer[\\/]splash\.html/.test(t.url));
} catch (_) { return false; }
};
let ready = false;
for (let i = 0; i < 60 && !ready; i++) {
if (await splashOpen()) startup.sawSplash = true;
ready = await ev("document.readyState === 'complete' && typeof showProgress === 'function' && typeof showUploadHistory === 'function' && !!document.getElementById('toolbar')").catch(() => false);
if (!ready) await sleep(300);
}
if (!ready) throw new Error('Die Oberfläche wurde nicht fertig geladen');
// 0. Startbildschirm: erscheint, verschwindet nach dem Laden, danach ist das Hauptfenster sichtbar
let splashGone = false;
for (let i = 0; i < 25 && !splashGone; i++) {
splashGone = !(await splashOpen());
if (!splashGone) await sleep(400);
}
check('Startanimation erscheint beim Start', startup.sawSplash);
check('Startanimation endet, sobald die Oberfläche geladen ist', splashGone);
check('Hauptfenster ist nach der Startanimation sichtbar', (await ev("document.visibilityState")) === 'visible');
await sleep(1000);
// 1. Start und Schnittstellen
const title = await ev('document.title');
check('App startet, Titel stimmt', title === 'Git Manager Explorer Pro', 'Titel: ' + JSON.stringify(title));
const apis = ['cancelUpload', 'pauseUpload', 'resumeUpload', 'getUploadHistory', 'exportUploadHistoryCsv', 'getGitignoreTemplates',
'inspectReleaseFile', 'prepareRelease', 'onTransferPauseState', 'onOpenUploadHistory', 'previewUploadPaths'];
const missing = await ev(`${JSON.stringify(apis)}.filter(k => typeof window.electronAPI[k] !== 'function')`);
check('Alle neuen Schnittstellen sind vorhanden', missing.length === 0, missing.join(', '));
// 2. Toolbar bricht bei keiner Fensterbreite um
for (const width of [1216, 960, 720]) {
await ev(`window.resizeTo(${width}, 780); 1`);
await sleep(900);
const r = await ev(`(() => {
const bad = [];
for (const b of document.querySelectorAll('#toolbar button')) {
const rect = b.getBoundingClientRect();
if (rect.width === 0) continue;
if (rect.height > 40) bad.push(b.id || b.textContent.trim());
}
return { iw: innerWidth, bad, overflowX: document.documentElement.scrollWidth > innerWidth };
})()`);
check(`Toolbar bei ${width} px: keine umgebrochenen Knöpfe, kein seitliches Scrollen`, r.iw === width && r.bad.length === 0 && !r.overflowX,
r.iw !== width ? `Fensterbreite ${r.iw}` : (r.bad.join(', ') || (r.overflowX ? 'seitlicher Überlauf' : '')));
}
await ev('window.resizeTo(1216, 828); 1');
await sleep(600);
// 3. Repo-Leiste ohne Repo ausgeblendet
check('Repo-Leiste ist in der Startansicht ausgeblendet', (await ev("getComputedStyle(document.querySelector('.toolbar-row--context')).display")) === 'none');
// 4. Fortschrittsbalken mit Pause und Abbrechen
const prog = await ev(`(async () => {
showProgress(40, describeTransferProgress('Upload', { processed: 48, total: 120, percent: 40, totalBytes: 48 * 1024 * 1024 }), { cancellable: true });
const cancel = document.getElementById('folderProgressCancel');
const pause = document.getElementById('folderProgressPause');
const shown = { cancel: cancel.style.display, pause: pause.style.display, text: document.getElementById('folderProgressText').innerText };
applyPauseState(true);
const paused = { label: pause.textContent, text: document.getElementById('folderProgressText').innerText };
applyPauseState(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await new Promise(r => setTimeout(r, 200));
const afterEsc = { label: cancel.textContent, disabled: cancel.disabled };
hideProgress();
await new Promise(r => setTimeout(r, 800));
return { shown, paused, afterEsc, reset: { cancelDisabled: cancel.disabled, display: cancel.style.display, pauseDisplay: pause.style.display } };
})()`);
check('Fortschritt zeigt Pausieren und Abbrechen', prog.shown.cancel !== 'none' && prog.shown.pause !== 'none');
check('Fortschritt nennt MB in deutscher Schreibweise', /19,2 MB \/ 48,0 MB/.test(prog.shown.text), prog.shown.text);
check('Pause schaltet die Beschriftung um und vermerkt „pausiert“', prog.paused.label === 'Fortsetzen' && /pausiert/.test(prog.paused.text));
check('Esc löst Abbrechen aus', prog.afterEsc.disabled && /abgebrochen/.test(prog.afterEsc.label));
check('Knöpfe werden nach dem Ausblenden zurückgesetzt', !prog.reset.cancelDisabled && prog.reset.display === 'none' && prog.reset.pauseDisplay === 'none');
// 5. Pause/Abbrechen ohne laufende Übertragung
const idle = await ev(`(async () => ({ pause: await window.electronAPI.pauseUpload(), state: await window.electronAPI.getPauseState(), cancel: await window.electronAPI.cancelUpload() }))()`);
check('Ohne laufende Übertragung lassen sich Pause und Abbrechen nicht auslösen', idle.pause.ok === false && idle.cancel.ok === false && idle.state.paused === false);
// 6. Upload-Verlauf: Aufzeichnung, Filter, Dialog
await ev('window.electronAPI.clearUploadHistory()');
await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'alpha', paths: [] })");
await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'beta', paths: [] })");
const hist = await ev(`(async () => {
const all = (await window.electronAPI.getUploadHistory()).history.length;
const ok = (await window.electronAPI.getUploadHistory({ status: 'ok' })).history.length;
const alpha = (await window.electronAPI.getUploadHistory({ query: 'ALPHA' })).history.map(e => e.repo);
showUploadHistory();
await new Promise(r => setTimeout(r, 700));
const rows = document.querySelectorAll('.upload-history-row').length;
const exportEnabled = !document.querySelector('.confirm-modal button:nth-of-type(2)').disabled;
const btns = [...document.querySelectorAll('.confirm-modal button')].map(b => b.textContent);
document.querySelector('.confirm-modal button').click();
return { all, ok, alpha, rows, btns, exportEnabled, offen: document.querySelectorAll('.confirm-modal').length };
})()`);
check('Verlauf zeichnet Uploads auf', hist.all === 2);
check('Verlauf filtert nach Status und Repo', hist.ok === 0 && hist.alpha.length === 1 && hist.alpha[0] === 'smoke/alpha');
check('Verlauf-Dialog zeigt klickbare Zeilen, Export und Leeren', hist.rows === 2 && hist.btns.includes('CSV exportieren') && hist.btns.includes('Verlauf leeren'));
check('Verlauf-Dialog schließt sich', hist.offen === 0);
await ev('window.electronAPI.clearUploadHistory()');
// 7. Sperre für Zugangsdaten (Vorschau nutzt dieselbe Dateiliste wie der Upload)
const folder = path.join(tmp, 'projekt');
fs.mkdirSync(path.join(folder, 'data'), { recursive: true });
fs.writeFileSync(path.join(folder, 'main.js'), '// ok\n');
fs.writeFileSync(path.join(folder, 'README.md'), '# ok\n');
fs.writeFileSync(path.join(folder, '.env'), 'TOKEN=geheim\n');
fs.writeFileSync(path.join(folder, 'data', 'credentials.json'), '{}\n');
fs.writeFileSync(path.join(folder, '.env.example'), 'TOKEN=\n');
const prev = await ev(`window.electronAPI.previewUploadPaths({ paths: [${JSON.stringify(folder)}], destPath: '' })`);
const names = (prev.files || []).map((f) => path.basename(f.localFile)).sort();
const blocked = (prev.blocked || []).map((b) => path.basename(b.path)).sort();
check('Sperre nimmt .env und credentials.json aus der Dateiliste', !names.includes('.env') && !names.includes('credentials.json'), names.join(', '));
check('Sperre meldet die ausgelassenen Dateien', JSON.stringify(blocked) === JSON.stringify(['.env', 'credentials.json']), blocked.join(', '));
check('Normale Dateien und Vorlagen (.env.example) bleiben erlaubt', names.includes('main.js') && names.includes('README.md') && names.includes('.env.example'));
// 8. Release-Datei prüfen (Prüfsumme, Abgleich, Version)
const exe = path.join(tmp, 'Git-Manager-GUI-Setup-9.8.7.exe');
fs.writeFileSync(exe, 'Testinhalt');
const hash = crypto.createHash('sha256').update('Testinhalt').digest('hex');
fs.writeFileSync(`${exe}.sha256`, `${hash} Git-Manager-GUI-Setup-9.8.7.exe\n`);
const good = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`);
check('Release-Datei: Version und Prüfsumme werden erkannt', good.ok && good.version === '9.8.7' && good.sha256 === hash);
check('Release-Datei: .sha256 stimmt überein', good.sidecarMatches === true);
fs.writeFileSync(`${exe}.sha256`, `${'0'.repeat(64)} Git-Manager-GUI-Setup-9.8.7.exe\n`);
const bad = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`);
check('Release-Datei: falsche .sha256 wird erkannt', bad.sidecarMatches === false);
// 9. .gitignore-Vorlagen
const tpl = await ev(`(async () => ({ list: (await window.electronAPI.getGitignoreTemplates()).templates.map(t => t.key), built: (await window.electronAPI.buildGitignore({ keys: ['java'] })).content }))()`);
check('.gitignore-Vorlagen: sieben Stück, Java enthält target/', tpl.list.length === 7 && /target\//.test(tpl.built));
// 10. Einstellungen: neue Schalter vorhanden
const toggles = await ev(`['prefBlockSecrets', 'prefNotifyTransfers'].map(id => !!document.getElementById(id))`);
check('Einstellungen: Sperre und Benachrichtigung sind einstellbar', toggles.every(Boolean));
cdp.close();
} finally {
killTree(app.pid);
await sleep(500);
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* Datei noch gesperrt */ }
}
}
// Führt eine eigenständige Electron-Prüfung aus (gibt ✓/✗-Zeilen aus) und übernimmt ihre Ergebnisse.
function runHarness(title, script) {
console.log(`\n${title}:`);
const res = spawnSync(electronPath, [path.join(__dirname, script)], { cwd: ROOT, encoding: 'utf8', timeout: 120000 });
const lines = String(res.stdout || '').split(/\r?\n/).filter((l) => /^(✓|✗) /.test(l));
for (const l of lines) {
console.log(l);
results.push({ name: l.slice(2), ok: l.startsWith('✓') });
}
if (lines.length === 0) check(`${title} liefert Ergebnisse`, false, String(res.stderr || res.error || '').slice(0, 200));
}
(async () => {
console.log(`UI-Testlauf (Debug-Port ${PORT})\n`);
try {
await run();
} catch (e) {
check('Testlauf ohne Ausnahme', false, e.message);
}
runHarness('Tray-Menüfenster', 'ui-smoke-tray.js');
runHarness('Startanimation', 'ui-smoke-splash.js');
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length} von ${results.length} Prüfungen bestanden.`);
process.exit(failed.length ? 1 : 0);
})();
+4
View File
@@ -12,18 +12,22 @@ const DEFAULTS = {
// Uploads // Uploads
uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster
uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen 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) 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 // Darstellung
theme: 'dark', // 'dark' | 'light' | 'system' theme: 'dark', // 'dark' | 'light' | 'system'
zoom: 100, // Prozent zoom: 100, // Prozent
// System // System
autoUpdateCheck: true, autoUpdateCheck: true,
showSplash: true, // Startbildschirm beim Öffnen (nie beim versteckten Start mit Windows)
closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit' closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit'
defaultCloneDir: '' defaultCloneDir: ''
}; };
const CHOICES = { const CHOICES = {
uploadPreview: ['always', 'warnings'], uploadPreview: ['always', 'warnings'],
notifyTransfers: ['hidden', 'never'],
theme: ['dark', 'light', 'system'], theme: ['dark', 'light', 'system'],
closeAction: ['auto', 'tray', 'quit'] closeAction: ['auto', 'tray', 'quit']
}; };
+2 -2
View File
@@ -88,7 +88,7 @@ function isPackFailure(stderr) {
Upload-Vorschau Upload-Vorschau
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
async function previewUpload({ paths, destPath }, deps) { 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 = []; const files = [];
let totalBytes = 0; let totalBytes = 0;
for (const item of items) { for (const item of items) {
@@ -109,7 +109,7 @@ async function previewUpload({ paths, destPath }, deps) {
...(ignored || []) ...(ignored || [])
]; ];
return { 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 previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan
}; };
} }
+49
View File
@@ -47,6 +47,55 @@ release/
*.exe *.exe
*.blockmap *.blockmap
*.sha256 *.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
` `
} }
}; };
+57
View File
@@ -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 };
+64
View File
@@ -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 };
+116
View File
@@ -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 };
+22
View File
@@ -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 };
+58
View File
@@ -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 };
+140
View File
@@ -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 };
+26
View File
@@ -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 };
+97
View File
@@ -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 };
+3 -1
View File
@@ -2190,7 +2190,7 @@ function isPayloadTooLargeError(err) {
* @param {function(number):void} [onProgress] Fortschritt in Prozent * @param {function(number):void} [onProgress] Fortschritt in Prozent
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>} * @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); const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL'); if (!base) throw new Error('Invalid Gitea base URL');
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen'); 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. // Ein Paket absenden; bei zu großem Body rekursiv halbieren.
const sendBatch = async (batch, retried = false) => { const sendBatch = async (batch, retried = false) => {
throwIfUploadCancelled(signal); 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 files = batch.map(item => {
const existingSha = shaByPath.get(item.targetPath); const existingSha = shaByPath.get(item.targetPath);
const entry = { const entry = {