Compare commits
No commits in common. "master" and "v3.3.86" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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 [
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
198
main.js
@ -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
20
package-lock.json
generated
@ -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"
|
||||||
|
|||||||
10
package.json
10
package.json
@ -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": [
|
||||||
|
|||||||
@ -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);
|
|
||||||
641
renderer/app.js
641
renderer/app.js
@ -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">👁</button>
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</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">👁</button>
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</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...';
|
||||||
|
|||||||
@ -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">×</button>
|
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</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 & 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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|||||||
130
tasks/lessons.md
130
tasks/lessons.md
@ -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 (Hauptprozess-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-Falschpositiv):** 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,2–2,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 ~8–15×/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 (~6–7 config-clones + unbounded history-walks; `limit` slict nur den Output). Gemessen 258 ms–6,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 ~5–9 ms SSD / 30–100 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.
|
|
||||||
|
|||||||
563
tasks/todo.md
563
tasks/todo.md
@ -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`
|
|
||||||
(200–840ms), 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 268–308MB): `SimpleWriteWrap ≈ active`, `FSReqCallback ≈ 0–1` (write/network-bound)
|
|
||||||
- BLOCKED (ELD 49–217ms, rss 540–610MB): `FSReqCallback ≈ active` (62–71 file reads in flight), `SimpleWriteWrap ≈ 0–4`
|
|
||||||
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 ~61–70
|
|
||||||
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 = 200–1000 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 (~5–9 ms SSD, 30–100 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 (tens–hundreds 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.
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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(', ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user