Compare commits

...

14 Commits

Author SHA1 Message Date
Administrator
ae8d82e42b docs(lessons): keep release verification focused 2026-08-07 18:41:29 +02:00
Administrator
ecdae8c59c fix(deps): close production audit findings
Raise only the direct undici and ws dependency floors to versions that include the published security fixes. The production audit now reports zero vulnerabilities while the complete test and lint gates remain green.
2026-08-07 18:25:44 +02:00
Administrator
c5350b6b60 chore(lint): recognize Node performance global
Declare the runtime-provided performance API as read-only so the full release lint gate validates existing timing instrumentation without altering production behavior.
2026-08-07 18:22:42 +02:00
Administrator
5079be718a test(ui): align smoke gate with account manager
Update the real Electron smoke contract for the four-tab, five-hoster account-manager UI and make Electron failures propagate through the outer Node process. This prevents stale selector failures and child-process timeouts from being reported as successful release gates.
2026-08-07 18:19:57 +02:00
Administrator
cad270dd2c fix(release): reject incompatible bridge recovery
Fail closed when an existing transport-tag release has a title that does not match the product version, preventing the new updater from re-entering the bridge loop.
2026-08-07 18:08:50 +02:00
Administrator
c7fa422d9b feat(updater): bridge 3.3.108 clients to v2.0.1 2026-08-07 18:02:16 +02:00
Administrator
95bff2581b fix(accounts): preserve single-flight save semantics
Keep the account submit lock independent of modal sessions, use the exact button labels, and separate persistence from renderer application so a post-save UI failure cannot invite a duplicate save.
2026-08-07 17:47:40 +02:00
Administrator
0652edf69f feat(accounts): validate and save in one action
Keep account validation and persistence inside one guarded submission. Recheck modal identity before committing, persist a copied hoster candidate, and publish renderer state only after saveConfig succeeds.
2026-08-07 17:38:43 +02:00
Administrator
b075961802 test(startup): cover window initialization lifecycle
Extract the startup window boundary so hidden creation, ready-to-show ordering, single display, and load rejection handling are verified without Electron UI timing.
2026-08-07 17:29:16 +02:00
Administrator
cfeb01e82a fix(startup): make renderer initialization deterministic 2026-08-07 17:20:09 +02:00
Administrator
b11b28e417 docs: define v2.0.1 account and startup work 2026-08-07 17:12:58 +02:00
Administrator
c58d9203bc docs(tasks): record v3.3.108 session-log 6-digit suffix release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:14:23 +02:00
Administrator
f0006f8003 feat(logs): append a 6-digit random suffix to session log filenames (v3.3.108)
Session-mode log files now end in a generated 6-digit number, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107
reintroduced same-minute collision risk on fast close/reopen; the random
suffix restores per-launch uniqueness without leaking the process id.

- formatSessionStamp(date, rand) appends -<rand> when supplied
- main.js stamps SESSION_ID with a 6-digit Math.random value
- stripModeStampFromFileName tolerates the optional -NNNNNN suffix
- tests cover stamp-with-rand and strip-with-suffix; 411 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:52:13 +02:00
Administrator
66ae240794 feat(logs): session log filename → DD-MM-YYYY-mdu-session-HH-MM (v3.3.107)
Per user request, the per-session log file is renamed from
fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour and minute only, no seconds or pid).

lib/log-mode.js:
- formatSessionStamp(date) returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}`
  (the pid argument is dropped; main.js still passes process.pid, harmlessly
  ignored). Same-minute restarts now share a session file, which is the intended
  human-readable trade.
- the session branch of resolveLogFileName returns `${sessionId}${ext}` — the stamp
  is the full app-defined stem and baseName is intentionally ignored for session
  mode (single/daily still use the 'fileuploader' base).
- stripModeStampFromFileName recognizes the new format and resets to the default
  'fileuploader' base (the new stem embeds no base, so the configured base is not
  recoverable from it); the existing daily and old-session strip regexes are kept
  for backward-compat with any persisted old paths, and the persist/re-resolve
  round-trip stays idempotent (no compounding stamps).

Tests updated for the new format (formatSessionStamp, session resolveLogFileName,
the new-format strip, and the idempotency regression). 410 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:56:43 +02:00
20 changed files with 1037 additions and 466 deletions

View File

@ -0,0 +1,208 @@
# 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

@ -0,0 +1,25 @@
# 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,6 +55,7 @@ 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: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log // - "session" → per-launch: DD-MM-YYYY-mdu-session-HH-MM-NNNNNN.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,13 +38,11 @@
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`; return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
} }
function formatSessionStamp(date, pid) { function formatSessionStamp(date, rand) {
const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`; const d = `${_two(date.getDate())}-${_two(date.getMonth() + 1)}-${date.getFullYear()}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`; const t = `${_two(date.getHours())}-${_two(date.getMinutes())}`;
// PID disambiguates a same-second close→reopen — a human can't but two const r = (rand !== undefined && rand !== null && String(rand).trim()) ? `-${String(rand).trim()}` : '';
// automated runs might. Cheap belt to a suspenders-not-required problem. return `${d}-mdu-session-${t}${r}`;
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
return `${d}_${t}${pidStr}`;
} }
/** /**
@ -67,9 +65,10 @@
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 // session — the stamp is the full app-defined stem (DD-MM-YYYY-mdu-session-HH-MM),
// independent of baseName.
const sid = a.sessionId && String(a.sessionId).trim(); const sid = a.sessionId && String(a.sessionId).trim();
if (sid) return `${base}-session-${sid}${ext}`; if (sid) return `${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}`;
@ -85,6 +84,9 @@
*/ */
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.

19
lib/startup-renderer.js Normal file
View File

@ -0,0 +1,19 @@
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,6 +37,14 @@ 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)
@ -90,11 +98,12 @@ async function checkForUpdate() {
} }
const release = releases[0]; const release = releases[0];
const remoteVersion = release.tag_name || release.name || ''; const remoteVersion = resolveReleaseVersion(release);
const transportTag = release.tag_name || '';
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
if (!isNewer(remoteVersion, currentVersion)) { if (!isNewer(remoteVersion, currentVersion)) {
cachedCheck = { available: false, currentVersion, remoteVersion }; cachedCheck = { available: false, currentVersion, remoteVersion, transportTag };
cachedCheckTs = Date.now(); cachedCheckTs = Date.now();
return cachedCheck; return cachedCheck;
} }
@ -109,7 +118,8 @@ async function checkForUpdate() {
cachedCheck = { cachedCheck = {
available: true, available: true,
currentVersion, currentVersion,
remoteVersion: remoteVersion.replace(/^v/i, ''), remoteVersion,
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,
@ -280,4 +290,4 @@ function abortUpdate() {
} }
} }
module.exports = { checkForUpdate, installUpdate, abortUpdate }; module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion };

29
main.js
View File

@ -1,6 +1,8 @@
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');
@ -27,16 +29,6 @@ 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;
@ -563,10 +555,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. PID is appended as // main process, so a new SESSION_ID, so a new session file. A 6-digit random is
// a cheap hedge against same-second restart collisions. // appended as a cheap hedge against same-minute 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(), process.pid); const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000)));
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;
@ -1230,7 +1222,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
} }
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ const startupWindow = createStartupWindow(BrowserWindow, {
width: 1100, width: 1100,
height: 750, height: 750,
minWidth: 800, minWidth: 800,
@ -1243,6 +1235,7 @@ 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);
@ -1288,12 +1281,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 {}
}
}); });
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
_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": "3.3.16", "version": "2.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.16", "version": "2.0.1",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.16.0", "undici": "^7.29.0",
"ws": "^8.19.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.3.0",
@ -4734,9 +4734,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.25.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"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.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"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": "3.3.106", "version": "2.0.1",
"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.16.0", "undici": "^7.29.0",
"ws": "^8.19.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.3.0",

View File

@ -0,0 +1,73 @@
(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

@ -4260,8 +4260,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
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');
@ -4281,17 +4279,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.`; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`;
hosterRow.style.display = 'none'; hosterRow.style.display = 'none';
saveBtn.textContent = 'Prüfen'; saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true });
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. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.';
hosterRow.style.display = 'flex'; hosterRow.style.display = 'flex';
saveBtn.textContent = 'Prüfen'; saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false });
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('');
@ -4308,10 +4306,6 @@ function openAccountModal(editAccountId) {
}); });
}); });
// 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';
@ -4321,11 +4315,7 @@ function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
// Cancel any pending auto-close so a stale timer can't close a future modal _resetAccountModalState();
// the user reopens within the auto-close window.
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_validatedCreds = null;
_accountModalBusy = false;
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -4381,66 +4371,54 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label }; return { enabled: !!apiKey, authType: 'api', apiKey, label };
} }
// --- Two-step account-modal state machine --- const _accountSubmitter = window.AccountSubmit.createAccountSubmitter();
// 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', () => { el.addEventListener('input', _invalidateAccountSubmit);
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';
} }
} }
@ -4458,12 +4436,18 @@ 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() {
// SYNCHRONOUS re-entry guard — must come before any await. Without this a if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return;
// 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;
@ -4476,37 +4460,8 @@ 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 = {
@ -4518,94 +4473,100 @@ async function saveAccount() {
otp otp
}; };
let row; const submission = _accountSubmitter.submit({
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 {
row = await window.api.validateCredentials(payload); result = await submission;
} catch (err) { } catch (error) {
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' }; result = { status: 'error', error };
} finally {
if (mySession === _accountModalSession) {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
} }
// Stale response — modal was closed/reopened while we awaited. Drop it. const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
if (mySession !== _accountModalSession) return; if (result.status === 'committed' && current) {
_accountModalCommitLocked = true;
if (row && row.status === 'otp_required') { const validation = result.validation || {};
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.textContent = validation.status === 'warn'
statusEl.className = 'account-modal-status error'; ? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
_showOtpField(); : validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
saveBtn.textContent = 'Mit OTP prüfen';
return;
}
if (row && (row.status === 'ok' || row.status === 'warn')) {
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
statusEl.className = 'account-modal-status ok'; statusEl.className = 'account-modal-status ok';
_hideOtpField(); _hideOtpField();
_validatedCreds = { saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
hosterName: ctx.hosterName, saveBtn.disabled = true;
authType: ctx.authType, if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
snapshot: snapshotKey, _autoCloseTimer = setTimeout(() => {
status: row.status, _autoCloseTimer = null;
message: row.message || '' closeAccountModal();
}; }, 600);
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
return; return;
} }
// error
const msg = (row && row.message) || 'Login fehlgeschlagen'; _syncAccountSubmitButton();
if (!current) return;
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';
_showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
return;
}
const validation = result.validation || {};
const msg = result.status === 'error'
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
: validation.message || 'Login fehlgeschlagen';
statusEl.textContent = msg; statusEl.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} }
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) { function _copyHosterTree(hosters) {
// Persist the validated creds to config.hosters and close the modal. By the const candidate = {};
// time we reach this function the validate-credentials IPC has already for (const [name, accounts] of Object.entries(hosters || {})) {
// returned ok/warn for these exact creds, so we skip a redundant re-check. candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
let accountId; }
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = []; return candidate;
}
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) {
accountId = ctx.accountId; const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId); if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
if (idx >= 0) { candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
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)}`;
config.hosters[ctx.hosterName].push({ id: accountId, ...creds }); candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
} }
await window.api.saveConfig({ hosters: config.hosters }); await window.api.saveConfig({ hosters: candidateHosters });
// Skip the redundant await getConfig() — the in-memory state is the source return { accountId, candidateHosters, isEdit: ctx.isEdit };
// of truth for what we just wrote, decrypted creds didn't change, and the }
// round-trip was the main lag source on add/delete.
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' }; function _applyCommittedAccount(persisted, validation) {
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters;
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
// Targeted updates instead of the 4-panel cascade. For add we need a full if (isEdit) {
// 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() {
@ -5170,6 +5131,7 @@ 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');
@ -5180,15 +5142,6 @@ function setupListeners() {
input.type = input.type === 'password' ? 'text' : 'password'; 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();
}); });

View File

@ -266,7 +266,7 @@
</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">Anlegen &amp; prüfen</button> <button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
</div> </div>
</div> </div>
</div> </div>
@ -421,6 +421,7 @@
<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,27 +1,61 @@
#!/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 { readFileSync, writeFileSync, statSync, createReadStream, existsSync } from 'fs'; import { 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 ---
const args = process.argv.slice(2); export function parseReleaseArgs(args) {
const dryRun = args.includes('--dry-run'); const version = Array.isArray(args) ? args[0] : '';
const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a)); if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
const notes = args.filter(a => a !== version && a !== '--dry-run').join(' ') || ''; throw new Error('Usage: node scripts/release_gitea.mjs <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]');
if (!version) {
console.error('Usage: node scripts/release_gitea.mjs <version> [release notes] [--dry-run]');
console.error('Example: node scripts/release_gitea.mjs 1.0.1 "Bugfix release"');
process.exit(1);
} }
const tag = `v${version}`; const transportTagIndex = args.indexOf('--transport-tag');
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
throw new Error('--transport-tag must match vX.Y.Z');
}
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 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 = {}) {
@ -109,8 +143,11 @@ async function uploadAsset(releaseId, filePath, token) {
} }
// --- Main --- // --- Main ---
async function main() { async function main(args = process.argv.slice(2)) {
console.log(`\nReleasing ${PRODUCT_NAME} ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`); const plan = createReleasePlan(parseReleaseArgs(args));
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();
@ -138,18 +175,19 @@ async function main() {
if (!recoveryMode) { if (!recoveryMode) {
// 4. Update package.json version // 4. Update package.json version
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8')); run(`npm version ${version} --no-git-tag-version --allow-same-version`);
pkg.version = version; console.log(`Updated package.json and package-lock.json -> ${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
run('git add package.json'); const versionStatus = run('git status --porcelain -- package.json package-lock.json', { allowDry: true });
if (versionStatus) {
run('git add package.json package-lock.json');
run(`git commit -m "release: ${tag}"`); 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}`);
@ -157,8 +195,7 @@ async function main() {
// 6b. Regenerate latest.yml to ensure correct SHA-512 // 6b. Regenerate latest.yml to ensure correct SHA-512
{ {
const setupName = `${PRODUCT_NAME} Setup ${version}.exe`; const setupPath = resolve(RELEASE_DIR, plan.setupName);
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');
@ -168,18 +205,14 @@ async function main() {
s.on('error', rej); s.on('error', rej);
}); });
const size = statSync(setupPath).size; const size = statSync(setupPath).size;
const yml = `version: ${version}\nfiles:\n - url: ${setupName}\n sha512: ${sha}\n size: ${size}\npath: ${setupName}\nsha512: ${sha}\nreleaseDate: '${new Date().toISOString()}'\n`; const yml = renderLatestYml(plan, sha, size);
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 = [ const expectedArtifacts = plan.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);
@ -190,7 +223,7 @@ async function main() {
} }
// Also check for blockmap // Also check for blockmap
const blockmapName = `${PRODUCT_NAME} Setup ${version}.exe.blockmap`; const blockmapName = plan.blockmapName;
const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName)); const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName));
console.log('\nArtifacts verified.'); console.log('\nArtifacts verified.');
@ -203,20 +236,19 @@ async function main() {
} }
// 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: `${PRODUCT_NAME} ${tag}`, body: releaseBody } { tag_name: tag, name: plan.releaseTitle, body: plan.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 = releases.id; releaseId = resolveExistingReleaseId(plan, releases);
console.log(`Release already exists (id: ${releaseId})`); console.log(`Release already exists (id: ${releaseId})`);
} else { } else {
releaseId = createData.id; releaseId = createData.id;
@ -234,7 +266,10 @@ async function main() {
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 : '';
if (entryPoint === import.meta.url) {
main().catch(err => { main().catch(err => {
console.error('\nRelease failed:', err.message); console.error('\nRelease failed:', err.message);
process.exit(1); process.exit(1);
}); });
}

View File

@ -1,5 +1,11 @@
# 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,3 +1,32 @@
# 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,13 +57,23 @@ test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
); );
}); });
test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => { test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
assert.equal( assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }), resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
'fileuploader-session-2026-05-28_22-44-52-12345.log' '26-05-2026-mdu-session-22-44.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' }),
@ -102,12 +112,17 @@ 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 = '2026-06-03_18-16-20-8132'; const sessionId = '03-06-2026-mdu-session-18-16';
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();
@ -129,12 +144,7 @@ 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: produces YYYY-MM-DD_HH-MM-SS-pid', () => { test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
const d = new Date(2026, 4, 28, 7, 9, 5); assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345'); assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
});
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

@ -0,0 +1,68 @@
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,7 +48,10 @@ 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('3 tabs exist', tabCount === 3); check('4 tabs exist', tabCount === 4);
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 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');
@ -56,12 +59,9 @@ setTimeout(async () => {
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("queueContainer")?.style.display'); const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.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);
@ -74,6 +74,45 @@ setTimeout(async () => {
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);
await wc.executeJavaScript('document.getElementById("addAccountBtn").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 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);
await wc.executeJavaScript('document.getElementById("cancelAccountModalBtn").click()');
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Account modal closes', accountModalHidden === 'none');
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()');
@ -82,23 +121,14 @@ 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 panels = await wc.executeJavaScript('document.querySelectorAll(".hoster-settings-panel").length'); const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
check('4 hoster panels', panels === 4); check('6 settings subtabs exist', settingsSubtabs === 6);
const hsInputCount = await wc.executeJavaScript('document.querySelectorAll(".hs-input").length'); const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
check('24 per-hoster inputs (6x4)', hsInputCount === 24); check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts'));
await wc.executeJavaScript('document.querySelector(".hoster-panel-header").click()'); const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
await new Promise(r => setTimeout(r, 200)); check('Global parallel uploads default 0', parallel === '0');
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()');
@ -137,8 +167,7 @@ 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);
if (failed > 0) process.exitCode = 1; app.exit(failed > 0 ? 1 : 0);
app.quit();
}, 5000); }, 5000);
`; `;
@ -166,9 +195,7 @@ try {
.join('\n'); .join('\n');
if (filtered.trim()) console.error(filtered); if (filtered.trim()) console.error(filtered);
} }
if (err.status && err.status !== 0 && !err.killed) { process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1;
process.exit(err.status);
}
} finally { } finally {
try { fs.unlinkSync(injectPath); } catch {} try { fs.unlinkSync(injectPath); } catch {}
} }

View File

@ -0,0 +1,98 @@
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,195 +1,208 @@
// 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'); const assert = require('node:assert/strict');
const {
createAccountSubmitter,
getAccountSubmitLabel,
submitValidatedAccount
} = require('../renderer/account-submit');
// ---- Re-implementations of the renderer's pure helpers ---- test('account submit labels stay exact for add, edit, and OTP retries', () => {
// These mirror the production code exactly so the tests serve as both a guard assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
// and executable spec for what saveAccount() must do. assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
function credsSnapshotKey(authType, creds) { assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function buildEphemeralHosterConfig(payload) {
return {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
}
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
function makeStateMachine({ validateImpl, persistImpl }) {
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('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => { test('close and reopen cannot start a second save while the first save is pending', async () => {
const persistCalls = []; const submitter = createAccountSubmitter();
let validateCount = 0; let current = true;
const sm = makeStateMachine({ let commits = 0;
validateImpl: async () => { validateCount++; return { status: 'ok' }; }, let applies = 0;
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) let saveStarted;
}); let finishSave;
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; const started = new Promise(resolve => { saveStarted = resolve; });
const creds = { username: 'u', password: 'p' }; const saving = new Promise(resolve => { finishSave = resolve; });
// Click 1 = validate → green. const first = submitter.submit({
await sm.click(ctx, creds); validate: async () => ({ status: 'ok' }),
// Click 2 = commit (same creds, validated snapshot matches). commit: async () => {
await sm.click(ctx, creds); commits++;
// Click 3 = guard prevents a second commit because after persistImpl the saveStarted();
// state-machine in real code closes the modal. In this simulator the await saving;
// validated snapshot is still set — but a real double-click WHILE persistImpl return { accountId: 'first' };
// 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(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
// 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('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
const persistCalls = [];
let calls = 0;
const sm = makeStateMachine({
validateImpl: async (payload) => {
calls++;
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
}, },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) afterCommit: async () => {
}); applies++;
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; },
const creds = { username: 'u', password: 'p' }; isCurrent: () => current
await sm.click(ctx, creds, ''); // first click → otp_required
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('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => { await started;
const persistCalls = []; current = false;
let validateCount = 0; const second = submitter.submit({
const sm = makeStateMachine({ validate: async () => ({ status: 'ok' }),
validateImpl: async () => { validateCount++; return { status: 'ok' }; }, commit: async () => {
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) commits++;
}); },
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; isCurrent: () => true
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
sm.editField(); // user edits cred field → snapshot dropped
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)', () => { assert.equal(second, null);
// Label changes must NOT invalidate validation — label is metadata, not a credential. assert.equal(submitter.isBusy(), true);
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }), finishSave();
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' })); const result = await first;
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case assert.equal(result.status, 'stale');
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }), assert.equal(result.committed, true);
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff assert.equal(commits, 1);
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }), assert.equal(applies, 1);
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' })); assert.equal(submitter.isBusy(), false);
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
}); });
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => { test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly. const expected = new Error('render failed');
// This guards the validate-credentials IPC contract from drifting. let saves = 0;
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' }); let applies = 0;
assert.equal(cfg.username, 'u'); const result = await submitValidatedAccount({
assert.equal(cfg.password, 'p'); validate: async () => ({ status: 'ok' }),
assert.equal(cfg.apiKey, ''); commit: async () => {
assert.equal(cfg.enabled, true); saves++;
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' }); return { accountId: 'saved-account' };
assert.equal(cfg2.apiKey, 'K'); },
assert.equal(cfg2.username, ''); 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 () => {
const expected = new Error('validation failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
throw expected;
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 0);
});
test('otp_required returns challenge without committing', async () => {
let commits = 0;
const validation = { status: 'otp_required', message: 'OTP gesendet' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'otp_required');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('stale submission is rejected immediately before commit', async () => {
let current = true;
let commits = 0;
const validation = { status: 'ok' };
const result = await submitValidatedAccount({
validate: async () => {
current = false;
return validation;
},
commit: async () => {
commits++;
},
isCurrent: () => current
});
assert.equal(result.status, 'stale');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('save failure returns error after one commit attempt', async () => {
const expected = new Error('save failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 1);
}); });