Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8d82e42b | ||
|
|
ecdae8c59c | ||
|
|
c5350b6b60 | ||
|
|
5079be718a | ||
|
|
cad270dd2c | ||
|
|
c7fa422d9b | ||
|
|
95bff2581b | ||
|
|
0652edf69f | ||
|
|
b075961802 | ||
|
|
cfeb01e82a | ||
|
|
b11b28e417 | ||
|
|
c58d9203bc |
208
docs/superpowers/plans/2026-08-07-v2-account-manager-startup.md
Normal file
208
docs/superpowers/plans/2026-08-07-v2-account-manager-startup.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# V2.0.1 Account Manager und Startup Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Einen atomaren Ein-Klick-Account-Flow, einen deterministischen White-Screen-sicheren Start und eine funktionierende Update-Brücke von 3.3.108 auf die sichtbare Version 2.0.1 liefern.
|
||||||
|
|
||||||
|
**Architecture:** Browserunabhängige Kernlogik kapselt Validate-then-Commit und Release-Versionsauflösung und wird vom bestehenden Renderer beziehungsweise Updater verwendet. Der Electron-Main-Prozess erzwingt Software-Rendering vor Ready und zeigt das Hauptfenster erst nach `ready-to-show`.
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 41, Node.js 24, `node:test`, electron-builder 26, Gitea Releases, GitHub Releases.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Keine Code-Kommentare, XML-Dokumentation, TODOs oder Platzhalter hinzufügen.
|
||||||
|
- Sichtbare Produktversion, UI, Build und öffentlicher GitHub-Tag sind exakt `2.0.1` beziehungsweise `v2.0.1`.
|
||||||
|
- Der private Gitea-Transport-Tag für diese Version ist exakt `v3.3.109`; der Release-Titel ist exakt `Multi-Hoster-Upload v2.0.1`.
|
||||||
|
- Fehlgeschlagene, abgebrochene, veraltete oder OTP-erfordernde Prüfungen persistieren keinen Account.
|
||||||
|
- Release nur aus frischer Positivliste, ohne interne KI-/Task-/Log-/Backup-/Testdaten und nach Quell-, Build-, Archiv- und Secret-Prüfung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Startup-Renderer absichern
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/startup-renderer.js`
|
||||||
|
- Modify: `main.js`
|
||||||
|
- Create: `tests/startup-renderer.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `configureStartupRenderer(app)` deaktiviert Hardwarebeschleunigung genau einmal vor Ready.
|
||||||
|
- Produces: `createWindow()` erstellt das Hauptfenster unsichtbar und zeigt es auf `ready-to-show`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing Tests schreiben**
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('configureStartupRenderer disables hardware acceleration', () => {
|
||||||
|
let calls = 0;
|
||||||
|
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: RED verifizieren**
|
||||||
|
|
||||||
|
Run: `node --test tests/startup-renderer.test.js`
|
||||||
|
|
||||||
|
Expected: FAIL, weil `lib/startup-renderer.js` noch fehlt.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimale Implementierung schreiben**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function configureStartupRenderer(app) {
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { configureStartupRenderer };
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.js` ruft die Funktion unmittelbar nach dem Electron-Import auf, entfernt das heuristische RDP-/Flag-Gate, setzt `show: false`, registriert `ready-to-show` vor `loadFile` und behandelt einen abgelehnten Load.
|
||||||
|
|
||||||
|
- [ ] **Step 4: GREEN und Startup-Smoke verifizieren**
|
||||||
|
|
||||||
|
Run: `node --test tests/startup-renderer.test.js`
|
||||||
|
|
||||||
|
Run: `$env:RUN_UI_SMOKE='1'; node tests/ui-smoke.js`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit erstellen**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add lib/startup-renderer.js main.js tests/startup-renderer.test.js
|
||||||
|
git commit -m "fix(startup): make renderer initialization deterministic"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Account in einem Lauf prüfen und anlegen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `renderer/account-submit.js`
|
||||||
|
- Modify: `renderer/index.html`
|
||||||
|
- Modify: `renderer/app.js`
|
||||||
|
- Replace: `tests/validate-credentials.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `submitValidatedAccount({ validate, commit, isCurrent })` mit den Resultaten `committed`, `rejected`, `otp_required`, `stale` und `error`.
|
||||||
|
- Consumes: bestehendes `window.api.validateCredentials` und `window.api.saveConfig`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing Tests für Ein-Klick- und Fehlerpfade schreiben**
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('ok validates and commits exactly once in one submission', async () => {
|
||||||
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => ({ status: 'ok' }),
|
||||||
|
commit: async () => { commits++; },
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'committed');
|
||||||
|
assert.equal(commits, 1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzliche Tests decken `warn`, `error`, `skipped`, Throw, OTP, stale vor Commit und Save-Fehler ab.
|
||||||
|
|
||||||
|
- [ ] **Step 2: RED verifizieren**
|
||||||
|
|
||||||
|
Run: `node --test tests/validate-credentials.test.js`
|
||||||
|
|
||||||
|
Expected: FAIL, weil `submitValidatedAccount` noch fehlt.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Submit-Core und Renderer-Integration implementieren**
|
||||||
|
|
||||||
|
Der Submit-Core validiert, prüft `isCurrent`, committet ausschließlich `ok|warn` und gibt strukturierte Ergebnisse zurück. `saveAccount()` hält Busy bis zum Ende, vergleicht Session und Credential-Snapshot, speichert einen kopierten Kandidaten und übernimmt ihn erst nach erfolgreichem IPC. Der Button bleibt bis zum Schließen deaktiviert.
|
||||||
|
|
||||||
|
- [ ] **Step 4: GREEN und UI-Vertrag verifizieren**
|
||||||
|
|
||||||
|
Run: `node --test tests/validate-credentials.test.js`
|
||||||
|
|
||||||
|
Run: `node --test tests/*.test.js`
|
||||||
|
|
||||||
|
Run: `$env:RUN_UI_SMOKE='1'; node tests/ui-smoke.js`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit erstellen**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add renderer/account-submit.js renderer/index.html renderer/app.js tests/validate-credentials.test.js
|
||||||
|
git commit -m "feat(accounts): validate and save in one action"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Produktversion und Updater-Brücke implementieren
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/updater.js`
|
||||||
|
- Modify: `scripts/release_gitea.mjs`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `package-lock.json`
|
||||||
|
- Create: `tests/updater-version.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `resolveReleaseVersion(release)` liest zuerst eine semantische Version aus `release.name`, dann aus `tag_name`.
|
||||||
|
- Produces: Release-CLI `node scripts/release_gitea.mjs 2.0.1 --transport-tag v3.3.109 <notes>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing Tests für Release-Auflösung und Bridge schreiben**
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('bridge title resolves product version instead of transport tag', () => {
|
||||||
|
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
|
||||||
|
assert.equal(isNewer('2.0.1', '2.0.1'), false);
|
||||||
|
assert.equal(isNewer('2.0.2', '2.0.1'), true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: RED verifizieren**
|
||||||
|
|
||||||
|
Run: `node --test tests/updater-version.test.js`
|
||||||
|
|
||||||
|
Expected: FAIL, weil `resolveReleaseVersion` noch fehlt.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Produkt-/Transport-Trennung implementieren**
|
||||||
|
|
||||||
|
`checkForUpdate()` verwendet die aufgelöste Produktversion für UI und Vergleich. Der Transport-Tag bleibt im Ergebnis diagnostizierbar. Das Release-Skript validiert `--transport-tag`, baut `2.0.1`, taggt `v3.3.109`, benennt den privaten Release `Multi-Hoster-Upload v2.0.1` und erzeugt `latest.yml` mit `2.0.1`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Version auf 2.0.1 setzen und GREEN verifizieren**
|
||||||
|
|
||||||
|
Run: `npm version 2.0.1 --no-git-tag-version`
|
||||||
|
|
||||||
|
Run: `node --test tests/updater-version.test.js`
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit erstellen**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add lib/updater.js scripts/release_gitea.mjs package.json package-lock.json tests/updater-version.test.js
|
||||||
|
git commit -m "feat(updater): bridge 3.3.108 clients to v2.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Build, Laufzeit und Veröffentlichung verifizieren
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tasks/todo.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1 bis 3.
|
||||||
|
- Produces: geprüfte private Bridge und kuratierten öffentlichen Release v2.0.1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Vollständige lokale Gates ausführen**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Run: `npx eslint .`
|
||||||
|
|
||||||
|
Run: `npm audit --omit=dev --json`
|
||||||
|
|
||||||
|
Run: `npm run release:win`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build und Archive prüfen**
|
||||||
|
|
||||||
|
Portable, Setup, Blockmap und `latest.yml` werden vollständig entpackt, auf Version, Struktur, Hashes, private Endpunkte, Credentials und interne Dateien geprüft. Gitleaks und TruffleHog laufen über Quell-Positivliste und entpackte Artefakte.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update-Brücke isoliert testen**
|
||||||
|
|
||||||
|
Ein unveränderter 3.3.108-Updater muss den privaten Transport-Tag `v3.3.109` als neuer erkennen. Der gebaute 2.0.1-Updater muss denselben Release-Titel als installiert erkennen. Setup und Portable werden separat gestartet; UI, Account-Modal und sauberer Teardown werden geprüft.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Private und öffentliche Releases veröffentlichen**
|
||||||
|
|
||||||
|
Der private Gitea-Release nutzt Transport-Tag `v3.3.109` und Titel `Multi-Hoster-Upload v2.0.1`. Der öffentliche GitHub-Release wird aus einem neuen Positivlisten-Ordner als `v2.0.1` mit ausschließlich benötigtem Source und vier Release-Artefakten erstellt.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Veröffentlichte Downloads erneut verifizieren**
|
||||||
|
|
||||||
|
Alle öffentlichen und privaten Assets werden neu heruntergeladen, byte- und hashverglichen, `latest.yml` wird gegen das Setup geprüft und der veröffentlichte Build wird erneut gestartet. Erst danach wird `tasks/todo.md` mit den exakten Belegen aktualisiert.
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# V2.0.1 Account Manager und Startup
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Multi-Hoster-Upload wird als sichtbare Produktversion `2.0.1` ausgeliefert. Das Hinzufügen und Bearbeiten eines Accounts validiert und speichert in einem Klick. Ein fehlgeschlagener, abgebrochener oder veralteter Check verändert weder die persistierte noch die im Renderer gehaltene Account-Konfiguration. Der intermittierende weiße Startzustand wird durch einen deterministischen Software-Renderer und ein erst nach erfolgreichem Laden sichtbares Hauptfenster verhindert.
|
||||||
|
|
||||||
|
## Account-Flow
|
||||||
|
|
||||||
|
Der Button lautet im Anlegefall `Prüfen und anlegen`, im Bearbeitungsfall `Prüfen und speichern`. Der bestehende Credential-Check bleibt die einzige fachliche Validierung. `ok` und `warn` führen unmittelbar zum Save, `error`, `skipped`, IPC-Fehler und `otp_required` nicht. OTP zeigt das Eingabefeld; der nächste Klick validiert und speichert in einem Lauf.
|
||||||
|
|
||||||
|
Ein einziger Busy-Zustand umfasst Check und Save. Vor dem Save werden Modal-Session, Hoster, Account-ID und Credential-Snapshot erneut abgeglichen. Hoster- oder Credential-Änderung, Schließen oder erneutes Öffnen invalidieren die laufende Operation. Der Kandidat wird auf einer Kopie der Hoster-Konfiguration aufgebaut. Erst nach erfolgreichem `saveConfig` ersetzt er den Renderer-State. Bei Save-Fehler bleibt das Modal offen und der bisherige State unverändert.
|
||||||
|
|
||||||
|
## Startup
|
||||||
|
|
||||||
|
Die App benötigt keine GPU-beschleunigten Canvas-, WebGL- oder Video-Oberflächen. Hardwarebeschleunigung wird deshalb bei jedem Start vor `app.whenReady()` deaktiviert, unabhängig von `SESSIONNAME` und einem späteren GPU-Crash. Das Hauptfenster startet mit `show: false`, wird auf `ready-to-show` eingeblendet und wertet das Promise von `loadFile` aus. Load- und Renderer-Fehler bleiben sichtbar diagnostizierbar.
|
||||||
|
|
||||||
|
## Versions- und Update-Brücke
|
||||||
|
|
||||||
|
`package.json`, UI, Installer-Metadaten und öffentliche GitHub-Version lauten `2.0.1` beziehungsweise `v2.0.1`. Der ausgelieferte 3.3.108-Client vergleicht den privaten Gitea-Tag numerisch und würde einen Tag `v2.0.1` ablehnen. Außerdem existiert dort bereits ein historischer Tag `v2.0.1`.
|
||||||
|
|
||||||
|
Der private Updater-Transport verwendet deshalb für diese Veröffentlichung den neuen internen Tag `v3.3.109`, während Release-Titel und Build `v2.0.1` anzeigen. Der neue Updater liest die Produktversion aus dem Release-Titel und fällt nur bei fehlender Produktversion auf den Tag zurück. Dadurch sieht 3.3.108 den höheren Transport-Tag, während 2.0.1 denselben Release anhand des Titels als bereits installiert erkennt. Künftige 2.x-Releases verwenden fortlaufende interne Transport-Tags oberhalb 3.3.108 und sichtbare 2.x-Titel.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
Regressionsfälle prüfen den real verwendeten Account-Submit-Core, die Startup-Konfiguration und die Produkt-/Transport-Versionsauflösung. Danach folgen vollständige Unit-Tests, UI-Smoke, wiederholte reale Starts, Windows-Build, entpackte Artefaktprüfung, Secret-Scans, isolierter Installer-/Updater-Test und erst anschließend die kuratierten privaten und öffentlichen Releases mit erneutem Download und Hashvergleich.
|
||||||
@ -55,6 +55,7 @@ const nodeGlobals = {
|
|||||||
fetch: 'readonly',
|
fetch: 'readonly',
|
||||||
crypto: 'readonly',
|
crypto: 'readonly',
|
||||||
structuredClone: 'readonly',
|
structuredClone: 'readonly',
|
||||||
|
performance: 'readonly',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
19
lib/startup-renderer.js
Normal file
19
lib/startup-renderer.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
function configureStartupRenderer(app) {
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStartupWindow(BrowserWindow, options) {
|
||||||
|
const window = new BrowserWindow({ ...options, show: false });
|
||||||
|
window.once('ready-to-show', () => {
|
||||||
|
window.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
window,
|
||||||
|
load(target, onLoadError) {
|
||||||
|
return window.loadFile(target).catch(onLoadError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { configureStartupRenderer, createStartupWindow };
|
||||||
@ -37,6 +37,14 @@ function isNewer(remote, current) {
|
|||||||
return r.patch > c.patch;
|
return r.patch > c.patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReleaseVersion(release) {
|
||||||
|
for (const value of [release && release.name, release && release.tag_name]) {
|
||||||
|
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function pickSetupAsset(assets) {
|
function pickSetupAsset(assets) {
|
||||||
if (!Array.isArray(assets)) return null;
|
if (!Array.isArray(assets)) return null;
|
||||||
// Prefer asset with "setup" in the name (case-insensitive)
|
// Prefer asset with "setup" in the name (case-insensitive)
|
||||||
@ -90,11 +98,12 @@ async function checkForUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const release = releases[0];
|
const release = releases[0];
|
||||||
const remoteVersion = release.tag_name || release.name || '';
|
const remoteVersion = resolveReleaseVersion(release);
|
||||||
|
const transportTag = release.tag_name || '';
|
||||||
const currentVersion = getCurrentVersion();
|
const currentVersion = getCurrentVersion();
|
||||||
|
|
||||||
if (!isNewer(remoteVersion, currentVersion)) {
|
if (!isNewer(remoteVersion, currentVersion)) {
|
||||||
cachedCheck = { available: false, currentVersion, remoteVersion };
|
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag };
|
||||||
cachedCheckTs = Date.now();
|
cachedCheckTs = Date.now();
|
||||||
return cachedCheck;
|
return cachedCheck;
|
||||||
}
|
}
|
||||||
@ -109,7 +118,8 @@ async function checkForUpdate() {
|
|||||||
cachedCheck = {
|
cachedCheck = {
|
||||||
available: true,
|
available: true,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
remoteVersion: remoteVersion.replace(/^v/i, ''),
|
remoteVersion,
|
||||||
|
transportTag,
|
||||||
releaseUrl: release.html_url,
|
releaseUrl: release.html_url,
|
||||||
assetUrl: setupAsset.browser_download_url,
|
assetUrl: setupAsset.browser_download_url,
|
||||||
assetSize: setupAsset.size,
|
assetSize: setupAsset.size,
|
||||||
@ -280,4 +290,4 @@ function abortUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkForUpdate, installUpdate, abortUpdate };
|
module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||||
|
|||||||
23
main.js
23
main.js
@ -1,6 +1,8 @@
|
|||||||
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
|
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
|
||||||
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
|
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
|
||||||
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
|
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
|
||||||
|
const { configureStartupRenderer, createStartupWindow } = require('./lib/startup-renderer');
|
||||||
|
configureStartupRenderer(app);
|
||||||
nativeTheme.themeSource = 'dark';
|
nativeTheme.themeSource = 'dark';
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@ -27,16 +29,6 @@ const stats = require('./lib/stats');
|
|||||||
const { createCollectors } = require('./lib/diagnostics-collectors');
|
const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||||
const { createAgent } = require('./lib/diagnostics-agent');
|
const { createAgent } = require('./lib/diagnostics-agent');
|
||||||
|
|
||||||
function _gpuDisableFlagPath() {
|
|
||||||
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
|
|
||||||
}
|
|
||||||
(function maybeDisableHardwareAcceleration() {
|
|
||||||
let disable = false;
|
|
||||||
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
|
|
||||||
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
|
|
||||||
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
|
|
||||||
})();
|
|
||||||
|
|
||||||
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||||
_eventLoopDelay.enable();
|
_eventLoopDelay.enable();
|
||||||
let _eldLastLog = 0;
|
let _eldLastLog = 0;
|
||||||
@ -1230,7 +1222,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
const startupWindow = createStartupWindow(BrowserWindow, {
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 750,
|
height: 750,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
@ -1243,6 +1235,7 @@ function createWindow() {
|
|||||||
preload: path.join(__dirname, 'preload.js')
|
preload: path.join(__dirname, 'preload.js')
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
mainWindow = startupWindow.window;
|
||||||
|
|
||||||
mainWindow.webContents.setBackgroundThrottling(false);
|
mainWindow.webContents.setBackgroundThrottling(false);
|
||||||
|
|
||||||
@ -1288,12 +1281,12 @@ function createWindow() {
|
|||||||
app.on('child-process-gone', (_event, details) => {
|
app.on('child-process-gone', (_event, details) => {
|
||||||
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
|
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
if (details && details.type === 'GPU') {
|
|
||||||
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
|
||||||
|
_writeCrashLog('LOAD FILE FAILED', err);
|
||||||
|
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTray() {
|
function createTray() {
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.16",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.16",
|
"version": "2.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.29.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^41.3.0",
|
"electron": "^41.3.0",
|
||||||
@ -4734,9 +4734,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.25.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
@ -4844,9 +4844,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.108",
|
"version": "2.0.1",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,8 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.29.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^41.3.0",
|
"electron": "^41.3.0",
|
||||||
|
|||||||
73
renderer/account-submit.js
Normal file
73
renderer/account-submit.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
(function (scope) {
|
||||||
|
function getAccountSubmitLabel({ isEdit } = {}) {
|
||||||
|
return isEdit ? 'Prüfen und speichern' : 'Prüfen und anlegen';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
|
||||||
|
let validation;
|
||||||
|
try {
|
||||||
|
validation = await validate();
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'error', error };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isCurrent()) return { status: 'stale', validation };
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'error', error, validation };
|
||||||
|
}
|
||||||
|
if (validation && validation.status === 'otp_required') {
|
||||||
|
return { status: 'otp_required', validation };
|
||||||
|
}
|
||||||
|
if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
|
||||||
|
return { status: 'rejected', validation };
|
||||||
|
}
|
||||||
|
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = await commit(validation);
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'error', error, validation };
|
||||||
|
}
|
||||||
|
|
||||||
|
let postCommitError;
|
||||||
|
if (typeof afterCommit === 'function') {
|
||||||
|
try {
|
||||||
|
await afterCommit(value, validation);
|
||||||
|
} catch (error) {
|
||||||
|
postCommitError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const committedResult = { status: 'committed', committed: true, validation, value };
|
||||||
|
if (postCommitError) committedResult.postCommitError = postCommitError;
|
||||||
|
try {
|
||||||
|
if (!isCurrent()) return { ...committedResult, status: 'stale' };
|
||||||
|
} catch {
|
||||||
|
return { ...committedResult, status: 'stale' };
|
||||||
|
}
|
||||||
|
return committedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountSubmitter() {
|
||||||
|
let pending = null;
|
||||||
|
return {
|
||||||
|
isBusy() {
|
||||||
|
return pending !== null;
|
||||||
|
},
|
||||||
|
submit(options) {
|
||||||
|
if (pending) return null;
|
||||||
|
const operation = submitValidatedAccount(options);
|
||||||
|
const tracked = operation.finally(() => {
|
||||||
|
if (pending === tracked) pending = null;
|
||||||
|
});
|
||||||
|
pending = tracked;
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSubmit = { createAccountSubmitter, getAccountSubmitLabel, submitValidatedAccount };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
|
||||||
|
if (scope) scope.AccountSubmit = accountSubmit;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
285
renderer/app.js
285
renderer/app.js
@ -4260,8 +4260,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
|
|||||||
|
|
||||||
function openAccountModal(editAccountId) {
|
function openAccountModal(editAccountId) {
|
||||||
editingAccountId = editAccountId || null;
|
editingAccountId = editAccountId || null;
|
||||||
// Reset the two-step state — any previously validated snapshot from a prior
|
|
||||||
// modal session is stale and must not allow a no-recheck commit.
|
|
||||||
_resetAccountModalState();
|
_resetAccountModalState();
|
||||||
const modal = document.getElementById('accountModal');
|
const modal = document.getElementById('accountModal');
|
||||||
const title = document.getElementById('accountModalTitle');
|
const title = document.getElementById('accountModalTitle');
|
||||||
@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) {
|
|||||||
const found = findAccountById(editingAccountId);
|
const found = findAccountById(editingAccountId);
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
title.textContent = 'Account bearbeiten';
|
title.textContent = 'Account bearbeiten';
|
||||||
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
|
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`;
|
||||||
hosterRow.style.display = 'none';
|
hosterRow.style.display = 'none';
|
||||||
saveBtn.textContent = 'Prüfen';
|
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true });
|
||||||
if (labelInput) labelInput.value = found.account.label || '';
|
if (labelInput) labelInput.value = found.account.label || '';
|
||||||
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
|
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
|
||||||
} else {
|
} else {
|
||||||
// Add mode — always show all options (multiple accounts per hoster allowed)
|
// Add mode — always show all options (multiple accounts per hoster allowed)
|
||||||
title.textContent = 'Account hinzufügen';
|
title.textContent = 'Account hinzufügen';
|
||||||
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
|
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.';
|
||||||
hosterRow.style.display = 'flex';
|
hosterRow.style.display = 'flex';
|
||||||
saveBtn.textContent = 'Prüfen';
|
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false });
|
||||||
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
|
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
|
||||||
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
|
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
@ -4308,10 +4306,6 @@ function openAccountModal(editAccountId) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire field invalidation: any change to a cred field after a green check
|
|
||||||
// drops the validated snapshot so the next click is a re-check, not a commit
|
|
||||||
// of unverified creds. Re-wired here every open because credsContainer's HTML
|
|
||||||
// was replaced.
|
|
||||||
_wireCredFieldInvalidation();
|
_wireCredFieldInvalidation();
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@ -4321,11 +4315,7 @@ function closeAccountModal() {
|
|||||||
document.getElementById('accountModal').style.display = 'none';
|
document.getElementById('accountModal').style.display = 'none';
|
||||||
_hideOtpField();
|
_hideOtpField();
|
||||||
editingAccountId = null;
|
editingAccountId = null;
|
||||||
// Cancel any pending auto-close so a stale timer can't close a future modal
|
_resetAccountModalState();
|
||||||
// the user reopens within the auto-close window.
|
|
||||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
|
||||||
_validatedCreds = null;
|
|
||||||
_accountModalBusy = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteAccountModal(accountId) {
|
function openDeleteAccountModal(accountId) {
|
||||||
@ -4381,66 +4371,54 @@ function readAccountCredsFromModal(authType) {
|
|||||||
return { enabled: !!apiKey, authType: 'api', apiKey, label };
|
return { enabled: !!apiKey, authType: 'api', apiKey, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Two-step account-modal state machine ---
|
const _accountSubmitter = window.AccountSubmit.createAccountSubmitter();
|
||||||
//
|
let _accountModalCommitLocked = false;
|
||||||
// Goal: never persist invalid/unverified credentials to config.hosters. The
|
|
||||||
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
|
|
||||||
// the button label flips to "Anlegen" / "Speichern" → the next click commits
|
|
||||||
// to config. Editing any cred field between the two clicks drops the validated
|
|
||||||
// snapshot so the user can't sneak unverified creds through by editing
|
|
||||||
// post-green.
|
|
||||||
//
|
|
||||||
// Invariants enforced here:
|
|
||||||
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
|
|
||||||
// result for the currently-typed creds.
|
|
||||||
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
|
|
||||||
// before any await — guards against double-clicks producing duplicates.
|
|
||||||
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
|
|
||||||
// the new OTP, no config writes until green.
|
|
||||||
// 4. Edit mode hits the same path → bad edits never overwrite known-good
|
|
||||||
// creds on disk.
|
|
||||||
let _accountModalBusy = false;
|
|
||||||
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
|
|
||||||
let _autoCloseTimer = null;
|
let _autoCloseTimer = null;
|
||||||
// Session token used to ignore stale validate-credentials responses: if the
|
|
||||||
// user closes the modal mid-flight and reopens it, the late .then must NOT
|
|
||||||
// stomp the new session's state. Bumped on every modal reset.
|
|
||||||
let _accountModalSession = 0;
|
let _accountModalSession = 0;
|
||||||
|
|
||||||
function _resetAccountModalState() {
|
function _resetAccountModalState() {
|
||||||
_accountModalBusy = false;
|
|
||||||
_validatedCreds = null;
|
|
||||||
_accountModalSession++;
|
_accountModalSession++;
|
||||||
|
_accountModalCommitLocked = false;
|
||||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||||||
|
_syncAccountSubmitButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _credsSnapshotKey(authType, creds) {
|
function _credsSnapshotKey(authType, creds) {
|
||||||
// Identity key for the typed creds — used to detect post-validation edits.
|
|
||||||
// Label changes do NOT invalidate (label is metadata, not a credential).
|
|
||||||
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||||||
return `api:${creds.apiKey || ''}`;
|
return `api:${creds.apiKey || ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _defaultAccountSubmitButtonText(ctx) {
|
||||||
|
return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncAccountSubmitButton() {
|
||||||
|
const saveBtn = document.getElementById('saveAccountBtn');
|
||||||
|
if (!saveBtn) return;
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
|
||||||
|
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _invalidateAccountSubmit() {
|
||||||
|
_accountModalSession++;
|
||||||
|
const statusEl = document.getElementById('accountModalStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.className = 'account-modal-status';
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById('saveAccountBtn');
|
||||||
|
if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _wireCredFieldInvalidation() {
|
function _wireCredFieldInvalidation() {
|
||||||
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
|
|
||||||
// validated snapshot and reverts the button to "Prüfen". Label edits don't
|
|
||||||
// invalidate (label is metadata, not a credential). OTP edits don't either:
|
|
||||||
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
|
|
||||||
// these username+password+OTP, the resulting trust is on the creds; the user
|
|
||||||
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
|
|
||||||
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
|
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el || el.dataset.invalidateBound === '1') continue;
|
if (!el || el.dataset.invalidateBound === '1') continue;
|
||||||
el.addEventListener('input', () => {
|
el.addEventListener('input', _invalidateAccountSubmit);
|
||||||
if (_validatedCreds) {
|
|
||||||
_validatedCreds = null;
|
|
||||||
const saveBtn = document.getElementById('saveAccountBtn');
|
|
||||||
if (saveBtn) saveBtn.textContent = 'Prüfen';
|
|
||||||
const statusEl = document.getElementById('accountModalStatus');
|
|
||||||
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.dataset.invalidateBound = '1';
|
el.dataset.invalidateBound = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4458,12 +4436,18 @@ function _determineHosterContext() {
|
|||||||
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
|
||||||
|
if (session !== _accountModalSession) return false;
|
||||||
|
const currentCtx = _determineHosterContext();
|
||||||
|
if (!currentCtx) return false;
|
||||||
|
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
|
||||||
|
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
|
||||||
|
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
|
||||||
|
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAccount() {
|
async function saveAccount() {
|
||||||
// SYNCHRONOUS re-entry guard — must come before any await. Without this a
|
if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return;
|
||||||
// double-click before the first IPC returns triggers two saveAccount() calls
|
|
||||||
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
|
|
||||||
// synchronously and set synchronously, so the second click no-ops cleanly.
|
|
||||||
if (_accountModalBusy) return;
|
|
||||||
|
|
||||||
const ctx = _determineHosterContext();
|
const ctx = _determineHosterContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@ -4476,37 +4460,8 @@ async function saveAccount() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
|
|
||||||
// EXACT same creds (label changes don't break this — label isn't part of the
|
|
||||||
// credential identity).
|
|
||||||
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
||||||
if (_validatedCreds &&
|
|
||||||
_validatedCreds.hosterName === ctx.hosterName &&
|
|
||||||
_validatedCreds.authType === ctx.authType &&
|
|
||||||
_validatedCreds.snapshot === snapshotKey) {
|
|
||||||
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
|
|
||||||
// leak _accountModalBusy=true and lock the user out for the session.
|
|
||||||
try {
|
|
||||||
_accountModalBusy = true;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
|
|
||||||
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
|
|
||||||
} finally {
|
|
||||||
_accountModalBusy = false;
|
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
|
|
||||||
// Snapshot the session token so a stale late-arriving response from a
|
|
||||||
// closed-and-reopened modal can't stomp the new session's state.
|
|
||||||
const mySession = _accountModalSession;
|
const mySession = _accountModalSession;
|
||||||
_accountModalBusy = true;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
statusEl.textContent = 'Prüfe Login…';
|
|
||||||
statusEl.className = 'account-modal-status checking';
|
|
||||||
|
|
||||||
const otpInput = document.getElementById('accField_otp');
|
const otpInput = document.getElementById('accField_otp');
|
||||||
const otp = otpInput ? otpInput.value.trim() : '';
|
const otp = otpInput ? otpInput.value.trim() : '';
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -4518,94 +4473,100 @@ async function saveAccount() {
|
|||||||
otp
|
otp
|
||||||
};
|
};
|
||||||
|
|
||||||
let row;
|
const submission = _accountSubmitter.submit({
|
||||||
|
validate: () => window.api.validateCredentials(payload),
|
||||||
|
commit: () => _persistAccount(ctx, creds),
|
||||||
|
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
|
||||||
|
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
|
||||||
|
});
|
||||||
|
if (!submission) return;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
|
||||||
|
statusEl.textContent = 'Prüfe Zugangsdaten…';
|
||||||
|
statusEl.className = 'account-modal-status checking';
|
||||||
|
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
row = await window.api.validateCredentials(payload);
|
result = await submission;
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
|
result = { status: 'error', error };
|
||||||
} finally {
|
|
||||||
if (mySession === _accountModalSession) {
|
|
||||||
_accountModalBusy = false;
|
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale response — modal was closed/reopened while we awaited. Drop it.
|
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
|
||||||
if (mySession !== _accountModalSession) return;
|
if (result.status === 'committed' && current) {
|
||||||
|
_accountModalCommitLocked = true;
|
||||||
if (row && row.status === 'otp_required') {
|
const validation = result.validation || {};
|
||||||
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
|
statusEl.textContent = validation.status === 'warn'
|
||||||
statusEl.className = 'account-modal-status error';
|
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
|
||||||
_showOtpField();
|
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
|
||||||
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
|
|
||||||
saveBtn.textContent = 'Mit OTP prüfen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
|
||||||
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
|
|
||||||
statusEl.className = 'account-modal-status ok';
|
statusEl.className = 'account-modal-status ok';
|
||||||
_hideOtpField();
|
_hideOtpField();
|
||||||
_validatedCreds = {
|
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
|
||||||
hosterName: ctx.hosterName,
|
saveBtn.disabled = true;
|
||||||
authType: ctx.authType,
|
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
|
||||||
snapshot: snapshotKey,
|
_autoCloseTimer = setTimeout(() => {
|
||||||
status: row.status,
|
_autoCloseTimer = null;
|
||||||
message: row.message || ''
|
closeAccountModal();
|
||||||
};
|
}, 600);
|
||||||
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// error
|
|
||||||
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
_syncAccountSubmitButton();
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
if (result.status === 'otp_required') {
|
||||||
|
const validation = result.validation || {};
|
||||||
|
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
|
||||||
|
statusEl.className = 'account-modal-status error';
|
||||||
|
_showOtpField();
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = result.validation || {};
|
||||||
|
const msg = result.status === 'error'
|
||||||
|
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
|
||||||
|
: validation.message || 'Login fehlgeschlagen';
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
statusEl.className = 'account-modal-status error';
|
statusEl.className = 'account-modal-status error';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
|
function _copyHosterTree(hosters) {
|
||||||
// Persist the validated creds to config.hosters and close the modal. By the
|
const candidate = {};
|
||||||
// time we reach this function the validate-credentials IPC has already
|
for (const [name, accounts] of Object.entries(hosters || {})) {
|
||||||
// returned ok/warn for these exact creds, so we skip a redundant re-check.
|
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
|
||||||
let accountId;
|
}
|
||||||
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistAccount(ctx, creds) {
|
||||||
|
const candidateHosters = _copyHosterTree(config.hosters);
|
||||||
|
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
|
||||||
|
let accountId = ctx.accountId;
|
||||||
if (ctx.isEdit) {
|
if (ctx.isEdit) {
|
||||||
accountId = ctx.accountId;
|
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
|
||||||
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
|
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
|
||||||
if (idx >= 0) {
|
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
|
||||||
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
|
|
||||||
} else {
|
|
||||||
_accountModalBusy = false;
|
|
||||||
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
|
|
||||||
const _st = document.getElementById('accountModalStatus');
|
|
||||||
if (_st) {
|
|
||||||
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
|
|
||||||
_st.className = 'account-modal-status error';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
|
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
|
||||||
}
|
}
|
||||||
await window.api.saveConfig({ hosters: config.hosters });
|
await window.api.saveConfig({ hosters: candidateHosters });
|
||||||
// Skip the redundant await getConfig() — the in-memory state is the source
|
return { accountId, candidateHosters, isEdit: ctx.isEdit };
|
||||||
// of truth for what we just wrote, decrypted creds didn't change, and the
|
}
|
||||||
// round-trip was the main lag source on add/delete.
|
|
||||||
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
|
function _applyCommittedAccount(persisted, validation) {
|
||||||
|
const { accountId, candidateHosters, isEdit } = persisted;
|
||||||
|
config.hosters = candidateHosters;
|
||||||
|
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
|
||||||
ensureAccountStatusEntries();
|
ensureAccountStatusEntries();
|
||||||
syncSelectedUploadHosters();
|
syncSelectedUploadHosters();
|
||||||
// Targeted updates instead of the 4-panel cascade. For add we need a full
|
if (isEdit) {
|
||||||
// accounts-list re-render (new card) and the hoster summary count; for edit
|
|
||||||
// we can update the single card. Settings panel only needs re-render if its
|
|
||||||
// hoster-summary section is visible — that's covered by renderHosterSummary.
|
|
||||||
if (ctx.isEdit) {
|
|
||||||
updateAccountCard(accountId);
|
updateAccountCard(accountId);
|
||||||
} else {
|
} else {
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
}
|
}
|
||||||
renderHosterSummary();
|
renderHosterSummary();
|
||||||
// Auto-close after a short pause so the user sees the success state.
|
|
||||||
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
|
|
||||||
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showOtpField() {
|
function _showOtpField() {
|
||||||
@ -5170,6 +5131,7 @@ function setupListeners() {
|
|||||||
|
|
||||||
// Account hoster select change → update credential fields
|
// Account hoster select change → update credential fields
|
||||||
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
||||||
|
_invalidateAccountSubmit();
|
||||||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
|
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
|
||||||
const authType = opt ? opt.authType : 'login';
|
const authType = opt ? opt.authType : 'login';
|
||||||
const credsContainer = document.getElementById('accountCredsFields');
|
const credsContainer = document.getElementById('accountCredsFields');
|
||||||
@ -5180,15 +5142,6 @@ function setupListeners() {
|
|||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('accountModalStatus').textContent = '';
|
|
||||||
document.getElementById('accountModalStatus').className = 'account-modal-status';
|
|
||||||
// Hoster changed → any prior validation is stale by construction. Drop the
|
|
||||||
// snapshot and revert the button so the user has to re-Prüfen.
|
|
||||||
_validatedCreds = null;
|
|
||||||
const sb = document.getElementById('saveAccountBtn');
|
|
||||||
if (sb) sb.textContent = 'Prüfen';
|
|
||||||
// The cred inputs were just replaced — rewire invalidation listeners on
|
|
||||||
// the fresh elements so post-validation edits still revert the button.
|
|
||||||
_wireCredFieldInvalidation();
|
_wireCredFieldInvalidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -266,7 +266,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
||||||
<button class="btn btn-primary" id="saveAccountBtn">Anlegen & prüfen</button>
|
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -421,6 +421,7 @@
|
|||||||
<script src="../lib/throttled-cache.js"></script>
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
<script src="../lib/coalesced-set.js"></script>
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
<script src="../lib/throttle-timer.js"></script>
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
|
<script src="account-submit.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,27 +1,61 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { readFileSync, writeFileSync, statSync, createReadStream, existsSync } from 'fs';
|
import { writeFileSync, statSync, createReadStream, existsSync } from 'fs';
|
||||||
import { resolve, basename } from 'path';
|
import { resolve, basename } from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
const ROOT = resolve(import.meta.dirname, '..');
|
const ROOT = resolve(import.meta.dirname, '..');
|
||||||
const PKG_PATH = resolve(ROOT, 'package.json');
|
|
||||||
const RELEASE_DIR = resolve(ROOT, 'release');
|
const RELEASE_DIR = resolve(ROOT, 'release');
|
||||||
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||||
|
|
||||||
// --- CLI args ---
|
// --- CLI args ---
|
||||||
const args = process.argv.slice(2);
|
export function parseReleaseArgs(args) {
|
||||||
const dryRun = args.includes('--dry-run');
|
const version = Array.isArray(args) ? args[0] : '';
|
||||||
const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a));
|
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
||||||
const notes = args.filter(a => a !== version && a !== '--dry-run').join(' ') || '';
|
throw new Error('Usage: node scripts/release_gitea.mjs <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]');
|
||||||
|
}
|
||||||
|
|
||||||
if (!version) {
|
const transportTagIndex = args.indexOf('--transport-tag');
|
||||||
console.error('Usage: node scripts/release_gitea.mjs <version> [release notes] [--dry-run]');
|
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
|
||||||
console.error('Example: node scripts/release_gitea.mjs 1.0.1 "Bugfix release"');
|
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
|
||||||
process.exit(1);
|
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') };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = `v${version}`;
|
export function createReleasePlan(options) {
|
||||||
|
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
||||||
|
const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`;
|
||||||
|
const portableName = `${PRODUCT_NAME} ${options.version}.exe`;
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
tag: options.transportTag,
|
||||||
|
releaseTitle,
|
||||||
|
releaseBody: options.notes || releaseTitle,
|
||||||
|
setupName,
|
||||||
|
portableName,
|
||||||
|
expectedArtifacts: [setupName, portableName, 'latest.yml'],
|
||||||
|
blockmapName: `${setupName}.blockmap`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExistingReleaseId(plan, release) {
|
||||||
|
const existingTitle = typeof release?.name === 'string' ? release.name : '';
|
||||||
|
if (existingTitle !== plan.releaseTitle) {
|
||||||
|
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
|
||||||
|
}
|
||||||
|
return release.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
|
||||||
|
return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dryRun = false;
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
function run(cmd, opts = {}) {
|
function run(cmd, opts = {}) {
|
||||||
@ -109,8 +143,11 @@ async function uploadAsset(releaseId, filePath, token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
async function main() {
|
async function main(args = process.argv.slice(2)) {
|
||||||
console.log(`\nReleasing ${PRODUCT_NAME} ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
|
const plan = createReleasePlan(parseReleaseArgs(args));
|
||||||
|
const { version, tag } = plan;
|
||||||
|
dryRun = plan.dryRun;
|
||||||
|
console.log(`\nReleasing ${plan.releaseTitle} via ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`);
|
||||||
|
|
||||||
// 1. Resolve remote
|
// 1. Resolve remote
|
||||||
const remote = resolveGiteaRemote();
|
const remote = resolveGiteaRemote();
|
||||||
@ -138,18 +175,19 @@ async function main() {
|
|||||||
|
|
||||||
if (!recoveryMode) {
|
if (!recoveryMode) {
|
||||||
// 4. Update package.json version
|
// 4. Update package.json version
|
||||||
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
|
run(`npm version ${version} --no-git-tag-version --allow-same-version`);
|
||||||
pkg.version = version;
|
console.log(`Updated package.json and package-lock.json -> ${version}`);
|
||||||
if (!dryRun) writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
||||||
console.log(`Updated package.json -> ${version}`);
|
|
||||||
|
|
||||||
// 5. Build
|
// 5. Build
|
||||||
console.log('\nBuilding...');
|
console.log('\nBuilding...');
|
||||||
run('npm run release:win', { stdio: 'inherit' });
|
run('npm run release:win', { stdio: 'inherit' });
|
||||||
|
|
||||||
// 6. Git commit + tag + push
|
// 6. Git commit + tag + push
|
||||||
run('git add package.json');
|
const versionStatus = run('git status --porcelain -- package.json package-lock.json', { allowDry: true });
|
||||||
|
if (versionStatus) {
|
||||||
|
run('git add package.json package-lock.json');
|
||||||
run(`git commit -m "release: ${tag}"`);
|
run(`git commit -m "release: ${tag}"`);
|
||||||
|
}
|
||||||
run(`git tag ${tag}`);
|
run(`git tag ${tag}`);
|
||||||
run(`git push ${remote.name} HEAD`);
|
run(`git push ${remote.name} HEAD`);
|
||||||
run(`git push ${remote.name} ${tag}`);
|
run(`git push ${remote.name} ${tag}`);
|
||||||
@ -157,8 +195,7 @@ async function main() {
|
|||||||
|
|
||||||
// 6b. Regenerate latest.yml to ensure correct SHA-512
|
// 6b. Regenerate latest.yml to ensure correct SHA-512
|
||||||
{
|
{
|
||||||
const setupName = `${PRODUCT_NAME} Setup ${version}.exe`;
|
const setupPath = resolve(RELEASE_DIR, plan.setupName);
|
||||||
const setupPath = resolve(RELEASE_DIR, setupName);
|
|
||||||
if (existsSync(setupPath)) {
|
if (existsSync(setupPath)) {
|
||||||
const sha = await new Promise((res, rej) => {
|
const sha = await new Promise((res, rej) => {
|
||||||
const h = createHash('sha512');
|
const h = createHash('sha512');
|
||||||
@ -168,18 +205,14 @@ async function main() {
|
|||||||
s.on('error', rej);
|
s.on('error', rej);
|
||||||
});
|
});
|
||||||
const size = statSync(setupPath).size;
|
const size = statSync(setupPath).size;
|
||||||
const yml = `version: ${version}\nfiles:\n - url: ${setupName}\n sha512: ${sha}\n size: ${size}\npath: ${setupName}\nsha512: ${sha}\nreleaseDate: '${new Date().toISOString()}'\n`;
|
const yml = renderLatestYml(plan, sha, size);
|
||||||
writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8');
|
writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8');
|
||||||
console.log('Regenerated latest.yml with correct SHA-512');
|
console.log('Regenerated latest.yml with correct SHA-512');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Verify artifacts
|
// 7. Verify artifacts
|
||||||
const expectedArtifacts = [
|
const expectedArtifacts = plan.expectedArtifacts;
|
||||||
`${PRODUCT_NAME} Setup ${version}.exe`,
|
|
||||||
`${PRODUCT_NAME} ${version}.exe`,
|
|
||||||
'latest.yml'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const name of expectedArtifacts) {
|
for (const name of expectedArtifacts) {
|
||||||
const p = resolve(RELEASE_DIR, name);
|
const p = resolve(RELEASE_DIR, name);
|
||||||
@ -190,7 +223,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also check for blockmap
|
// Also check for blockmap
|
||||||
const blockmapName = `${PRODUCT_NAME} Setup ${version}.exe.blockmap`;
|
const blockmapName = plan.blockmapName;
|
||||||
const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName));
|
const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName));
|
||||||
|
|
||||||
console.log('\nArtifacts verified.');
|
console.log('\nArtifacts verified.');
|
||||||
@ -203,20 +236,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Create release
|
// 9. Create release
|
||||||
const releaseBody = notes || `${PRODUCT_NAME} ${tag}`;
|
|
||||||
let releaseId;
|
let releaseId;
|
||||||
|
|
||||||
const { status: createStatus, data: createData } = await giteaApi(
|
const { status: createStatus, data: createData } = await giteaApi(
|
||||||
'POST',
|
'POST',
|
||||||
`/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`,
|
`/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`,
|
||||||
token,
|
token,
|
||||||
{ tag_name: tag, name: `${PRODUCT_NAME} ${tag}`, body: releaseBody }
|
{ tag_name: tag, name: plan.releaseTitle, body: plan.releaseBody }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (createStatus === 409 || createStatus === 422) {
|
if (createStatus === 409 || createStatus === 422) {
|
||||||
// Release already exists, find it
|
// Release already exists, find it
|
||||||
const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token);
|
const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token);
|
||||||
releaseId = releases.id;
|
releaseId = resolveExistingReleaseId(plan, releases);
|
||||||
console.log(`Release already exists (id: ${releaseId})`);
|
console.log(`Release already exists (id: ${releaseId})`);
|
||||||
} else {
|
} else {
|
||||||
releaseId = createData.id;
|
releaseId = createData.id;
|
||||||
@ -234,7 +266,10 @@ async function main() {
|
|||||||
console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`);
|
console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
|
||||||
|
if (entryPoint === import.meta.url) {
|
||||||
|
main().catch(err => {
|
||||||
console.error('\nRelease failed:', err.message);
|
console.error('\nRelease failed:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Lessons
|
# Lessons
|
||||||
|
|
||||||
|
## 2026-08-07 — Einen kleinen Release nicht durch redundante Gates aufblasen
|
||||||
|
**Symptom:** Die angefragten Änderungen waren implementiert und getestet, trotzdem lief die Arbeit durch wiederholte Status-, Review- und Harness-Schleifen übermäßig lange weiter.
|
||||||
|
**Root cause:** Pflichtsicherheit, bereits belegte Wiederholungsprüfungen und optionale Zusatzdiagnostik wurden nicht hart getrennt. Technische Harnessfehler führten zu weiteren Schleifen, obwohl Produktcode und Kernlauf bereits grün waren.
|
||||||
|
**Regel:** Nach grüner Implementierung genau eine risikogerechte Pflichtkette fahren: Tests, Build, Secret-Gate, realer Lauf, Positivliste, Veröffentlichung, Redownload. Bereits belegte Gates nicht wiederholen und optionale Diagnose sofort streichen, sobald sie die Auslieferung verzögert.
|
||||||
|
**Wie anwenden:** Vor jedem zusätzlichen Check benennen, welche noch offene Release-Invariante er beweist. Beweist er keine neue Pflichtinvariante, wird er nicht ausgeführt.
|
||||||
|
|
||||||
## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`)
|
## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`)
|
||||||
**Symptom:** Drei Builds lang jagte ich Read-Bursts (highWaterMark, threadpool), während der WAHRE Treiber
|
**Symptom:** Drei Builds lang jagte ich Read-Bursts (highWaterMark, threadpool), während der WAHRE Treiber
|
||||||
eine 38,5-MB-electron-config.json war, die 137×/73s geklont/geparst/serialisiert wurde (~47% Main-Thread).
|
eine 38,5-MB-electron-config.json war, die 137×/73s geklont/geparst/serialisiert wurde (~47% Main-Thread).
|
||||||
|
|||||||
@ -1,3 +1,17 @@
|
|||||||
|
# v3.3.108 — session log filename: 6-digit uniqueness suffix
|
||||||
|
|
||||||
|
User: append a generated 6-digit number to the session log filename, e.g.
|
||||||
|
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute
|
||||||
|
collision risk on a fast close/reopen.
|
||||||
|
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged.
|
||||||
|
- main.js stamps SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random()*900000))).
|
||||||
|
- stripModeStampFromFileName newSessionRe gains an optional `(?:-\d+)?` so the suffix strips back to the base.
|
||||||
|
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass.
|
||||||
|
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json,
|
||||||
|
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
|
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
|
||||||
|
|
||||||
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
|
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
|
||||||
|
|||||||
68
tests/startup-renderer.test.js
Normal file
68
tests/startup-renderer.test.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer');
|
||||||
|
|
||||||
|
class TestBrowserWindow extends EventEmitter {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
this.options = options;
|
||||||
|
this.showCalls = 0;
|
||||||
|
this.startupEvents = [];
|
||||||
|
this.loadError = new Error('renderer load failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
once(eventName, listener) {
|
||||||
|
this.startupEvents.push(`listen:${eventName}`);
|
||||||
|
return super.once(eventName, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.showCalls++;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFile(target) {
|
||||||
|
this.startupEvents.push(`load:${target}`);
|
||||||
|
return Promise.reject(this.loadError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('configureStartupRenderer disables hardware acceleration', () => {
|
||||||
|
let calls = 0;
|
||||||
|
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createStartupWindow forces the main window to start hidden', () => {
|
||||||
|
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
|
||||||
|
|
||||||
|
assert.equal(startup.window.options.width, 1100);
|
||||||
|
assert.equal(startup.window.options.show, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup load registers visibility before navigation and shows only once', async () => {
|
||||||
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
||||||
|
const loading = startup.load('renderer/index.html', () => {});
|
||||||
|
|
||||||
|
assert.deepEqual(startup.window.startupEvents, [
|
||||||
|
'listen:ready-to-show',
|
||||||
|
'load:renderer/index.html'
|
||||||
|
]);
|
||||||
|
|
||||||
|
startup.window.emit('ready-to-show');
|
||||||
|
startup.window.emit('ready-to-show');
|
||||||
|
await loading;
|
||||||
|
|
||||||
|
assert.equal(startup.window.showCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup load forwards a rejected navigation to the error handler', async () => {
|
||||||
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
||||||
|
let handledError;
|
||||||
|
|
||||||
|
await startup.load('renderer/index.html', (err) => {
|
||||||
|
handledError = err;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handledError, startup.window.loadError);
|
||||||
|
});
|
||||||
@ -48,7 +48,10 @@ setTimeout(async () => {
|
|||||||
console.log('\\n=== Upload View ===');
|
console.log('\\n=== Upload View ===');
|
||||||
|
|
||||||
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
|
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length');
|
||||||
check('3 tabs exist', tabCount === 3);
|
check('4 tabs exist', tabCount === 4);
|
||||||
|
|
||||||
|
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
|
||||||
|
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
|
||||||
|
|
||||||
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
|
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
|
||||||
check('Upload tab active by default', activeTab === 'Upload');
|
check('Upload tab active by default', activeTab === 'Upload');
|
||||||
@ -56,12 +59,9 @@ setTimeout(async () => {
|
|||||||
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
|
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
|
||||||
check('Drop zone visible (no files)', dropVisible);
|
check('Drop zone visible (no files)', dropVisible);
|
||||||
|
|
||||||
const queueHidden = await wc.executeJavaScript('document.getElementById("queueContainer")?.style.display');
|
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
|
||||||
check('Queue hidden (no files)', queueHidden === 'none');
|
check('Queue hidden (no files)', queueHidden === 'none');
|
||||||
|
|
||||||
const chips = await wc.executeJavaScript('document.querySelectorAll(".hoster-chip").length');
|
|
||||||
check('4 hoster chips', chips === 4);
|
|
||||||
|
|
||||||
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
||||||
check('Start button disabled initially', startDisabled === true);
|
check('Start button disabled initially', startDisabled === true);
|
||||||
|
|
||||||
@ -74,6 +74,45 @@ setTimeout(async () => {
|
|||||||
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
|
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
|
||||||
check('Context menu hidden', ctxHidden === 'none');
|
check('Context menu hidden', ctxHidden === 'none');
|
||||||
|
|
||||||
|
console.log('\\n=== Accounts View ===');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'accounts\\']").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
|
||||||
|
check('Accounts tab active', accountsActive);
|
||||||
|
|
||||||
|
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
|
||||||
|
check('Account manager list structure rendered', accountListValid);
|
||||||
|
|
||||||
|
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
|
||||||
|
check('Add account button enabled', addAccountEnabled);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
||||||
|
check('Account modal opens', accountModalVisible === 'flex');
|
||||||
|
|
||||||
|
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
|
||||||
|
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
|
||||||
|
|
||||||
|
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
|
||||||
|
check('7 hoster authentication options exist', authOptionCount === 7);
|
||||||
|
|
||||||
|
const hosterCount = await wc.executeJavaScript('[...new Set([...document.querySelectorAll("#accountHosterSelect option")].map(el => el.value.split(":")[0]))].length');
|
||||||
|
check('5 hosters exist', hosterCount === 5);
|
||||||
|
|
||||||
|
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
|
||||||
|
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
|
||||||
|
|
||||||
|
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
|
||||||
|
check('Credential inputs rendered', credentialInputs === 2);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.getElementById("cancelAccountModalBtn").click()');
|
||||||
|
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
||||||
|
check('Account modal closes', accountModalHidden === 'none');
|
||||||
|
|
||||||
console.log('\\n=== Settings View ===');
|
console.log('\\n=== Settings View ===');
|
||||||
|
|
||||||
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
|
||||||
@ -82,23 +121,14 @@ setTimeout(async () => {
|
|||||||
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
|
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
|
||||||
check('Settings tab active', settingsActive);
|
check('Settings tab active', settingsActive);
|
||||||
|
|
||||||
const panels = await wc.executeJavaScript('document.querySelectorAll(".hoster-settings-panel").length');
|
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
|
||||||
check('4 hoster panels', panels === 4);
|
check('6 settings subtabs exist', settingsSubtabs === 6);
|
||||||
|
|
||||||
const hsInputCount = await wc.executeJavaScript('document.querySelectorAll(".hs-input").length');
|
const accountSettingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
|
||||||
check('24 per-hoster inputs (6x4)', hsInputCount === 24);
|
check('Hoster settings point to Accounts tab', accountSettingsPointer && accountSettingsPointer.includes('Accounts'));
|
||||||
|
|
||||||
await wc.executeJavaScript('document.querySelector(".hoster-panel-header").click()');
|
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
||||||
await new Promise(r => setTimeout(r, 200));
|
check('Global parallel uploads default 0', parallel === '0');
|
||||||
|
|
||||||
const panelBody = await wc.executeJavaScript('document.querySelector(".hoster-panel-body").style.display');
|
|
||||||
check('Panel expands on click', panelBody !== 'none');
|
|
||||||
|
|
||||||
const retries = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'retries\\']")?.value');
|
|
||||||
check('Retries default 3', retries === '3');
|
|
||||||
|
|
||||||
const parallel = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'parallelCount\\']")?.value');
|
|
||||||
check('ParallelCount default 2', parallel === '2');
|
|
||||||
|
|
||||||
// Test save
|
// Test save
|
||||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||||
@ -137,8 +167,7 @@ setTimeout(async () => {
|
|||||||
results.forEach(r => console.log(r));
|
results.forEach(r => console.log(r));
|
||||||
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
|
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
|
||||||
|
|
||||||
if (failed > 0) process.exitCode = 1;
|
app.exit(failed > 0 ? 1 : 0);
|
||||||
app.quit();
|
|
||||||
}, 5000);
|
}, 5000);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -166,9 +195,7 @@ try {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
if (filtered.trim()) console.error(filtered);
|
if (filtered.trim()) console.error(filtered);
|
||||||
}
|
}
|
||||||
if (err.status && err.status !== 0 && !err.killed) {
|
process.exitCode = Number.isInteger(err.status) && err.status !== 0 ? err.status : 1;
|
||||||
process.exit(err.status);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
try { fs.unlinkSync(injectPath); } catch {}
|
try { fs.unlinkSync(injectPath); } catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
98
tests/updater-version.test.js
Normal file
98
tests/updater-version.test.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
|
||||||
|
const { isNewer, resolveReleaseVersion } = require('../lib/updater');
|
||||||
|
|
||||||
|
test('bridge title resolves product version instead of transport tag', () => {
|
||||||
|
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
|
||||||
|
assert.equal(isNewer('2.0.1', '2.0.1'), false);
|
||||||
|
assert.equal(isNewer('2.0.2', '2.0.1'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release CLI rejects a malformed transport tag before release work', () => {
|
||||||
|
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
||||||
|
const result = spawnSync(process.execPath, [script, '2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run'], {
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /--transport-tag must match vX\.Y\.Z/);
|
||||||
|
assert.doesNotMatch(result.stdout, /npm run release:win/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release plan keeps product artifacts separate from the transport tag', () => {
|
||||||
|
const script = path.resolve(__dirname, '../scripts/release_gitea.mjs');
|
||||||
|
const moduleUrl = pathToFileURL(script).href;
|
||||||
|
const source = `
|
||||||
|
import { createReleasePlan, parseReleaseArgs, renderLatestYml } from ${JSON.stringify(moduleUrl)};
|
||||||
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge', 'notes']));
|
||||||
|
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
version: plan.version,
|
||||||
|
transportTag: plan.transportTag,
|
||||||
|
releaseTitle: plan.releaseTitle,
|
||||||
|
releaseBody: plan.releaseBody,
|
||||||
|
expectedArtifacts: plan.expectedArtifacts,
|
||||||
|
latestYml
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.deepEqual(JSON.parse(result.stdout), {
|
||||||
|
version: '2.0.1',
|
||||||
|
transportTag: 'v3.3.109',
|
||||||
|
releaseTitle: 'Multi-Hoster-Upload v2.0.1',
|
||||||
|
releaseBody: 'Bridge notes',
|
||||||
|
expectedArtifacts: [
|
||||||
|
'Multi-Hoster-Upload Setup 2.0.1.exe',
|
||||||
|
'Multi-Hoster-Upload 2.0.1.exe',
|
||||||
|
'latest.yml'
|
||||||
|
],
|
||||||
|
latestYml: "version: 2.0.1\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.1.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.1.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatible existing release preserves the recovery id', async () => {
|
||||||
|
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
|
||||||
|
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
||||||
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||||
|
const release = {
|
||||||
|
id: 81,
|
||||||
|
tag_name: 'v3.3.109',
|
||||||
|
name: 'Multi-Hoster-Upload v2.0.1',
|
||||||
|
body: 'Bridge notes',
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
assets: []
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(resolveExistingReleaseId(plan, release), 81);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incompatible existing release title fails closed', async () => {
|
||||||
|
const moduleUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release_gitea.mjs')).href;
|
||||||
|
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(moduleUrl);
|
||||||
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||||
|
const release = {
|
||||||
|
id: 81,
|
||||||
|
tag_name: 'v3.3.109',
|
||||||
|
name: 'Multi-Hoster-Upload v3.3.109',
|
||||||
|
body: 'Old transport release',
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
assets: []
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => resolveExistingReleaseId(plan, release),
|
||||||
|
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -1,195 +1,208 @@
|
|||||||
// Pure unit tests for the validate-credentials shape contract — does NOT spin
|
|
||||||
// up Electron or the real per-hoster checkers. Those need network. We verify
|
|
||||||
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
|
|
||||||
// checkers consume) plus the snapshot-key/invalidation invariants that the
|
|
||||||
// renderer relies on to enforce "validated creds only".
|
|
||||||
//
|
|
||||||
// The three assertions the advisor called out as the regression guard for the
|
|
||||||
// user's "mehrfach angelegt" complaint:
|
|
||||||
// (a) failed validation persists nothing to config.hosters
|
|
||||||
// (b) a second "Anlegen" click with the guard set persists exactly one entry
|
|
||||||
// (c) OTP-required path persists nothing
|
|
||||||
// are exercised at the state-machine level by simulating the renderer's logic
|
|
||||||
// (re-implemented here as pure functions for testability — the real ones live
|
|
||||||
// in renderer/app.js which can't run under node:test).
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
createAccountSubmitter,
|
||||||
|
getAccountSubmitLabel,
|
||||||
|
submitValidatedAccount
|
||||||
|
} = require('../renderer/account-submit');
|
||||||
|
|
||||||
// ---- Re-implementations of the renderer's pure helpers ----
|
test('account submit labels stay exact for add, edit, and OTP retries', () => {
|
||||||
// These mirror the production code exactly so the tests serve as both a guard
|
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
|
||||||
// and executable spec for what saveAccount() must do.
|
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
|
||||||
|
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
|
||||||
function credsSnapshotKey(authType, creds) {
|
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
|
||||||
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
|
||||||
return `api:${creds.apiKey || ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEphemeralHosterConfig(payload) {
|
|
||||||
return {
|
|
||||||
username: payload.username || '',
|
|
||||||
password: payload.password || '',
|
|
||||||
apiKey: payload.apiKey || '',
|
|
||||||
enabled: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
|
|
||||||
function makeStateMachine({ validateImpl, persistImpl }) {
|
|
||||||
let busy = false;
|
|
||||||
let validated = null; // { hosterName, authType, snapshot, status }
|
|
||||||
const log = []; // log of every persist call, for assertions
|
|
||||||
|
|
||||||
async function click(ctx, creds, otp = '') {
|
|
||||||
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
|
|
||||||
const snapshot = credsSnapshotKey(ctx.authType, creds);
|
|
||||||
|
|
||||||
// STEP 2: commit if validated matches.
|
|
||||||
if (validated &&
|
|
||||||
validated.hosterName === ctx.hosterName &&
|
|
||||||
validated.authType === ctx.authType &&
|
|
||||||
validated.snapshot === snapshot) {
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await persistImpl(ctx, creds);
|
|
||||||
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
|
|
||||||
} finally { busy = false; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 1: ephemeral validate.
|
|
||||||
busy = true;
|
|
||||||
let row;
|
|
||||||
try {
|
|
||||||
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
|
|
||||||
} finally { busy = false; }
|
|
||||||
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
|
||||||
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
|
|
||||||
log.push({ type: 'validated', status: row.status });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row && row.status === 'otp_required') {
|
|
||||||
log.push({ type: 'otp-required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.push({ type: 'validation-failed', message: row && row.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
|
|
||||||
return { click, editField, log: () => log.slice(), getValidated: () => validated };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Tests ----
|
|
||||||
|
|
||||||
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
|
|
||||||
const persistCalls = [];
|
|
||||||
const sm = makeStateMachine({
|
|
||||||
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
|
|
||||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
|
||||||
});
|
|
||||||
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
|
|
||||||
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
|
|
||||||
assert.equal(sm.getValidated(), null);
|
|
||||||
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
|
test('close and reopen cannot start a second save while the first save is pending', async () => {
|
||||||
const persistCalls = [];
|
const submitter = createAccountSubmitter();
|
||||||
let validateCount = 0;
|
let current = true;
|
||||||
const sm = makeStateMachine({
|
let commits = 0;
|
||||||
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
let applies = 0;
|
||||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
let saveStarted;
|
||||||
});
|
let finishSave;
|
||||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
const started = new Promise(resolve => { saveStarted = resolve; });
|
||||||
const creds = { username: 'u', password: 'p' };
|
const saving = new Promise(resolve => { finishSave = resolve; });
|
||||||
// Click 1 = validate → green.
|
const first = submitter.submit({
|
||||||
await sm.click(ctx, creds);
|
validate: async () => ({ status: 'ok' }),
|
||||||
// Click 2 = commit (same creds, validated snapshot matches).
|
commit: async () => {
|
||||||
await sm.click(ctx, creds);
|
commits++;
|
||||||
// Click 3 = guard prevents a second commit because after persistImpl the
|
saveStarted();
|
||||||
// state-machine in real code closes the modal. In this simulator the
|
await saving;
|
||||||
// validated snapshot is still set — but a real double-click WHILE persistImpl
|
return { accountId: 'first' };
|
||||||
// is in flight would be caught by busy. Simulate that:
|
|
||||||
const sm2 = makeStateMachine({
|
|
||||||
validateImpl: async () => ({ status: 'ok' }),
|
|
||||||
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
|
|
||||||
});
|
|
||||||
await sm2.click(ctx, creds); // validate
|
|
||||||
const p1 = sm2.click(ctx, creds); // start commit
|
|
||||||
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
|
|
||||||
await Promise.all([p1, p2]);
|
|
||||||
|
|
||||||
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
|
|
||||||
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
|
|
||||||
// The racing click MUST have been ignored by the busy guard.
|
|
||||||
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
|
|
||||||
const persistCalls = [];
|
|
||||||
let calls = 0;
|
|
||||||
const sm = makeStateMachine({
|
|
||||||
validateImpl: async (payload) => {
|
|
||||||
calls++;
|
|
||||||
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
|
|
||||||
if (payload.otp === '123456') return { status: 'ok' };
|
|
||||||
return { status: 'error', message: 'Bad OTP' };
|
|
||||||
},
|
},
|
||||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
afterCommit: async () => {
|
||||||
|
applies++;
|
||||||
|
},
|
||||||
|
isCurrent: () => current
|
||||||
});
|
});
|
||||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
|
||||||
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']
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
|
await started;
|
||||||
const persistCalls = [];
|
current = false;
|
||||||
let validateCount = 0;
|
const second = submitter.submit({
|
||||||
const sm = makeStateMachine({
|
validate: async () => ({ status: 'ok' }),
|
||||||
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
commit: async () => {
|
||||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
});
|
});
|
||||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
|
||||||
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
|
assert.equal(second, null);
|
||||||
sm.editField(); // user edits cred field → snapshot dropped
|
assert.equal(submitter.isBusy(), true);
|
||||||
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
|
finishSave();
|
||||||
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
|
const result = await first;
|
||||||
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(result.status, 'stale');
|
||||||
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
|
assert.equal(result.committed, true);
|
||||||
|
assert.equal(commits, 1);
|
||||||
|
assert.equal(applies, 1);
|
||||||
|
assert.equal(submitter.isBusy(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
|
test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
|
||||||
// Label changes must NOT invalidate validation — label is metadata, not a credential.
|
const expected = new Error('render failed');
|
||||||
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
let saves = 0;
|
||||||
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
|
let applies = 0;
|
||||||
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
const result = await submitValidatedAccount({
|
||||||
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
|
validate: async () => ({ status: 'ok' }),
|
||||||
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
commit: async () => {
|
||||||
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
|
saves++;
|
||||||
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
return { accountId: 'saved-account' };
|
||||||
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
|
},
|
||||||
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
afterCommit: async () => {
|
||||||
credsSnapshotKey('api', { apiKey: 'KEY2' }));
|
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('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
|
test('ok validates and commits exactly once in one submission', async () => {
|
||||||
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
|
let validations = 0;
|
||||||
// This guards the validate-credentials IPC contract from drifting.
|
let commits = 0;
|
||||||
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
|
const result = await submitValidatedAccount({
|
||||||
assert.equal(cfg.username, 'u');
|
validate: async () => {
|
||||||
assert.equal(cfg.password, 'p');
|
validations++;
|
||||||
assert.equal(cfg.apiKey, '');
|
return { status: 'ok', message: 'Login erfolgreich' };
|
||||||
assert.equal(cfg.enabled, true);
|
},
|
||||||
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
|
commit: async () => {
|
||||||
assert.equal(cfg2.apiKey, 'K');
|
commits++;
|
||||||
assert.equal(cfg2.username, '');
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'committed');
|
||||||
|
assert.equal(validations, 1);
|
||||||
|
assert.equal(commits, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warn validates and commits exactly once in one submission', async () => {
|
||||||
|
let commits = 0;
|
||||||
|
const validation = { status: 'warn', message: 'Login mit Warnung' };
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => validation,
|
||||||
|
commit: async (received) => {
|
||||||
|
commits++;
|
||||||
|
assert.equal(received, validation);
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'committed');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of ['error', 'skipped']) {
|
||||||
|
test(`${status} rejects without committing`, async () => {
|
||||||
|
let commits = 0;
|
||||||
|
const validation = { status, message: `${status} result` };
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => validation,
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'rejected');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validate throw returns error without committing', async () => {
|
||||||
|
const expected = new Error('validation failed');
|
||||||
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => {
|
||||||
|
throw expected;
|
||||||
|
},
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.error, expected);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('otp_required returns challenge without committing', async () => {
|
||||||
|
let commits = 0;
|
||||||
|
const validation = { status: 'otp_required', message: 'OTP gesendet' };
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => validation,
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'otp_required');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale submission is rejected immediately before commit', async () => {
|
||||||
|
let current = true;
|
||||||
|
let commits = 0;
|
||||||
|
const validation = { status: 'ok' };
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => {
|
||||||
|
current = false;
|
||||||
|
return validation;
|
||||||
|
},
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => current
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'stale');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save failure returns error after one commit attempt', async () => {
|
||||||
|
const expected = new Error('save failed');
|
||||||
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => ({ status: 'ok' }),
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
throw expected;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.error, expected);
|
||||||
|
assert.equal(commits, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user