Compare commits

..

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

35 changed files with 743 additions and 2780 deletions

3
.gitignore vendored
View File

@ -6,10 +6,7 @@ __pycache__/
electron-config.json electron-config.json
electron-config.json.bak electron-config.json.bak
electron-config.json.tmp electron-config.json.tmp
electron-config.json.pre-history-split.bak
electron-config.pre-import-*.json electron-config.pre-import-*.json
electron-history.json
electron-history.json.tmp
*.log *.log
debug.log debug.log
fileuploader.log fileuploader.log

View File

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

View File

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

View File

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

View File

@ -105,7 +105,7 @@ class ClouddropUploader {
let bytesRead = 0; let bytesRead = 0;
async function* generate() { async function* generate() {
yield preambleBuf; yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); const fileStream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
for await (const chunk of fileStream) { for await (const chunk of fileStream) {
if (signal && signal.aborted) throw new Error('Aborted'); if (signal && signal.aborted) throw new Error('Aborted');
if (throttle) await throttle.consume(chunk.length, signal); if (throttle) await throttle.consume(chunk.length, signal);
@ -159,7 +159,7 @@ class ClouddropUploader {
// Reuse a single buffer for all chunks (only the last chunk may be smaller, // Reuse a single buffer for all chunks (only the last chunk may be smaller,
// in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB // in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB
// file — real GC pressure during busy uploads. // file — real GC pressure during busy uploads.
const fh = await fs.promises.open(filePath, 'r'); const fd = fs.openSync(filePath, 'r');
let bytesSent = 0; let bytesSent = 0;
const reusableBuf = Buffer.allocUnsafe(chunkSize); const reusableBuf = Buffer.allocUnsafe(chunkSize);
try { try {
@ -169,7 +169,7 @@ class ClouddropUploader {
const offset = i * chunkSize; const offset = i * chunkSize;
const remaining = fileSize - offset; const remaining = fileSize - offset;
const thisChunkSize = Math.min(chunkSize, remaining); const thisChunkSize = Math.min(chunkSize, remaining);
await fh.read(reusableBuf, 0, thisChunkSize, offset); fs.readSync(fd, reusableBuf, 0, thisChunkSize, offset);
const body = thisChunkSize === chunkSize const body = thisChunkSize === chunkSize
? reusableBuf ? reusableBuf
: reusableBuf.subarray(0, thisChunkSize); : reusableBuf.subarray(0, thisChunkSize);
@ -194,7 +194,7 @@ class ClouddropUploader {
if (progressCb) progressCb(bytesSent, fileSize); if (progressCb) progressCb(bytesSent, fileSize);
} }
} finally { } finally {
try { await fh.close(); } catch {} try { fs.closeSync(fd); } catch {}
} }
// 3. Complete session — all bytes are already on the server at this point. // 3. Complete session — all bytes are already on the server at this point.

View File

@ -184,87 +184,12 @@ class ConfigStore {
? app.getPath('userData') ? app.getPath('userData')
: path.join(__dirname, '..'); : path.join(__dirname, '..');
this.filePath = path.join(dir, 'electron-config.json'); this.filePath = path.join(dir, 'electron-config.json');
this.historyPath = path.join(dir, 'electron-history.json');
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
this._historyWriteQueue = Promise.resolve();
this._historyMigrated = false;
this._cache = null;
this._cacheKey = '';
this._perfLog = null;
this._wqDepth = 0;
// Migrate config from old location if current doesn't exist // Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) { if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
this._migrateFromOldPath(app); this._migrateFromOldPath(app);
} }
if (app && app.isPackaged) {
this._migrateHistory();
}
}
_readHistoryFile() {
try {
const raw = fs.readFileSync(this.historyPath, 'utf-8');
if (!raw || raw.trim().length < 2) return [];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (parsed && Array.isArray(parsed.history)) return parsed.history;
return [];
} catch {
return null;
}
}
_writeHistoryFileDurable(arr) {
const tmp = this.historyPath + '.tmp';
const fd = fs.openSync(tmp, 'w');
try {
fs.writeSync(fd, JSON.stringify(arr));
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, this.historyPath);
}
_writeHistoryFileAtomic(arr) {
return new Promise((resolve, reject) => {
const tmp = this.historyPath + '.tmp';
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
if (err) return reject(err);
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
resolve();
});
});
}
_enqueueHistoryWrite(fn) {
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
return this._historyWriteQueue;
}
_migrateHistory() {
try {
if (fs.existsSync(this.historyPath)) {
this._historyMigrated = Array.isArray(this._readHistoryFile());
return;
}
let cfg = null;
try { cfg = this._readAndParse(this.filePath); } catch {}
const hist = (cfg && Array.isArray(cfg.history)) ? cfg.history : [];
this._writeHistoryFileDurable(hist);
const check = this._readHistoryFile();
if (Array.isArray(check) && check.length === hist.length) {
if (hist.length > 0) {
try { fs.copyFileSync(this.filePath, this.filePath + '.pre-history-split.bak'); } catch {}
}
this._historyMigrated = true;
} else {
this._historyMigrated = false;
}
} catch {
this._historyMigrated = false;
}
} }
_migrateFromOldPath(app) { _migrateFromOldPath(app) {
@ -296,71 +221,15 @@ class ConfigStore {
return JSON.parse(raw); return JSON.parse(raw);
} }
_clone(obj) {
try { return structuredClone(obj); }
catch { return JSON.parse(JSON.stringify(obj)); }
}
setPerfLog(fn) { this._perfLog = typeof fn === 'function' ? fn : null; }
_pqLen(globalSettings) {
const pq = globalSettings && globalSettings.pendingQueue;
return pq && Array.isArray(pq.queueJobs) ? pq.queueJobs.length : 0;
}
_callerTag() {
const lines = (new Error().stack || '').split('\n');
const out = [];
for (let i = 2; i < lines.length && out.length < 3; i++) {
const line = lines[i].trim();
if (/config-store\.js/.test(line)) continue;
const m = line.match(/at (?:async )?([^ (]+)/);
if (m) out.push(m[1].split('.').pop());
}
return out.join('<') || '?';
}
load() { load() {
if (!this._perfLog) return this._loadImpl();
const hadCache = !!this._cache;
const t0 = performance.now();
const r = this._loadImpl();
const dt = performance.now() - t0;
if (dt >= 20) {
const q = this._pqLen(r && r.globalSettings);
const h = (r && r.history || []).length;
this._perfLog(`config-load wall=${dt.toFixed(0)}ms cache=${hadCache ? 'hit' : 'miss'} hist=${h} queue=${q} via=${this._callerTag()}`);
}
return r;
}
_loadImpl() {
try { try {
// In-memory cache keyed on the file's mtime+size. The processed config
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
// when the file actually changes. Our own writes refresh the cache (see
// _commit), and an external edit changes mtime/size so the cache misses
// and we reread. Without this, every one of the ~38 main.js load() call
// sites (incl. the per-500ms log-flush path) re-read disk + JSON.parse the
// whole growing history + DPAPI-decrypt every credential — the dominant
// long-running main-thread drag. load() always returns a CLONE so callers
// can mutate the result without corrupting the cache.
let stat = null;
try { stat = fs.statSync(this.filePath); } catch {}
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
if (stat && this._cache && this._cacheKey === statKey) {
return this._clone(this._cache);
}
let data = null; let data = null;
// Try main config // Try main config
try { data = this._readAndParse(this.filePath); } catch {} try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt // Fallback to backup if main is empty/corrupt
if (!data) { if (!data) {
try { data = this._readAndParse(this.filePath + '.bak'); } catch {} const backupPath = this.filePath + '.bak';
} try { data = this._readAndParse(backupPath); } catch {}
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
} }
if (!data) { if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS)); const fresh = JSON.parse(JSON.stringify(DEFAULTS));
@ -436,15 +305,11 @@ class ConfigStore {
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors)) const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
? data.rotationCursors ? data.rotationCursors
: {}; : {};
const result = { hosters, hosterSettings, globalSettings, history: this._historyMigrated ? [] : (data.history || []), rotationCursors }; const result = { hosters, hosterSettings, globalSettings, history: data.history || [], rotationCursors };
// Decrypt credentials stored with safeStorage so the rest of the app // Decrypt credentials stored with safeStorage so the rest of the app
// keeps working with plaintext in memory. // keeps working with plaintext in memory.
secretStore.decryptCredentials(result); secretStore.decryptCredentials(result);
if (stat) { return result;
this._cache = result;
this._cacheKey = statKey;
}
return this._clone(result);
} catch { } catch {
const fresh = JSON.parse(JSON.stringify(DEFAULTS)); const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
@ -452,80 +317,30 @@ class ConfigStore {
} }
} }
// Encrypt credential fields without mutating the caller's plaintext object. // Deep-clone a config and encrypt its credential fields. Never mutate the
// Only `hosters` carries credentials, so we clone ONLY that subtree — the rest // caller's object — the rest of the app holds plaintext references.
// (history, globalSettings, …) is referenced read-only into the stringified
// object. Deep-cloning the whole config here (incl. an ever-growing history)
// on every write was a primary long-running main-thread stall.
_serializeForDisk(config) { _serializeForDisk(config) {
const hosters = this._clone(config.hosters || {}); const clone = JSON.parse(JSON.stringify(config));
secretStore.encryptCredentials({ hosters }); secretStore.encryptCredentials(clone);
return JSON.stringify({ ...config, hosters }, null, 2); return JSON.stringify(clone, null, 2);
}
_commit(config) {
if (!this._perfLog) return this._atomicWrite(this._serializeForDisk(config));
const t0 = performance.now();
const data = this._serializeForDisk(config);
const dt = performance.now() - t0;
if (dt >= 20) {
const q = this._pqLen(config.globalSettings);
const h = (config.history || []).length;
this._perfLog(`config-serialize wall=${dt.toFixed(0)}ms bytes=${data.length} hist=${h} queue=${q} wqDepth=${this._wqDepth} via=${this._callerTag()}`);
}
return this._atomicWrite(data);
} }
_enqueueWrite(fn) { _enqueueWrite(fn) {
this._wqDepth++; this._writeQueue = this._writeQueue.then(fn, fn);
const done = () => { this._wqDepth--; };
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
return this._writeQueue; return this._writeQueue;
} }
_anyHosters(cfg) {
const h = cfg && cfg.hosters;
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
}
_recoverHostersFromDisk() {
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
try {
const raw = fs.readFileSync(p, 'utf-8');
if (!raw || raw.trim().length < 2) continue;
const data = JSON.parse(raw);
if (this._anyHosters(data)) return data.hosters;
} catch {}
}
return null;
}
_guardHosters(current, hostersIntentional) {
if (!hostersIntentional && !this._anyHosters(current)) {
const recovered = this._recoverHostersFromDisk();
if (recovered) {
current.hosters = recovered;
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
}
}
return current;
}
save(config) { save(config) {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const current = this.load(); const current = this.load();
if (config.hosters) current.hosters = config.hosters; if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings; if (config.globalSettings) current.globalSettings = config.globalSettings;
this._guardHosters(current, !!config.hosters); return this._atomicWrite(this._serializeForDisk(current));
return this._commit(current);
}); });
} }
loadHistory() { loadHistory() {
if (this._historyMigrated) {
return this._readHistoryFile() || [];
}
const config = this.load(); const config = this.load();
return config.history || []; return config.history || [];
} }
@ -534,78 +349,45 @@ class ConfigStore {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp'; const tmpPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak'; const backupPath = this.filePath + '.bak';
let fd; fs.writeFile(tmpPath, data, 'utf-8', (err) => {
try { if (err) return reject(err);
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
} catch (e) {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
return reject(e);
}
try { fs.closeSync(fd); } catch {}
Promise.resolve().then(() => {
try { try {
// Refresh .bak from the previous live file. Wrapped in try/catch
// so an AV/indexer briefly locking the file doesn't fail the whole
// save — the rename to the live path is the part that matters,
// a stale .bak is preferable to losing the new write entirely.
try { try {
if (fs.existsSync(this.filePath)) { if (fs.existsSync(this.filePath)) {
const cur = fs.readFileSync(this.filePath, 'utf-8'); const existing = fs.readFileSync(this.filePath, 'utf-8');
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8'); if (existing && existing.trim().length > 2) {
let isValid = false;
try {
const parsed = JSON.parse(existing);
isValid = parsed && typeof parsed === 'object' && (parsed.hosters || parsed.hosterSettings || parsed.globalSettings);
} catch {}
if (isValid) fs.writeFileSync(backupPath, existing, 'utf-8');
}
} }
} catch {} } catch {}
fs.renameSync(tmpPath, this.filePath); fs.renameSync(tmpPath, this.filePath);
} catch (e) { return reject(e); } } catch (e) { return reject(e); }
// Invalidate the read cache: the next load() re-reads + re-merges the
// freshly-written file (the on-disk format is sparse — load() fills
// defaults — so we must NOT serve a pre-merge in-memory object).
this._cache = null;
this._cacheKey = '';
resolve(); resolve();
}); });
}); });
} }
appendHistory(entry) { appendHistory(entry) {
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => {
const cur = this._readHistoryFile();
if (cur === null && fs.existsSync(this.historyPath)) return;
const arr = cur || [];
arr.push(entry);
const gs = this.load().globalSettings;
const retention = (gs && gs.historyRetention) || 'all';
const pruned = applyHistoryRetention(arr, retention, Date.now());
return this._writeHistoryFileAtomic(pruned);
});
}
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.history.push(entry); config.history.push(entry);
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
config.history = applyHistoryRetention(config.history, retention, Date.now()); config.history = applyHistoryRetention(config.history, retention, Date.now());
return this._commit(config); return this._atomicWrite(this._serializeForDisk(config));
}); });
} }
pruneHistory(retention, opts = {}) { pruneHistory(retention, opts = {}) {
const dryRun = !!opts.dryRun; const dryRun = !!opts.dryRun;
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => {
const current = this._readHistoryFile() || [];
const beforeBatches = current.length;
const beforeRows = countHistoryRows(current);
const pruned = applyHistoryRetention(current, retention, Date.now());
const result = {
removedBatches: beforeBatches - pruned.length,
removedRows: beforeRows - countHistoryRows(pruned),
keptBatches: pruned.length,
keptRows: countHistoryRows(pruned)
};
if (dryRun) return result;
return this._writeHistoryFileAtomic(pruned)
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
.then(() => result);
});
}
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
const beforeBatches = config.history.length; const beforeBatches = config.history.length;
@ -620,18 +402,15 @@ class ConfigStore {
if (dryRun) return result; if (dryRun) return result;
config.history = pruned; config.history = pruned;
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all'); if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
return this._commit(config).then(() => result); return this._atomicWrite(this._serializeForDisk(config)).then(() => result);
}); });
} }
clearHistory() { clearHistory() {
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => this._writeHistoryFileAtomic([]));
}
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.history = []; config.history = [];
return this._commit(config); return this._atomicWrite(this._serializeForDisk(config));
}); });
} }
@ -639,8 +418,7 @@ class ConfigStore {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {}; config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
this._guardHosters(config, false); return this._atomicWrite(this._serializeForDisk(config));
return this._commit(config);
}); });
} }
} }

View File

@ -11,7 +11,7 @@ const READABLE_LOGS = {
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped']; const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
function createCollectors(deps) { function createCollectors(deps) {
const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() { function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; } try { return support.collectSecretValues(loadConfig()); } catch { return []; }
@ -205,9 +205,8 @@ function createCollectors(deps) {
function getHistory(args) { function getHistory(args) {
const a = args || {}; const a = args || {};
const history = typeof loadHistory === 'function' const cfg = loadConfig();
? (loadHistory() || []) const history = Array.isArray(cfg.history) ? cfg.history : [];
: (Array.isArray(loadConfig().history) ? loadConfig().history : []);
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200); const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history); const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse(); const recent = [...history].slice(-limit).reverse();

View File

@ -27,11 +27,7 @@ function _doodstreamLogPath() {
return path.join(__dirname, '..', 'doodstream-debug.log'); return path.join(__dirname, '..', 'doodstream-debug.log');
} }
let _debugVerbose = false;
function setDebugVerbose(v) { _debugVerbose = !!v; }
function _debugLog(msg) { function _debugLog(msg) {
if (!_debugVerbose) return;
try { try {
const logPath = _doodstreamLogPath(); const logPath = _doodstreamLogPath();
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS); maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
@ -339,7 +335,7 @@ class DoodstreamUploader {
const epilogueBuf = Buffer.from(epilogue, 'utf-8'); const epilogueBuf = Buffer.from(epilogue, 'utf-8');
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
const CHUNK_SIZE = 1024 * 1024; const CHUNK_SIZE = 256 * 1024;
let bytesRead = 0; let bytesRead = 0;
async function* generate() { async function* generate() {
@ -702,4 +698,3 @@ class DoodstreamUploader {
} }
module.exports = DoodstreamUploader; module.exports = DoodstreamUploader;
module.exports.setDebugVerbose = setDebugVerbose;

View File

@ -288,7 +288,7 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields); const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
let bytesRead = 0; let bytesRead = 0;
const CHUNK_SIZE = 1024 * 1024; const CHUNK_SIZE = 256 * 1024;
async function* generate() { async function* generate() {
yield preambleBuf; yield preambleBuf;

View File

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

View File

@ -21,7 +21,7 @@ class RemoteServer {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._config = opts; this._config = opts;
const wssOpts = { port: opts.port, maxPayload: 256 * 1024 }; const wssOpts = { port: opts.port };
if (opts.host) wssOpts.host = opts.host; if (opts.host) wssOpts.host = opts.host;
this._wss = new WebSocketServer(wssOpts, () => { this._wss = new WebSocketServer(wssOpts, () => {
resolve(); resolve();
@ -175,9 +175,7 @@ class RemoteServer {
sendToClient(clientId, data) { sendToClient(clientId, data) {
for (const [ws, client] of this._clients) { for (const [ws, client] of this._clients) {
if (client.id === clientId && client.authenticated) { if (client.id === clientId && client.authenticated) {
if (ws.readyState === 1) { ws.send(JSON.stringify(data));
try { ws.send(JSON.stringify(data)); } catch {}
}
break; break;
} }
} }

View File

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

View File

@ -37,14 +37,6 @@ function isNewer(remote, current) {
return r.patch > c.patch; return r.patch > c.patch;
} }
function resolveReleaseVersion(release) {
for (const value of [release && release.name, release && release.tag_name]) {
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
if (match) return match[1];
}
return '';
}
function pickSetupAsset(assets) { function pickSetupAsset(assets) {
if (!Array.isArray(assets)) return null; if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive) // Prefer asset with "setup" in the name (case-insensitive)
@ -98,12 +90,11 @@ async function checkForUpdate() {
} }
const release = releases[0]; const release = releases[0];
const remoteVersion = resolveReleaseVersion(release); const remoteVersion = release.tag_name || release.name || '';
const transportTag = release.tag_name || '';
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
if (!isNewer(remoteVersion, currentVersion)) { if (!isNewer(remoteVersion, currentVersion)) {
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag }; cachedCheck = { available: false, currentVersion, remoteVersion };
cachedCheckTs = Date.now(); cachedCheckTs = Date.now();
return cachedCheck; return cachedCheck;
} }
@ -118,8 +109,7 @@ async function checkForUpdate() {
cachedCheck = { cachedCheck = {
available: true, available: true,
currentVersion, currentVersion,
remoteVersion, remoteVersion: remoteVersion.replace(/^v/i, ''),
transportTag,
releaseUrl: release.html_url, releaseUrl: release.html_url,
assetUrl: setupAsset.browser_download_url, assetUrl: setupAsset.browser_download_url,
assetSize: setupAsset.size, assetSize: setupAsset.size,
@ -189,27 +179,10 @@ async function installUpdate(onProgress) {
let downloadedBytes = 0; let downloadedBytes = 0;
const chunks = []; const chunks = [];
const DOWNLOAD_STALL_MS = 45000;
let stallTimer = null;
const reader = res.body.getReader(); const reader = res.body.getReader();
while (true) { while (true) {
if (signal.aborted) throw new Error('Abgebrochen'); if (signal.aborted) throw new Error('Abgebrochen');
let chunk; const { done, value } = await reader.read();
try {
chunk = await Promise.race([
reader.read(),
new Promise((_, reject) => { stallTimer = setTimeout(() => reject(new Error('__STALL__')), DOWNLOAD_STALL_MS); })
]);
} catch (e) {
if (e && e.message === '__STALL__') {
try { activeAbort.abort(); } catch {}
throw new Error('Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.');
}
throw e;
} finally {
if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; }
}
const { done, value } = chunk;
if (done) break; if (done) break;
chunks.push(value); chunks.push(value);
downloadedBytes += value.length; downloadedBytes += value.length;
@ -290,4 +263,4 @@ function abortUpdate() {
} }
} }
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion }; module.exports = { checkForUpdate, installUpdate, abortUpdate };

View File

@ -34,11 +34,10 @@ class UploadManager extends EventEmitter {
this.stopAfterActive = false; this.stopAfterActive = false;
this.statsInterval = null; this.statsInterval = null;
this.startTime = 0; this.startTime = 0;
this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded, hoster } this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded }
this.jobAbortControllers = new Map(); // jobId -> AbortController this.jobAbortControllers = new Map(); // jobId -> AbortController
this.cancelledJobIds = new Set(); this.cancelledJobIds = new Set();
this.sessionBytes = 0; this.sessionBytes = 0;
this._transientErrorTotal = 0;
this.lastStartTime = {}; // hoster -> timestamp of last upload start this.lastStartTime = {}; // hoster -> timestamp of last upload start
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
this.globalThrottle = null; this.globalThrottle = null;
@ -82,17 +81,6 @@ class UploadManager extends EventEmitter {
return this.activeJobs.size; return this.activeJobs.size;
} }
getDiagnostics() {
const activeByHoster = {};
for (const v of this.activeJobs.values()) {
const h = v && v.hoster ? v.hoster : 'unknown';
activeByHoster[h] = (activeByHoster[h] || 0) + 1;
}
let pending = 0;
for (const sem of Object.values(this.semaphores)) pending += (sem && sem.pending) || 0;
return { activeByHoster, transientErrors: this._transientErrorTotal, pending, active: this.activeJobs.size };
}
clearFailedAccount(hoster, accountId) { clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`); return this._failedAccounts.delete(`${hoster}:${accountId}`);
} }
@ -364,17 +352,16 @@ class UploadManager extends EventEmitter {
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) { for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
if (signal.aborted) break; if (signal.aborted) break;
const end = Math.min(i + DEDUP_CHUNK, tasks.length); const end = Math.min(i + DEDUP_CHUNK, tasks.length);
const toStat = [];
for (let j = i; j < end; j++) { for (let j = i; j < end; j++) {
const task = tasks[j]; const task = tasks[j];
if (!results.has(task.file)) { if (!results.has(task.file)) {
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] }); const fileName = path.basename(task.file);
toStat.push(task.file); let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] });
} }
} }
await Promise.all(toStat.map(async (f) => { if (end < tasks.length) await new Promise(setImmediate);
try { const st = await fs.promises.stat(f); const e = results.get(f); if (e) e.size = st.size; } catch {}
}));
} }
this._startStatsTimer(); this._startStatsTimer();
@ -426,7 +413,7 @@ class UploadManager extends EventEmitter {
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) { if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
fileSize = cachedResult.size; fileSize = cachedResult.size;
} else { } else {
try { fileSize = (await fs.promises.stat(task.file)).size; } catch { fileNotFound = true; } try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
} }
const maxAttempts = Math.max(1, (settings.retries || 0) + 1); const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
@ -638,7 +625,7 @@ class UploadManager extends EventEmitter {
// Mutate this single object on each progress callback instead of // Mutate this single object on each progress callback instead of
// allocating a fresh one — callback fires on every stream chunk // allocating a fresh one — callback fires on every stream chunk
// (hundreds/sec per active job). // (hundreds/sec per active job).
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0; let lastEmitTime = 0;
@ -699,7 +686,6 @@ class UploadManager extends EventEmitter {
return; return;
} catch (err) { } catch (err) {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
if (this._isTransientNetworkError(err)) this._transientErrorTotal++;
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) { if (!signal.aborted && !isSpeedRestart) {
@ -952,11 +938,9 @@ class UploadManager extends EventEmitter {
let lastBytes = 0; let lastBytes = 0;
let lastSpeedTime = jobStart; let lastSpeedTime = jobStart;
let currentSpeedKbs = 0; let currentSpeedKbs = 0;
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0;
const PROGRESS_EMIT_INTERVAL = 250;
const progressCb = (bytesUploaded, bytesTotal) => { const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now(); const now = Date.now();
const timeDelta = (now - lastSpeedTime) / 1000; const timeDelta = (now - lastSpeedTime) / 1000;
@ -967,8 +951,6 @@ class UploadManager extends EventEmitter {
} }
activeEntry.speedKbs = currentSpeedKbs; activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded; activeEntry.bytesUploaded = bytesUploaded;
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
const elapsed = Math.round((now - jobStart) / 1000); const elapsed = Math.round((now - jobStart) / 1000);
const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
@ -1088,10 +1070,8 @@ class UploadManager extends EventEmitter {
let lastBytes = 0; let lastBytes = 0;
let lastSpeedTime = jobStart; let lastSpeedTime = jobStart;
let currentSpeedKbs = 0; let currentSpeedKbs = 0;
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0;
const PROGRESS_EMIT_INTERVAL = 250;
const progressCb = (bytesUploaded, bytesTotal) => { const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now(); const now = Date.now();
const timeDelta = (now - lastSpeedTime) / 1000; const timeDelta = (now - lastSpeedTime) / 1000;
@ -1102,8 +1082,6 @@ class UploadManager extends EventEmitter {
} }
activeEntry.speedKbs = currentSpeedKbs; activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded; activeEntry.bytesUploaded = bytesUploaded;
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
const elapsed = Math.round((now - jobStart) / 1000); const elapsed = Math.round((now - jobStart) / 1000);
const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,

View File

@ -187,7 +187,7 @@ class VidmolyUploader {
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
let bytesRead = 0; let bytesRead = 0;
const CHUNK_SIZE = 1024 * 1024; const CHUNK_SIZE = 256 * 1024;
async function* generate() { async function* generate() {
yield preambleBuf; yield preambleBuf;

View File

@ -242,7 +242,7 @@ class VoeUploader {
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
let bytesRead = 0; let bytesRead = 0;
const CHUNK_SIZE = 1024 * 1024; const CHUNK_SIZE = 256 * 1024;
async function* generate() { async function* generate() {
yield preambleBuf; yield preambleBuf;

198
main.js
View File

@ -1,8 +1,4 @@
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu } = require('electron');
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
const { configureStartupRenderer, createStartupWindow } = require('./lib/startup-renderer');
configureStartupRenderer(app);
nativeTheme.themeSource = 'dark'; nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@ -29,72 +25,11 @@ 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');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
let _eldLastLog = 0;
let _lastCpu = process.cpuUsage();
let _lastCpuT = Date.now();
let _gcCount = 0;
let _gcTotalMs = 0;
let _gcMaxMs = 0;
try {
const _gcObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
_gcCount++;
_gcTotalMs += entry.duration;
if (entry.duration > _gcMaxMs) _gcMaxMs = entry.duration;
}
});
_gcObserver.observe({ entryTypes: ['gc'] });
} catch {}
const _perfOn = process.env.MHU_PERF !== '0';
let _lastIpcChannel = '';
if (_perfOn) {
let _driftTick = Date.now();
setInterval(() => {
const now = Date.now();
const drift = now - _driftTick - 100;
_driftTick = now;
if (drift >= 100) {
try { logInfo(`main-longtask blocked=${drift}ms lastIpc=${_lastIpcChannel || '-'} gc=${_gcCount} gcMax=${_gcMaxMs.toFixed(0)}ms`); } catch {}
}
}, 100).unref();
const IPC_SLOW_MS = 50;
const _ipcLog = (m) => { try { logInfo(m); } catch {} };
const _rawHandle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, fn) => _rawHandle(channel, function (evt, ...args) {
_lastIpcChannel = channel;
const t0 = performance.now();
let p;
try { p = fn.call(this, evt, ...args); }
catch (e) { _ipcLog(`ipc ${channel} sync-throw wall=${(performance.now() - t0).toFixed(0)}ms`); throw e; }
const sync = performance.now() - t0;
if (p && typeof p.then === 'function') {
return Promise.resolve(p).finally(() => {
const total = performance.now() - t0;
if (total >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${total.toFixed(0)}ms sync=${sync.toFixed(0)}ms`);
});
}
if (sync >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${sync.toFixed(0)}ms sync`);
return p;
});
const _rawOn = ipcMain.on.bind(ipcMain);
ipcMain.on = (channel, fn) => _rawOn(channel, function (evt, ...args) {
_lastIpcChannel = channel;
const t0 = performance.now();
try { return fn.call(this, evt, ...args); }
finally { const dt = performance.now() - t0; if (dt >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${dt.toFixed(0)}ms sync-on`); }
});
}
let mainWindow; let mainWindow;
let _lastImportPath = null; let _lastImportPath = null;
let dropTargetWindow = null; let dropTargetWindow = null;
let tray = null; let tray = null;
const configStore = new ConfigStore(app); const configStore = new ConfigStore(app);
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
let uploadManager = null; let uploadManager = null;
let diagnosticAgent = null; let diagnosticAgent = null;
let _diagHandler = null; let _diagHandler = null;
@ -204,7 +139,7 @@ function debugLog(msg) {
} }
let _logVerbose = false; let _logVerbose = false;
function setLogVerbose(v) { _logVerbose = !!v; try { require('./lib/doodstream-upload').setDebugVerbose(_logVerbose); } catch {} } function setLogVerbose(v) { _logVerbose = !!v; }
function _ctxTag(ctx) { function _ctxTag(ctx) {
if (!ctx || typeof ctx !== 'object') return ''; if (!ctx || typeof ctx !== 'object') return '';
const tags = []; const tags = [];
@ -246,56 +181,6 @@ function logMarker(label, fields) {
debugLog(`────── ${label}${extra} ──────`); debugLog(`────── ${label}${extra} ──────`);
} }
function _maybeLogEventLoopDelay(activeJobs) {
const now = Date.now();
if (now - _eldLastLog < 5000) return;
_eldLastLog = now;
try {
const ns = 1e6;
const mean = (_eventLoopDelay.mean / ns).toFixed(1);
const max = (_eventLoopDelay.max / ns).toFixed(1);
const p99 = (_eventLoopDelay.percentile(99) / ns).toFixed(1);
const stddev = (_eventLoopDelay.stddev / ns).toFixed(1);
let resStr = '';
try {
const info = process.getActiveResourcesInfo();
const hist = {};
for (const t of info) hist[t] = (hist[t] || 0) + 1;
const top = Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, v]) => `${k}:${v}`).join(',');
resStr = ` resources=${info.length} {${top}}`;
} catch {}
let cpuStr = '';
try {
const d = process.cpuUsage(_lastCpu);
const wall = now - _lastCpuT;
const pct = wall > 0 ? Math.round((d.user + d.system) / 1000 / wall * 100) : 0;
const mem = process.memoryUsage();
const rss = Math.round(mem.rss / 1048576);
const heap = Math.round(mem.heapUsed / 1048576);
const ext = Math.round(mem.external / 1048576);
const ab = Math.round((mem.arrayBuffers || 0) / 1048576);
cpuStr = ` cpu=${pct}%core rss=${rss}MB heap=${heap}MB ext=${ext}MB ab=${ab}MB`;
_lastCpu = process.cpuUsage();
_lastCpuT = now;
} catch {}
let gcStr = '';
try {
gcStr = ` gc=${_gcCount} gcTotal=${_gcTotalMs.toFixed(0)}ms gcMax=${_gcMaxMs.toFixed(0)}ms`;
_gcCount = 0; _gcTotalMs = 0; _gcMaxMs = 0;
} catch {}
let upStr = '';
try {
if (uploadManager && typeof uploadManager.getDiagnostics === 'function') {
const d = uploadManager.getDiagnostics();
const byHoster = Object.entries(d.activeByHoster || {}).map(([h, c]) => `${h.replace(/\..*$/, '')}:${c}`).join(',');
upStr = ` active-by-hoster={${byHoster}} transient-errs=${d.transientErrors || 0} pending=${d.pending || 0}`;
}
} catch {}
logInfo(`eventloop-delay active=${activeJobs} mean=${mean}ms p99=${p99}ms max=${max}ms stddev=${stddev}ms threadpool=${process.env.UV_THREADPOOL_SIZE}${cpuStr}${gcStr}${resStr}${upStr}`);
_eventLoopDelay.reset();
} catch {}
}
// Dedicated account-rotation log so users can trace fallback decisions // Dedicated account-rotation log so users can trace fallback decisions
// without wading through general debug output. Writes to account-rotation.log // without wading through general debug output. Writes to account-rotation.log
// in the same directory as fileuploader.log (honors user's configured path). // in the same directory as fileuploader.log (honors user's configured path).
@ -529,41 +414,27 @@ function getDefaultLogFilePath() {
return path.join(__dirname, 'fileuploader.log'); return path.join(__dirname, 'fileuploader.log');
} }
// The log flush paths resolve the log file ~8x/second during uploads. Going
// through configStore.load() there meant re-reading + cloning the whole config
// (incl. an 8 MB+ history) on every flush — a major long-running main-thread
// drag. logFilePath/logMode change only when the user saves settings, so cache
// the two strings and invalidate on those saves (see _invalidateLogSettings).
let _cachedLogSettings = null;
function _getLogSettings() {
if (!_cachedLogSettings) {
const gs = (configStore.load() || {}).globalSettings || {};
_cachedLogSettings = {
logFilePath: String(gs.logFilePath || '').trim(),
logMode: gs.logMode || 'single'
};
}
return _cachedLogSettings;
}
function _invalidateLogSettings() { _cachedLogSettings = null; }
function getBaseLogFilePath() { function getBaseLogFilePath() {
const customPath = _getLogSettings().logFilePath; const config = configStore.load();
const customPath = config && config.globalSettings
? String(config.globalSettings.logFilePath || '').trim()
: '';
return customPath || getDefaultLogFilePath(); return customPath || getDefaultLogFilePath();
} }
// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session. // Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session.
// The session-id is stamped ONCE at main-process startup so every write of a // The session-id is stamped ONCE at main-process startup so every write of a
// given session lands in the same file. A close→reopen of the app starts a new // given session lands in the same file. A close→reopen of the app starts a new
// main process, so a new SESSION_ID, so a new session file. A 6-digit random is // main process, so a new SESSION_ID, so a new session file. PID is appended as
// appended as a cheap hedge against same-minute restart collisions. // a cheap hedge against same-second restart collisions.
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode'); const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000))); const SESSION_ID = formatSessionStamp(new Date(), process.pid);
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
let _activeLogPath = null; let _activeLogPath = null;
function getLogFilePath() { function getLogFilePath() {
const mode = _getLogSettings().logMode; const config = configStore.load();
const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single';
const base = getBaseLogFilePath(); const base = getBaseLogFilePath();
const dir = path.dirname(base); const dir = path.dirname(base);
const ext = path.extname(base); const ext = path.extname(base);
@ -583,7 +454,8 @@ function getLogFilePath() {
function buildFallbackLogName(dir) { function buildFallbackLogName(dir) {
// Match the active log-mode's naming so the fallback file is consistent with // Match the active log-mode's naming so the fallback file is consistent with
// what the primary write would have produced. // what the primary write would have produced.
const mode = _getLogSettings().logMode; const config = configStore.load();
const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single';
return path.join(dir, resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date: new Date(), sessionId: SESSION_ID })); return path.join(dir, resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date: new Date(), sessionId: SESSION_ID }));
} }
@ -724,7 +596,6 @@ function _persistFallbackLogPath(workingPath) {
cfg.globalSettings = gs; cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {}); configStore.save({ globalSettings: gs }).catch(() => {});
_invalidateUploadLogTargetCache(); _invalidateUploadLogTargetCache();
_invalidateLogSettings();
safeSend('log-path-auto-updated', { logFilePath: toSave }); safeSend('log-path-auto-updated', { logFilePath: toSave });
} catch (err) { } catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`); debugLog(`persist fallback logpath failed: ${err.message}`);
@ -1222,7 +1093,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
} }
function createWindow() { function createWindow() {
const startupWindow = createStartupWindow(BrowserWindow, { mainWindow = new BrowserWindow({
width: 1100, width: 1100,
height: 750, height: 750,
minWidth: 800, minWidth: 800,
@ -1235,7 +1106,6 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js') preload: path.join(__dirname, 'preload.js')
} }
}); });
mainWindow = startupWindow.window;
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
@ -1283,27 +1153,12 @@ function createWindow() {
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}`);
}); });
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => { mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
_writeCrashLog('LOAD FILE FAILED', err);
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
});
} }
function createTray() { function createTray() {
try { const iconPath = path.join(__dirname, 'assets', 'app_icon.ico');
const candidates = [ tray = new Tray(iconPath);
path.join(process.resourcesPath || __dirname, 'assets', 'app_icon.ico'),
path.join(__dirname, 'assets', 'app_icon.ico'),
path.join(__dirname, 'assets', 'icon.png')
];
let icon = null;
for (const p of candidates) {
try {
const img = nativeImage.createFromPath(p);
if (img && !img.isEmpty()) { icon = img; break; }
} catch {}
}
tray = new Tray(icon || nativeImage.createEmpty());
tray.setToolTip('Multi-Hoster-Upload'); tray.setToolTip('Multi-Hoster-Upload');
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
@ -1316,10 +1171,6 @@ function createTray() {
tray.on('click', () => { tray.on('click', () => {
if (mainWindow) { mainWindow.show(); mainWindow.focus(); } if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
}); });
} catch (err) {
tray = null;
debugLog(`createTray failed (non-fatal): ${err && err.message ? err.message : err}`);
}
} }
function updateTrayTooltip(text) { function updateTrayTooltip(text) {
@ -1464,7 +1315,6 @@ ipcMain.handle('get-config', () => {
ipcMain.handle('save-config', async (_event, config) => { ipcMain.handle('save-config', async (_event, config) => {
await configStore.save(config); await configStore.save(config);
if (config && config.globalSettings) _invalidateLogSettings();
try { try {
if (config && config.globalSettings && Object.prototype.hasOwnProperty.call(config.globalSettings, 'logVerbose')) { if (config && config.globalSettings && Object.prototype.hasOwnProperty.call(config.globalSettings, 'logVerbose')) {
setLogVerbose(!!config.globalSettings.logVerbose); setLogVerbose(!!config.globalSettings.logVerbose);
@ -1840,7 +1690,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (data.state === 'uploading' && data.activeJobs > 0) { if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1); const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`); updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
_maybeLogEventLoopDelay(data.activeJobs);
} else { } else {
updateTrayTooltip('Multi-Hoster-Upload'); updateTrayTooltip('Multi-Hoster-Upload');
} }
@ -2209,11 +2058,9 @@ ipcMain.handle('clear-history', async () => {
// --- Backup export / import --- // --- Backup export / import ---
ipcMain.handle('export-backup', async () => { ipcMain.handle('export-backup', async () => {
const _bd = new Date();
const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`;
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren', title: 'Backup exportieren',
defaultPath: `${_bdate}-multihoster-backup.mhu`, defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
filters: [ filters: [
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] }, { name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] } { name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
@ -2305,7 +2152,6 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
history: [] history: []
}; };
await configStore._atomicWrite(configStore._serializeForDisk(merged)); await configStore._atomicWrite(configStore._serializeForDisk(merged));
_invalidateLogSettings();
return { ok: true, config: configStore.load() }; return { ok: true, config: configStore.load() };
}); });
@ -2382,7 +2228,6 @@ ipcMain.handle('app:check-updates', async () => {
}); });
ipcMain.handle('app:install-update', () => { ipcMain.handle('app:install-update', () => {
try { if (uploadManager) uploadManager.cancel(); } catch {}
installUpdate((progress) => { installUpdate((progress) => {
safeSend('app:update-progress', progress); safeSend('app:update-progress', progress);
}).catch((err) => { }).catch((err) => {
@ -2441,7 +2286,6 @@ function _preserveDiagSubtree(globalSettings) {
ipcMain.handle('save-global-settings', async (_event, globalSettings) => { ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
globalSettings = _preserveDiagSubtree(globalSettings); globalSettings = _preserveDiagSubtree(globalSettings);
await configStore.save({ globalSettings }); await configStore.save({ globalSettings });
_invalidateLogSettings();
if (uploadManager) uploadManager.updateSettings(null, globalSettings); if (uploadManager) uploadManager.updateSettings(null, globalSettings);
return true; return true;
}); });
@ -2484,12 +2328,9 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics; const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
current.globalSettings = globalSettings; current.globalSettings = globalSettings;
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag; if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
try { configStore._guardHosters(current, false); } catch {}
_invalidateLogSettings();
const data = configStore._serializeForDisk(current); const data = configStore._serializeForDisk(current);
const backupPath = configStore.filePath + '.bak'; const backupPath = configStore.filePath + '.bak';
const _fd = fs.openSync(tmpPath, 'w'); fs.writeFileSync(tmpPath, data, 'utf-8');
try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); }
if (fs.existsSync(configStore.filePath)) { if (fs.existsSync(configStore.filePath)) {
// Use try/catch around the read so an AV/lock race doesn't fail the // Use try/catch around the read so an AV/lock race doesn't fail the
// whole save just because we couldn't refresh the .bak — the write to // whole save just because we couldn't refresh the .bak — the write to
@ -2627,7 +2468,6 @@ function _diagAgentInfo() {
function _buildDiagnosticHandler() { function _buildDiagnosticHandler() {
const collectors = createCollectors({ const collectors = createCollectors({
loadConfig: () => configStore.load(), loadConfig: () => configStore.load(),
loadHistory: () => configStore.loadHistory(),
getAllLogPaths, getAllLogPaths,
support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED }, support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED },
stats, stats,

20
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.2", "version": "3.3.86",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@ -12,8 +12,8 @@
}, },
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.16.0",
"ws": "^8.21.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.3.0",
@ -33,9 +33,7 @@
"main.js", "main.js",
"preload.js", "preload.js",
"lib/**/*", "lib/**/*",
"renderer/**/*", "renderer/**/*"
"assets/app_icon.ico",
"assets/app_icon.png"
], ],
"win": { "win": {
"target": [ "target": [

View File

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

View File

@ -18,62 +18,6 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {}; let hosterSettings = {};
let uploading = false; let uploading = false;
let healthCheckRunning = false; let healthCheckRunning = false;
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
function _rElLabel(el) {
try {
if (!el || !el.tagName) return '?';
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
else if (el.className && typeof el.className === 'string') { const c = el.className.trim().split(/\s+/)[0]; if (c) s += '.' + c; }
const a = el.getAttribute && (el.getAttribute('data-action') || el.getAttribute('data-tab') || el.getAttribute('aria-label') || el.getAttribute('title'));
if (a) s += `[${String(a).slice(0, 24)}]`;
return s;
} catch { return '?'; }
}
try {
if (window.PerformanceObserver) {
new window.PerformanceObserver((list) => {
for (const e of list.getEntries()) {
_rLongTasks++;
if (e.duration > _rLongTaskMax) _rLongTaskMax = e.duration;
if (e.duration >= 100 && window.api && window.api.debugLog) window.api.debugLog(`renderer-longtask dur=${Math.round(e.duration)}ms`);
}
}).observe({ entryTypes: ['longtask'] });
}
} catch {}
try {
if (window.PerformanceObserver) {
new window.PerformanceObserver((list) => {
for (const e of list.getEntries()) {
const proc = Math.round((e.processingEnd || 0) - (e.processingStart || 0));
if (window.api && window.api.debugLog) window.api.debugLog(`renderer-interaction ${e.name} dur=${Math.round(e.duration)}ms proc=${proc}ms target=${_rElLabel(e.target)}`);
}
}).observe({ type: 'event', durationThreshold: 50, buffered: true });
}
} catch {}
function _rFrameTick(ts) {
if (_rFrameLast) { const d = ts - _rFrameLast; _rFrameCount++; if (d > _rFrameWorst) _rFrameWorst = d; if (d > 33) _rFrameJank++; }
_rFrameLast = ts;
requestAnimationFrame(_rFrameTick);
}
requestAnimationFrame(_rFrameTick);
function _resetRendererPerf() {
_rPerfWindowStart = Date.now();
_rFrameCount = 0; _rFrameJank = 0; _rFrameWorst = 0; _rLongTasks = 0; _rLongTaskMax = 0;
}
function _maybeLogRendererPerf(activeJobs) {
const now = Date.now();
if (!_rPerfWindowStart) _rPerfWindowStart = now;
if (now - _rPerfLastLog < 5000) return;
const winSec = (now - _rPerfWindowStart) / 1000;
const fps = winSec > 0 ? Math.round(_rFrameCount / winSec) : 0;
if (window.api && window.api.debugLog) {
window.api.debugLog(`renderer-perf active=${activeJobs} fps=${fps} jankFrames=${_rFrameJank} worstFrame=${Math.round(_rFrameWorst)}ms longtasks=${_rLongTasks} maxTask=${Math.round(_rLongTaskMax)}ms`);
}
_rPerfLastLog = now;
_resetRendererPerf();
}
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountId = null; // null = adding, string = editing account by ID let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true; let autoHealthCheckEnabled = true;
@ -332,7 +276,6 @@ async function init() {
// --- Tab switching --- // --- Tab switching ---
let _historyDirty = false; let _historyDirty = false;
let _historyEverLoaded = false;
function _isHistoryTabActive() { function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active'); const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history'); return !!(tab && tab.dataset.view === 'history');
@ -353,18 +296,15 @@ function _isHistoryTabActive() {
if (!tab || tab === activeTab) return; if (!tab || tab === activeTab) return;
if (activeTab) { if (activeTab) {
activeTab.classList.remove('active'); activeTab.classList.remove('active');
activeTab.setAttribute('aria-selected', 'false');
activeTab.tabIndex = -1;
const prevView = viewsById[`${activeTab.dataset.view}-view`]; const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active'); if (prevView) prevView.classList.remove('active');
} }
tab.classList.add('active'); tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
const nextView = viewsById[`${tab.dataset.view}-view`]; const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active'); if (nextView) nextView.classList.add('active');
activeTab = tab; activeTab = tab;
if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) { if (tab.dataset.view === 'history') {
_historyDirty = false;
loadHistory(); loadHistory();
} }
}; };
@ -372,18 +312,6 @@ function _isHistoryTabActive() {
const tabBar = tabs[0] && tabs[0].parentElement; const tabBar = tabs[0] && tabs[0].parentElement;
if (tabBar) { if (tabBar) {
tabBar.addEventListener('click', (e) => handle(e.target)); tabBar.addEventListener('click', (e) => handle(e.target));
tabBar.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
const current = Math.max(0, tabs.indexOf(activeTab));
const next = e.key === 'Home'
? 0
: e.key === 'End'
? tabs.length - 1
: (current + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
handle(tabs[next]);
});
} else { } else {
// Fallback: bind per-tab if somehow no common parent // Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t))); tabs.forEach(t => t.addEventListener('click', () => handle(t)));
@ -1249,7 +1177,7 @@ let _recentRenderQueued = false;
function scheduleRecentRender() { function scheduleRecentRender() {
if (_recentRenderQueued) return; if (_recentRenderQueued) return;
_recentRenderQueued = true; _recentRenderQueued = true;
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(true); }); requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
} }
// Toggle the .selected class on existing rows without rebuilding the table. // Toggle the .selected class on existing rows without rebuilding the table.
@ -1936,20 +1864,9 @@ document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu(); if (!e.target.closest('.context-menu')) hideContextMenu();
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
const accountModal = document.getElementById('accountModal');
if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') {
const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
hideContextMenu(); hideContextMenu();
cancelHosterModal(); cancelHosterModal();
if (accountModal && accountModal.style.display !== 'none') closeAccountModal();
} }
if (e.target.closest('input, textarea, select')) return; if (e.target.closest('input, textarea, select')) return;
const activeView = document.querySelector('.view.active'); const activeView = document.querySelector('.view.active');
@ -2622,12 +2539,6 @@ function _handleStatsImpl(data) {
updateStatusBar(); updateStatusBar();
updateStatsPanel(); updateStatsPanel();
if (data.state === 'uploading' && (data.activeJobs || 0) > 0) {
_maybeLogRendererPerf(data.activeJobs);
} else {
_resetRendererPerf();
}
// Track run time // Track run time
if (data.state === 'uploading' || data.state === 'stopping') { if (data.state === 'uploading' || data.state === 'stopping') {
if (!statsStartTime) { if (!statsStartTime) {
@ -2839,13 +2750,13 @@ const SESSION_FILES_CAP = 2000;
function maybeAddSessionFile(job) { function maybeAddSessionFile(job) {
if (!job) return; if (!job) return;
const dt = formatDateTime(new Date());
if (job.status === 'done' && job.result) { if (job.status === 'done' && job.result) {
const link = job.result.download_url || job.result.embed_url || ''; const link = job.result.download_url || job.result.embed_url || '';
if (!link) return; if (!link) return;
const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`; const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`;
if (!_sessionFileKeys.has(dedupKey)) { if (!_sessionFileKeys.has(dedupKey)) {
_sessionFileKeys.add(dedupKey); _sessionFileKeys.add(dedupKey);
const dt = formatDateTime(new Date());
sessionFilesData.push({ sessionFilesData.push({
date: dt.text, date: dt.text,
dateTs: dt.ts, dateTs: dt.ts,
@ -2857,7 +2768,6 @@ function maybeAddSessionFile(job) {
}); });
_recentDataVersion++; _recentDataVersion++;
_sessionDoneCount++; _sessionDoneCount++;
_recentPendingAppends++;
// Drop oldest entries past the cap to keep render cost bounded. // Drop oldest entries past the cap to keep render cost bounded.
// Without this, sessionFilesData grows unbounded across the session // Without this, sessionFilesData grows unbounded across the session
// and every renderRecentUploadsPanel call becomes a megabyte-sized // and every renderRecentUploadsPanel call becomes a megabyte-sized
@ -2978,12 +2888,12 @@ function updateStatusBar() {
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Verbindungen ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`; document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Verbleibend ${stats.remaining}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `Läuft ${stats.inProgress}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
document.getElementById('sbDoneCount').textContent = `Fertig ${_sessionDoneCount}`; document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
document.getElementById('sbErrorCount').textContent = `Fehler ${_sessionErrorCount}`; document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
} }
// --- Health Check --- // --- Health Check ---
@ -3891,10 +3801,8 @@ function renderAccounts() {
if (allAccounts.length === 0) { if (allAccounts.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="accounts-empty"> <div class="accounts-empty">
<div class="accounts-empty-icon" aria-hidden="true">+</div> <p>Keine Accounts vorhanden</p>
<h3>Noch keine Accounts</h3> <span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
<p>Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.</p>
<button class="btn btn-primary" type="button" data-account-empty-add>Ersten Account hinzufügen</button>
</div>`; </div>`;
if (footer) footer.style.display = 'none'; if (footer) footer.style.display = 'none';
if (!_accountListenersBound) bindAccountListeners(container); if (!_accountListenersBound) bindAccountListeners(container);
@ -4135,7 +4043,6 @@ function bindAccountListeners(container) {
} }
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (!btn) return; if (!btn) return;
if (btn.hasAttribute('data-account-empty-add')) return openAccountModal(null);
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
@ -4270,43 +4177,28 @@ function getCredsFieldsHtml(authType, account, hoster) {
}; };
return ` return `
<div class="settings-row"> <div class="settings-row">
<label for="accField_username">${escapeHtml(fld.label)}</label> <label>${escapeHtml(fld.label)}</label>
<input type="${fld.inputType}" class="key-input" id="accField_username" name="username" autocomplete="username" spellcheck="false" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}"> <input type="${fld.inputType}" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}">
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label for="accField_password">Passwort</label> <label>Passwort</label>
<input type="password" class="key-input" id="accField_password" name="password" autocomplete="current-password" value="${escapeAttr(account.password || '')}" placeholder="Passwort"> <input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Passwort anzeigen" aria-label="Passwort anzeigen" aria-pressed="false">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
// API key // API key
return ` return `
<div class="settings-row"> <div class="settings-row">
<label for="accField_apiKey">API-Key</label> <label>API Key</label>
<input type="password" class="key-input" id="accField_apiKey" name="apiKey" autocomplete="off" spellcheck="false" value="${escapeAttr(account.apiKey || '')}" placeholder="API-Key"> <input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
<button class="toggle-vis" type="button" title="API-Key anzeigen" aria-label="API-Key anzeigen" aria-pressed="false">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
function wireCredentialVisibilityButtons(container) {
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
const visible = input.type === 'password';
const fieldName = input.id === 'accField_apiKey' ? 'API-Key' : 'Passwort';
input.type = visible ? 'text' : 'password';
btn.setAttribute('aria-pressed', String(visible));
btn.setAttribute('aria-label', `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`);
btn.title = `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`;
});
});
}
let _accountModalReturnFocus = null;
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
_accountModalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
editingAccountId = editAccountId || null; editingAccountId = editAccountId || null;
// Reset the two-step state — any previously validated snapshot from a prior
// modal session is stale and must not allow a no-recheck commit.
_resetAccountModalState(); _resetAccountModalState();
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle'); const title = document.getElementById('accountModalTitle');
@ -4326,17 +4218,17 @@ function openAccountModal(editAccountId) {
const found = findAccountById(editingAccountId); const found = findAccountById(editingAccountId);
if (!found) return; if (!found) return;
title.textContent = 'Account bearbeiten'; title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none'; hosterRow.style.display = 'none';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true }); saveBtn.textContent = 'Prüfen';
if (labelInput) labelInput.value = found.account.label || ''; if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else { } else {
// Add mode — always show all options (multiple accounts per hoster allowed) // Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen'; title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
hosterRow.style.display = 'flex'; hosterRow.style.display = 'flex';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false }); saveBtn.textContent = 'Prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>` `<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join(''); ).join('');
@ -4345,30 +4237,32 @@ function openAccountModal(editAccountId) {
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
} }
wireCredentialVisibilityButtons(credsContainer); // Toggle visibility buttons
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
// Wire field invalidation: any change to a cred field after a green check
// drops the validated snapshot so the next click is a re-check, not a commit
// of unverified creds. Re-wired here every open because credsContainer's HTML
// was replaced.
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
modal.style.display = 'flex'; modal.style.display = 'flex';
requestAnimationFrame(() => {
const firstControl = editingAccountId
? document.getElementById('accField_label')
: hosterSelect;
if (firstControl) firstControl.focus();
});
} }
function closeAccountModal() { function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
_resetAccountModalState(); // Cancel any pending auto-close so a stale timer can't close a future modal
const returnFocus = _accountModalReturnFocus; // the user reopens within the auto-close window.
_accountModalReturnFocus = null; if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
const focusTarget = returnFocus && returnFocus.isConnected _validatedCreds = null;
? returnFocus _accountModalBusy = false;
: document.getElementById('addAccountBtn');
if (focusTarget) focusTarget.focus();
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -4424,54 +4318,66 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label }; return { enabled: !!apiKey, authType: 'api', apiKey, label };
} }
const _accountSubmitter = window.AccountSubmit.createAccountSubmitter(); // --- Two-step account-modal state machine ---
let _accountModalCommitLocked = false; //
// Goal: never persist invalid/unverified credentials to config.hosters. The
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
// the button label flips to "Anlegen" / "Speichern" → the next click commits
// to config. Editing any cred field between the two clicks drops the validated
// snapshot so the user can't sneak unverified creds through by editing
// post-green.
//
// Invariants enforced here:
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
// result for the currently-typed creds.
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
// before any await — guards against double-clicks producing duplicates.
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
// the new OTP, no config writes until green.
// 4. Edit mode hits the same path → bad edits never overwrite known-good
// creds on disk.
let _accountModalBusy = false;
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
let _autoCloseTimer = null; let _autoCloseTimer = null;
// Session token used to ignore stale validate-credentials responses: if the
// user closes the modal mid-flight and reopens it, the late .then must NOT
// stomp the new session's state. Bumped on every modal reset.
let _accountModalSession = 0; let _accountModalSession = 0;
function _resetAccountModalState() { function _resetAccountModalState() {
_accountModalBusy = false;
_validatedCreds = null;
_accountModalSession++; _accountModalSession++;
_accountModalCommitLocked = false;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_syncAccountSubmitButton();
} }
function _credsSnapshotKey(authType, creds) { function _credsSnapshotKey(authType, creds) {
// Identity key for the typed creds — used to detect post-validation edits.
// Label changes do NOT invalidate (label is metadata, not a credential).
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`; return `api:${creds.apiKey || ''}`;
} }
function _defaultAccountSubmitButtonText(ctx) {
return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
}
function _syncAccountSubmitButton() {
const saveBtn = document.getElementById('saveAccountBtn');
if (!saveBtn) return;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
}
function _invalidateAccountSubmit() {
_accountModalSession++;
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) {
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
}
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
saveBtn.disabled = false;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
}
}
function _wireCredFieldInvalidation() { function _wireCredFieldInvalidation() {
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
// validated snapshot and reverts the button to "Prüfen". Label edits don't
// invalidate (label is metadata, not a credential). OTP edits don't either:
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
// these username+password+OTP, the resulting trust is on the creds; the user
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
const ids = ['accField_username', 'accField_password', 'accField_apiKey']; const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) { for (const id of ids) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue; if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', _invalidateAccountSubmit); el.addEventListener('input', () => {
if (_validatedCreds) {
_validatedCreds = null;
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn) saveBtn.textContent = 'Prüfen';
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
}
});
el.dataset.invalidateBound = '1'; el.dataset.invalidateBound = '1';
} }
} }
@ -4489,18 +4395,12 @@ function _determineHosterContext() {
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
} }
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
if (session !== _accountModalSession) return false;
const currentCtx = _determineHosterContext();
if (!currentCtx) return false;
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
}
async function saveAccount() { async function saveAccount() {
if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return; // SYNCHRONOUS re-entry guard — must come before any await. Without this a
// double-click before the first IPC returns triggers two saveAccount() calls
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
// synchronously and set synchronously, so the second click no-ops cleanly.
if (_accountModalBusy) return;
const ctx = _determineHosterContext(); const ctx = _determineHosterContext();
if (!ctx) return; if (!ctx) return;
@ -4513,8 +4413,37 @@ async function saveAccount() {
return; return;
} }
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
// EXACT same creds (label changes don't break this — label isn't part of the
// credential identity).
const snapshotKey = _credsSnapshotKey(ctx.authType, creds); const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
if (_validatedCreds &&
_validatedCreds.hosterName === ctx.hosterName &&
_validatedCreds.authType === ctx.authType &&
_validatedCreds.snapshot === snapshotKey) {
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
// leak _accountModalBusy=true and lock the user out for the session.
try {
_accountModalBusy = true;
saveBtn.disabled = true;
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
} finally {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
return;
}
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
// Snapshot the session token so a stale late-arriving response from a
// closed-and-reopened modal can't stomp the new session's state.
const mySession = _accountModalSession; const mySession = _accountModalSession;
_accountModalBusy = true;
saveBtn.disabled = true;
statusEl.textContent = 'Prüfe Login…';
statusEl.className = 'account-modal-status checking';
const otpInput = document.getElementById('accField_otp'); const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : ''; const otp = otpInput ? otpInput.value.trim() : '';
const payload = { const payload = {
@ -4526,100 +4455,94 @@ async function saveAccount() {
otp otp
}; };
const submission = _accountSubmitter.submit({ let row;
validate: () => window.api.validateCredentials(payload),
commit: () => _persistAccount(ctx, creds),
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
if (!submission) return;
saveBtn.disabled = true;
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
let result;
try { try {
result = await submission; row = await window.api.validateCredentials(payload);
} catch (error) { } catch (err) {
result = { status: 'error', error }; row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
} finally {
if (mySession === _accountModalSession) {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
} }
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey); // Stale response — modal was closed/reopened while we awaited. Drop it.
if (result.status === 'committed' && current) { if (mySession !== _accountModalSession) return;
_accountModalCommitLocked = true;
const validation = result.validation || {};
statusEl.textContent = validation.status === 'warn'
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
statusEl.className = 'account-modal-status ok';
_hideOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
saveBtn.disabled = true;
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => {
_autoCloseTimer = null;
closeAccountModal();
}, 600);
return;
}
_syncAccountSubmitButton(); if (row && row.status === 'otp_required') {
if (!current) return; statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
if (result.status === 'otp_required') {
const validation = result.validation || {};
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
_showOtpField(); _showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx); _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
saveBtn.textContent = 'Mit OTP prüfen';
return; return;
} }
if (row && (row.status === 'ok' || row.status === 'warn')) {
const validation = result.validation || {}; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
const msg = result.status === 'error' statusEl.className = 'account-modal-status ok';
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen' _hideOtpField();
: validation.message || 'Login fehlgeschlagen'; _validatedCreds = {
hosterName: ctx.hosterName,
authType: ctx.authType,
snapshot: snapshotKey,
status: row.status,
message: row.message || ''
};
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
return;
}
// error
const msg = (row && row.message) || 'Login fehlgeschlagen';
statusEl.textContent = msg; statusEl.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} }
function _copyHosterTree(hosters) { async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
const candidate = {}; // Persist the validated creds to config.hosters and close the modal. By the
for (const [name, accounts] of Object.entries(hosters || {})) { // time we reach this function the validate-credentials IPC has already
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts; // returned ok/warn for these exact creds, so we skip a redundant re-check.
} let accountId;
return candidate; if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
}
async function _persistAccount(ctx, creds) {
const candidateHosters = _copyHosterTree(config.hosters);
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
let accountId = ctx.accountId;
if (ctx.isEdit) { if (ctx.isEdit) {
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId); accountId = ctx.accountId;
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.'); const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds }; if (idx >= 0) {
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
} else {
_accountModalBusy = false;
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
const _st = document.getElementById('accountModalStatus');
if (_st) {
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
_st.className = 'account-modal-status error';
}
return;
}
} else { } else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds }); config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
} }
await window.api.saveConfig({ hosters: candidateHosters }); await window.api.saveConfig({ hosters: config.hosters });
return { accountId, candidateHosters, isEdit: ctx.isEdit }; // Skip the redundant await getConfig() — the in-memory state is the source
} // of truth for what we just wrote, decrypted creds didn't change, and the
// round-trip was the main lag source on add/delete.
function _applyCommittedAccount(persisted, validation) { accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters;
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
if (isEdit) { // Targeted updates instead of the 4-panel cascade. For add we need a full
// accounts-list re-render (new card) and the hoster summary count; for edit
// we can update the single card. Settings panel only needs re-render if its
// hoster-summary section is visible — that's covered by renderHosterSummary.
if (ctx.isEdit) {
updateAccountCard(accountId); updateAccountCard(accountId);
} else { } else {
renderAccounts(); renderAccounts();
} }
renderHosterSummary(); renderHosterSummary();
// Auto-close after a short pause so the user sees the success state.
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
} }
function _showOtpField() { function _showOtpField() {
@ -4644,8 +4567,6 @@ function _hideOtpField() {
async function loadHistory() { async function loadHistory() {
const history = await window.api.getHistory(); const history = await window.api.getHistory();
window._historyForStats = history || []; window._historyForStats = history || [];
_historyEverLoaded = true;
_historyDirty = false;
_invalidateHosterLifetimeCache(); _invalidateHosterLifetimeCache();
const retSel = document.getElementById('historyRetentionSelect'); const retSel = document.getElementById('historyRetentionSelect');
if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
@ -4751,68 +4672,44 @@ function _buildRecentRowHtml(row) {
// accumulating new uploads (the default case: sort=date desc, rows only grow). // accumulating new uploads (the default case: sort=date desc, rows only grow).
let _recentLastRenderedSig = ''; let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0; let _recentLastRenderedLen = 0;
let _recentPendingAppends = 0;
let _recentWorking = [];
let _recentLastRange = { start: -1, end: -1 };
let _recentScrollQueued = false;
function _onRecentScroll() { function renderRecentUploadsPanel() {
if (_recentScrollQueued) return;
_recentScrollQueued = true;
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
}
function _renderRecentVirtualRows() {
const wrap = document.querySelector('.recent-files-table-wrap');
const tbody = document.getElementById('recentFilesBody');
if (!wrap || !tbody) return;
const total = _recentWorking.length;
if (!total) return;
const scrollTop = wrap.scrollTop;
const viewportHeight = Math.max(wrap.clientHeight, 400);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
_recentLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
tbody.innerHTML = parts.join('');
}
function renderRecentUploadsPanel(appendOnly = false) {
const tbody = document.getElementById('recentFilesBody'); const tbody = document.getElementById('recentFilesBody');
if (!tbody) return; if (!tbody) return;
_recentPendingAppends = 0;
const wrap = tbody.closest('.recent-files-table-wrap');
if (!sessionFilesData.length) { if (!sessionFilesData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
_recentWorking = []; _recentLastRenderedSig = '';
_recentLastRange = { start: -1, end: -1 }; _recentLastRenderedLen = 0;
} else { return;
const prevLen = _recentWorking.length; }
_recentWorking = sortRecentFiles(sessionFilesData);
_recentLastRange = { start: -1, end: -1 }; const rows = sortRecentFiles(sessionFilesData);
const sig = `${recentSortState.key}|${recentSortState.direction}`; const sig = `${recentSortState.key}|${recentSortState.direction}`;
if (wrap) { const dateDescAppendOnly = sig === 'date|desc'
const added = _recentWorking.length - prevLen; && _recentLastRenderedSig === sig
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0; && rows.length > _recentLastRenderedLen
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT; && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
}
_renderRecentVirtualRows(); const wrap = tbody.closest('.recent-files-table-wrap');
const wasAtTop = !wrap || wrap.scrollTop <= 48;
let wasAppendOnly = false;
if (dateDescAppendOnly) {
const added = rows.length - _recentLastRenderedLen;
let html = '';
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
tbody.insertAdjacentHTML('afterbegin', html);
wasAppendOnly = true;
} else {
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
} }
if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
_recentLastRenderedSig = sig;
_recentLastRenderedLen = rows.length;
// Event delegation bind once, not per-row // Event delegation bind once, not per-row
if (!_recentListenersBound) { if (!_recentListenersBound) {
_recentListenersBound = true; _recentListenersBound = true;
if (wrap) {
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
}
tbody.addEventListener('click', (e) => { tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row'); const tr = e.target.closest('.recent-file-row');
if (!tr) return; if (!tr) return;
@ -4851,63 +4748,17 @@ function renderRecentUploadsPanel(appendOnly = false) {
}); });
} }
updateRecentSortHeaders(); // Sort headers only change when the sort state changes — skip on appends.
if (!wasAppendOnly) updateRecentSortHeaders();
} }
const HISTORY_RENDER_CAP = 2000; const HISTORY_RENDER_CAP = 2000;
let _historyWorking = [];
let _historyLastRange = { start: -1, end: -1 };
let _historyListenersBound = false;
let _historyScrollQueued = false;
function _onHistoryScroll() {
if (_historyScrollQueued) return;
_historyScrollQueued = true;
requestAnimationFrame(() => { _historyScrollQueued = false; _renderHistoryVirtualRows(); });
}
function _renderHistoryVirtualRows() {
const container = document.getElementById('historyContainer');
const tbody = document.getElementById('historyBody');
if (!container || !tbody) return;
const total = _historyWorking.length;
const scrollTop = container.scrollTop;
const viewportHeight = Math.max(container.clientHeight, 600);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _historyLastRange.start && endIdx === _historyLastRange.end) return;
_historyLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
for (let i = startIdx; i < endIdx; i++) {
const row = _historyWorking[i];
const link = row.link || '';
parts.push('<tr class="history-row');
if (row.isError) parts.push(' error');
parts.push('" data-link="');
parts.push(escapeAttr(link));
parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`);
parts.push(escapeHtml(row.date));
parts.push('</td><td class="col-filename">');
parts.push(escapeHtml(row.filename));
parts.push('</td><td class="col-host">');
parts.push(escapeHtml(row.host));
parts.push('</td><td class="col-link">');
parts.push(escapeHtml(link));
parts.push('</td></tr>');
}
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
tbody.innerHTML = parts.join('');
}
function renderHistoryTable(container) { function renderHistoryTable(container) {
if (!container || !historyRowsData.length) { if (!container || !historyRowsData.length) {
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>'; if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
const emptyNotice = document.getElementById('historyCapNotice'); const emptyNotice = document.getElementById('historyCapNotice');
if (emptyNotice) emptyNotice.style.display = 'none'; if (emptyNotice) emptyNotice.style.display = 'none';
_historyWorking = [];
return; return;
} }
@ -4923,22 +4774,50 @@ function renderHistoryTable(container) {
} }
} }
_historyWorking = sortHistoryRows(working); const rows = sortHistoryRows(working);
_historyLastRange = { start: -1, end: -1 };
const headerCell = (key, label) => { const headerCell = (key, label) => {
const active = historySortState.key === key; const active = historySortState.key === key;
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`; return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
}; };
container.innerHTML = `<table class="results-table history-table"><thead><tr> let html = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Link')} ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody id="historyBody"></tbody></table>`; </tr></thead><tbody>`;
if (!_historyListenersBound) { const parts = [html];
_historyListenersBound = true; const len = rows.length;
container.addEventListener('scroll', _onHistoryScroll, { passive: true }); for (let i = 0; i < len; i++) {
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onHistoryScroll).observe(container); const row = rows[i];
const link = row.link || '';
const date = escapeHtml(row.date);
const filename = escapeHtml(row.filename);
const host = escapeHtml(row.host);
const linkHtml = escapeHtml(link);
const linkAttr = escapeAttr(link);
parts.push('<tr class="history-row');
if (row.isError) parts.push(' error');
parts.push('" data-link="');
parts.push(linkAttr);
parts.push('"><td class="col-date">');
parts.push(date);
parts.push('</td><td class="col-filename">');
parts.push(filename);
parts.push('</td><td class="col-host">');
parts.push(host);
parts.push('</td><td class="col-link">');
parts.push(linkHtml);
parts.push('</td></tr>');
}
parts.push('</tbody></table>');
container.innerHTML = parts.join('');
// Delegated listeners: bind once per render-target instead of once per
// row/header. With a 5000-row history the per-row bind path was a
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
// dominant cause of "tab switching lags" in the user report.
if (!container.dataset.historyListenersBound) {
container.dataset.historyListenersBound = '1';
container.addEventListener('click', (e) => { container.addEventListener('click', (e) => {
const th = e.target.closest('th.sortable'); const th = e.target.closest('th.sortable');
if (th && container.contains(th)) { if (th && container.contains(th)) {
@ -4951,7 +4830,6 @@ function renderHistoryTable(container) {
} else { } else {
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
} }
container.scrollTop = 0;
renderHistoryTable(container); renderHistoryTable(container);
return; return;
} }
@ -4962,8 +4840,6 @@ function renderHistoryTable(container) {
} }
}); });
} }
_renderHistoryVirtualRows();
} }
function sortHistoryRows(rows) { function sortHistoryRows(rows) {
@ -5184,12 +5060,25 @@ function setupListeners() {
// Account hoster select change → update credential fields // Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => { document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
_invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login'; const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value); credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value);
wireCredentialVisibilityButtons(credsContainer); credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
document.getElementById('accountModalStatus').textContent = '';
document.getElementById('accountModalStatus').className = 'account-modal-status';
// Hoster changed → any prior validation is stale by construction. Drop the
// snapshot and revert the button so the user has to re-Prüfen.
_validatedCreds = null;
const sb = document.getElementById('saveAccountBtn');
if (sb) sb.textContent = 'Prüfen';
// The cred inputs were just replaced — rewire invalidation listeners on
// the fresh elements so post-validation edits still revert the button.
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
}); });
@ -5233,7 +5122,7 @@ function showUpdateBanner(info) {
function handleUpdateProgress(data) { function handleUpdateProgress(data) {
const msg = document.getElementById('updateMessage'); const msg = document.getElementById('updateMessage');
if (!msg) return; if (!msg) return;
if (data.stage === 'downloading') msg.textContent = `Wird heruntergeladen… ${data.percent || 0}%`; if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`;
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';

View File

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

View File

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

View File

@ -1,61 +1,27 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { writeFileSync, statSync, createReadStream, existsSync } from 'fs'; import { readFileSync, writeFileSync, statSync, createReadStream, existsSync } from 'fs';
import { resolve, basename } from 'path'; import { resolve, basename } from 'path';
import { pathToFileURL } from 'url';
const ROOT = resolve(import.meta.dirname, '..'); const ROOT = resolve(import.meta.dirname, '..');
const PKG_PATH = resolve(ROOT, 'package.json');
const RELEASE_DIR = resolve(ROOT, 'release'); const RELEASE_DIR = resolve(ROOT, 'release');
const PRODUCT_NAME = 'Multi-Hoster-Upload'; const PRODUCT_NAME = 'Multi-Hoster-Upload';
// --- CLI args --- // --- CLI args ---
export function parseReleaseArgs(args) { const args = process.argv.slice(2);
const version = Array.isArray(args) ? args[0] : ''; const dryRun = args.includes('--dry-run');
if (!/^\d+\.\d+\.\d+$/.test(version || '')) { const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a));
throw new Error('Usage: node scripts/release_gitea.mjs <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]'); const notes = args.filter(a => a !== version && a !== '--dry-run').join(' ') || '';
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 transportTagIndex = args.indexOf('--transport-tag'); const tag = `v${version}`;
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 = {}) {
@ -143,11 +109,8 @@ async function uploadAsset(releaseId, filePath, token) {
} }
// --- Main --- // --- Main ---
async function main(args = process.argv.slice(2)) { async function main() {
const plan = createReleasePlan(parseReleaseArgs(args)); console.log(`\nReleasing ${PRODUCT_NAME} ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
const { version, tag } = plan;
dryRun = plan.dryRun;
console.log(`\nReleasing ${plan.releaseTitle} via ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
// 1. Resolve remote // 1. Resolve remote
const remote = resolveGiteaRemote(); const remote = resolveGiteaRemote();
@ -175,19 +138,18 @@ async function main(args = process.argv.slice(2)) {
if (!recoveryMode) { if (!recoveryMode) {
// 4. Update package.json version // 4. Update package.json version
run(`npm version ${version} --no-git-tag-version --allow-same-version`); const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
console.log(`Updated package.json and package-lock.json -> ${version}`); pkg.version = version;
if (!dryRun) writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
console.log(`Updated package.json -> ${version}`);
// 5. Build // 5. Build
console.log('\nBuilding...'); console.log('\nBuilding...');
run('npm run release:win', { stdio: 'inherit' }); run('npm run release:win', { stdio: 'inherit' });
// 6. Git commit + tag + push // 6. Git commit + tag + push
const versionStatus = run('git status --porcelain -- package.json package-lock.json', { allowDry: true }); run('git add package.json');
if (versionStatus) {
run('git 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}`);
@ -195,7 +157,8 @@ async function main(args = process.argv.slice(2)) {
// 6b. Regenerate latest.yml to ensure correct SHA-512 // 6b. Regenerate latest.yml to ensure correct SHA-512
{ {
const setupPath = resolve(RELEASE_DIR, plan.setupName); const setupName = `${PRODUCT_NAME} Setup ${version}.exe`;
const setupPath = resolve(RELEASE_DIR, setupName);
if (existsSync(setupPath)) { if (existsSync(setupPath)) {
const sha = await new Promise((res, rej) => { const sha = await new Promise((res, rej) => {
const h = createHash('sha512'); const h = createHash('sha512');
@ -205,14 +168,18 @@ async function main(args = process.argv.slice(2)) {
s.on('error', rej); s.on('error', rej);
}); });
const size = statSync(setupPath).size; const size = statSync(setupPath).size;
const yml = renderLatestYml(plan, sha, size); const yml = `version: ${version}\nfiles:\n - url: ${setupName}\n sha512: ${sha}\n size: ${size}\npath: ${setupName}\nsha512: ${sha}\nreleaseDate: '${new Date().toISOString()}'\n`;
writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8'); writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8');
console.log('Regenerated latest.yml with correct SHA-512'); console.log('Regenerated latest.yml with correct SHA-512');
} }
} }
// 7. Verify artifacts // 7. Verify artifacts
const expectedArtifacts = plan.expectedArtifacts; const expectedArtifacts = [
`${PRODUCT_NAME} Setup ${version}.exe`,
`${PRODUCT_NAME} ${version}.exe`,
'latest.yml'
];
for (const name of expectedArtifacts) { for (const name of expectedArtifacts) {
const p = resolve(RELEASE_DIR, name); const p = resolve(RELEASE_DIR, name);
@ -223,7 +190,7 @@ async function main(args = process.argv.slice(2)) {
} }
// Also check for blockmap // Also check for blockmap
const blockmapName = plan.blockmapName; const blockmapName = `${PRODUCT_NAME} Setup ${version}.exe.blockmap`;
const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName)); const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName));
console.log('\nArtifacts verified.'); console.log('\nArtifacts verified.');
@ -236,19 +203,20 @@ async function main(args = process.argv.slice(2)) {
} }
// 9. Create release // 9. Create release
const releaseBody = notes || `${PRODUCT_NAME} ${tag}`;
let releaseId; let releaseId;
const { status: createStatus, data: createData } = await giteaApi( const { status: createStatus, data: createData } = await giteaApi(
'POST', 'POST',
`/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`, `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`,
token, token,
{ tag_name: tag, name: plan.releaseTitle, body: plan.releaseBody } { tag_name: tag, name: `${PRODUCT_NAME} ${tag}`, body: releaseBody }
); );
if (createStatus === 409 || createStatus === 422) { if (createStatus === 409 || createStatus === 422) {
// Release already exists, find it // Release already exists, find it
const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token); const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token);
releaseId = resolveExistingReleaseId(plan, releases); releaseId = releases.id;
console.log(`Release already exists (id: ${releaseId})`); console.log(`Release already exists (id: ${releaseId})`);
} else { } else {
releaseId = createData.id; releaseId = createData.id;
@ -266,10 +234,7 @@ async function main(args = process.argv.slice(2)) {
console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`); console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`);
} }
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
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,86 +1,5 @@
# Lessons # Lessons
## 2026-08-07 — Einen kleinen Release nicht durch redundante Gates aufblasen
**Symptom:** Die angefragten Änderungen waren implementiert und getestet, trotzdem lief die Arbeit durch wiederholte Status-, Review- und Harness-Schleifen übermäßig lange weiter.
**Root cause:** Pflichtsicherheit, bereits belegte Wiederholungsprüfungen und optionale Zusatzdiagnostik wurden nicht hart getrennt. Technische Harnessfehler führten zu weiteren Schleifen, obwohl Produktcode und Kernlauf bereits grün waren.
**Regel:** Nach grüner Implementierung genau eine risikogerechte Pflichtkette fahren: Tests, Build, Secret-Gate, realer Lauf, Positivliste, Veröffentlichung, Redownload. Bereits belegte Gates nicht wiederholen und optionale Diagnose sofort streichen, sobald sie die Auslieferung verzögert.
**Wie anwenden:** Vor jedem zusätzlichen Check benennen, welche noch offene Release-Invariante er beweist. Beweist er keine neue Pflichtinvariante, wird er nicht ausgeführt.
## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`)
**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).
Erst das in v3.3.98 eingebaute `config-load/config-serialize`-Log machte es sichtbar — aber mein eigenes
Log-Feld `queue=` las `.length` auf dem pendingQueue-OBJEKT (immer undefined) und hätte mich fast in die
falsche Richtung (pendingQueue statt history) geschickt.
**Root cause:** (1) Ich hatte die config-Persistenz als „instrumentieren, nicht fixen" zurückgestellt (richtig
für die unsichere Migration), aber den Lag-Treiber dort nicht früh genug vermutet. (2) Instrument-Felder selbst
müssen verifiziert werden: `(obj || []).length` auf einem Objekt = undefined, still falsch.
**Regel:** Wenn der User „es laggt unverändert" sagt obwohl die letzte Messung gut aussah, ist der gemessene
Pfad NICHT der Hot-Path — sofort BREITER messen (jeden IPC-Handler, jede periodische Main-Op, Main-Thread-
Longtask-Monitor), nicht den schon-gemessenen Pfad weiter optimieren. Und Instrument-Ausgaben gegen ein
bekanntes Beispiel prüfen (zeigt `queue=` je eine echte Zahl?).
**Wie anwenden:** Bei „Symptom unverändert trotz Fix": Hypothese fallen lassen, Coverage verbreitern. Log-Felder
beim Schreiben mit einem realen Wert gegenchecken, nie blind `(x||[]).length` auf unklar getypten Feldern.
## 2026-06-21 — „Brot finden, nicht Krümel": die EINE Änderung, die alle Kosten killt, schlägt drei sichere Teilfixes
**Symptom:** Fix-Design bot loadShallow (Klon vermeiden) + cache-repopulate + resolution-cache. Adversary zeigte:
loadShallow killt nur den Klon (~10s von 34s), die 38 Serializes (8,4s) + 38 Post-Write-Reparses (15,2s) bleiben,
weil Writes den Cache nullen → loadShallow allein = Krümel.
**Root cause:** Alle drei Kosten (parse+clone+serialize) entstehen daraus, dass history IM Hot-Config liegt.
Nur history RAUS aus der immer-geladenen Datei (eigene electron-history.json) killt alle drei gleichzeitig.
cache-repopulate-Gate feuerte nie (toter Code); resolution-cache hätte stale-Pools → Failover-Regression
(rotation/byse) riskiert = die EINE Sache die Uploads STILL korrumpiert, schlimmer als Lag.
**Regel:** Wenn der User „komplett wegmachen" fordert und mehrere sichere Teilfixes vs. ein riskanterer
Komplettfix zur Wahl stehen: den Komplettfix nehmen, aber RICHTIG absichern (hier: fsync+verify-before-strip,
permanenter .pre-history-split.bak, Migration packaged-only + per-Init nicht in load(), Crash-Window-Fallback,
Test gegen die ECHTE 194MB-Fixture). Teilfixes die den Treiber nur anknabbern NICHT bündeln (verwässert Messung
+ Risiko). Einen Fix der etwas STILL korrumpieren könnte (stale Account-Pools) NIE für Performance einbauen.
**Wie anwenden:** Bei mehreren Fix-Optionen fragen: „welche EINE Änderung entfernt die gemeinsame Wurzel ALLER
Kostenpfade?" — die nehmen und maximal absichern, statt N sichere Teilfixes die je nur einen Pfad treffen.
## 2026-06-21 — Ein-Variablen-Disziplin: nicht zwei Fixes bündeln, wenn einer den anderen maskiert
**Symptom:** Nach dem tp=8-Win wollte ich in EINEM Build A (1MB highWaterMark, Read-Burst) + B (Renderer
chunked rAF Batch-Drain, der 243ms-Longtask) + C-Instrument shippen.
**Root cause / Korrektur (Advisor):** Der Renderer war 14/15 Fenstern gesund; der EINE 243ms-Longtask (W14)
ist laut beiden Agenten DOWNSTREAM des Main-Thread-Read-Bursts (geflutetes IPC). Fix A reduziert diese
Stalls → der Renderer-Longtask verschwindet wahrscheinlich OHNE B. B mitzuliefern (a) verwässert die nächste
Messung (war die Besserung A oder B?) und (b) fasst den Progress-Hot-Path an, der hier schon gebissen hat
(formatDateTime-Burst, ghost-fix).
**Regel:** Wenn Fix A einen vermuteten Symptom-Treiber X reduziert und Fix B genau X behandeln würde —
NUR A shippen, messen, B nur nachziehen wenn X überlebt. Sonst kann das nächste Log nicht sauber attribuieren.
Bei gekoppelten Symptomen ist die Reihenfolge (Upstream-Fix zuerst, dann messen) wichtiger als „alles auf
einmal".
**Wie anwenden:** Vor dem Bündeln fragen: „Maskiert Fix A die Wirkung, die Fix B beheben soll?" Wenn ja →
entkoppeln, A zuerst, eine Variable pro Build.
## 2026-06-21 — Nicht aus EINEM konfundierten Sample eine Ursache behaupten
**Symptom:** Ich wollte dem User sagen „1-Sekunden-Persist-Freeze gefunden" auf Basis von W13 (max=1021ms,
heap→142MB).
**Root cause / Korrektur (Advisor):** W13 ist EIN Sample und konfundiert (hat gleichzeitig FSReqCallback=66)
und das EINZIGE Heap-Spike-Fenster. Die anderen isolierten Maxes (W4 415ms/heap41, W10 852ms/heap18) haben
NIEDRIGEN Heap → sind KEIN 140MB-structuredClone+stringify → eine andere Ursache (account-failed sync load()
nahe Connection-Churn). Eine Behauptung aus einem konfundierten Punkt hätte den falschen Fix priorisiert.
**Regel:** Bei isolierten Spitzen erst die Co-Signale (heap, FSReq, gc, Nachbarfenster) gegenchecken, ob sie
EINE Familie sind. Wenn die Magnitude-Signatur (hier: Heap-Spike) nicht bei allen passt → es sind mehrere
Ursachen. „Instrumentieren + bestätigen", nicht „gefunden", solange nur ein konfundierter Punkt existiert.
**Wie anwenden:** Vor „Ursache X gefunden": gibt es ≥2 unkonfundierte Samples mit derselben Signatur? Wenn
nein → als Hypothese formulieren und messen, nicht als Befund verkaufen.
## 2026-06-21 — Histogram-Korrelation beweist KEINE Kausalrichtung; rss-Mathe als Sanity-Check
**Symptom:** ELD-Spikes korrelierten exakt mit hohem `FSReqCallback` (File-Reads in flight) → ich wollte
sofort ein Read-Concurrency-Semaphore über 5 Dateien bauen.
**Root cause / Korrektur (Advisor):** (1) Korrelation ≠ Kausalität: hohe in-flight-Reads können auch SYMPTOM
sein — ein aus ANDEREM Grund blockierter Loop drained die Read-Completions nicht, also stapeln sie sich im
Snapshot. (2) rss-Mathe widerlegte meine „Read-Buffer ballonen den Speicher"-These: 70 Streams × 256KB ≈
18MB, aber rss schwang ~300MB → das ist Heap-/Objekt-Churn (GC), nicht die Read-Buffer.
**Regel:** Bevor ich auf Basis einer Histogramm-Korrelation einen Multi-File-Refactor baue: (a) Kausalrichtung
mit einem BILLIGEN reversiblen 1-Zeilen-Hebel testen (hier UV_THREADPOOL_SIZE 64→8), (b) die Größenordnung
gegenrechnen (passt die vermutete Quelle zahlenmäßig zur beobachteten Wirkung?), (c) den fehlenden Co-Faktor
(GC) erst MESSEN, bevor ich ihn aus- oder einschließe. Ein Build kann gleichzeitig Kandidaten-Fix UND
Diskriminator sein.
**Wie anwenden:** Bei „X korreliert mit Y, also fixe X": erst fragen „könnte Y → X statt X → Y?" und „passt
die Magnitude?". Wenn nein/unklar → erst der billige reversible Knopf + Messung, dann der teure Refactor.
## 2026-04-21 — DOM-Doppelrender bei Bulk-State-Changes ## 2026-04-21 — DOM-Doppelrender bei Bulk-State-Changes
**Symptom:** User klickt auf "Erneut versuchen" mit 500+ Jobs → App hängt sekundenlang. **Symptom:** User klickt auf "Erneut versuchen" mit 500+ Jobs → App hängt sekundenlang.
**Root cause:** `retrySelectedJobs()` ruft `renderQueueTable + updateQueueActionButtons + updateStatusBar` auf, `startSelectedUpload()` ruft direkt danach genau dieselben Funktionen nochmal auf. **Root cause:** `retrySelectedJobs()` ruft `renderQueueTable + updateQueueActionButtons + updateStatusBar` auf, `startSelectedUpload()` ruft direkt danach genau dieselben Funktionen nochmal auf.
@ -204,52 +123,3 @@ die Magnitude?". Wenn nein/unklar → erst der billige reversible Knopf + Messun
**Bug 2 (Probe, Whitelist-Integrität):** Die Op-Tabelle war ein Plain-Object-Literal → `OPS['constructor']`/`['toString']`/`['valueOf']` lösen geerbte Object.prototype-Funktionen auf, bestehen `typeof fn==='function'` und liefern `{ok:true}`. Harmlos (kein Secret/Write), aber Whitelist-Loch. Fix: `typeof op==='string' && Object.prototype.hasOwnProperty.call(OPS,op)`. **Bug 2 (Probe, Whitelist-Integrität):** Die Op-Tabelle war ein Plain-Object-Literal → `OPS['constructor']`/`['toString']`/`['valueOf']` lösen geerbte Object.prototype-Funktionen auf, bestehen `typeof fn==='function'` und liefern `{ok:true}`. Harmlos (kein Secret/Write), aber Whitelist-Loch. Fix: `typeof op==='string' && Object.prototype.hasOwnProperty.call(OPS,op)`.
**Bug 3 (nur Live-Integration sichtbar):** Der Gateway las die App-Version aus `info.data.version`, der Collector liefert sie aber als `info.data.app.version` — der "connected to vX.Y.Z"-Hinweis war still leer. Unit-Tests mit Stubs fingen das NIE; nur das Fahren des echten Gateway-Prozesses gegen den echten Collector deckte die Shape-Diskrepanz auf. **Bug 3 (nur Live-Integration sichtbar):** Der Gateway las die App-Version aus `info.data.version`, der Collector liefert sie aber als `info.data.app.version` — der "connected to vX.Y.Z"-Hinweis war still leer. Unit-Tests mit Stubs fingen das NIE; nur das Fahren des echten Gateway-Prozesses gegen den echten Collector deckte die Shape-Diskrepanz auf.
**Regeln:** (1) Ein read-only Diagnose-Agent, der IM Zielprozess läuft, darf keine vom Client kontrollierte Synchron-Operation mit unbegrenztem Aufwand ausführen (Regex, JSON.parse von Riesen-Payloads, etc.) — sonst DoS der diagnostizierten App. Literal-Match statt Regex; alles clampen. (2) Whitelist NIE als Plain-Object mit `obj[key]`-Lookup — Prototype-Member lecken; Set/Map/null-proto/hasOwnProperty. (3) Eine Live-Integration gegen den ECHTEN Out-of-Process-Consumer findet Shape-/Contract-Mismatches, die Mock-Unit-Tests strukturell nicht sehen — bei jedem Protokoll-Grenzübergang (MCP-Tool↔Collector) mindestens EINEN echten End-to-End-Lauf. (4) Ein grüner Custom-E2E ist kein Freibrief: der Advisor/Audit fand den Queue-Leak NACH grünem E2E, weil das Gate bequeme Flags (includeJobs:false) setzte — jeden Collector mit DEFAULT-Args fahren. **Regeln:** (1) Ein read-only Diagnose-Agent, der IM Zielprozess läuft, darf keine vom Client kontrollierte Synchron-Operation mit unbegrenztem Aufwand ausführen (Regex, JSON.parse von Riesen-Payloads, etc.) — sonst DoS der diagnostizierten App. Literal-Match statt Regex; alles clampen. (2) Whitelist NIE als Plain-Object mit `obj[key]`-Lookup — Prototype-Member lecken; Set/Map/null-proto/hasOwnProperty. (3) Eine Live-Integration gegen den ECHTEN Out-of-Process-Consumer findet Shape-/Contract-Mismatches, die Mock-Unit-Tests strukturell nicht sehen — bei jedem Protokoll-Grenzübergang (MCP-Tool↔Collector) mindestens EINEN echten End-to-End-Lauf. (4) Ein grüner Custom-E2E ist kein Freibrief: der Advisor/Audit fand den Queue-Leak NACH grünem E2E, weil das Gate bequeme Flags (includeJobs:false) setzte — jeden Collector mit DEFAULT-Args fahren.
## 2026-06-19 — "Mach es wie <anderes Projekt>" = das Projekt FINDEN und EXAKT mappen, nicht raten (Tailscale/Allowlist, v3.3.86)
**Kontext:** User: "nutzen wir dasselbe wie der downloader mit tailscale was mcp betrifft" + "ka, das was der downloader nutzt, mach dasselbe". Der User kannte die Details NICHT — er wollte 1:1-Replikation eines Schwester-Projekts.
**Vorgehen das funktioniert hat:** (1) Sibling-Projekte gelistet, per Grep nach `tailscale`/`100.64`/`ts.net` gesucht → `Real-Debrid-Downloader/tools/rd-diagnostics-mcp` gefunden (eine fast identische MCP-Ferndiagnose existierte schon). (2) Einen Explore-Subagenten eine PRÄZISE Implementierungs-Map mit file:line + Code-Excerpts erstellen lassen (bind modes, fail-closed allowlist, code-format, bridge, UI, IPC). (3) Das Muster EXAKT repliziert statt zu raten.
**Was der Downloader anders macht (und warum es Tailscale ermöglicht):** Host steckt IM Code (`{v,h,p,t,n,fp?,s?}`), nicht extern. Zwei Bind-Modi: lokal (127.0.0.1) ODER Netzwerk (0.0.0.0) — letzteres NUR mit nicht-leerer **fail-closed IP-Allowlist**: leere Allowlist = nur Loopback; geprüft am ECHTEN socket.remoteAddress (NIE forwarded-Header), `::ffff:`-normalisiert, CIDR-Matching. Tailscale wird NICHT autodetektiert — es ist nur eine der `os.networkInterfaces()`-IPs, erreicht über den Tunnel; Allowlist (auf den Tailnet, z.B. `100.64.0.0/10`) + Token sind das Gate, WireGuard ist die Verschlüsselung.
**Regel:** Bei "mach es wie X": X lokalisieren (grep), die security-kritischen Teile mit einem Subagenten verbatim mappen, dann replizieren. Eine fail-closed Allowlist (Loopback immer erlaubt, leer=loopback-only, real peer IP) ist das richtige Modell für netzwerk-erreichbare read-only Diagnose über einen vertrauten Tunnel — plaintext-Transport ist ok, WENN der Tunnel (Tailscale/WireGuard) verschlüsselt UND die Allowlist+Token den Zugriff gaten. Den HAPPY-Path (allowlisted non-loopback peer über echten 0.0.0.0-Socket) auch LIVE testen, nicht nur per Komposition aus Unit+Wiring.
**Prozess-Stolperstein:** Test NACH dem Feature-Commit hinzugefügt → release_gitea.mjs brach ab ("uncommitted tracked changes"). Vor jedem Release: `git status --porcelain | grep -v '^??'` muss leer sein. Tracked-aber-uncommitted (auch ein nachgereichter Test) blockt den Build.
## 2026-06-21 — "Gefühlt laggy nach Zeit, CPU/RAM normal" = ERST messen, dann in echtem Blink profilen (v3.3.87)
**Kontext:** User: Programm fühlt sich nach langer Laufzeit mit vielen Uploads zäh an, CPU ~40%/8 Kerne, RAM 6/32 GB — beide normal/stabil. Erste Hypothese (Haupt­prozess-Config-I/O skaliert mit wachsender History) war für DIESEN User FALSCH.
**Was es wirklich war (gemessen + profiliert):** `renderRecentUploadsPanel` hatte einen Append-only-Fastpath, gegated auf `rows.length > _recentLastRenderedLen`. `maybeAddSessionFile` capped per push-then-slice (2000→2001→zurück auf 2000). Ab dem Cap ist `rows.length` auf 2000 fixiert → Gate für IMMER false → JEDE Completion fiel in den Full-`innerHTML`-Rebuild von 2000 Zeilen. Blink-Messung (Playwright, table-layout:fixed, gleiche Engine wie Electron): **~80 ms pro Completion** → wiederkehrender 80-ms-Freeze. Fix (append-evict, Gate auf `pendingAppends>0`, Overflow vom DOM-Boden evicten): **80 ms → 7,4 ms** (>10×), DOM bleibt exakt == Daten (Cap/Reihenfolge/keine Dupes), über 5000 Completions verifiziert.
**Regel 1 — Magnituden NICHT raten, LESEN:** „wächst über Zeit" ist eine Annahme über GRÖSSE. Die echte electron-config.json war 52 KB (History 23 Zeilen) — ein einziger `node`-Read killte die ganze Config-I/O-Theorie. Bevor man eine „skaliert-mit-X"-Ursache fixt: X am echten Artefakt messen (Dateigröße, Array-Länge, Job-Count im persistierten State).
**Regel 2 — Im ECHTEN Renderer-Engine profilen, nicht analytisch raten:** jsdom rendert kein Blink-Layout. Playwright (Chromium = Electron-Blink) mit `performance.now()` um (a) Rebuild und (b) erzwungenes Relayout nach Style-Write liefert die Zahl, die entscheidet: 3 ms = unsichtbar, 80 ms = DIE Ursache. Dieselbe Messung ist Fix-Auswahl UND Vorher/Nachher-Verifikation (das Goal verlangt „verifiziere dass behoben" — ein grüner Test beweist Korrektheit, NICHT dass der Lag weg ist).
**Regel 3 — Multi-Agent-Findings gegen primäre Evidenz prüfen (Control-Char-Falsch­positiv):** Der Hunt meldete HIGH-ish einen „_sessionFileKeys delete-key separator mismatch". Beim Versuch ihn zu fixen matchte der Edit-`old_string` NICHT. Char-Code-Dump (`HAS_U0001: True`) zeigte: die Zeile hat ECHTE U+0001-Zeichen — die Read-Tools der Verifier-Agenten rendern Steuerzeichen unsichtbar, sie schlossen fälschlich „keine Separatoren". KEIN Bug. **Wenn ein Fix-`old_string` nicht matcht obwohl Grep ihn zeigt: Char-Codes dumpen, bevor man dem Tool misstraut — die Quelle kann unsichtbar von der Read-Anzeige abweichen.**
**Regel 4 — Den negligible-aber-realen Befund mit Zahl ABLEHNEN, nicht aus dem Bauch:** queueJobs O(N)-Scan pro Render (wächst unbounded, da removeFromQueueOnDone=false UND Folder-Monitor EINEN Batch via addJobs am Leben hält → 500-Cap-Prune feuert nie) — real, aber Blink-gemessen <0,1 ms bei 3000 Jobs. Den riskanten Inkremental-Counter-Refactor mit DIESER Zahl skippen, nicht mit fühlt sich klein an".
**Wie anwenden:** Append-only-Optimierungen, die auf Längenwachstum gaten, brechen still an JEDEM Cap (push-then-slice fixiert die Länge) — stattdessen die Anzahl NEUER Items zählen und am Boden evicten. „Mach es wie die Queue-Tabelle (virtualisieren)" war hier NICHT nötig: die Messung zeigte stehende 2000 Zeilen kosten median 0,4 ms; nur der Rebuild war teuer. Simplest-Fix der die gemessene Ursache trifft schlägt die größere Architektur-Änderung.
## 2026-06-21 — "Audit JEDE zeile" = audit + measure + risk-appropriate DEFER, nicht fix-everything (v3.3.88)
**Kontext:** Nach dem v3.3.87-Lag-Fix Folge-Goal: „schau dir wirklich JEDE zeile an die du geschrieben hast und schau ob es solche probleme gibt o. geben könnte". 18-Agenten-Audit + Eigen-Review jeder Hot-Path-Zeile + Blink-Benchmarks.
**Befund:** Der Audit fand, dass MEIN eigener T1-Fix (config-store cache, 29d1944) eine latente Regression einführte: `load()` macht ein unconditionales `structuredClone` der GANZEN config (inkl. unbounded history) pro Call → write-interleaved loads 2,22,4× LANGSAMER als das alte read+parse (gemessen @8000 Batches: 9,65 ms → 22,96 ms). Skaliert mit historySize. ABER: der echte User hat 8 Batches / 4,8 KB → Mikrosekunden. Negligible.
**Die Falle (Advisor hat geblockt):** Ich wollte es „elegant" fixen mit `history.slice()` (shallow) statt deep-clone. Advisor: STOPP. `load()` ist der gefährlichste Code im Repo (config + credentials; Korruption = Datenverlust), ich war hier schon mal von Cache-Semantik gebissen worden. Und: der Perf-Win und das Risiko sind DIESELBE Münze — der Speedup kommt NUR vom Sharing der Batch-Objekte by-reference, und genau dieses Sharing IST die Silent-Cache-Corruption-Gefahr (hängt an einem globalen Invariant „nichts deep-mutated je eine history-Batch" den ich über zukünftigen Code + jeden getHistory-Consumer nicht erzwingen kann). Es gibt KEINE sichere Version dieses Ansatzes → falsches Werkzeug für safety-kritischen Code. Hardcoded 5 keys in `_cloneConfig` wäre ein zweiter Footgun (zukünftiger top-level key verschwindet still aus jedem load()).
**Regel:** „Audit jede Zeile" heißt JEDE Zeile ANSCHAUEN + die Magnitude MESSEN + eine risiko-angemessene Entscheidung treffen — NICHT jeden geflaggten Befund fixen. Bei einem Audit-Goal ist „ich habe jede Zeile geprüft, jeden Befund als sub-ms bei realistischer History gemessen, den Mechanismus bestätigt aber den Fix als riskante Persistenz-Chirurgie für einen latenten Mikro-Cost eingestuft, also dokumentiere ich ihn statt ihn zu shippen" die VOLLSTÄNDIGE, gründliche Antwort. Jeden geflaggten Punkt unabhängig vom Risiko zu fixen ist keine Gründlichkeit — so wird aus einer Lag-Fix-Session ein Datenverlust-Incident. Nur den EINEN Befund shippen der im echten Szenario beißt (doodstream `_debugLog`: sync statSync+appendFileSync ~815×/Upload auf dem Main-Loop während des Uploads → hinter `logVerbose` gaten, default off, near-zero risk). Den Rest als bewusste Defers mit Messzahlen dokumentieren.
**Wie anwenden:** Wenn ein Goal („JEDE!! JEDE!!!") + ein Stop-Hook Druck erzeugen, immer weiterzuschneiden: das ist genau der Moment, den Advisor VOR dem Edit zu rufen. Magnitude am ECHTEN Artefakt prüfen (der User-Config, nicht @8000-Batches-Hypothese). Persistenz-/Credential-Code nur anfassen wenn der Fix risiko-frei UND der Gewinn real-spürbar ist — sonst dokumentieren und stoppen.
## 2026-06-21 — "Nicht-Persistenz also sicher" ist ein Trugschluss: der Redaktions-Layer ist GENAUSO gefährlich (v3.3.89)
**Kontext:** 3. identische /goal-Re-Fire („JEDE zeile, alles drum-und-dran"). Diesmal die un-auditierte Remote-Diagnostics-Code (v3.3.84/85) zeilenweise auditiert (52 Agenten). 14 von 15 actionable Findings konvergierten auf EINEN Cold-Path-Freeze: `server_health` macht O(historySize) sync-Arbeit pro Request (~67 config-clones + unbounded history-walks; `limit` slict nur den Output). Gemessen 258 ms6,7 s bei großer History → friert die App ein, die es diagnostiziert (verletzt die v3.3.85-Regel).
**Der Trugschluss (Advisor hat geblockt):** Ich begründete „diesmal ist der Fix sicher, weil es Diagnostics-Collectors sind, KEIN Credential-Persistenz-Code wie letzte Runde". FALSCH. `lib/diagnostics-collectors.js` IST die Credential-Oberfläche — es ist der Redaktions-Code (`_secrets`/`_deepRedact`/`collectSecretValues`/`sanitizeConfig`/`redactLogText`). Genau dieser Code ist schon ZWEIMAL geleakt (7b5420e „one collector still leaked", 8d757a9 „redaction gaps") — bei grünem E2E. Der „elegante" Fix (config+secrets einmal snapshoten und durch die Collectors threaden) ist EXAKT die gefährliche Form: ein Pfad verpasst / ein stale secrets-array → SECRET LEAK, ein schlimmeres Versagen als der Cold-Freeze. Dieselbe Kategorie-Fehler wie letzte Runde (damals Datenverlust an config-store, jetzt Secret-Leak an der Redaktion), nur andere Datei. „Nicht Persistenz" hat mich getäuscht.
**Regel:** Die Frage ist nicht „ist es Persistenz?", sondern „trägt dieser Code eine Korrektheits-/Sicherheits-GARANTIE, deren Bruch still und katastrophal ist?" — Persistenz (Datenverlust) UND Redaktion (Secret-Leak) sind beide solche Oberflächen. Bei einem „könnte-existieren"-Audit-Goal ist FINDEN + DOKUMENTIEREN die Lieferung; einen latenten Cold-Path-Cost zu fixen indem man in eine zweimal-geleakte Redaktions-Pipeline schneidet (unter einem Stop-Hook, ohne Per-Collector-E2E + Advisor-Pass) ist derselbe Fehler den ich letzte Runde schon ins lessons.md geschrieben hatte. KONSISTENT anwenden. Nur die isolierten Null-Redaktions-Fixes shippen (ws maxPayload gegen unbounded pre-auth JSON.parse; sendToClient readyState+try-guard gegen uncaughtException). Wenn der Freeze je gehärtet wird: NUR den history-walk via vorhandenem `opts.lastNBatches` bounden (NICHT das secret-threading), mit Redaktions-E2E pro Collector.
**Meta:** Bei der N-ten identischen /goal-Re-Fire + Stop-Hook ist der Druck „schneide weiter ins Riskante um den Hook zu befriedigen" maximal — genau dann Advisor VOR jedem Edit an einer Garantie-Oberfläche rufen, und „sauberes Gesundheitszeugnis für die echte Nutzung + dokumentierte Cold-Path-Defers" als vollständige Antwort akzeptieren.
## 2026-06-21 — User-Hypothese MESSEN bevor man ihr folgt; der echte Main-Thread-Blocker war sync-fs, nicht der Renderer (v3.3.90)
**Kontext:** „lag ist immernoch da, ich vermute ab X gleichzeitigen Uploads muss er ALLE Zeilen gebündelt updaten statt sauber einzeln". 44-Agenten-High-Concurrency-Audit + Blink-Benchmark der Render-Pipeline.
**Befund:** Die User-Hypothese (Renderer rendert bei vielen Uploads alle Zeilen gebündelt → Lag) ist durch Messung WIDERLEGT: `renderQueueTable` virtualisiert ≥200 Zeilen, `_updateRowInPlace` ist change-detecting (kein Forced-Reflow), Blink-Median <1 ms bei Q=1000, nur ~4/60 Renders sind Full-Rebuilds. Der Renderer ist NICHT der Flaschenhals. Der ECHTE Blocker: `lib/clouddrop-upload.js _uploadChunked` las jeden 16-MB-Chunk mit `fs.readSync` SYNCHRON auf dem Main-Event-Loop (einzigartig unter den 5 Uploadern die anderen 4 streamen async). Bei jedem Read ~59 ms SSD / 30100 ms langsame Platte friert der GANZE Main-Loop (alle Progress/IPC/Render/andere-Uploads). Skaliert mit der Zahl paralleler clouddrop-Uploads. Passt exakt auf laggy beim Hochladen, schlimmer mit mehr gleichzeitig". User nutzt clouddrop.
**Fix:** `fs.openSync`/`readSync`/`closeSync` → `fs.promises.open` + `await fh.read` + `await fh.close()`. Byte-Äquivalenz mit Hash-Vergleich über alle Chunk-Grenzfälle verifiziert (volle/partielle/multi-Chunk/1-Byte) BEVOR geshipped — ein Chunk-Read-Bug = korrupter Upload, deshalb Pflicht-Verifikation, nicht „sieht richtig aus". Separater Fix: rotation-retry + suspect-alternate progressCb in upload-manager.js feuerten `_emitProgress` (sync `emit` + frischer Object-Spread) bei JEDEM Stream-Chunk (hunderte/s/Job) — der 250-ms-`lastEmitTime`-Gate des Primary-Path fehlte. Gate gespiegelt (activeEntry-Mutation bleibt ungated für Stats/Speed-Monitor, nur der emit ist gegated).
**Regel:** Wenn der User eine konkrete Mechanik vermutet („er updatet alle Zeilen gebündelt"), die Mechanik MESSEN bevor man sie fixt — nicht der Plausibilität folgen. Die Messung kann die Hypothese widerlegen UND den echten Verursacher woanders aufdecken (hier: nicht Renderer-DOM, sondern sync-fs im Upload-Datapfad). „Laggy bei moderater CPU" (40%/8 Kerne = ein Kern bei 100%) zeigt auf Main-Thread-Sättigung/sync-Blocking, NICHT auf DOM-Amplifikation. Bei Daten-Pfad-Fixes (Upload-Bytes) immer Byte-Äquivalenz beweisen, nicht nur Tests grün.
**Discriminator nicht vergessen:** Mit der echten User-Config (parallelCount 2×5 Hoster ≈10 gleichzeitig) sind „100 gleichzeitig" nur erreichbar wenn die Parallel-Counts hochgedreht wurden — sonst sind „100" die QUEUE-Größe, nicht concurrent. Nach dem Ship dem User die Unterscheidungsfrage stellen (Lag clouddrop-spezifisch? Parallel-Counts erhöht?), statt blind Sieg zu erklären — bei echter High-Concurrency bräuchte es ein Concurrency-Cap / Worker-Prozess, keinen Mikro-Fix.
## 2026-06-21 — Zwei lebende Hypothesen mit GEGENSÄTZLICHEN Fixes: instrumentieren statt per Elimination refactoren (v3.3.91)
**Kontext:** Discriminator beantwortet — Lag ist TRUE high-concurrency (User fährt 50+ gleichzeitig, Counts hochgedreht), nicht clouddrop. Ich wollte einen Mess-Workflow starten, um „inherent TLS → Worker" zu belegen.
**Die Falle (Advisor hat geblockt):** Der Workflow hätte die JS-Kosten (schon weitgehend als billig gemessen) nur RE-bestätigt und dann „TLS → Worker" per ELIMINATION geschlossen — derselbe Renderer-Rate-Fehler eine Ebene höher. Den credential-tragenden Upload-Core (throttle/rotation/abort/progress) auf Eliminations-Schluss umzubauen ist genau „measure-before-build" verletzt. ZWEI Hypothesen leben und brauchen GEGENSÄTZLICHE Fixes: (A) Main-Thread CPU-blockiert (TLS/crypto/sync) → Event-Loop stallt → Cap/Worker helfen; (B) Main-Thread fein aber IO-STARVED (libuv-Threadpool/Sockets) → Loop bleibt responsiv, Uploads stauen nur → Worker sind VERSCHWENDET, Config fixt es. Ich konnte im Sandbox die echte 50-fach-TLS-Last nicht messen → also hätte JEDER Sandbox-Bench die falsche Antwort per Elimination geliefert.
**Regel:** Wenn zwei Hypothesen gegensätzliche, teure/schwer-reversible Fixes implizieren UND du die entscheidende Größe im Sandbox nicht messen kannst — baue das MESSINSTRUMENT in die echte App, nicht den Fix. Hier: `perf_hooks.monitorEventLoopDelay` im Main-Prozess, geloggt während Uploads (reine Zahlen → keine Redaktions-Oberfläche). Hohe mean/p99 → CPU-blockiert → Worker gerechtfertigt; niedrige Delay während Uploads stauen → IO-bound → Worker verschwendet, Threadpool/Sockets ist der Hebel. Die Zahl entscheidet die ganze Architektur und blockiert nicht. Den Worker/Child-Process-Refactor NIE off-sandbox per Elimination shippen — erst die echte-App-Zahl + explizites User-OK (hart reversibel, fasst Credentials/Abort/Rotation an).
**Billigster konkreter Verdächtiger zuerst (reversibel, kein Refactor):** `UV_THREADPOOL_SIZE` Default 4 — alle Uploader speisen undici aus `fs.createReadStream` + DNS getaddrinfo durch denselben Pool → 50 concurrent vs 4 Threads = harter Cliff bei kleiner Connection-Zahl = exakt „ab X connections". Auf 64 (erste Zeile vor require('electron'), libuv liest beim Lazy-Init; Threads on-demand → 64-Max kostet nichts wenn ungenutzt). EINE Env-Var testet die Hypothese mit null Risiko. Windows-Eigenheit dabei gefunden: getaddrinfo wird vom Windows-DNS-Client-Service serialisiert → die DNS-Hälfte des Cliffs ist auf Windows maskiert (fs-Read-Hälfte profitiert trotzdem) — weiterer Grund, warum nur die echte-App-ELD-Zahl zählt, nicht der Sandbox-Bench.
## 2026-06-21 — Das REGIME erfragen bevor man misst/fixt; die Lag-Knoten war eine verschwendete Intl-Format pro Progress-Event (v3.3.93)
**Kontext:** User liefert echte Daten: „25 connections okay, 50+61 laggt, EVTL wenn die uploadenden Zeilen nicht im Bild sind." Ich wollte sofort meine „non-virtual reflow"-Theorie benchmarken/fixen.
**Die Falle (Advisor hat geblockt — ZUM DRITTEN MAL die Regime-Falle):** Meine Theorie ruhte auf zwei UNBESTÄTIGTEN Annahmen — (1) Queue <200 (non-virtual), (2) Dateiname-Sort. Beide für einen User mit 50-61 concurrent wahrscheinlich FALSCH. Nach M=10 (falsches Regime) und Renderer-cleared-dann-doch-nicht wäre das der dritte Regime-Fehler gewesen: synthetischer Bench auf angenommenem Regime". Advisor: ERST die zwei Fakten vom User holen (Queue-Größe? Geklickte Sort-Spalte?) sie entscheiden, OB die Theorie überhaupt gilt. Antwort: Queue 200-1000 (VIRTUELL off-screen Zeilen NICHT im DOM reflow-Theorie tot) + Sort nach Fortschritt/Speed (dynamisch). Das lenkte auf den PER-EVENT-Pfad statt den Render-Pfad.
**Befund (gemessen, nicht geraten):** `maybeAddSessionFile(job)` berechnete `formatDateTime(new Date())` UNBEDINGT ganz oben — VOR dem `status==='done'`-Check, der für alles andere früh returnt. formatDateTime macht ZWEI Intl-Locale-Formate (toLocaleDateString+toLocaleTimeString) = ~83µs/Call gemessen. Läuft bei JEDEM Progress-Event (onUploadProgressBatch loopt den M-Item-Batch → handleProgress → maybeAddSessionFile) = 10×M/s, und WIRFT es weg für alle nicht-done-Events. Skaliert exakt mit M (250/s @25 → 610/s @61) und feuert in BURSTS: jeder Batch = M Calls back-to-back = synchroner Main-Thread-Block ~2,4ms@25 → ~5ms@61 alle 100ms → sprengt das 16ms-Frame-Budget → Scroll-Stutter. Per-Event, NICHT per-Render → scroll-unabhängig → erklärt „Lag wenn aktive Zeilen off-screen" exakt. DAS war der 25→50-Cliff.
**Fix:** `const dt = formatDateTime(new Date())` in den `if (!_sessionFileKeys.has(dedupKey))`-Block verschoben → läuft 1× pro echt-neuem fertigen Upload statt pro Progress-Tick. Faithful Blink-Bench am BESTÄTIGTEN Regime (Q=500 virtuell, Progress-Sort, Scrolling, M=25/50/61, OLD vs FIXED): Per-Batch 1,7/3,2/4,1ms (OLD, M-skalierend) → 0/0/0ms (FIXED, flach). Frame-P95 7,3→4,2ms @61. Render/Scroll-Pfad selbst flach ~2,5ms über alle M → KEIN zweiter Knoten dort.
**Regel:** Bei perzeptuellem Lag IMMER zuerst das REGIME erfragen (Datenmenge, aktive Konfiguration wie Sort-Spalte), bevor man benchmarkt oder fixt — eine plausible Mechanik für das FALSCHE Regime zu messen ist exakt der M=10-Fehler. Wenn der User eine konkrete Beobachtung liefert („wenn off-screen"), ist scroll-UNABHÄNGIG (per-event) vs scroll-abhängig (per-render) der Schlüssel-Diskriminator. Und: verschwendete Arbeit auf dem heißesten Pfad (Intl/new Date/Regex/DOM-Query UNBEDINGT berechnet, dann verworfen) ist ein klassischer M-skalierender Lag-Knoten — `formatDateTime` immer hinter den Guard schieben der das Ergebnis tatsächlich nutzt. Prozess-Grenze beachten: Renderer-Jank ≠ Main-Prozess; das Main-ELD-Log sieht Renderer-Lag NICHT.

View File

@ -1,527 +1,36 @@
# v3.3.108 — session log filename: 6-digit uniqueness suffix # Tailscale / network-bind diagnostics — match the downloader (rd-diagnostics-mcp)
User: append a generated 6-digit number to the session log filename, e.g. Goal: make the MHU read-only diagnostics reachable like the Real-Debrid-Downloader does over
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute Tailscale — host embedded in the connection code, two bind modes (local / network), and a
collision risk on a fast close/reopen. fail-closed IP allowlist as the access gate. No Tailscale auto-detection (the downloader has none);
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged. Tailscale is just one of the offered interface IPs reached over the tunnel, gated by the allowlist.
- 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. ## Plan
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass. - [ ] lib/ip-allowlist.js — fail-closed allowlist (normalizeIp/::ffff:, isLoopback, ipv4ToInt, matchIpRule exact+CIDR+wildcard, evaluateClientAllowed: loopback always, empty=loopback-only). + unit tests.
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json, - [ ] remote-server.js — accept config.allowlist; reject non-allowlisted peers at connection (close 4005). + protocol test.
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit). - [ ] config-store.js — diagnostics subtree: bindMode ('local'), publicHost (''), allowlist ([]).
- [ ] main.js — bindMode->host (local=127.0.0.1, network=0.0.0.0, network requires non-empty allowlist); buildDiagnosticCode with host (h); getSuggestedRemoteHosts (os.networkInterfaces); pass allowlist; IPC save-settings/status.
--- - [ ] gateway/code.js — decode h/p/t/n/fp/s (tolerant of old port/token/label). + test.
- [ ] gateway/index.js — connect_server takes host from the code; host arg optional override.
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM - [ ] renderer/app.js — bind-mode selector, publicHost input + suggested-host chips, allowlist textarea (network), network-requires-allowlist validation.
- [ ] docs/remote-diagnostics-setup.md — network mode + allowlist + Tailscale (set allowlist to your tailnet, e.g. 100.64.0.0/10).
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to - [ ] Tests: ip-allowlist unit, remote-server allowlist protocol, gateway decode, integration (network bind + allowlist), adversarial fail-closed (empty allowlist rejects non-loopback).
DD-MM-YYYY-mdu-session-HH-MM.log (hour-minute, no seconds/pid). lib/log-mode.js: - [ ] Release v3.3.86 (gitea + github mirror).
- formatSessionStamp(date) now returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}` (pid arg dropped; main.js
still passes process.pid, harmlessly ignored). ## Review (done)
- resolveLogFileName session branch returns `${sid}${ext}` (the stamp is the full app-defined stem, baseName All steps implemented and verified. lib/ip-allowlist.js (fail-closed, ::ffff:, CIDR incl.
ignored — single/daily still use baseName 'fileuploader'). 100.64.0.0/10) + 8 unit tests. remote-server.js rejects non-allowlisted peers (close 4005),
- stripModeStampFromFileName recognizes the new format (^DD-MM-YYYY-mdu-session-HH-MM(.ext)$) and resets to the opt-in via config.allowlist (existing remote-control unaffected) + 2 protocol tests (fail-closed
default 'fileuploader' base (the new format embeds no base); the old daily + old-session strip regexes stay wiring + loopback-always-allowed). Code now carries the host (mhu1_{v,h,p,t,n,fp?,s?}); gateway
for backward-compat with any persisted old paths. The compounding round-trip stays idempotent. decode is tolerant of the legacy long keys; connect_server takes the host from the code (host arg
Tests updated (formatSessionStamp, resolveLogFileName session, strip new-format, idempotency). 410 pass. optional override) — proven end-to-end by the integration harness connecting with NO host arg.
Renderer: bind-mode selector + public-host input + suggested-host chips + allowlist textarea +
--- network-requires-allowlist validation. Docs rewritten for Tailscale (set allowlist to the tailnet,
put the Tailscale IP/MagicDNS in the code address). 393 app tests + 9 gateway tests + e2e +
# v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename integration + adversarial all green, lint 0 errors.
Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the ## Security model shift
static menu bar) on a 12-vCPU Windows VM over RemoteDesktop. Workflow wn2k04x5t (2 agents + adversarial verify) v3.3.85 hard-locked loopback. This change replaces that with the downloader's model: network bind
nailed it by one airtight deduction: the BrowserWindow backgroundColor is DARK (#16181c, main.js:1228), so (0.0.0.0) is allowed ONLY with a non-empty fail-closed IP allowlist (empty => loopback only). The
"white" can NEVER be an un-painted/loading/failed state — those all show DARK. Pure-white + no menu bar + no allowlist (real socket peer, ::ffff: normalized, CIDR) + token are the gate; the tunnel
banner + SILENT eliminates every DOM/init/CSS/load mode (each leaves the dark styled shell, unstyled-black-on- (Tailscale/WireGuard) is the confidentiality layer. Plaintext ws:// — document the trust boundary.
white menu text, or the red init().catch banner) → the ONLY match is a GPU/compositor surface failure on the
RDP virtual display adapter. Confirmed: NO disableHardwareAcceleration / disable-gpu / appendSwitch ANYWHERE,
and child-process-gone (GPU) is log-only (matches the silent symptom) while render-process-gone pops a dialog.
Renderer has ZERO WebGL/canvas/video (audited) → software compositing costs ~nothing and doesn't undo the perf
work; a webContents.reload() doesn't disrupt uploads (uploadManager lives in main, torn down only on quit).
Watchdog REJECTED: the GPU mode lets init complete, so an init-complete signal wouldn't detect the white screen.
SHIPPED v3.3.106 (main.js — adversary's safe subset):
- app.disableHardwareAcceleration() at module top (before app.whenReady) GATED on RDP (process.env.SESSIONNAME
matches /^RDP/) OR a persisted gpu-disabled.flag (in userData). Zero-regression for local/console users.
- Auto-heal: on a GPU child-process-gone, write gpu-disabled.flag → next launch disables HW accel even if the
RDP gate missed (covers VM-via-console / bad virtual GPU). Self-healing after at most one white screen.
- Kept the child-process-gone/render-process-gone/did-fail-load instrumentation to CONFIRM on the server log
(caveat: root cause is the standard RDP-GPU bet, not yet confirmed on the affected machine — next white-start
log shows CHILD PROCESS GONE type=GPU = confirmed).
- (B) export-backup defaultPath: multi-hoster-backup-YYYY-MM-DD.mhu → DD-MM-YYYY-multihoster-backup.mhu.
409 tests pass, clean boot (guard inert on non-RDP dev machine).
DEFERRED (perf polish, workflow w5o2rpffx design ready): history JSONL + batch-start residual.
---
# v3.3.105 — URGENT data-safety: config write fsync + account-wipe guard
User report: after a server CRASHED during upload (NOT the v3.3.104 update — the other server updated fine and
kept its accounts), the accounts/credentials were gone. Root-cause chain (in code): config writes were atomic
(tmp+rename) but had NO fsync — a hard crash can leave electron-config.json truncated/unflushed → on restart
load() reads the corrupt/empty file, falls to .bak, and if that's also bad returns empty DEFAULTS → the next
settings/queue save persists EMPTY hosters → accounts permanently wiped (and the async _atomicWrite blindly
copyFileSync'd the live → .bak, so an empty live could clobber a good .bak).
SHIPPED v3.3.105 (lib/config-store.js + main.js — data-safety, no behavior change):
- fsync before rename in BOTH write paths: _atomicWrite (openSync+writeSync+fsyncSync+closeSync, then
guarded-.bak + rename) and main.js save-global-settings-sync (openSync+writeSync+fsyncSync+closeSync). A hard
crash can no longer leave a truncated config.
- _atomicWrite .bak is now GUARDED: read the live file and only refresh .bak if it's non-trivial (trim>2) —
an empty/truncated live can never clobber a good .bak (matches what the sync-save already did).
- WIPE-GUARD (_guardHosters): in save()/saveRotationCursors()/the sync-save, when the write does NOT
intentionally set hosters (config.hosters absent) AND the resulting hosters are all-empty, recover the
hosters from disk (_recoverHostersFromDisk tries live → .bak → .pre-history-split.bak) instead of persisting
the wipe. An EXPLICIT save({hosters:{}}) (user deleted all) is still allowed (hostersIntentional=true).
- load() gained a 3rd fallback tier: .pre-history-split.bak (the permanent v3.3.99 snapshot with accounts) so
load() itself recovers after corruption.
2 new tests (post-wipe valid-empty live + .bak → guard restores; explicit empty NOT blocked). 409 tests pass.
RECOVERY for the affected server: %APPDATA%\multi-hoster-uploader\electron-config.json.pre-history-split.bak
(or .bak) → copy over electron-config.json with the app closed.
DEFERRED (perf polish, workflow w5o2rpffx designs ready): history JSONL (append-only, kills per-batch 185MB
rewrite + first-open parse via loadHistoryRecent tail-read + meta sidecar) and the batch-start one-time
render/231ms residual. Do these AFTER the data-safety fix is confirmed stable.
---
# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
that view laid out ~2000 rows.
Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
SHIPPED v3.3.104 (renderer/app.js + styles.css):
- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
~visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
top, else += added*ROW_HEIGHT).
- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
rows is correct.
- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
table-layout:fixed, so no column-jump fix needed unlike History).
- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
Uploads" message.
- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
row height exactly 28. 407 tests pass, clean boot.
Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
batch parse-cache/JSONL.
---
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),
steady-state pristine (fps=32, ELD 11.8ms). Residual = a ~6s BATCH-START spin-up burst: main ELD
max=336ms @cpu=0%core + renderer-longtasks 196-391ms; settles to clean by +6s. Workflow w3bzkumo8
(4 agents + adversarial verify) CORRECTED my hypothesis:
- My "uncapped renderer progress-drain" theory was WRONG: handleProgress only mutates JS + SCHEDULES
coalesced renders (rAF/200ms); render is already one-per-frame; main coalesces to ~50 latest-per-job/100ms.
Chunking the drain fixes nothing. ADVERSARY FOUND IT WOULD REGRESS: rAF throttles to ~0 when the window is
minimized (the common background-uploader state) → unbounded _pBuf backlog AND deferred persistQueueStateSoon
(last line of _handleProgressImpl) → terminal 'done' lost on close = the queue-persistence-ghost-fix class.
DEFERRED/REJECTED as sketched.
- REAL cause (cpu=0%core = blocked on I/O): synchronous fs.statSync storm in UploadManager.startBatch dedup
loop (lib/upload-manager.js:363-377): up to DEDUP_CHUNK=200 fs.statSync in ONE tick before yielding. 200 ×
~1.68ms (measured on the VM) = the exact 336ms. Plus a per-job statSync (428). On a disk already saturated
by the 1MB read-ahead.
SHIPPED v3.3.103 (lib/upload-manager.js — adversary's zero-risk headline fix, but the more thorough async form):
- Dedup loop: dedup synchronously (cheap Map ops), then stat the unique files in PARALLEL via
`await Promise.all(toStat.map(f => fs.promises.stat(f)))` per chunk → stats run on the libuv threadpool,
main thread NEVER blocks. Preserves the exact results-Map shape {name,size,results:[]} + dedup semantics
(size=0 on failure). The 336ms sync block → 0 main-thread block.
- Per-job statSync (428) → `await fs.promises.stat` (in an async fn before the first real await; cachedResult
fast-path already skips it for ~all jobs — consistency only).
- Tests: updated the fs.statSync mocks in upload-manager.test.js (2 sites) + suspect-reject-alternates.test.js
to also mock fs.promises.stat (returns the same fake sizes). 407 tests pass, clean boot.
The renderer-longtasks (391/280/299ms) during the burst were (medium-confidence) the user's OWN tab clicks
landing while the main thread was stalled — fixing the main stall frees IPC so those clicks stay responsive.
DEFERRED still (only if needed): first-get-history-after-batch parse-cache/JSONL; the renderer chunked drain
ONLY if ever needed for interaction-responsiveness AND gated on a high-water-mark sync drain + a
document.hidden setTimeout fallback (never rAF-only).
---
# v3.3.102 — virtualize the History table (the last tab-switch layout cost)
v3.3.101's gate killed the get-history PARSE on tab switch, but the v3.3.101 log showed a RESIDUAL: tab
clicks still 216ms with a ~197ms `renderer-longtask` and NO get-history (gate worked). proc=0ms → pure
browser layout, not JS. Cause: `.view{display:none}`→`.active{display:flex}` + the History table builds up
to 2000 `<tr>` NON-virtualized (renderHistoryTable), so showing the view lays out 2000 rows (~197ms on the
RDP VM). The queue was already virtualized; history was the only non-virtual large table.
MEASURED with Playwright (real DOM, this machine; VM ≈1.7×):
- current 2000 rows auto-layout: 118ms ; content-visibility+fixed: 117ms (USELESS — rows still laid out)
- cap 300: 16ms (but rejected: history rows are per-file×hoster, a single 1280-file batch ≈3840 rows, so a
small cap would HIDE a recent batch's links)
- virtualize (40 visible of 2000): 2.3ms ✓
Verified the virtualization end-to-end @6000 rows: showCost 1.1ms, DOM stays 32-42 rows, scroll maps
correctly (top=row0/mid=row2990/bottom=row5999), scrollHeight exact, columns STABLE (table-layout:fixed),
rows update on scroll.
SHIPPED v3.3.102 (renderer/app.js + styles.css):
- Virtualized renderHistoryTable mirroring the queue's _renderVirtualRows: header always rendered, tbody#historyBody
gets only visible rows + top/bottom spacer `<tr>` (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler
(_onHistoryScroll, rAF-coalesced) + ResizeObserver on #historyContainer — the ResizeObserver doubles as the
show-trigger (hidden 0×0 container → visible size → re-render at correct height). Sort resets scrollTop=0 +
re-renders. Click delegation (copy-link / sort) unchanged. _historyWorking holds the sorted working set.
- styles.css: `.history-table{table-layout:fixed}` + scoped col widths (16/34/12/38%) so columns don't jump as
rows scroll in/out (does NOT touch the shared .col-* used by the queue). Measured: content-visibility was a
no-op, so NOT used.
All rows stay scrollable (no UX loss); show cost ~100× lower. 407 tests pass, clean boot. Playwright-verified.
DEFERRED still (only if needed): the FIRST get-history after a new batch parses 185MB once (~450ms) — needs
the ConfigStore parse-cache (+185MB RAM, guarded) or JSONL. appendHistory still rewrites 185MB per batch-done
(JSONL fixes that). The ~625ms batch-start spin-up + 107ms debug-log residuals.
---
# v3.3.101 — History-tab lag: gate the unconditional reload + fix the diagnostics history regression
v3.3.100's interaction instrument named the residual exactly: EVERY slow click was `button.tab`/`nav.tab-bar`
(200840ms), each coupled to `ipc get-history wall=150-200ms sync` + `main-longtask blocked=242-289ms
lastIpc=get-history` + `renderer-longtask 200-248ms`. Uploads themselves pristine (ELD mean 11.7ms; the only
spikes line up with the get-history tab-switches). Workflow wgxr06myb (4 agents + adversarial verify):
- get-history fires ONLY entering the History tab (not every tab) — but the handler called loadHistory()
UNCONDITIONALLY (app.js:362-365) despite tracking `_historyDirty` and never checking it. Each call:
synchronous readFileSync+JSON.parse of the ~185MB / 30000-entry electron-history.json (no cache), ships all
30000 over IPC, renderer flattens ~120000 row objects then .slice(-2000) for the DOM (DOM already capped 2000).
- Adversary safe subset = STEP 1 ALONE (gate the load, dirty-coverage verified complete: every append routes
batch-done→appendHistory + upload-batch-done→handleBatchDone sets _historyDirty=true). Zero risk.
SHIPPED v3.3.101 (STEP 1 + a regression fix, both safe):
- renderer/app.js: gate `if (tab.dataset.view==='history' && (_historyDirty || !_historyEverLoaded)) loadHistory()`;
added `_historyEverLoaded`, set both flags inside loadHistory() AFTER the await succeeds (retry on failure).
→ REPEAT History tab-switches (no new uploads) now do ZERO ipc/parse/flatten = instant. (Honest limit: the
FIRST History open after a new batch still parses 185MB once ~450ms — needs the parse-cache, see below.)
- lib/diagnostics-collectors.js + main.js: getHistory now reads loadHistory() not load().history — fixes a
CORRECTNESS regression I introduced in v3.3.99 (migrated mode → load().history is [] → remote diagnostics
reported totalBatches:0 despite 30000 real batches). Backward-compatible fallback kept. +2 regression tests.
407 tests pass, clean boot.
DEFERRED (adversary-flagged, by design — do only if the next log/user still shows pain):
- STEP 2(1) ConfigStore parse-cache for history (mtime+size key, invalidate BOTH _writeHistoryFileAtomic AND
_writeHistoryFileDurable, slice-before-push for 'all'-retention same-ref aliasing). Makes first-after-upload
switch instant + appendHistory read-half free, but ADDS ~185MB resident in main (NOT a relocation — adversary
corrected the design's false RAM claim).
- STEP 2(2) slice get-history to last-N-batches: REGRESSION VECTOR (breaks browse/sort-all 30000), needs a
net-new paging/search-in-main IPC + UI that doesn't exist. Defer.
- JSONL append-only storage: the ONLY thing that kills appendHistory's 185MB-rewrite-per-batch-done AND the
parse entirely (tail-readable). On-disk format migration → own careful build.
- Two minor independent residuals from the sweep: ~625ms batch-start spin-up (synchronous 22-job build/prime
burst in one tick) + 107ms debug-log block mid-batch. Separate, low priority.
---
# v3.3.100 — close the LAST measurement gap: renderer interaction timing (switches/clicks)
User asked "haben wir wirklich ALLES gemessen, auch switches/wechsel?". Audit: main-side was already
fully covered (IPC wrapper ≥50ms on every handler, main-longtask >100ms with lastIpc, config instrument);
account switchAccount is a trivial sync Map-set + the rotation work is async (can't block) → already covered.
The REAL gap was RENDERER-side: renderer-perf was upload-gated (idle clicks unmeasured) and only aggregate
(no per-interaction latency, no element attribution). Closed it (renderer/app.js, additive, self-silencing):
- Event Timing API observer (`type:'event', durationThreshold:50, buffered`) → `renderer-interaction <type>
dur=Xms proc=Yms target=<el>` for EVERY UI interaction ≥50ms (switch/sort-header/tab/button), always-on,
names the element (id/class/data-action/aria-label). The direct "click→reaction" latency.
- Idle renderer-longtask logging: any longtask ≥100ms logged immediately (`renderer-longtask dur=Xms`),
not just during uploads.
405 tests pass, clean boot. Now EVERY action — main or renderer, idle or under load — names itself if slow.
---
# v3.3.99 — THE KILL: 38.5MB config-thrash → history split out of the hot config + full instrumentation
THE ROOT CAUSE (from v3.3.98's instrument, the real "bread"): electron-config.json was **38.5MB** and got
loaded/cloned/serialized **137× in 73s** on the main thread (140-592ms each) = **~47% main-thread occupancy**
→ that IS the 1-2s button lag. The 1MB read-ahead was irrelevant against it. NOT the queue (`queue=undefined`
was a LOGGING BUG: read `.length` on the pendingQueue OBJECT); the bulk is HISTORY — each batch-done appended
the full per-file result list (`summary.files` w/ per-hoster URLs), 75 batches, default historyRetention='all'
never prunes → unbounded. (5-agent workflow wz2g4bwka + adversarial verify; bench fixture confirmed 185MB =
100MB history/30000 entries.)
Why writes drove the storm: save-global-settings (queue-persist) did 2 loads + 1 serialize per call, and
_atomicWrite NULLS the cache → next load is a full 38.5MB reparse. Even cache HITS structuredCloned 38.5MB.
Per-job upload path makes ZERO config calls (selectUploadAuth/rotation take config by param) — pending=1280
is NOT the driver.
THE FIX (history split — kills clone AND serialize AND reparse at once; advisor-gated, adversarially verified):
- History moved to its OWN file **electron-history.json** (lib/config-store.js). _migrateHistory() runs ONCE
at init (packaged only), fail-safe: write history.json + fsync + verify count BEFORE the config is ever
allowed to drop history, keep a permanent `electron-config.json.pre-history-split.bak`. _loadImpl returns
`history:[]` when migrated → cached result is tiny → clones cheap; config file shrinks to ~KB on the first
save() → reparse cheap; _serializeForDisk writes ~KB → serialize cheap. loadHistory/appendHistory/
pruneHistory/clearHistory redirected to history.json (own write-queue, no-clobber guard); legacy config
path kept as fallback when migration fails. get-history/export-history go through loadHistory.
- REAL 194MB-FIXTURE VALIDATION: migrate 1.5s (1×), all 30000 entries preserved + .bak; load() 631ms cold
(1×) → **0.1ms** after strip; save() strips config **185MB→2.1KB**; loadHistory() still 30000. RESULT:
data-preserved=true, hotpath-fast=true.
- DROPPED per advisor (one-variable + risk): loadShallow (moot after split), Fix#2 cache-repopulate (dead
gate), Fix#4 resolution-cache (stale-cache → rotation/byse failover-regression class — the one thing that
could SILENTLY corrupt uploads). Fix#3 (this split) was the only complete fix.
"MEASURE EVERYTHING" instrumentation (user demand) — all additive, threshold-gated, MHU_PERF=0 to disable:
- IPC handler wrapper (monkey-patch ipcMain.handle/.on) → `ipc <channel> wall=Xms sync=Yms` ≥50ms = the
button-press→response latency, hardened so a logging throw can never break IPC (Promise.resolve(p).finally).
- Main-process long-task drift monitor (setInterval 100ms) → `main-longtask blocked=Xms lastIpc=… gc=… gcMax=…`
for any single main-thread turn >100ms (catches GC, fs scans, serialize the IPC wrapper structurally can't see).
- config-store: caller attribution `via=<stack>` + `wqDepth=` on config-load/config-serialize lines; FIXED the
`queue=` logging bug (now reads pendingQueue.queueJobs.length).
405 tests pass (9 new migration tests covering preserve-count, round-trip, save()-never-loses-history,
crash-window fallback, idempotency). Clean Electron boot (9s, no errors). No repo pollution (migration packaged-only).
NEXT LOG must show: config-load/config-serialize wall= drop to single digits (or vanish), main-longtask rare,
ipc lines name any residual. If a residual remains it's pendingQueue (own follow-up, not a regression).
---
# v3.3.98 — read-burst absorption (1MB hwm) + persist/load instrument; B (renderer) DEFERRED
v3.3.97 (threadpool 64→8) was a DECISIVE win: mean ELD 200ms→~11ms at 70 active (18×), renderer healthy
14/15 windows. User: "ganz flüssig isses noch nicht". A 5-agent ultracode workflow + adversarial verify
localized the RESIDUAL to TWO distinct, measured spike sources (full data: subagents output wjskjo1xk):
1. READ-BURSTS (tail W13/14/15, 15:18:53-19:04): FSReqCallback 66/70/46 vs threadpool=8 (~8.75× queue
depth), SimpleWriteWrap collapses to 7/4/24, mean climbs 12.9→30.3→41.9ms. GC EXCLUDED (gcMax ≤27ms
always). The FSReq↔SimpleWrite inversion at stable active=70/pending=1287 proves reads are CAUSAL, not
a symptom of a block elsewhere.
2. SYNC CONFIG PERSIST (suspected): save()→load() reparses the WHOLE electron-config.json (1287-job
pendingQueue nested in globalSettings + full history) on every persist because _atomicWrite nulls the
cache; _serializeForDisk JSON.stringify(...,null,2) of all of it. W13's single 1021ms max with heap→142MB
fits a big synchronous structuredClone+stringify. CAVEAT (advisor): W13 is ONE confounded sample (also
FSReq=66) and the ONLY heap-spike window; W4(415ms,heap41) & W10(852ms,heap18) are LOW-heap → NOT persist
clones → likely the SECONDARY suspect: account-failed's synchronous configStore.load() per failure near
connection churn (W6 teardown had doodstream connect-timeouts). So: INSTRUMENT, don't claim "found a 1s
freeze".
SHIPPED v3.3.98 (one-variable discipline — advisor cut B to keep the next measurement clean):
- A: highWaterMark 256KB→1MB in all 5 streaming read loops (hosters.js:291, doodstream:342, voe:245,
vidmoly:190 CHUNK_SIZE consts; clouddrop:108 inline — NOT clouddrop:12's 16MB server chunk). Keep tp=8.
Deepens per-stream read-ahead 0.43s→~1.7s (absorbs threadpool-queue latency so writes don't starve),
4× fewer read completions + allocs. Zero multipart byte-risk (Content-Length=preamble+fileSize+epilogue,
independent of chunk size). REVERSIBLE PROBE; read-semaphore held in reserve (trigger: FSReq still ~70 +
writes starved + mean elevated after 1MB).
- C-instrument (BROADENED per advisor): config-store.js times load() (full reparse, incl. account-failed
path) AND _commit serialize; logs `config-load wall=Xms cache=hit/miss hist=N queue=M` and
`config-serialize wall=Xms bytes=Y hist=N queue=M` when ≥20ms (perfLog hook set in main.js via
configStore.setPerfLog→logInfo). load() split into wrapper + _loadImpl. 397 tests pass.
- B (renderer chunked rAF batch drain, app.js:188-193 — the 243ms longtask at W14) DEFERRED: renderer was
healthy 14/15 windows and the one longtask is DOWNSTREAM of the main-thread read-burst flooding IPC.
Fix A should make it self-heal. Bundling B would confound attribution + touches the progress hot path
that bit before (formatDateTime burst, ghost-fix). Add B next round ONLY if renderer still janks after A.
NEXT LOG answers 3 things cleanly: (1) did A kill the read-bursts (FSReq per-window + tail mean drop)?
(2) is the persist/load actually heavy (new config-load/config-serialize lines + their wall/queue/hist)?
(3) did the renderer self-heal from A alone (longtasks back to 0)? Then decide: persist refactor for v3.3.99
(queue-out-of-config OR cache-repopulation — latter lower-risk but renderer's incoming globalSettings isn't
default-merged like load() produces, so confirm merge-equivalence first), and/or B, and/or read-semaphore.
---
# v3.3.97 — DECISIVE ELD finding: file-read phase-flip + threadpool 64→8 + GC instrument
The v3.3.96 `eventloop-delay` logs gave the decisive signal. At CONSTANT active-count, the system flips
between two regimes:
- HEALTHY (ELD ~11ms, rss 268308MB): `SimpleWriteWrap ≈ active`, `FSReqCallback ≈ 01` (write/network-bound)
- BLOCKED (ELD 49217ms, rss 540610MB): `FSReqCallback ≈ active` (6271 file reads in flight), `SimpleWriteWrap ≈ 04`
ELD spike + rss balloon both track `FSReqCallback` → file-read path through the libuv threadpool, NOT crypto,
NOT renderer, NOT GC-alone. All 5 uploaders read identically (256KB createReadStream); byse/dood/voe run
through the GENERIC uploadFile in hosters.js (no dedicated module).
Advisor caveats baked into the build (do NOT skip on re-measure):
1. Causation UNPROVEN — high FSReqCallback could be a SYMPTOM (blocked loop can't drain read-completions).
2. rss math kills "read buffers ballooned": 70×256KB ≈ 18MB, but rss swings ~300MB → heap/object churn (GC).
3. Cheapest discriminator already wired: `UV_THREADPOOL_SIZE` 64→8 (1 line, reversible, NOT an upload cap;
8×256KB reads ≈ 100MB/s ≫ 41MB/s aggregate). Suspect tp=64 made it WORSE (removed read-serialization).
Shipped v3.3.97 = candidate-fix + discriminator in one build:
- main.js:1 `UV_THREADPOOL_SIZE` 64→8.
- ELD line now also logs `heap=`(heapUsed) `ext=`(external) `ab=`(arrayBuffers) `gc=`/`gcTotal=`/`gcMax=`ms
(PerformanceObserver entryTypes:['gc'], reset per window).
DECISION RULE for the next user log:
- ELD drops with tp=8 → read over-parallelism confirmed → keep 8 or productionize a DEDICATED read-semaphore.
- ELD high + gcTotal/gcMax align with spikes → heap churn → hunt the allocator (semaphore would be wasted).
- ELD high + gc flat → causation reversed (symptom) → pivot.
WAIT for the next `eventloop-delay` log before any read-path refactor. NO upload cap (user rejected it).
---
# v3.3.94 — comprehensive measurement build (user: "mach alles messen was man messen kann")
Localization so far (each step EMPIRICAL, not by elimination — advisor caught the elimination-leap):
- Renderer queue render PROVEN cheap: loaded the REAL app.js in headless Chromium (Playwright) with a mocked
window.api, populated Q=1000 / 61 active / progress sort, drove the real onUploadProgressBatch +
renderQueueTable + scroll → ALL <0.5ms. (Caveat: component cost, not frame rate.)
- User CONFIRMED the discriminator: a full 1000-row queue scrolls SMOOTH when idle, ruckelt ONLY while ~6170
uploads are active → the lag is driven by the active uploads (main-process / system load), not the table.
- Screenshot: 70 connections, 1413 files, 41.3 MB/s, "write ECONNRESET". ECONNRESET is already classified
transient (upload-manager _isTransientNetworkError line 171 → retried, not account-fatal) — it's the
SIGNATURE of oversubscription (servers RST the excess connections). Same root cause as the lag.
Immediate user lever (already exists): Settings → Uploads → "Globale parallele Uploads" (parallelUploadCount,
global semaphore, default 0=off). Capping total concurrent uploads (~20) should fix lag AND ECONNRESET AND
likely keep throughput (bandwidth-limited at 41 MB/s; reset connections waste bandwidth on retries).
SHIPPED measurement (all additive, zero upload-behavior change) to pinpoint CPU-vs-IO vs renderer from the
user's REAL 70-connection run:
- main.js ELD line now also logs: cpu=X%core (process.cpuUsage delta / wall, >100% = multi-core),
rss=YMB, active-by-hoster={dood:.., voe:.., ...} (per-hoster live connection distribution → shows which
hoster is oversubscribed), transient-errs=N (cumulative ECONNRESET-class on the primary path), pending=M.
- lib/upload-manager.js: getDiagnostics() {activeByHoster, transientErrors, pending, active}; activeEntry
now carries hoster; _transientErrorTotal++ in the primary catch when _isTransientNetworkError.
- renderer/app.js: PerformanceObserver('longtask') + a rAF frame-time monitor → logs every 5s WHILE
uploading: `renderer-perf active=N fps=X jankFrames=Y worstFrame=Zms longtasks=W maxTask=Vms`. This is the
DIRECT renderer ground truth (the component-timing harness couldn't capture real frame rate). Low fps /
high jankFrames / longtasks → renderer IS blocked; ~60fps + no jank while it still feels laggy → it's the
main-process/system, and the cpu=/eld= numbers in the same log say CPU-bound (→ workers/cap) vs IO-bound.
Both logs land in the normal debug log (logInfo / window.api.debugLog). 397/397 tests, eslint clean.
NEXT: user runs the 70-load on v3.3.94, shares the `eventloop-delay` + `renderer-perf` log lines (or connects
diagnostics). Those two lines together localize it definitively. Do NOT build workers/cap before that.
---
# v3.3.93 — THE renderer lag knot FOUND + FIXED + MEASURED: formatDateTime per progress event
User gave the decisive data: "25 connections okay, 50+61 laggt, EVTL wenn die uploadenden Zeilen nicht im
Bild sind." Two regime facts (asked, not assumed — advisor caught the assume-the-regime trap a 3rd time):
queue = 2001000 rows (VIRTUAL mode) + sort = clicked PROGRESS/SPEED (dynamic). This killed the non-virtual
reflow theory (off-screen rows aren't in the DOM when virtual) AND pointed at the per-event path.
ROOT CAUSE (renderer process, NOT main — the v3.3.91 ELD log can't see this): `maybeAddSessionFile(job)`
computed `const dt = formatDateTime(new Date())` UNCONDITIONALLY at the top, before the `status==='done'`
check that early-returns for everything else. formatDateTime does TWO Intl locale formats
(toLocaleDateString + toLocaleTimeString) = ~83µs/call MEASURED. It runs on EVERY progress event
(onUploadProgressBatch loops the M-item batch → handleProgress → _handleProgressImpl → maybeAddSessionFile),
i.e. 10×M/sec, and THROWS IT AWAY for all non-done events (the overwhelming majority while uploading).
- Scales exactly with M (active count): 250/sec at M=25 → 610/sec at M=61.
- Bursts: each progress batch runs M calls back-to-back = a SYNCHRONOUS main-thread block of
~2.4ms (M=25) → ~5ms (M=61) every 100ms, on top of render+sort → blows the 16ms frame budget → scroll
stutter. Scroll-independent (per-event, not per-render) → matches "lag when actives off-screen" exactly.
This is the 25→50 cliff.
FIX: move `const dt = formatDateTime(new Date())` inside the `if (!_sessionFileKeys.has(dedupKey))` block, so
it runs ONCE per genuinely-new completed upload, never per progress tick.
VERIFIED (faithful Blink benchmark at the CONFIRMED regime: Q=500 virtual, dynamic progress sort, scrolling,
M=25/50/61, OLD vs FIXED): per-batch cost 1.7/3.2/4.1 ms (OLD, scales with M) → 0.0/0.0/0.0 ms (FIXED, flat).
Frame P95 7.3→4.2 ms at M=61. M-scaling ELIMINATED. The render/scroll path itself is flat ~2.5ms median
across all M → NO second knot there. updateStatusBar/StatsPanel = one cached O(Q) arithmetic pass (cheap);
updateQueueActionButtons = O(selection) (cheap). No other Intl/Date on any per-event/per-frame hot path
(2582 = job-log modal, 4587 = History view — both on-demand/cold). 397/397 tests pass, eslint clean.
NOTE: the v3.3.91/92 main-process event-loop-delay instrument is for the OTHER (CPU-vs-IO) hypothesis and is
a separate process — keep it; it still answers whether the main thread also saturates at 50+ TLS streams.
---
# High-concurrency lag audit (v3.3.90) — "lag ist immernoch da, ich vermute ab X gleichzeitig muss er alle Zeilen gebündelt updaten"
Method: 44-agent high-concurrency audit of the full upload→IPC→render path + Blink benchmark of the
renderer queue table (Playwright/Chromium = same Blink engine), targeting the user's NEW hypothesis:
"with ~100 concurrent uploads the renderer has to update ALL rows bundled rather than cleanly per-row."
## The user's hypothesis is MEASURED-REFUTED — the renderer is NOT the bottleneck.
Blink benchmark over scenarios Q=150..1000, M=10 active, 60 ticks each:
- renderQueueTable virtualizes at ≥200 rows; <200 = change-detecting in-place update.
- _updateRowInPlace is change-detecting (no forced reflow, no layout reads).
- median render <1 ms at Q=1000; only ~4/60 renders are full rebuilds even with progress-crossing sorts.
- progress is coalesced main-side (_progressByJob Map keyed by jobId + 100ms flush → one batch sized by
active-job count, ~10/sec); renderer iterates the batch with cheap per-row handleProgress.
DOM amplification is ruled out by measurement. "Laggy at ~40% CPU / 8 cores" = ONE core at 100% =
main-thread saturation / synchronous blocking, not DOM.
## SHIPPED (v3.3.90) — the two real main-thread blockers, both behavior-preserving
1. lib/clouddrop-upload.js `_uploadChunked`: was reading each 16 MB chunk with `fs.readSync` SYNCHRONOUSLY
on the main event loop — unique among the 5 uploaders (the other 4 stream async). Each read blocks the
WHOLE loop (~59 ms SSD, 30100 ms slow disk) → freezes all progress/IPC/render/other-uploads, scaling
with the number of concurrent clouddrop uploads. Fits "laggy when uploading, worse with more concurrent."
User uses clouddrop. Fix: `fs.openSync`/`readSync`/`closeSync` → `fs.promises.open` + `await fh.read` +
`await fh.close()`. Byte-equivalence verified by SHA-256 over all chunk-boundary cases (full chunk,
partial last chunk, 2/3/4-chunk, single byte) before shipping — a chunk-read bug = corrupt upload.
2. lib/upload-manager.js rotation-retry (944) + suspect-alternate (1075) progressCb: both called
`_emitProgress` (a synchronous `emit('progress')` + fresh object spread) on EVERY stream chunk
(hundreds/sec per job) — they were missing the 250 ms `lastEmitTime` gate that the primary path (631)
has. With many concurrent uploads in rotation/suspect mode that's real main-thread emit amplification.
Mirrored the gate exactly: activeEntry mutation stays UNGATED (stats/speed-monitor stay fresh), only the
emit is throttled to 4/sec. Behavior-preserving.
397/397 tests pass, eslint clean (1 pre-existing unrelated warning at line 554).
## DROPPED (advisor: measured fine, don't chase perception)
- Lowering the virtual-row threshold below 200: the Blink benchmark shows <200 in-place updates are already
sub-ms; no change warranted.
## DISCRIMINATOR ANSWERED (user, 2026-06-21)
(a) Lag NOT clouddrop-specific — other hosters. (b) Parallel counts RAISED deliberately (10+).
(c) 50+ uploading SIMULTANEOUSLY active. → This is the TRUE high-concurrency main-thread-funnel branch,
NOT clouddrop. v3.3.90 stands but does not target this user's case.
## v3.3.91 — instrument first, don't refactor the upload core off elimination-reasoning
Advisor reframe: two LIVE hypotheses need OPPOSITE fixes — (A) main thread CPU-blocked (TLS/crypto/sync) →
event loop stalls → a cap/workers help; (B) main thread fine but IO-STARVED (libuv threadpool/sockets) →
loop stays responsive, uploads just queue → workers are WASTED, config fixes it. A worker/child-process
upload refactor touches throttle/rotation/abort/progress/credentials and is hard to reverse — DO NOT ship it
off sandbox elimination. One measurement splits the hypotheses and must run in the REAL app.
SHIPPED (both reversible, zero upload-core refactor):
1. main.js: `perf_hooks.monitorEventLoopDelay({resolution:10})` enabled at startup; logged via logInfo every
~5 s WHILE uploading (state==='uploading' && activeJobs>0) as
`eventloop-delay active=N mean=..ms p99=..ms max=..ms stddev=..ms threadpool=..`. Pure numbers, no secret
→ does NOT touch the redaction surface. This is the GROUND TRUTH: high mean/p99 → CPU-blocking → workers
justified; low delay while uploads stall → IO-bound → workers wasted, threadpool/sockets is the fix.
2. main.js (first statement, before require('electron')): `UV_THREADPOOL_SIZE = env || '64'`. Default is 4;
every async uploader feeds undici from fs.createReadStream (+ clouddrop fh.read) and DNS getaddrinfo goes
through the same pool → 50 concurrent vs 4 threads = reads/DNS serialize 4-at-a-time = a hard cliff at a
small connection count = the "ab X connections" symptom. Threads are created lazily on demand → 64-max
costs nothing if unused (zero-risk, reversible). The advisor's prescribed one-env-var hypothesis test.
CAVEAT (honest): synthetic sandbox benches could NOT confirm the threadpool is the bottleneck — pbkdf2 is
CPU-core-bound (masks pool size); DNS .invalid returns instantly; real-RTT DNS showed NO pool benefit because
WINDOWS serializes getaddrinfo via the OS DNS Client service (so on Windows the DNS half of the cliff is
masked by the resolver, though the fs-read half still benefits). This is exactly why the ELD number must come
from the user's real load, not the sandbox. Per-uploader undici Agent audit: clouddrop has a shared
module-level Agent (connections:50); doodstream/voe/vidmoly use the global dispatcher (pooled per origin, NO
per-call agent explosion) — so no agent fix needed.
## v3.3.92 — make the single measurement decisive + breadth audit of un-checked main.js hot paths
Enriched the ELD log line with `process.getActiveResourcesInfo()` as a compact type-histogram:
`eventloop-delay active=N mean/p99/max/stddev ms threadpool=64 resources=K {TCPSocketWrap:50,FSReqCallback:4,...}`.
Now ONE run splits all three readings in a single line: high mean/p99 → CPU-blocked (workers/cap);
low delay + many TCP/FS/GetAddrInfo resources → IO-bound queueing (threadpool/sockets, NOT workers);
low delay + few resources → not saturated (lag elsewhere / perception). Pure numbers, no redaction surface.
Breadth audit this round (4th /goal re-fire, code I wrote, NOT re-measuring cleared render/persist):
- main.js logging (debug/rot/upload): all buffered + ASYNC fs.appendFile (write-guard flag, 500ms timer,
setImmediate re-flush). Sync appendFileSync ONLY in crash/signal/exit handlers (correct there). CLEAN.
- main.js progress coalescing (_progressByJob Map + 100ms batch → one upload-progress-batch via safeSend):
non-terminal = Map.set (keeps latest/job); gated upstream to 4/sec/job. CLEAN at N=50.
- _appendJobLog: capped in-memory ring buffer (Map, FIFO-evict). CLEAN.
- All 5 uploaders: doodstream/voe/vidmoly/clouddrop-simple stream via async createReadStream + for-await +
async throttle.consume; clouddrop-chunked now async fh.read. NONE block the main loop per chunk. CLEAN
(clouddrop's old readSync was the unique outlier, fixed v3.3.90).
## NEXT (gated on the real-app ELD number + user's explicit nod)
User runs their 50-concurrent load once; the enriched `eventloop-delay` log line decides:
- mean/p99 HIGH (tenshundreds ms) → CPU-blocked → propose worker_threads/child-process upload pool OR a
smart concurrency cap (WITH the user's nod — it's hard to reverse and touches credentials/abort/rotation).
- delay LOW while it still lags → IO-bound → threadpool bump already addresses it; if not, look at socket
caps / undici Agent connection limits / per-origin pooling, NOT workers.
Do NOT build the worker refactor before this number exists.

View File

@ -17,7 +17,6 @@ function createStore() {
// We override by setting filePath directly // We override by setting filePath directly
store = new ConfigStore(fakeApp); store = new ConfigStore(fakeApp);
store.filePath = path.join(tmpDir, 'electron-config.json'); store.filePath = path.join(tmpDir, 'electron-config.json');
store.historyPath = path.join(tmpDir, 'electron-history.json');
return store; return store;
} }
@ -254,32 +253,6 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.alwaysOnTop, true); assert.equal(config.globalSettings.alwaysOnTop, true);
}); });
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
store.load(); // warm the cache
const a = store.load();
a.globalSettings.alwaysOnTop = true;
a.hosters['voe.sx'].push({ id: 'mutant' });
a.history.push({ id: 'ghost' });
const b = store.load();
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
assert.equal(b.hosters['voe.sx'].length, 0);
assert.equal(b.history.length, 0);
});
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
store.load(); // warm cache on the no-file defaults
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
});
it('save() invalidates the cache so the next load() sees the new value', async () => {
assert.equal(store.load().globalSettings.alwaysOnTop, false);
await store.save({ globalSettings: { alwaysOnTop: true } });
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
});
it('backup recovery when main file is corrupted', () => { it('backup recovery when main file is corrupted', () => {
// Write valid config first // Write valid config first
fs.writeFileSync(store.filePath, JSON.stringify({ fs.writeFileSync(store.filePath, JSON.stringify({
@ -293,117 +266,4 @@ describe('ConfigStore', () => {
const config = store.load(); const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup'); assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
}); });
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
hosterSettings: {}, globalSettings: {}, history: []
}), 'utf-8');
await store.save({ globalSettings: { alwaysOnTop: true } });
const cfg = store.load();
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
assert.equal(cfg.globalSettings.alwaysOnTop, true);
});
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
await store.save({ hosters: {} });
const cfg = store.load();
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
});
});
describe('ConfigStore history split (electron-history.json)', () => {
let dir;
let s;
function makeStore() {
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
st.filePath = path.join(dir, 'electron-config.json');
st.historyPath = path.join(dir, 'electron-history.json');
return st;
}
function writeConfigWithHistory(n) {
const history = [];
for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] });
fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({
hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] },
hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history
}), 'utf-8');
}
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('migration moves history into electron-history.json, preserving every entry', () => {
writeConfigWithHistory(50);
s._migrateHistory();
assert.equal(s._historyMigrated, true);
assert.ok(fs.existsSync(s.historyPath));
const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8'));
assert.equal(hist.length, 50);
assert.equal(hist[0].id, 'batch-0');
assert.equal(hist[49].id, 'batch-49');
assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept');
});
it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => {
writeConfigWithHistory(30);
s._migrateHistory();
assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config');
assert.equal(s.loadHistory().length, 30);
});
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
writeConfigWithHistory(10);
s._migrateHistory();
await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] });
assert.equal(s.loadHistory().length, 11, 'append goes to history.json');
await s.save({ globalSettings: { alwaysOnTop: true } });
const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file');
assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write');
});
it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => {
writeConfigWithHistory(40);
s._migrateHistory();
await s.save({ globalSettings: { alwaysOnTop: true } });
assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history');
assert.equal(s.load().globalSettings.alwaysOnTop, true);
});
it('clearHistory empties history.json only', async () => {
writeConfigWithHistory(20);
s._migrateHistory();
await s.clearHistory();
assert.equal(s.loadHistory().length, 0);
});
it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => {
writeConfigWithHistory(15);
s._migrateHistory();
const after = makeStore();
after._migrateHistory();
assert.equal(after._historyMigrated, true);
assert.equal(after.loadHistory().length, 15);
});
it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => {
writeConfigWithHistory(7);
assert.equal(s._historyMigrated, false);
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
});
it('pruneHistory trims history.json and persists the retention setting', async () => {
writeConfigWithHistory(12);
s._migrateHistory();
const res = await s.pruneHistory('all', { dryRun: false });
assert.equal(s.loadHistory().length, 12);
assert.ok(res.keptBatches === 12);
});
}); });

View File

@ -53,32 +53,6 @@ test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs
assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted'); assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted');
}); });
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
loadHistory: () => [
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'a.mkv', results: [{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/a' }] }] },
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'b.mkv', results: [{ hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/b' }] }] }
],
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const out = c.getHistory({ limit: 10 });
assert.equal(out.totalBatches, 2, 'must report real history from loadHistory, not the empty load().history');
assert.equal(out.returned, 2);
});
test('getHistory falls back to loadConfig().history when loadHistory is absent (legacy mode)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [{ timestamp: '2026-01-01T00:00:00.000Z', files: [] }] }),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
});
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => { test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture(); const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 }); const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });

View File

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

View File

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

View File

@ -26,20 +26,14 @@ describe('suspect-reject alternate accounts', () => {
fileProbe.probeFileHead = (...a) => mockProbe(...a); fileProbe.probeFileHead = (...a) => mockProbe(...a);
const fs = require('fs'); const fs = require('fs');
const fakeSize = (p) => {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
};
const origStatSync = fs.statSync; const origStatSync = fs.statSync;
fs.statSync = function (p) { fs.statSync = function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p); if (typeof p === 'string' && p.startsWith('/test/')) {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
}
return origStatSync.call(this, p); return origStatSync.call(this, p);
}; };
const origStat = fs.promises.stat;
fs.promises.stat = async function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager'); UploadManager = require('../lib/upload-manager');
}); });

View File

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

View File

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

View File

@ -33,7 +33,7 @@ describe('UploadManager', () => {
hosters.uploadFile = mockUploadFile; hosters.uploadFile = mockUploadFile;
hosters.prefetchBaseline = async () => null; hosters.prefetchBaseline = async () => null;
// Mock fs.statSync + fs.promises.stat for test file paths // Mock fs.statSync for test file paths
const fs = require('fs'); const fs = require('fs');
const origStatSync = fs.statSync; const origStatSync = fs.statSync;
fs.statSync = function(p) { fs.statSync = function(p) {
@ -42,13 +42,6 @@ describe('UploadManager', () => {
} }
return origStatSync.call(this, p); return origStatSync.call(this, p);
}; };
const origStat = fs.promises.stat;
fs.promises.stat = async function(p) {
if (typeof p === 'string' && p.startsWith('/test/')) {
return { size: fakeFileSize };
}
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager'); UploadManager = require('../lib/upload-manager');
}); });
@ -338,10 +331,10 @@ describe('UploadManager', () => {
}); });
it('file not found produces descriptive error', async () => { it('file not found produces descriptive error', async () => {
// Override fs.promises.stat to throw ENOENT for a specific path // Override fs.statSync to throw ENOENT for a specific path
const fs = require('fs'); const fs = require('fs');
const origStat = fs.promises.stat; const origStat = fs.statSync;
fs.promises.stat = async function(p) { fs.statSync = function(p) {
if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return origStat.call(this, p); return origStat.call(this, p);
}; };
@ -354,7 +347,7 @@ describe('UploadManager', () => {
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' } { file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]); ]);
fs.promises.stat = origStat; fs.statSync = origStat;
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`); assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
}); });

View File

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