Compare commits

..

No commits in common. "master" and "v3.3.106" have entirely different histories.

21 changed files with 562 additions and 1276 deletions

View File

@ -1,208 +0,0 @@
# V2.0.1 Account Manager und Startup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Einen atomaren Ein-Klick-Account-Flow, einen deterministischen White-Screen-sicheren Start und eine funktionierende Update-Brücke von 3.3.108 auf die sichtbare Version 2.0.1 liefern.
**Architecture:** Browserunabhängige Kernlogik kapselt Validate-then-Commit und Release-Versionsauflösung und wird vom bestehenden Renderer beziehungsweise Updater verwendet. Der Electron-Main-Prozess erzwingt Software-Rendering vor Ready und zeigt das Hauptfenster erst nach `ready-to-show`.
**Tech Stack:** Electron 41, Node.js 24, `node:test`, electron-builder 26, Gitea Releases, GitHub Releases.
## Global Constraints
- Keine Code-Kommentare, XML-Dokumentation, TODOs oder Platzhalter hinzufügen.
- Sichtbare Produktversion, UI, Build und öffentlicher GitHub-Tag sind exakt `2.0.1` beziehungsweise `v2.0.1`.
- Der private Gitea-Transport-Tag für diese Version ist exakt `v3.3.109`; der Release-Titel ist exakt `Multi-Hoster-Upload v2.0.1`.
- Fehlgeschlagene, abgebrochene, veraltete oder OTP-erfordernde Prüfungen persistieren keinen Account.
- Release nur aus frischer Positivliste, ohne interne KI-/Task-/Log-/Backup-/Testdaten und nach Quell-, Build-, Archiv- und Secret-Prüfung.
---
### Task 1: Startup-Renderer absichern
**Files:**
- Create: `lib/startup-renderer.js`
- Modify: `main.js`
- Create: `tests/startup-renderer.test.js`
**Interfaces:**
- Produces: `configureStartupRenderer(app)` deaktiviert Hardwarebeschleunigung genau einmal vor Ready.
- Produces: `createWindow()` erstellt das Hauptfenster unsichtbar und zeigt es auf `ready-to-show`.
- [ ] **Step 1: Failing Tests schreiben**
```js
test('configureStartupRenderer disables hardware acceleration', () => {
let calls = 0;
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } });
assert.equal(calls, 1);
});
```
- [ ] **Step 2: RED verifizieren**
Run: `node --test tests/startup-renderer.test.js`
Expected: FAIL, weil `lib/startup-renderer.js` noch fehlt.
- [ ] **Step 3: Minimale Implementierung schreiben**
```js
function configureStartupRenderer(app) {
app.disableHardwareAcceleration();
}
module.exports = { configureStartupRenderer };
```
`main.js` ruft die Funktion unmittelbar nach dem Electron-Import auf, entfernt das heuristische RDP-/Flag-Gate, setzt `show: false`, registriert `ready-to-show` vor `loadFile` und behandelt einen abgelehnten Load.
- [ ] **Step 4: GREEN und Startup-Smoke verifizieren**
Run: `node --test tests/startup-renderer.test.js`
Run: `$env:RUN_UI_SMOKE='1'; node tests/ui-smoke.js`
- [ ] **Step 5: Commit erstellen**
```powershell
git add lib/startup-renderer.js main.js tests/startup-renderer.test.js
git commit -m "fix(startup): make renderer initialization deterministic"
```
### Task 2: Account in einem Lauf prüfen und anlegen
**Files:**
- Create: `renderer/account-submit.js`
- Modify: `renderer/index.html`
- Modify: `renderer/app.js`
- Replace: `tests/validate-credentials.test.js`
**Interfaces:**
- Produces: `submitValidatedAccount({ validate, commit, isCurrent })` mit den Resultaten `committed`, `rejected`, `otp_required`, `stale` und `error`.
- Consumes: bestehendes `window.api.validateCredentials` und `window.api.saveConfig`.
- [ ] **Step 1: Failing Tests für Ein-Klick- und Fehlerpfade schreiben**
```js
test('ok validates and commits exactly once in one submission', async () => {
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => { commits++; },
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(commits, 1);
});
```
Zusätzliche Tests decken `warn`, `error`, `skipped`, Throw, OTP, stale vor Commit und Save-Fehler ab.
- [ ] **Step 2: RED verifizieren**
Run: `node --test tests/validate-credentials.test.js`
Expected: FAIL, weil `submitValidatedAccount` noch fehlt.
- [ ] **Step 3: Submit-Core und Renderer-Integration implementieren**
Der Submit-Core validiert, prüft `isCurrent`, committet ausschließlich `ok|warn` und gibt strukturierte Ergebnisse zurück. `saveAccount()` hält Busy bis zum Ende, vergleicht Session und Credential-Snapshot, speichert einen kopierten Kandidaten und übernimmt ihn erst nach erfolgreichem IPC. Der Button bleibt bis zum Schließen deaktiviert.
- [ ] **Step 4: GREEN und UI-Vertrag verifizieren**
Run: `node --test tests/validate-credentials.test.js`
Run: `node --test tests/*.test.js`
Run: `$env:RUN_UI_SMOKE='1'; node tests/ui-smoke.js`
- [ ] **Step 5: Commit erstellen**
```powershell
git add renderer/account-submit.js renderer/index.html renderer/app.js tests/validate-credentials.test.js
git commit -m "feat(accounts): validate and save in one action"
```
### Task 3: Produktversion und Updater-Brücke implementieren
**Files:**
- Modify: `lib/updater.js`
- Modify: `scripts/release_gitea.mjs`
- Modify: `package.json`
- Modify: `package-lock.json`
- Create: `tests/updater-version.test.js`
**Interfaces:**
- Produces: `resolveReleaseVersion(release)` liest zuerst eine semantische Version aus `release.name`, dann aus `tag_name`.
- Produces: Release-CLI `node scripts/release_gitea.mjs 2.0.1 --transport-tag v3.3.109 <notes>`.
- [ ] **Step 1: Failing Tests für Release-Auflösung und Bridge schreiben**
```js
test('bridge title resolves product version instead of transport tag', () => {
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
assert.equal(isNewer('2.0.1', '2.0.1'), false);
assert.equal(isNewer('2.0.2', '2.0.1'), true);
});
```
- [ ] **Step 2: RED verifizieren**
Run: `node --test tests/updater-version.test.js`
Expected: FAIL, weil `resolveReleaseVersion` noch fehlt.
- [ ] **Step 3: Produkt-/Transport-Trennung implementieren**
`checkForUpdate()` verwendet die aufgelöste Produktversion für UI und Vergleich. Der Transport-Tag bleibt im Ergebnis diagnostizierbar. Das Release-Skript validiert `--transport-tag`, baut `2.0.1`, taggt `v3.3.109`, benennt den privaten Release `Multi-Hoster-Upload v2.0.1` und erzeugt `latest.yml` mit `2.0.1`.
- [ ] **Step 4: Version auf 2.0.1 setzen und GREEN verifizieren**
Run: `npm version 2.0.1 --no-git-tag-version`
Run: `node --test tests/updater-version.test.js`
Run: `npm test`
- [ ] **Step 5: Commit erstellen**
```powershell
git add lib/updater.js scripts/release_gitea.mjs package.json package-lock.json tests/updater-version.test.js
git commit -m "feat(updater): bridge 3.3.108 clients to v2.0.1"
```
### Task 4: Build, Laufzeit und Veröffentlichung verifizieren
**Files:**
- Modify: `tasks/todo.md`
**Interfaces:**
- Consumes: Tasks 1 bis 3.
- Produces: geprüfte private Bridge und kuratierten öffentlichen Release v2.0.1.
- [ ] **Step 1: Vollständige lokale Gates ausführen**
Run: `npm test`
Run: `npx eslint .`
Run: `npm audit --omit=dev --json`
Run: `npm run release:win`
- [ ] **Step 2: Build und Archive prüfen**
Portable, Setup, Blockmap und `latest.yml` werden vollständig entpackt, auf Version, Struktur, Hashes, private Endpunkte, Credentials und interne Dateien geprüft. Gitleaks und TruffleHog laufen über Quell-Positivliste und entpackte Artefakte.
- [ ] **Step 3: Update-Brücke isoliert testen**
Ein unveränderter 3.3.108-Updater muss den privaten Transport-Tag `v3.3.109` als neuer erkennen. Der gebaute 2.0.1-Updater muss denselben Release-Titel als installiert erkennen. Setup und Portable werden separat gestartet; UI, Account-Modal und sauberer Teardown werden geprüft.
- [ ] **Step 4: Private und öffentliche Releases veröffentlichen**
Der private Gitea-Release nutzt Transport-Tag `v3.3.109` und Titel `Multi-Hoster-Upload v2.0.1`. Der öffentliche GitHub-Release wird aus einem neuen Positivlisten-Ordner als `v2.0.1` mit ausschließlich benötigtem Source und vier Release-Artefakten erstellt.
- [ ] **Step 5: Veröffentlichte Downloads erneut verifizieren**
Alle öffentlichen und privaten Assets werden neu heruntergeladen, byte- und hashverglichen, `latest.yml` wird gegen das Setup geprüft und der veröffentlichte Build wird erneut gestartet. Erst danach wird `tasks/todo.md` mit den exakten Belegen aktualisiert.

View File

@ -1,25 +0,0 @@
# V2.0.1 Account Manager und Startup
## Ziel
Multi-Hoster-Upload wird als sichtbare Produktversion `2.0.1` ausgeliefert. Das Hinzufügen und Bearbeiten eines Accounts validiert und speichert in einem Klick. Ein fehlgeschlagener, abgebrochener oder veralteter Check verändert weder die persistierte noch die im Renderer gehaltene Account-Konfiguration. Der intermittierende weiße Startzustand wird durch einen deterministischen Software-Renderer und ein erst nach erfolgreichem Laden sichtbares Hauptfenster verhindert.
## Account-Flow
Der Button lautet im Anlegefall `Prüfen und anlegen`, im Bearbeitungsfall `Prüfen und speichern`. Der bestehende Credential-Check bleibt die einzige fachliche Validierung. `ok` und `warn` führen unmittelbar zum Save, `error`, `skipped`, IPC-Fehler und `otp_required` nicht. OTP zeigt das Eingabefeld; der nächste Klick validiert und speichert in einem Lauf.
Ein einziger Busy-Zustand umfasst Check und Save. Vor dem Save werden Modal-Session, Hoster, Account-ID und Credential-Snapshot erneut abgeglichen. Hoster- oder Credential-Änderung, Schließen oder erneutes Öffnen invalidieren die laufende Operation. Der Kandidat wird auf einer Kopie der Hoster-Konfiguration aufgebaut. Erst nach erfolgreichem `saveConfig` ersetzt er den Renderer-State. Bei Save-Fehler bleibt das Modal offen und der bisherige State unverändert.
## Startup
Die App benötigt keine GPU-beschleunigten Canvas-, WebGL- oder Video-Oberflächen. Hardwarebeschleunigung wird deshalb bei jedem Start vor `app.whenReady()` deaktiviert, unabhängig von `SESSIONNAME` und einem späteren GPU-Crash. Das Hauptfenster startet mit `show: false`, wird auf `ready-to-show` eingeblendet und wertet das Promise von `loadFile` aus. Load- und Renderer-Fehler bleiben sichtbar diagnostizierbar.
## Versions- und Update-Brücke
`package.json`, UI, Installer-Metadaten und öffentliche GitHub-Version lauten `2.0.1` beziehungsweise `v2.0.1`. Der ausgelieferte 3.3.108-Client vergleicht den privaten Gitea-Tag numerisch und würde einen Tag `v2.0.1` ablehnen. Außerdem existiert dort bereits ein historischer Tag `v2.0.1`.
Der private Updater-Transport verwendet deshalb für diese Veröffentlichung den neuen internen Tag `v3.3.109`, während Release-Titel und Build `v2.0.1` anzeigen. Der neue Updater liest die Produktversion aus dem Release-Titel und fällt nur bei fehlender Produktversion auf den Tag zurück. Dadurch sieht 3.3.108 den höheren Transport-Tag, während 2.0.1 denselben Release anhand des Titels als bereits installiert erkennt. Künftige 2.x-Releases verwenden fortlaufende interne Transport-Tags oberhalb 3.3.108 und sichtbare 2.x-Titel.
## Verifikation
Regressionsfälle prüfen den real verwendeten Account-Submit-Core, die Startup-Konfiguration und die Produkt-/Transport-Versionsauflösung. Danach folgen vollständige Unit-Tests, UI-Smoke, wiederholte reale Starts, Windows-Build, entpackte Artefaktprüfung, Secret-Scans, isolierter Installer-/Updater-Test und erst anschließend die kuratierten privaten und öffentlichen Releases mit erneutem Download und Hashvergleich.

View File

@ -55,7 +55,6 @@ const nodeGlobals = {
fetch: 'readonly', fetch: 'readonly',
crypto: 'readonly', crypto: 'readonly',
structuredClone: 'readonly', structuredClone: 'readonly',
performance: 'readonly',
}; };
export default [ export default [

View File

@ -1,7 +1,7 @@
// Log-file mode resolution for fileuploader.log: // Log-file mode resolution for fileuploader.log:
// - "single" → one file: fileuploader.log // - "single" → one file: fileuploader.log
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log // - "daily" → per-day: fileuploader-YYYY-MM-DD.log
// - "session" → per-launch: DD-MM-YYYY-mdu-session-HH-MM-NNNNNN.log // - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log
// //
// Pure functions only — no fs, no Date.now() at call time — so they unit-test // Pure functions only — no fs, no Date.now() at call time — so they unit-test
// cleanly and the main.js call sites pass in `new Date()` + the session stamp. // cleanly and the main.js call sites pass in `new Date()` + the session stamp.
@ -38,11 +38,13 @@
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`; return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
} }
function formatSessionStamp(date, rand) { function formatSessionStamp(date, pid) {
const d = `${_two(date.getDate())}-${_two(date.getMonth() + 1)}-${date.getFullYear()}`; const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}`; const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`;
const r = (rand !== undefined && rand !== null && String(rand).trim()) ? `-${String(rand).trim()}` : ''; // PID disambiguates a same-second close→reopen — a human can't but two
return `${d}-mdu-session-${t}${r}`; // automated runs might. Cheap belt to a suspenders-not-required problem.
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
return `${d}_${t}${pidStr}`;
} }
/** /**
@ -65,10 +67,9 @@
const date = a.date instanceof Date ? a.date : new Date(); const date = a.date instanceof Date ? a.date : new Date();
return `${base}-${formatDateStamp(date)}${ext}`; return `${base}-${formatDateStamp(date)}${ext}`;
} }
// session — the stamp is the full app-defined stem (DD-MM-YYYY-mdu-session-HH-MM), // session
// independent of baseName.
const sid = a.sessionId && String(a.sessionId).trim(); const sid = a.sessionId && String(a.sessionId).trim();
if (sid) return `${sid}${ext}`; if (sid) return `${base}-session-${sid}${ext}`;
// Defensive: if a session-id wasn't passed, fall back to single rather // Defensive: if a session-id wasn't passed, fall back to single rather
// than emit a malformed name. main.js always supplies one. // than emit a malformed name. main.js always supplies one.
return `${base}${ext}`; return `${base}${ext}`;
@ -84,9 +85,6 @@
*/ */
function stripModeStampFromFileName(fileName) { function stripModeStampFromFileName(fileName) {
if (!fileName || typeof fileName !== 'string') return fileName; if (!fileName || typeof fileName !== 'string') return fileName;
const newSessionRe = /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
const mNew = fileName.match(newSessionRe);
if (mNew) return `fileuploader${mNew[1] || ''}`;
// Order matters: session first (longer, more specific) before daily. // Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so // Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary. // matching is linear — the eslint security warning is precautionary.

View File

@ -1,19 +0,0 @@
function configureStartupRenderer(app) {
app.disableHardwareAcceleration();
}
function createStartupWindow(BrowserWindow, options) {
const window = new BrowserWindow({ ...options, show: false });
window.once('ready-to-show', () => {
window.show();
});
return {
window,
load(target, onLoadError) {
return window.loadFile(target).catch(onLoadError);
}
};
}
module.exports = { configureStartupRenderer, createStartupWindow };

View File

@ -37,14 +37,6 @@ function isNewer(remote, current) {
return r.patch > c.patch; return r.patch > c.patch;
} }
function resolveReleaseVersion(release) {
for (const value of [release && release.name, release && release.tag_name]) {
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
if (match) return match[1];
}
return '';
}
function pickSetupAsset(assets) { function pickSetupAsset(assets) {
if (!Array.isArray(assets)) return null; if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive) // Prefer asset with "setup" in the name (case-insensitive)
@ -98,12 +90,11 @@ async function checkForUpdate() {
} }
const release = releases[0]; const release = releases[0];
const remoteVersion = resolveReleaseVersion(release); const remoteVersion = release.tag_name || release.name || '';
const transportTag = release.tag_name || '';
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
if (!isNewer(remoteVersion, currentVersion)) { if (!isNewer(remoteVersion, currentVersion)) {
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag }; cachedCheck = { available: false, currentVersion, remoteVersion };
cachedCheckTs = Date.now(); cachedCheckTs = Date.now();
return cachedCheck; return cachedCheck;
} }
@ -118,8 +109,7 @@ async function checkForUpdate() {
cachedCheck = { cachedCheck = {
available: true, available: true,
currentVersion, currentVersion,
remoteVersion, remoteVersion: remoteVersion.replace(/^v/i, ''),
transportTag,
releaseUrl: release.html_url, releaseUrl: release.html_url,
assetUrl: setupAsset.browser_download_url, assetUrl: setupAsset.browser_download_url,
assetSize: setupAsset.size, assetSize: setupAsset.size,
@ -290,4 +280,4 @@ function abortUpdate() {
} }
} }
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion }; module.exports = { checkForUpdate, installUpdate, abortUpdate };

29
main.js
View File

@ -1,8 +1,6 @@
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
const { configureStartupRenderer, createStartupWindow } = require('./lib/startup-renderer');
configureStartupRenderer(app);
nativeTheme.themeSource = 'dark'; nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@ -29,6 +27,16 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors'); const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent'); const { createAgent } = require('./lib/diagnostics-agent');
function _gpuDisableFlagPath() {
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
}
(function maybeDisableHardwareAcceleration() {
let disable = false;
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
})();
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable(); _eventLoopDelay.enable();
let _eldLastLog = 0; let _eldLastLog = 0;
@ -555,10 +563,10 @@ function getBaseLogFilePath() {
// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session. // Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session.
// The session-id is stamped ONCE at main-process startup so every write of a // The session-id is stamped ONCE at main-process startup so every write of a
// given session lands in the same file. A close→reopen of the app starts a new // given session lands in the same file. A close→reopen of the app starts a new
// main process, so a new SESSION_ID, so a new session file. A 6-digit random is // main process, so a new SESSION_ID, so a new session file. PID is appended as
// appended as a cheap hedge against same-minute restart collisions. // a cheap hedge against same-second restart collisions.
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode'); const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000))); const SESSION_ID = formatSessionStamp(new Date(), process.pid);
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
let _activeLogPath = null; let _activeLogPath = null;
@ -1222,7 +1230,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
} }
function createWindow() { function createWindow() {
const startupWindow = createStartupWindow(BrowserWindow, { mainWindow = new BrowserWindow({
width: 1100, width: 1100,
height: 750, height: 750,
minWidth: 800, minWidth: 800,
@ -1235,7 +1243,6 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js') preload: path.join(__dirname, 'preload.js')
} }
}); });
mainWindow = startupWindow.window;
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
@ -1281,12 +1288,12 @@ function createWindow() {
app.on('child-process-gone', (_event, details) => { app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details); _writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`); debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
if (details && details.type === 'GPU') {
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
}
}); });
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => { mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
_writeCrashLog('LOAD FILE FAILED', err);
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
});
} }
function createTray() { function createTray() {

20
package-lock.json generated
View File

@ -1,16 +1,16 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.2", "version": "3.3.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.2", "version": "3.3.16",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.16.0",
"ws": "^8.21.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.3.0",
@ -4734,9 +4734,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.29.0", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
@ -4844,9 +4844,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.2", "version": "3.3.106",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@ -12,8 +12,8 @@
}, },
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.16.0",
"ws": "^8.21.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.3.0",

View File

@ -1,73 +0,0 @@
(function (scope) {
function getAccountSubmitLabel({ isEdit } = {}) {
return isEdit ? 'Prüfen und speichern' : 'Prüfen und anlegen';
}
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
let validation;
try {
validation = await validate();
} catch (error) {
return { status: 'error', error };
}
try {
if (!isCurrent()) return { status: 'stale', validation };
} catch (error) {
return { status: 'error', error, validation };
}
if (validation && validation.status === 'otp_required') {
return { status: 'otp_required', validation };
}
if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
return { status: 'rejected', validation };
}
let value;
try {
value = await commit(validation);
} catch (error) {
return { status: 'error', error, validation };
}
let postCommitError;
if (typeof afterCommit === 'function') {
try {
await afterCommit(value, validation);
} catch (error) {
postCommitError = error;
}
}
const committedResult = { status: 'committed', committed: true, validation, value };
if (postCommitError) committedResult.postCommitError = postCommitError;
try {
if (!isCurrent()) return { ...committedResult, status: 'stale' };
} catch {
return { ...committedResult, status: 'stale' };
}
return committedResult;
}
function createAccountSubmitter() {
let pending = null;
return {
isBusy() {
return pending !== null;
},
submit(options) {
if (pending) return null;
const operation = submitValidatedAccount(options);
const tracked = operation.finally(() => {
if (pending === tracked) pending = null;
});
pending = tracked;
return tracked;
}
};
}
const accountSubmit = { createAccountSubmitter, getAccountSubmitLabel, submitValidatedAccount };
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
if (scope) scope.AccountSubmit = accountSubmit;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@ -353,14 +353,10 @@ function _isHistoryTabActive() {
if (!tab || tab === activeTab) return; if (!tab || tab === activeTab) return;
if (activeTab) { if (activeTab) {
activeTab.classList.remove('active'); activeTab.classList.remove('active');
activeTab.setAttribute('aria-selected', 'false');
activeTab.tabIndex = -1;
const prevView = viewsById[`${activeTab.dataset.view}-view`]; const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active'); if (prevView) prevView.classList.remove('active');
} }
tab.classList.add('active'); tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
const nextView = viewsById[`${tab.dataset.view}-view`]; const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active'); if (nextView) nextView.classList.add('active');
activeTab = tab; activeTab = tab;
@ -372,18 +368,6 @@ function _isHistoryTabActive() {
const tabBar = tabs[0] && tabs[0].parentElement; const tabBar = tabs[0] && tabs[0].parentElement;
if (tabBar) { if (tabBar) {
tabBar.addEventListener('click', (e) => handle(e.target)); tabBar.addEventListener('click', (e) => handle(e.target));
tabBar.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
const current = Math.max(0, tabs.indexOf(activeTab));
const next = e.key === 'Home'
? 0
: e.key === 'End'
? tabs.length - 1
: (current + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
handle(tabs[next]);
});
} else { } else {
// Fallback: bind per-tab if somehow no common parent // Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t))); tabs.forEach(t => t.addEventListener('click', () => handle(t)));
@ -1936,20 +1920,9 @@ document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu(); if (!e.target.closest('.context-menu')) hideContextMenu();
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
const accountModal = document.getElementById('accountModal');
if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') {
const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
hideContextMenu(); hideContextMenu();
cancelHosterModal(); cancelHosterModal();
if (accountModal && accountModal.style.display !== 'none') closeAccountModal();
} }
if (e.target.closest('input, textarea, select')) return; if (e.target.closest('input, textarea, select')) return;
const activeView = document.querySelector('.view.active'); const activeView = document.querySelector('.view.active');
@ -2978,12 +2951,12 @@ function updateStatusBar() {
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Verbindungen ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`; document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Verbleibend ${stats.remaining}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `Läuft ${stats.inProgress}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
document.getElementById('sbDoneCount').textContent = `Fertig ${_sessionDoneCount}`; document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
document.getElementById('sbErrorCount').textContent = `Fehler ${_sessionErrorCount}`; document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
} }
// --- Health Check --- // --- Health Check ---
@ -3891,10 +3864,8 @@ function renderAccounts() {
if (allAccounts.length === 0) { if (allAccounts.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="accounts-empty"> <div class="accounts-empty">
<div class="accounts-empty-icon" aria-hidden="true">+</div> <p>Keine Accounts vorhanden</p>
<h3>Noch keine Accounts</h3> <span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
<p>Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.</p>
<button class="btn btn-primary" type="button" data-account-empty-add>Ersten Account hinzufügen</button>
</div>`; </div>`;
if (footer) footer.style.display = 'none'; if (footer) footer.style.display = 'none';
if (!_accountListenersBound) bindAccountListeners(container); if (!_accountListenersBound) bindAccountListeners(container);
@ -4135,7 +4106,6 @@ function bindAccountListeners(container) {
} }
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (!btn) return; if (!btn) return;
if (btn.hasAttribute('data-account-empty-add')) return openAccountModal(null);
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
@ -4270,43 +4240,28 @@ function getCredsFieldsHtml(authType, account, hoster) {
}; };
return ` return `
<div class="settings-row"> <div class="settings-row">
<label for="accField_username">${escapeHtml(fld.label)}</label> <label>${escapeHtml(fld.label)}</label>
<input type="${fld.inputType}" class="key-input" id="accField_username" name="username" autocomplete="username" spellcheck="false" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}"> <input type="${fld.inputType}" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}">
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label for="accField_password">Passwort</label> <label>Passwort</label>
<input type="password" class="key-input" id="accField_password" name="password" autocomplete="current-password" value="${escapeAttr(account.password || '')}" placeholder="Passwort"> <input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Passwort anzeigen" aria-label="Passwort anzeigen" aria-pressed="false">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
// API key // API key
return ` return `
<div class="settings-row"> <div class="settings-row">
<label for="accField_apiKey">API-Key</label> <label>API Key</label>
<input type="password" class="key-input" id="accField_apiKey" name="apiKey" autocomplete="off" spellcheck="false" value="${escapeAttr(account.apiKey || '')}" placeholder="API-Key"> <input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
<button class="toggle-vis" type="button" title="API-Key anzeigen" aria-label="API-Key anzeigen" aria-pressed="false">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
function wireCredentialVisibilityButtons(container) {
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
const visible = input.type === 'password';
const fieldName = input.id === 'accField_apiKey' ? 'API-Key' : 'Passwort';
input.type = visible ? 'text' : 'password';
btn.setAttribute('aria-pressed', String(visible));
btn.setAttribute('aria-label', `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`);
btn.title = `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`;
});
});
}
let _accountModalReturnFocus = null;
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
_accountModalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
editingAccountId = editAccountId || null; editingAccountId = editAccountId || null;
// Reset the two-step state — any previously validated snapshot from a prior
// modal session is stale and must not allow a no-recheck commit.
_resetAccountModalState(); _resetAccountModalState();
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle'); const title = document.getElementById('accountModalTitle');
@ -4326,17 +4281,17 @@ function openAccountModal(editAccountId) {
const found = findAccountById(editingAccountId); const found = findAccountById(editingAccountId);
if (!found) return; if (!found) return;
title.textContent = 'Account bearbeiten'; title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none'; hosterRow.style.display = 'none';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true }); saveBtn.textContent = 'Prüfen';
if (labelInput) labelInput.value = found.account.label || ''; if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else { } else {
// Add mode — always show all options (multiple accounts per hoster allowed) // Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen'; title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
hosterRow.style.display = 'flex'; hosterRow.style.display = 'flex';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false }); saveBtn.textContent = 'Prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>` `<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join(''); ).join('');
@ -4345,30 +4300,32 @@ function openAccountModal(editAccountId) {
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
} }
wireCredentialVisibilityButtons(credsContainer); // Toggle visibility buttons
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
// Wire field invalidation: any change to a cred field after a green check
// drops the validated snapshot so the next click is a re-check, not a commit
// of unverified creds. Re-wired here every open because credsContainer's HTML
// was replaced.
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
modal.style.display = 'flex'; modal.style.display = 'flex';
requestAnimationFrame(() => {
const firstControl = editingAccountId
? document.getElementById('accField_label')
: hosterSelect;
if (firstControl) firstControl.focus();
});
} }
function closeAccountModal() { function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
_resetAccountModalState(); // Cancel any pending auto-close so a stale timer can't close a future modal
const returnFocus = _accountModalReturnFocus; // the user reopens within the auto-close window.
_accountModalReturnFocus = null; if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
const focusTarget = returnFocus && returnFocus.isConnected _validatedCreds = null;
? returnFocus _accountModalBusy = false;
: document.getElementById('addAccountBtn');
if (focusTarget) focusTarget.focus();
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -4424,54 +4381,66 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label }; return { enabled: !!apiKey, authType: 'api', apiKey, label };
} }
const _accountSubmitter = window.AccountSubmit.createAccountSubmitter(); // --- Two-step account-modal state machine ---
let _accountModalCommitLocked = false; //
// Goal: never persist invalid/unverified credentials to config.hosters. The
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
// the button label flips to "Anlegen" / "Speichern" → the next click commits
// to config. Editing any cred field between the two clicks drops the validated
// snapshot so the user can't sneak unverified creds through by editing
// post-green.
//
// Invariants enforced here:
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
// result for the currently-typed creds.
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
// before any await — guards against double-clicks producing duplicates.
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
// the new OTP, no config writes until green.
// 4. Edit mode hits the same path → bad edits never overwrite known-good
// creds on disk.
let _accountModalBusy = false;
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
let _autoCloseTimer = null; let _autoCloseTimer = null;
// Session token used to ignore stale validate-credentials responses: if the
// user closes the modal mid-flight and reopens it, the late .then must NOT
// stomp the new session's state. Bumped on every modal reset.
let _accountModalSession = 0; let _accountModalSession = 0;
function _resetAccountModalState() { function _resetAccountModalState() {
_accountModalBusy = false;
_validatedCreds = null;
_accountModalSession++; _accountModalSession++;
_accountModalCommitLocked = false;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_syncAccountSubmitButton();
} }
function _credsSnapshotKey(authType, creds) { function _credsSnapshotKey(authType, creds) {
// Identity key for the typed creds — used to detect post-validation edits.
// Label changes do NOT invalidate (label is metadata, not a credential).
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`; return `api:${creds.apiKey || ''}`;
} }
function _defaultAccountSubmitButtonText(ctx) {
return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
}
function _syncAccountSubmitButton() {
const saveBtn = document.getElementById('saveAccountBtn');
if (!saveBtn) return;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
}
function _invalidateAccountSubmit() {
_accountModalSession++;
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) {
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
}
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
saveBtn.disabled = false;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
}
}
function _wireCredFieldInvalidation() { function _wireCredFieldInvalidation() {
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
// validated snapshot and reverts the button to "Prüfen". Label edits don't
// invalidate (label is metadata, not a credential). OTP edits don't either:
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
// these username+password+OTP, the resulting trust is on the creds; the user
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
const ids = ['accField_username', 'accField_password', 'accField_apiKey']; const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) { for (const id of ids) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue; if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', _invalidateAccountSubmit); el.addEventListener('input', () => {
if (_validatedCreds) {
_validatedCreds = null;
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn) saveBtn.textContent = 'Prüfen';
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
}
});
el.dataset.invalidateBound = '1'; el.dataset.invalidateBound = '1';
} }
} }
@ -4489,18 +4458,12 @@ function _determineHosterContext() {
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
} }
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
if (session !== _accountModalSession) return false;
const currentCtx = _determineHosterContext();
if (!currentCtx) return false;
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
}
async function saveAccount() { async function saveAccount() {
if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return; // SYNCHRONOUS re-entry guard — must come before any await. Without this a
// double-click before the first IPC returns triggers two saveAccount() calls
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
// synchronously and set synchronously, so the second click no-ops cleanly.
if (_accountModalBusy) return;
const ctx = _determineHosterContext(); const ctx = _determineHosterContext();
if (!ctx) return; if (!ctx) return;
@ -4513,8 +4476,37 @@ async function saveAccount() {
return; return;
} }
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
// EXACT same creds (label changes don't break this — label isn't part of the
// credential identity).
const snapshotKey = _credsSnapshotKey(ctx.authType, creds); const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
if (_validatedCreds &&
_validatedCreds.hosterName === ctx.hosterName &&
_validatedCreds.authType === ctx.authType &&
_validatedCreds.snapshot === snapshotKey) {
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
// leak _accountModalBusy=true and lock the user out for the session.
try {
_accountModalBusy = true;
saveBtn.disabled = true;
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
} finally {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
return;
}
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
// Snapshot the session token so a stale late-arriving response from a
// closed-and-reopened modal can't stomp the new session's state.
const mySession = _accountModalSession; const mySession = _accountModalSession;
_accountModalBusy = true;
saveBtn.disabled = true;
statusEl.textContent = 'Prüfe Login…';
statusEl.className = 'account-modal-status checking';
const otpInput = document.getElementById('accField_otp'); const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : ''; const otp = otpInput ? otpInput.value.trim() : '';
const payload = { const payload = {
@ -4526,100 +4518,94 @@ async function saveAccount() {
otp otp
}; };
const submission = _accountSubmitter.submit({ let row;
validate: () => window.api.validateCredentials(payload),
commit: () => _persistAccount(ctx, creds),
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
if (!submission) return;
saveBtn.disabled = true;
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
let result;
try { try {
result = await submission; row = await window.api.validateCredentials(payload);
} catch (error) { } catch (err) {
result = { status: 'error', error }; row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
} finally {
if (mySession === _accountModalSession) {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
} }
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey); // Stale response — modal was closed/reopened while we awaited. Drop it.
if (result.status === 'committed' && current) { if (mySession !== _accountModalSession) return;
_accountModalCommitLocked = true;
const validation = result.validation || {};
statusEl.textContent = validation.status === 'warn'
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
statusEl.className = 'account-modal-status ok';
_hideOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
saveBtn.disabled = true;
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => {
_autoCloseTimer = null;
closeAccountModal();
}, 600);
return;
}
_syncAccountSubmitButton(); if (row && row.status === 'otp_required') {
if (!current) return; statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
if (result.status === 'otp_required') {
const validation = result.validation || {};
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
_showOtpField(); _showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx); _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
saveBtn.textContent = 'Mit OTP prüfen';
return; return;
} }
if (row && (row.status === 'ok' || row.status === 'warn')) {
const validation = result.validation || {}; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
const msg = result.status === 'error' statusEl.className = 'account-modal-status ok';
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen' _hideOtpField();
: validation.message || 'Login fehlgeschlagen'; _validatedCreds = {
hosterName: ctx.hosterName,
authType: ctx.authType,
snapshot: snapshotKey,
status: row.status,
message: row.message || ''
};
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
return;
}
// error
const msg = (row && row.message) || 'Login fehlgeschlagen';
statusEl.textContent = msg; statusEl.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} }
function _copyHosterTree(hosters) { async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
const candidate = {}; // Persist the validated creds to config.hosters and close the modal. By the
for (const [name, accounts] of Object.entries(hosters || {})) { // time we reach this function the validate-credentials IPC has already
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts; // returned ok/warn for these exact creds, so we skip a redundant re-check.
} let accountId;
return candidate; if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
}
async function _persistAccount(ctx, creds) {
const candidateHosters = _copyHosterTree(config.hosters);
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
let accountId = ctx.accountId;
if (ctx.isEdit) { if (ctx.isEdit) {
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId); accountId = ctx.accountId;
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.'); const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds }; if (idx >= 0) {
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
} else {
_accountModalBusy = false;
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
const _st = document.getElementById('accountModalStatus');
if (_st) {
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
_st.className = 'account-modal-status error';
}
return;
}
} else { } else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds }); config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
} }
await window.api.saveConfig({ hosters: candidateHosters }); await window.api.saveConfig({ hosters: config.hosters });
return { accountId, candidateHosters, isEdit: ctx.isEdit }; // Skip the redundant await getConfig() — the in-memory state is the source
} // of truth for what we just wrote, decrypted creds didn't change, and the
// round-trip was the main lag source on add/delete.
function _applyCommittedAccount(persisted, validation) { accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters;
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
if (isEdit) { // Targeted updates instead of the 4-panel cascade. For add we need a full
// accounts-list re-render (new card) and the hoster summary count; for edit
// we can update the single card. Settings panel only needs re-render if its
// hoster-summary section is visible — that's covered by renderHosterSummary.
if (ctx.isEdit) {
updateAccountCard(accountId); updateAccountCard(accountId);
} else { } else {
renderAccounts(); renderAccounts();
} }
renderHosterSummary(); renderHosterSummary();
// Auto-close after a short pause so the user sees the success state.
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
} }
function _showOtpField() { function _showOtpField() {
@ -4932,7 +4918,7 @@ function renderHistoryTable(container) {
}; };
container.innerHTML = `<table class="results-table history-table"><thead><tr> container.innerHTML = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Link')} ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody id="historyBody"></tbody></table>`; </tr></thead><tbody id="historyBody"></tbody></table>`;
if (!_historyListenersBound) { if (!_historyListenersBound) {
@ -5184,12 +5170,25 @@ function setupListeners() {
// Account hoster select change → update credential fields // Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => { document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
_invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login'; const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value); credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value);
wireCredentialVisibilityButtons(credsContainer); credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
document.getElementById('accountModalStatus').textContent = '';
document.getElementById('accountModalStatus').className = 'account-modal-status';
// Hoster changed → any prior validation is stale by construction. Drop the
// snapshot and revert the button so the user has to re-Prüfen.
_validatedCreds = null;
const sb = document.getElementById('saveAccountBtn');
if (sb) sb.textContent = 'Prüfen';
// The cred inputs were just replaced — rewire invalidation listeners on
// the fresh elements so post-validation edits still revert the button.
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
}); });
@ -5233,7 +5232,7 @@ function showUpdateBanner(info) {
function handleUpdateProgress(data) { function handleUpdateProgress(data) {
const msg = document.getElementById('updateMessage'); const msg = document.getElementById('updateMessage');
if (!msg) return; if (!msg) return;
if (data.stage === 'downloading') msg.textContent = `Wird heruntergeladen… ${data.percent || 0}%`; if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`;
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';

View File

@ -66,11 +66,11 @@
</div> </div>
</nav> </nav>
<nav class="tab-bar" role="tablist" aria-label="Hauptbereiche"> <nav class="tab-bar">
<button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload">Upload</button> <button class="tab active" data-view="upload">Upload</button>
<button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts">Accounts</button> <button class="tab" data-view="accounts">Accounts</button>
<button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings">Einstellungen</button> <button class="tab" data-view="settings">Einstellungen</button>
<button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history">Verlauf</button> <button class="tab" data-view="history">Verlauf</button>
<span class="version-label" id="versionLabel"></span> <span class="version-label" id="versionLabel"></span>
</nav> </nav>
@ -80,7 +80,7 @@
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">&times;</button> <button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">&times;</button>
</div> </div>
<div id="upload-view" class="view active" role="tabpanel" aria-labelledby="upload-tab"> <div id="upload-view" class="view active">
<div class="upload-toolbar"> <div class="upload-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="hoster-summary" id="hosterSummary" style="display:none"></span> <span class="hoster-summary" id="hosterSummary" style="display:none"></span>
@ -99,35 +99,35 @@
<div class="queue-shell" id="queueShell" style="display:none"> <div class="queue-shell" id="queueShell" style="display:none">
<div class="queue-command-bar" id="queueCommandBar"> <div class="queue-command-bar" id="queueCommandBar">
<button class="toolbar-btn" id="startUploadBtn" title="Alle Uploads starten" aria-label="Alle Uploads starten" disabled> <button class="toolbar-btn" id="startUploadBtn" title="Start all" disabled>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="startSelectedBtn" title="Ausgewählte Uploads starten" aria-label="Ausgewählte Uploads starten" disabled> <button class="toolbar-btn" id="startSelectedBtn" title="Start selected" disabled>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="reuploadSelectedBtn" title="Ausgewählte Datei erneut hochladen" aria-label="Ausgewählte Datei erneut hochladen"> <button class="toolbar-btn" id="reuploadSelectedBtn" title="Reupload selected file">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="abortSelectedBtn" title="Ausgewählten Upload abbrechen" aria-label="Ausgewählten Upload abbrechen"> <button class="toolbar-btn" id="abortSelectedBtn" title="Abort selected file">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
<button class="toolbar-btn" id="finishStopBtn" title="Aktive Uploads beenden und stoppen" aria-label="Aktive Uploads beenden und stoppen"> <button class="toolbar-btn" id="finishStopBtn" title="Finish Uploads in Progress and Stop">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg>
</button> </button>
<button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Alle Uploads abbrechen" aria-label="Alle Uploads abbrechen"> <button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Abort all Downloads">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
</button> </button>
<span class="toolbar-sep"></span> <span class="toolbar-sep"></span>
<button class="toolbar-btn" id="moveTopBtn" title="Ganz nach oben" aria-label="Ganz nach oben"> <button class="toolbar-btn" id="moveTopBtn" title="Move to the top">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveUpBtn" title="Nach oben" aria-label="Nach oben"> <button class="toolbar-btn" id="moveUpBtn" title="Move up">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveDownBtn" title="Nach unten" aria-label="Nach unten"> <button class="toolbar-btn" id="moveDownBtn" title="Move down">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveBottomBtn" title="Ganz nach unten" aria-label="Ganz nach unten"> <button class="toolbar-btn" id="moveBottomBtn" title="Move to the bottom">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
</div> </div>
@ -136,14 +136,14 @@
<table class="queue-table" id="queueTable"> <table class="queue-table" id="queueTable">
<thead> <thead>
<tr> <tr>
<th class="col-filename sortable" data-col="filename" data-sort="filename">Dateiname<span class="col-resizer"></span></th> <th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th>
<th class="col-size sortable" data-col="size" data-sort="size">Hochgeladen / Größe<span class="col-resizer"></span></th> <th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th>
<th class="col-host sortable" data-col="host" data-sort="host">Hoster<span class="col-resizer"></span></th> <th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th>
<th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th> <th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
<th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th> <th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
<th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th> <th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
<th class="col-speed sortable" data-col="speed" data-sort="speed">Geschwindigkeit<span class="col-resizer"></span></th> <th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th>
<th class="col-progress sortable" data-col="progress" data-sort="progress">Fortschritt</th> <th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th>
</tr> </tr>
</thead> </thead>
<tbody id="queueBody"></tbody> <tbody id="queueBody"></tbody>
@ -168,8 +168,8 @@
<div class="recent-files-panel" id="recentFilesPanel"> <div class="recent-files-panel" id="recentFilesPanel">
<div class="recent-files-header"> <div class="recent-files-header">
<div class="recent-tabs"> <div class="recent-tabs">
<button class="recent-tab active" data-panel="filesTab">Dateien</button> <button class="recent-tab active" data-panel="filesTab">Files</button>
<button class="recent-tab" data-panel="statsTab">Statistik</button> <button class="recent-tab" data-panel="statsTab">Stats</button>
</div> </div>
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span> <span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button> <button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
@ -181,8 +181,8 @@
<thead id="recentFilesHead"> <thead id="recentFilesHead">
<tr> <tr>
<th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th> <th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th>
<th class="col-filename sortable" data-recent-sort="filename">Dateiname<span class="sort-indicator"></span></th> <th class="col-filename sortable" data-recent-sort="filename">Filename<span class="sort-indicator"></span></th>
<th class="col-host sortable" data-recent-sort="host">Hoster<span class="sort-indicator"></span></th> <th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator"></span></th>
<th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th> <th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th>
</tr> </tr>
</thead> </thead>
@ -193,24 +193,24 @@
<div class="recent-tab-body" id="statsTab"> <div class="recent-tab-body" id="statsTab">
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-col"> <div class="stats-col">
<h4>Dateien in der Warteschlange</h4> <h4>Files in queue (count)</h4>
<div class="stats-row"><span>Gesamt:</span><span id="statQueueTotal">0</span></div> <div class="stats-row"><span>total:</span><span id="statQueueTotal">0</span></div>
<div class="stats-row"><span>Fertig:</span><span id="statQueueDone">0</span></div> <div class="stats-row"><span>done:</span><span id="statQueueDone">0</span></div>
<div class="stats-row"><span>Verbleibend:</span><span id="statQueueRemaining">0</span></div> <div class="stats-row"><span>remaining:</span><span id="statQueueRemaining">0</span></div>
<div class="stats-row"><span>Läuft:</span><span id="statQueueInProgress">0</span></div> <div class="stats-row"><span>in progress:</span><span id="statQueueInProgress">0</span></div>
<div class="stats-row"><span>Fehler:</span><span id="statQueueError">0</span></div> <div class="stats-row"><span>error:</span><span id="statQueueError">0</span></div>
</div> </div>
<div class="stats-col"> <div class="stats-col">
<h4>Dateigröße in der Warteschlange</h4> <h4>File size in queue</h4>
<div class="stats-row"><span>Gesamt:</span><span id="statSizeTotal">0 B</span></div> <div class="stats-row"><span>total:</span><span id="statSizeTotal">0 B</span></div>
<div class="stats-row"><span>Verbleibend:</span><span id="statSizeRemaining">0 B</span></div> <div class="stats-row"><span>remaining:</span><span id="statSizeRemaining">0 B</span></div>
</div> </div>
<div class="stats-col"> <div class="stats-col">
<h4>Sitzung</h4> <h4>Session</h4>
<div class="stats-row"><span>Upload-Geschwindigkeit:</span><span id="statSpeed">0 B/s</span></div> <div class="stats-row"><span>Upload speed:</span><span id="statSpeed">0 B/s</span></div>
<div class="stats-row"><span>Restzeit:</span><span id="statEta">--:--</span></div> <div class="stats-row"><span>Remaining time:</span><span id="statEta">--:--</span></div>
<div class="stats-row"><span>Laufzeit:</span><span id="statRunTime">00:00:00</span></div> <div class="stats-row"><span>Run time:</span><span id="statRunTime">00:00:00</span></div>
<div class="stats-row"><span>In diesem Lauf hochgeladen:</span><span id="statSessionBytes">0 B</span></div> <div class="stats-row"><span>Uploaded (this run):</span><span id="statSessionBytes">0 B</span></div>
</div> </div>
</div> </div>
</div> </div>
@ -219,7 +219,7 @@
</div> </div>
</div> </div>
<div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab"> <div id="accounts-view" class="view">
<div class="accounts-container"> <div class="accounts-container">
<div class="accounts-header"> <div class="accounts-header">
<div> <div>
@ -232,7 +232,7 @@
<input type="checkbox" id="autoHealthCheckToggle" checked> <input type="checkbox" id="autoHealthCheckToggle" checked>
<span>Auto-Check vor Upload</span> <span>Auto-Check vor Upload</span>
</label> </label>
<button class="btn btn-primary" id="addAccountBtn">Account hinzufügen</button> <button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button>
</div> </div>
</div> </div>
<div class="health-check-results account-health-results" id="healthCheckResults"></div> <div class="health-check-results account-health-results" id="healthCheckResults"></div>
@ -244,7 +244,7 @@
</div> </div>
<div class="modal-overlay" id="accountModal" style="display:none"> <div class="modal-overlay" id="accountModal" style="display:none">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle"> <div class="modal-card">
<div class="modal-header"> <div class="modal-header">
<div> <div>
<h3 id="accountModalTitle">Account hinzufügen</h3> <h3 id="accountModalTitle">Account hinzufügen</h3>
@ -254,19 +254,19 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="settings-row" id="accountHosterRow"> <div class="settings-row" id="accountHosterRow">
<label for="accountHosterSelect">Hoster</label> <label>Hoster</label>
<select class="key-input" id="accountHosterSelect" name="hoster" autocomplete="off" style="max-width:300px"></select> <select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label for="accField_label">Label (optional)</label> <label>Label (optional)</label>
<input type="text" class="key-input" id="accField_label" name="accountLabel" autocomplete="off" placeholder="z. B. Hauptaccount, Premium, Kunde XY" maxlength="60"> <input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60">
</div> </div>
<div id="accountCredsFields"></div> <div id="accountCredsFields"></div>
<div class="account-modal-status" id="accountModalStatus" role="status" aria-live="polite"></div> <div class="account-modal-status" id="accountModalStatus"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button> <button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button> <button class="btn btn-primary" id="saveAccountBtn">Anlegen &amp; prüfen</button>
</div> </div>
</div> </div>
</div> </div>
@ -303,7 +303,7 @@
</div> </div>
</div> </div>
<div id="settings-view" class="view" role="tabpanel" aria-labelledby="settings-tab"> <div id="settings-view" class="view">
<div class="settings-container"> <div class="settings-container">
<h2>Upload-Einstellungen</h2> <h2>Upload-Einstellungen</h2>
<p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p> <p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p>
@ -315,7 +315,7 @@
</div> </div>
</div> </div>
<div id="history-view" class="view" role="tabpanel" aria-labelledby="history-tab"> <div id="history-view" class="view">
<div class="history-container"> <div class="history-container">
<div class="history-header"> <div class="history-header">
<h2>Upload-Verlauf</h2> <h2>Upload-Verlauf</h2>
@ -368,17 +368,17 @@
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-eta" id="sbEta">ETA --:--</span> <span class="sb-eta" id="sbEta">ETA --:--</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-connections" id="sbConnections">Verbindungen 0</span> <span class="sb-connections" id="sbConnections">Aktive Verbindungen 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span> <span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-remaining-count" id="sbRemainingCount">Verbleibend 0</span> <span class="sb-remaining-count" id="sbRemainingCount">Remaining 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-progress-count" id="sbInProgressCount">Läuft 0</span> <span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-done-count" id="sbDoneCount">Fertig 0</span> <span class="sb-done-count" id="sbDoneCount">Done 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-error-count" id="sbErrorCount">Fehler 0</span> <span class="sb-error-count" id="sbErrorCount">Error 0</span>
</div> </div>
<div class="copy-toast" id="copyToast"></div> <div class="copy-toast" id="copyToast"></div>
@ -421,7 +421,6 @@
<script src="../lib/throttled-cache.js"></script> <script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script> <script src="../lib/coalesced-set.js"></script>
<script src="../lib/throttle-timer.js"></script> <script src="../lib/throttle-timer.js"></script>
<script src="account-submit.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,5 +1,4 @@
:root { :root {
color-scheme: dark;
--bg-primary: #16181c; --bg-primary: #16181c;
--bg-secondary: #20242b; --bg-secondary: #20242b;
--bg-card: #262b33; --bg-card: #262b33;
@ -9,7 +8,7 @@
--border-hover: rgba(255, 255, 255, 0.18); --border-hover: rgba(255, 255, 255, 0.18);
--text: #edf1f7; --text: #edf1f7;
--text-muted: #9ea7b3; --text-muted: #9ea7b3;
--text-dim: #8490a0; --text-dim: #727b88;
--accent: #3ea7ff; --accent: #3ea7ff;
--accent-end: #65d8ff; --accent-end: #65d8ff;
--success: #43c788; --success: #43c788;
@ -217,7 +216,7 @@ body {
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: color 0.2s, background-color 0.2s, border-color 0.2s; transition: all 0.2s;
} }
.tab:hover { color: var(--text); } .tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); } .tab.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); }
@ -257,7 +256,7 @@ body {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
transition: color 0.2s, background-color 0.2s, border-color 0.2s, filter 0.2s, transform 0.2s; transition: all 0.2s;
} }
.btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; } .btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; }
.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; } .btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; }
@ -320,7 +319,7 @@ body {
border: 1px dashed rgba(126, 220, 255, 0.28); border: 1px dashed rgba(126, 220, 255, 0.28);
border-radius: 18px; border-radius: 18px;
cursor: pointer; cursor: pointer;
transition: color 0.3s, background-color 0.3s, border-color 0.3s; transition: all 0.3s;
color: var(--text-muted); color: var(--text-muted);
min-height: 200px; min-height: 200px;
background: background:
@ -360,7 +359,7 @@ body {
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
color: var(--text-muted); color: var(--text-muted);
transition: color 0.15s, background-color 0.15s, border-color 0.15s, transform 0.15s; transition: all 0.15s;
} }
.toolbar-btn:hover { .toolbar-btn:hover {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
@ -572,7 +571,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
border-bottom: none; border-bottom: none;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background-color 0.15s, border-color 0.15s; transition: all 0.15s;
} }
.recent-tab:first-child { border-radius: 4px 0 0 0; } .recent-tab:first-child { border-radius: 4px 0 0 0; }
.recent-tab:last-child { border-radius: 0 4px 0 0; } .recent-tab:last-child { border-radius: 0 4px 0 0; }
@ -678,7 +677,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
background: rgba(5, 7, 16, 0.72); background: rgba(5, 7, 16, 0.72);
z-index: 2500; z-index: 2500;
padding: 24px; padding: 24px;
overscroll-behavior: contain;
} }
.modal-card { .modal-card {
width: min(560px, 100%); width: min(560px, 100%);
@ -715,8 +713,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
padding: 14px 16px 10px; padding: 14px 16px 10px;
overflow: auto; overflow: auto;
} }
.modal-body .settings-row { gap: 10px; }
.modal-body .key-input { min-height: 36px; }
.modal-actions-inline { .modal-actions-inline {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -1194,29 +1190,12 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
.accounts-empty { .accounts-empty {
width: min(520px, 100%);
margin: clamp(56px, 12vh, 120px) auto 0;
padding: 32px;
border: 1px solid var(--border);
border-radius: 16px;
background: linear-gradient(180deg, rgba(62, 167, 255, 0.06), rgba(255, 255, 255, 0.025));
box-shadow: var(--panel-shadow);
text-align: center; text-align: center;
padding: 48px 16px;
color: var(--text-dim);
} }
.accounts-empty-icon { .accounts-empty p { font-size: 14px; margin-bottom: 4px; }
display: grid; .accounts-empty .hint { font-size: 12px; }
place-items: center;
width: 48px;
height: 48px;
margin: 0 auto 14px;
border-radius: 14px;
background: rgba(62, 167, 255, 0.14);
color: var(--accent-end);
font-size: 30px;
font-weight: 300;
}
.accounts-empty h3 { font-size: 17px; margin-bottom: 6px; }
.accounts-empty p { max-width: 390px; margin: 0 auto 18px; color: var(--text-muted); font-size: 13px; line-height: 1.55; }
/* History View */ /* History View */
.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
@ -1313,7 +1292,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
font-size: 12px; font-size: 12px;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 0.3s, transform 0.3s; transition: all 0.3s;
z-index: 2000; z-index: 2000;
} }
.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@ -1360,29 +1339,3 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
:where(button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--accent-end);
outline-offset: 2px;
}
.key-input:focus-visible,
.hs-input:focus-visible {
border-color: var(--accent);
outline: 2px solid rgba(101, 216, 255, 0.45);
outline-offset: 1px;
}
.menu-spinner input[type="text"]:focus-visible {
outline: 2px solid var(--accent-end);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@ -1,61 +1,27 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { writeFileSync, statSync, createReadStream, existsSync } from 'fs'; import { readFileSync, writeFileSync, statSync, createReadStream, existsSync } from 'fs';
import { resolve, basename } from 'path'; import { resolve, basename } from 'path';
import { pathToFileURL } from 'url';
const ROOT = resolve(import.meta.dirname, '..'); const ROOT = resolve(import.meta.dirname, '..');
const PKG_PATH = resolve(ROOT, 'package.json');
const RELEASE_DIR = resolve(ROOT, 'release'); const RELEASE_DIR = resolve(ROOT, 'release');
const PRODUCT_NAME = 'Multi-Hoster-Upload'; const PRODUCT_NAME = 'Multi-Hoster-Upload';
// --- CLI args --- // --- CLI args ---
export function parseReleaseArgs(args) { const args = process.argv.slice(2);
const version = Array.isArray(args) ? args[0] : ''; const dryRun = args.includes('--dry-run');
if (!/^\d+\.\d+\.\d+$/.test(version || '')) { const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a));
throw new Error('Usage: node scripts/release_gitea.mjs <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]'); const notes = args.filter(a => a !== version && a !== '--dry-run').join(' ') || '';
}
const transportTagIndex = args.indexOf('--transport-tag'); if (!version) {
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : ''; console.error('Usage: node scripts/release_gitea.mjs <version> [release notes] [--dry-run]');
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) { console.error('Example: node scripts/release_gitea.mjs 1.0.1 "Bugfix release"');
throw new Error('--transport-tag must match vX.Y.Z'); process.exit(1);
}
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]);
const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ');
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
} }
export function createReleasePlan(options) { const tag = `v${version}`;
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`;
const portableName = `${PRODUCT_NAME} ${options.version}.exe`;
return {
...options,
tag: options.transportTag,
releaseTitle,
releaseBody: options.notes || releaseTitle,
setupName,
portableName,
expectedArtifacts: [setupName, portableName, 'latest.yml'],
blockmapName: `${setupName}.blockmap`
};
}
export function resolveExistingReleaseId(plan, release) {
const existingTitle = typeof release?.name === 'string' ? release.name : '';
if (existingTitle !== plan.releaseTitle) {
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
}
return release.id;
}
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
}
let dryRun = false;
// --- Helpers --- // --- Helpers ---
function run(cmd, opts = {}) { function run(cmd, opts = {}) {
@ -143,11 +109,8 @@ async function uploadAsset(releaseId, filePath, token) {
} }
// --- Main --- // --- Main ---
async function main(args = process.argv.slice(2)) { async function main() {
const plan = createReleasePlan(parseReleaseArgs(args)); console.log(`\nReleasing ${PRODUCT_NAME} ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
const { version, tag } = plan;
dryRun = plan.dryRun;
console.log(`\nReleasing ${plan.releaseTitle} via ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
// 1. Resolve remote // 1. Resolve remote
const remote = resolveGiteaRemote(); const remote = resolveGiteaRemote();
@ -175,19 +138,18 @@ async function main(args = process.argv.slice(2)) {
if (!recoveryMode) { if (!recoveryMode) {
// 4. Update package.json version // 4. Update package.json version
run(`npm version ${version} --no-git-tag-version --allow-same-version`); const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
console.log(`Updated package.json and package-lock.json -> ${version}`); pkg.version = version;
if (!dryRun) writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
console.log(`Updated package.json -> ${version}`);
// 5. Build // 5. Build
console.log('\nBuilding...'); console.log('\nBuilding...');
run('npm run release:win', { stdio: 'inherit' }); run('npm run release:win', { stdio: 'inherit' });
// 6. Git commit + tag + push // 6. Git commit + tag + push
const versionStatus = run('git status --porcelain -- package.json package-lock.json', { allowDry: true }); run('git add package.json');
if (versionStatus) { run(`git commit -m "release: ${tag}"`);
run('git add package.json package-lock.json');
run(`git commit -m "release: ${tag}"`);
}
run(`git tag ${tag}`); run(`git tag ${tag}`);
run(`git push ${remote.name} HEAD`); run(`git push ${remote.name} HEAD`);
run(`git push ${remote.name} ${tag}`); run(`git push ${remote.name} ${tag}`);
@ -195,7 +157,8 @@ async function main(args = process.argv.slice(2)) {
// 6b. Regenerate latest.yml to ensure correct SHA-512 // 6b. Regenerate latest.yml to ensure correct SHA-512
{ {
const setupPath = resolve(RELEASE_DIR, plan.setupName); const setupName = `${PRODUCT_NAME} Setup ${version}.exe`;
const setupPath = resolve(RELEASE_DIR, setupName);
if (existsSync(setupPath)) { if (existsSync(setupPath)) {
const sha = await new Promise((res, rej) => { const sha = await new Promise((res, rej) => {
const h = createHash('sha512'); const h = createHash('sha512');
@ -205,14 +168,18 @@ async function main(args = process.argv.slice(2)) {
s.on('error', rej); s.on('error', rej);
}); });
const size = statSync(setupPath).size; const size = statSync(setupPath).size;
const yml = renderLatestYml(plan, sha, size); const yml = `version: ${version}\nfiles:\n - url: ${setupName}\n sha512: ${sha}\n size: ${size}\npath: ${setupName}\nsha512: ${sha}\nreleaseDate: '${new Date().toISOString()}'\n`;
writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8'); writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8');
console.log('Regenerated latest.yml with correct SHA-512'); console.log('Regenerated latest.yml with correct SHA-512');
} }
} }
// 7. Verify artifacts // 7. Verify artifacts
const expectedArtifacts = plan.expectedArtifacts; const expectedArtifacts = [
`${PRODUCT_NAME} Setup ${version}.exe`,
`${PRODUCT_NAME} ${version}.exe`,
'latest.yml'
];
for (const name of expectedArtifacts) { for (const name of expectedArtifacts) {
const p = resolve(RELEASE_DIR, name); const p = resolve(RELEASE_DIR, name);
@ -223,7 +190,7 @@ async function main(args = process.argv.slice(2)) {
} }
// Also check for blockmap // Also check for blockmap
const blockmapName = plan.blockmapName; const blockmapName = `${PRODUCT_NAME} Setup ${version}.exe.blockmap`;
const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName)); const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName));
console.log('\nArtifacts verified.'); console.log('\nArtifacts verified.');
@ -236,19 +203,20 @@ async function main(args = process.argv.slice(2)) {
} }
// 9. Create release // 9. Create release
const releaseBody = notes || `${PRODUCT_NAME} ${tag}`;
let releaseId; let releaseId;
const { status: createStatus, data: createData } = await giteaApi( const { status: createStatus, data: createData } = await giteaApi(
'POST', 'POST',
`/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`, `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`,
token, token,
{ tag_name: tag, name: plan.releaseTitle, body: plan.releaseBody } { tag_name: tag, name: `${PRODUCT_NAME} ${tag}`, body: releaseBody }
); );
if (createStatus === 409 || createStatus === 422) { if (createStatus === 409 || createStatus === 422) {
// Release already exists, find it // Release already exists, find it
const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token); const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token);
releaseId = resolveExistingReleaseId(plan, releases); releaseId = releases.id;
console.log(`Release already exists (id: ${releaseId})`); console.log(`Release already exists (id: ${releaseId})`);
} else { } else {
releaseId = createData.id; releaseId = createData.id;
@ -266,10 +234,7 @@ async function main(args = process.argv.slice(2)) {
console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`); console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`);
} }
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; main().catch(err => {
if (entryPoint === import.meta.url) { console.error('\nRelease failed:', err.message);
main().catch(err => { process.exit(1);
console.error('\nRelease failed:', err.message); });
process.exit(1);
});
}

View File

@ -1,11 +1,5 @@
# Lessons # Lessons
## 2026-08-07 — Einen kleinen Release nicht durch redundante Gates aufblasen
**Symptom:** Die angefragten Änderungen waren implementiert und getestet, trotzdem lief die Arbeit durch wiederholte Status-, Review- und Harness-Schleifen übermäßig lange weiter.
**Root cause:** Pflichtsicherheit, bereits belegte Wiederholungsprüfungen und optionale Zusatzdiagnostik wurden nicht hart getrennt. Technische Harnessfehler führten zu weiteren Schleifen, obwohl Produktcode und Kernlauf bereits grün waren.
**Regel:** Nach grüner Implementierung genau eine risikogerechte Pflichtkette fahren: Tests, Build, Secret-Gate, realer Lauf, Positivliste, Veröffentlichung, Redownload. Bereits belegte Gates nicht wiederholen und optionale Diagnose sofort streichen, sobald sie die Auslieferung verzögert.
**Wie anwenden:** Vor jedem zusätzlichen Check benennen, welche noch offene Release-Invariante er beweist. Beweist er keine neue Pflichtinvariante, wird er nicht ausgeführt.
## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`) ## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`)
**Symptom:** Drei Builds lang jagte ich Read-Bursts (highWaterMark, threadpool), während der WAHRE Treiber **Symptom:** Drei Builds lang jagte ich Read-Bursts (highWaterMark, threadpool), während der WAHRE Treiber
eine 38,5-MB-electron-config.json war, die 137×/73s geklont/geparst/serialisiert wurde (~47% Main-Thread). eine 38,5-MB-electron-config.json war, die 137×/73s geklont/geparst/serialisiert wurde (~47% Main-Thread).

View File

@ -1,32 +1,3 @@
# v3.3.108 — session log filename: 6-digit uniqueness suffix
User: append a generated 6-digit number to the session log filename, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute
collision risk on a fast close/reopen.
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged.
- main.js stamps SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random()*900000))).
- stripModeStampFromFileName newSessionRe gains an optional `(?:-\d+)?` so the suffix strips back to the base.
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass.
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json,
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit).
---
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour-minute, no seconds/pid). lib/log-mode.js:
- formatSessionStamp(date) now returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}` (pid arg dropped; main.js
still passes process.pid, harmlessly ignored).
- resolveLogFileName session branch returns `${sid}${ext}` (the stamp is the full app-defined stem, baseName
ignored — single/daily still use baseName 'fileuploader').
- stripModeStampFromFileName recognizes the new format (^DD-MM-YYYY-mdu-session-HH-MM(.ext)$) and resets to the
default 'fileuploader' base (the new format embeds no base); the old daily + old-session strip regexes stay
for backward-compat with any persisted old paths. The compounding round-trip stays idempotent.
Tests updated (formatSessionStamp, resolveLogFileName session, strip new-format, idempotency). 410 pass.
---
# v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename # v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename
Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the

View File

@ -57,23 +57,13 @@ test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
); );
}); });
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => { test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => {
assert.equal( assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }), resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }),
'26-05-2026-mdu-session-22-44.log' 'fileuploader-session-2026-05-28_22-44-52-12345.log'
); );
}); });
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
const { formatSessionStamp } = require('../lib/log-mode');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
});
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
});
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => { test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
assert.equal( assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }), resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
@ -112,17 +102,12 @@ test('stripModeStampFromFileName: strips a session-stamp suffix (with and withou
); );
}); });
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
});
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => { test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
// This is the exact bug shape: persist the resolved path, then on next call // This is the exact bug shape: persist the resolved path, then on next call
// re-resolve from the saved base — must produce the same file, not a doubled // re-resolve from the saved base — must produce the same file, not a doubled
// session-stamped one. The fix is the strip; this test guards against // session-stamped one. The fix is the strip; this test guards against
// regressing _persistFallbackLogPath into the 3.3.35 bug. // regressing _persistFallbackLogPath into the 3.3.35 bug.
const sessionId = '03-06-2026-mdu-session-18-16'; const sessionId = '2026-06-03_18-16-20-8132';
const dailyDate = new Date(2026, 5, 3); const dailyDate = new Date(2026, 5, 3);
for (const mode of ['daily', 'session']) { for (const mode of ['daily', 'session']) {
const date = mode === 'daily' ? dailyDate : new Date(); const date = mode === 'daily' ? dailyDate : new Date();
@ -144,7 +129,12 @@ test('formatDateStamp: zero-pads month and day', () => {
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31'); assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
}); });
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => { test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => {
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09'); const d = new Date(2026, 4, 28, 7, 9, 5);
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44'); assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345');
});
test('formatSessionStamp: omits the pid suffix when none provided', () => {
const d = new Date(2026, 4, 28, 22, 44, 52);
assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52');
}); });

View File

@ -1,68 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer');
class TestBrowserWindow extends EventEmitter {
constructor(options) {
super();
this.options = options;
this.showCalls = 0;
this.startupEvents = [];
this.loadError = new Error('renderer load failed');
}
once(eventName, listener) {
this.startupEvents.push(`listen:${eventName}`);
return super.once(eventName, listener);
}
show() {
this.showCalls++;
}
loadFile(target) {
this.startupEvents.push(`load:${target}`);
return Promise.reject(this.loadError);
}
}
test('configureStartupRenderer disables hardware acceleration', () => {
let calls = 0;
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } });
assert.equal(calls, 1);
});
test('createStartupWindow forces the main window to start hidden', () => {
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
assert.equal(startup.window.options.width, 1100);
assert.equal(startup.window.options.show, false);
});
test('startup load registers visibility before navigation and shows only once', async () => {
const startup = createStartupWindow(TestBrowserWindow, {});
const loading = startup.load('renderer/index.html', () => {});
assert.deepEqual(startup.window.startupEvents, [
'listen:ready-to-show',
'load:renderer/index.html'
]);
startup.window.emit('ready-to-show');
startup.window.emit('ready-to-show');
await loading;
assert.equal(startup.window.showCalls, 1);
});
test('startup load forwards a rejected navigation to the error handler', async () => {
const startup = createStartupWindow(TestBrowserWindow, {});
let handledError;
await startup.load('renderer/index.html', (err) => {
handledError = err;
});
assert.equal(handledError, startup.window.loadError);
});

View File

@ -48,26 +48,20 @@ setTimeout(async () => {
console.log('\\n=== Upload View ==='); console.log('\\n=== Upload View ===');
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length'); const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
check('4 tabs exist', tabCount === 4); check('3 tabs exist', tabCount === 3);
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
const tabSemantics = await wc.executeJavaScript('document.querySelector(".tab-bar")?.getAttribute("role") + "|" + document.querySelector(".tab.active")?.getAttribute("aria-selected")');
check('Tab navigation exposes active state', tabSemantics === 'tablist|true');
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()'); const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
check('Upload tab active by default', activeTab === 'Upload'); check('Upload tab active by default', activeTab === 'Upload');
const tabStops = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.tabIndex).join("|")');
check('Tab navigation exposes one keyboard stop', tabStops === '0|-1|-1|-1');
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"'); const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
check('Drop zone visible (no files)', dropVisible); check('Drop zone visible (no files)', dropVisible);
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display'); const queueHidden = await wc.executeJavaScript('document.getElementById("queueContainer")?.style.display');
check('Queue hidden (no files)', queueHidden === 'none'); check('Queue hidden (no files)', queueHidden === 'none');
const chips = await wc.executeJavaScript('document.querySelectorAll(".hoster-chip").length');
check('4 hoster chips', chips === 4);
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled'); const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
check('Start button disabled initially', startDisabled === true); check('Start button disabled initially', startDisabled === true);
@ -77,90 +71,9 @@ setTimeout(async () => {
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent'); const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Version label present', version && version.startsWith('v')); check('Version label present', version && version.startsWith('v'));
const localizedQueueHeaders = await wc.executeJavaScript('[...document.querySelectorAll("#queueTable thead th")].map(el => el.childNodes[0]?.textContent.trim()).join("|")');
check('Upload table labels are consistently German', localizedQueueHeaders === 'Dateiname|Hochgeladen / Größe|Hoster|Status|Zeit|Rest|Geschwindigkeit|Fortschritt');
const localizedRecentTabs = await wc.executeJavaScript('[...document.querySelectorAll(".recent-tab")].map(el => el.textContent.trim()).join("|")');
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedStatusbar = await wc.executeJavaScript('["sbConnections", "sbQueueCount", "sbRemainingCount", "sbInProgressCount", "sbDoneCount", "sbErrorCount"].map(id => document.getElementById(id)?.textContent).join("|")');
check('Statusbar labels are consistently German', localizedStatusbar === 'Verbindungen 0|Gesamt 0|Verbleibend 0|Läuft 0|Fertig 0|Fehler 0');
const toolbarLabels = await wc.executeJavaScript('[...document.querySelectorAll("#queueCommandBar .toolbar-btn")].map(el => el.getAttribute("aria-label")).join("|")');
check('Upload toolbar actions have German accessible names', toolbarLabels === 'Alle Uploads starten|Ausgewählte Uploads starten|Ausgewählte Datei erneut hochladen|Ausgewählten Upload abbrechen|Aktive Uploads beenden und stoppen|Alle Uploads abbrechen|Ganz nach oben|Nach oben|Nach unten|Ganz nach unten');
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display'); const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
check('Context menu hidden', ctxHidden === 'none'); check('Context menu hidden', ctxHidden === 'none');
console.log('\\n=== Accounts View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'accounts\\']").click()');
await new Promise(r => setTimeout(r, 300));
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
check('Accounts tab active', accountsActive);
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
check('Account manager list structure rendered', accountListValid);
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
check('Add account button enabled', addAccountEnabled);
const emptyAccountAction = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]")?.textContent?.trim()');
check('Empty account state offers direct action', emptyAccountAction === 'Ersten Account hinzufügen');
await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click()');
await new Promise(r => setTimeout(r, 200));
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Account modal opens', accountModalVisible === 'flex');
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
const accountModalSemantics = await wc.executeJavaScript('document.querySelector("#accountModal .modal-card")?.getAttribute("role") + "|" + document.querySelector("#accountModal .modal-card")?.getAttribute("aria-modal")');
check('Account modal exposes dialog semantics', accountModalSemantics === 'dialog|true');
const accountFormLabels = await wc.executeJavaScript('["accountHosterSelect", "accField_label", "accField_username", "accField_password"].every(id => document.getElementById(id)?.labels?.length === 1)');
check('Account form controls have linked labels', accountFormLabels);
const accountStatusLive = await wc.executeJavaScript('document.getElementById("accountModalStatus")?.getAttribute("aria-live")');
check('Account validation status is announced', accountStatusLive === 'polite');
const initialAccountFocus = await wc.executeJavaScript('document.activeElement?.id');
check('Account modal focuses first control', initialAccountFocus === 'accountHosterSelect');
const trappedAccountFocus = await wc.executeJavaScript('document.getElementById("saveAccountBtn").focus(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); document.activeElement?.id');
check('Account modal keeps keyboard focus inside', trappedAccountFocus === 'closeAccountModalBtn');
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
check('7 hoster authentication options exist', authOptionCount === 7);
const hosterCount = await wc.executeJavaScript('[...new Set([...document.querySelectorAll("#accountHosterSelect option")].map(el => el.value.split(":")[0]))].length');
check('5 hosters exist', hosterCount === 5);
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
check('Credential inputs rendered', credentialInputs === 2);
const passwordToggleState = await wc.executeJavaScript('document.querySelector("#accountCredsFields .toggle-vis").click(); document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-label") + "|" + document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-pressed")');
check('Password visibility action exposes its state', passwordToggleState === 'Passwort verbergen|true');
await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))');
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Escape closes account modal', accountModalHidden === 'none');
const restoredAccountFocus = await wc.executeJavaScript('document.activeElement?.hasAttribute("data-account-empty-add")');
check('Account modal restores trigger focus', restoredAccountFocus === true);
const fallbackAccountFocus = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click(); document.querySelector("[data-account-empty-add]").remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); document.activeElement?.id');
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
console.log('\\n=== Settings View ==='); console.log('\\n=== Settings View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()'); await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
@ -169,14 +82,23 @@ setTimeout(async () => {
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")'); const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
check('Settings tab active', settingsActive); check('Settings tab active', settingsActive);
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length'); const panels = await wc.executeJavaScript('document.querySelectorAll(".hoster-settings-panel").length');
check('6 settings subtabs exist', settingsSubtabs === 6); check('4 hoster panels', panels === 4);
const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent'); const hsInputCount = await wc.executeJavaScript('document.querySelectorAll(".hs-input").length');
check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts')); check('24 per-hoster inputs (6x4)', hsInputCount === 24);
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value'); await wc.executeJavaScript('document.querySelector(".hoster-panel-header").click()');
check('Global parallel uploads default 0', parallel === '0'); await new Promise(r => setTimeout(r, 200));
const panelBody = await wc.executeJavaScript('document.querySelector(".hoster-panel-body").style.display');
check('Panel expands on click', panelBody !== 'none');
const retries = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'retries\\']")?.value');
check('Retries default 3', retries === '3');
const parallel = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'parallelCount\\']")?.value');
check('ParallelCount default 2', parallel === '2');
// Test save // Test save
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()'); await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
@ -215,7 +137,8 @@ setTimeout(async () => {
results.forEach(r => console.log(r)); results.forEach(r => console.log(r));
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed); console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
app.exit(failed > 0 ? 1 : 0); if (failed > 0) process.exitCode = 1;
app.quit();
}, 5000); }, 5000);
`; `;
@ -243,7 +166,9 @@ try {
.join('\n'); .join('\n');
if (filtered.trim()) console.error(filtered); if (filtered.trim()) console.error(filtered);
} }
process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1; if (err.status && err.status !== 0 && !err.killed) {
process.exit(err.status);
}
} finally { } finally {
try { fs.unlinkSync(injectPath); } catch {} try { fs.unlinkSync(injectPath); } catch {}
} }

View File

@ -1,98 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion } = require('../lib/updater');
test('bridge title resolves product version instead of transport tag', () => {
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
assert.equal(isNewer('2.0.1', '2.0.1'), false);
assert.equal(isNewer('2.0.2', '2.0.1'), true);
});
test('release CLI rejects a malformed transport tag before release work', () => {
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
const result = spawnSync(process.execPath, [script, '2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run'], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8'
});
assert.equal(result.status, 1);
assert.match(result.stderr, /--transport-tag must match vX\.Y\.Z/);
assert.doesNotMatch(result.stdout, /npm run release:win/);
});
test('release plan keeps product artifacts separate from the transport tag', () => {
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
const moduleUrl = pathToFileURL(script).href;
const source = `
import { createReleasePlan, parseReleaseArgs, renderLatestYml } from ${JSON.stringify(moduleUrl)};
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge', 'notes']));
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
process.stdout.write(JSON.stringify({
version: plan.version,
transportTag: plan.transportTag,
releaseTitle: plan.releaseTitle,
releaseBody: plan.releaseBody,
expectedArtifacts: plan.expectedArtifacts,
latestYml
}));
`;
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8'
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
version: '2.0.1',
transportTag: 'v3.3.109',
releaseTitle: 'Multi-Hoster-Upload v2.0.1',
releaseBody: 'Bridge notes',
expectedArtifacts: [
'Multi-Hoster-Upload Setup 2.0.1.exe',
'Multi-Hoster-Upload 2.0.1.exe',
'latest.yml'
],
latestYml: "version: 2.0.1\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.1.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.1.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
});
});
test('compatible existing release preserves the recovery id', async () => {
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v2.0.1',
body: 'Bridge notes',
draft: false,
prerelease: false,
assets: []
};
assert.equal(resolveExistingReleaseId(plan, release), 81);
});
test('incompatible existing release title fails closed', async () => {
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v3.3.109',
body: 'Old transport release',
draft: false,
prerelease: false,
assets: []
};
assert.throws(
() => resolveExistingReleaseId(plan, release),
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
);
});

View File

@ -1,208 +1,195 @@
// Pure unit tests for the validate-credentials shape contract — does NOT spin
// up Electron or the real per-hoster checkers. Those need network. We verify
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
// checkers consume) plus the snapshot-key/invalidation invariants that the
// renderer relies on to enforce "validated creds only".
//
// The three assertions the advisor called out as the regression guard for the
// user's "mehrfach angelegt" complaint:
// (a) failed validation persists nothing to config.hosters
// (b) a second "Anlegen" click with the guard set persists exactly one entry
// (c) OTP-required path persists nothing
// are exercised at the state-machine level by simulating the renderer's logic
// (re-implemented here as pure functions for testability — the real ones live
// in renderer/app.js which can't run under node:test).
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert');
const {
createAccountSubmitter,
getAccountSubmitLabel,
submitValidatedAccount
} = require('../renderer/account-submit');
test('account submit labels stay exact for add, edit, and OTP retries', () => { // ---- Re-implementations of the renderer's pure helpers ----
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen'); // These mirror the production code exactly so the tests serve as both a guard
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern'); // and executable spec for what saveAccount() must do.
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
});
test('close and reopen cannot start a second save while the first save is pending', async () => { function credsSnapshotKey(authType, creds) {
const submitter = createAccountSubmitter(); if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
let current = true; return `api:${creds.apiKey || ''}`;
let commits = 0;
let applies = 0;
let saveStarted;
let finishSave;
const started = new Promise(resolve => { saveStarted = resolve; });
const saving = new Promise(resolve => { finishSave = resolve; });
const first = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
saveStarted();
await saving;
return { accountId: 'first' };
},
afterCommit: async () => {
applies++;
},
isCurrent: () => current
});
await started;
current = false;
const second = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(second, null);
assert.equal(submitter.isBusy(), true);
finishSave();
const result = await first;
assert.equal(result.status, 'stale');
assert.equal(result.committed, true);
assert.equal(commits, 1);
assert.equal(applies, 1);
assert.equal(submitter.isBusy(), false);
});
test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
const expected = new Error('render failed');
let saves = 0;
let applies = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
saves++;
return { accountId: 'saved-account' };
},
afterCommit: async () => {
applies++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.value.accountId, 'saved-account');
assert.equal(result.postCommitError, expected);
assert.equal(saves, 1);
assert.equal(applies, 1);
});
test('ok validates and commits exactly once in one submission', async () => {
let validations = 0;
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
validations++;
return { status: 'ok', message: 'Login erfolgreich' };
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(validations, 1);
assert.equal(commits, 1);
});
test('warn validates and commits exactly once in one submission', async () => {
let commits = 0;
const validation = { status: 'warn', message: 'Login mit Warnung' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async (received) => {
commits++;
assert.equal(received, validation);
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.validation, validation);
assert.equal(commits, 1);
});
for (const status of ['error', 'skipped']) {
test(`${status} rejects without committing`, async () => {
let commits = 0;
const validation = { status, message: `${status} result` };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'rejected');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
} }
test('validate throw returns error without committing', async () => { function buildEphemeralHosterConfig(payload) {
const expected = new Error('validation failed'); return {
let commits = 0; username: payload.username || '',
const result = await submitValidatedAccount({ password: payload.password || '',
validate: async () => { apiKey: payload.apiKey || '',
throw expected; enabled: true
}, };
commit: async () => { }
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'error'); // State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
assert.equal(result.error, expected); function makeStateMachine({ validateImpl, persistImpl }) {
assert.equal(commits, 0); let busy = false;
let validated = null; // { hosterName, authType, snapshot, status }
const log = []; // log of every persist call, for assertions
async function click(ctx, creds, otp = '') {
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
const snapshot = credsSnapshotKey(ctx.authType, creds);
// STEP 2: commit if validated matches.
if (validated &&
validated.hosterName === ctx.hosterName &&
validated.authType === ctx.authType &&
validated.snapshot === snapshot) {
busy = true;
try {
await persistImpl(ctx, creds);
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
} finally { busy = false; }
return;
}
// STEP 1: ephemeral validate.
busy = true;
let row;
try {
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
} finally { busy = false; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
log.push({ type: 'validated', status: row.status });
return;
}
if (row && row.status === 'otp_required') {
log.push({ type: 'otp-required' });
return;
}
log.push({ type: 'validation-failed', message: row && row.message });
}
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
return { click, editField, log: () => log.slice(), getValidated: () => validated };
}
// ---- Tests ----
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
const persistCalls = [];
const sm = makeStateMachine({
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
assert.equal(sm.getValidated(), null);
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
}); });
test('otp_required returns challenge without committing', async () => { test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
let commits = 0; const persistCalls = [];
const validation = { status: 'otp_required', message: 'OTP gesendet' }; let validateCount = 0;
const result = await submitValidatedAccount({ const sm = makeStateMachine({
validate: async () => validation, validateImpl: async () => { validateCount++; return { status: 'ok' }; },
commit: async () => { persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
commits++;
},
isCurrent: () => true
}); });
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
// Click 1 = validate → green.
await sm.click(ctx, creds);
// Click 2 = commit (same creds, validated snapshot matches).
await sm.click(ctx, creds);
// Click 3 = guard prevents a second commit because after persistImpl the
// state-machine in real code closes the modal. In this simulator the
// validated snapshot is still set — but a real double-click WHILE persistImpl
// is in flight would be caught by busy. Simulate that:
const sm2 = makeStateMachine({
validateImpl: async () => ({ status: 'ok' }),
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
});
await sm2.click(ctx, creds); // validate
const p1 = sm2.click(ctx, creds); // start commit
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
await Promise.all([p1, p2]);
assert.equal(result.status, 'otp_required'); assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(result.validation, validation); assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
assert.equal(commits, 0); // The racing click MUST have been ignored by the busy guard.
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
}); });
test('stale submission is rejected immediately before commit', async () => { test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
let current = true; const persistCalls = [];
let commits = 0; let calls = 0;
const validation = { status: 'ok' }; const sm = makeStateMachine({
const result = await submitValidatedAccount({ validateImpl: async (payload) => {
validate: async () => { calls++;
current = false; if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
return validation; if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
}, },
commit: async () => { persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
commits++;
},
isCurrent: () => current
}); });
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
assert.equal(result.status, 'stale'); const creds = { username: 'u', password: 'p' };
assert.equal(result.validation, validation); await sm.click(ctx, creds, ''); // first click → otp_required
assert.equal(commits, 0); await sm.click(ctx, creds, '123456'); // retry with otp → ok
await sm.click(ctx, creds); // final click → commit
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
assert.deepEqual(
sm.log().map(e => e.type),
['otp-required', 'validated', 'persisted']
);
}); });
test('save failure returns error after one commit attempt', async () => { test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
const expected = new Error('save failed'); const persistCalls = [];
let commits = 0; let validateCount = 0;
const result = await submitValidatedAccount({ const sm = makeStateMachine({
validate: async () => ({ status: 'ok' }), validateImpl: async () => { validateCount++; return { status: 'ok' }; },
commit: async () => { persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
commits++;
throw expected;
},
isCurrent: () => true
}); });
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
assert.equal(result.status, 'error'); await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
assert.equal(result.error, expected); sm.editField(); // user edits cred field → snapshot dropped
assert.equal(commits, 1); await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
});
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential.
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
});
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
// This guards the validate-credentials IPC contract from drifting.
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p');
assert.equal(cfg.apiKey, '');
assert.equal(cfg.enabled, true);
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
assert.equal(cfg2.apiKey, 'K');
assert.equal(cfg2.username, '');
}); });