Commit Graph

119 Commits

Author SHA1 Message Date
Administrator
7a40afbe7e fix(config): fsync config writes + guard against account-wipe on a corrupt read (v3.3.105)
A user's server crashed hard during an upload and lost all configured accounts. It
was NOT the v3.3.104 update (a second server updated fine and kept its accounts) —
it was a data-durability hole exposed by the crash:

- Config writes were atomic (tmp + rename) but never fsync'd, so a hard crash could
  leave electron-config.json truncated/unflushed on disk.
- On restart, load() reads the truncated file, falls back to .bak, and if that is
  also bad returns empty DEFAULTS. The next settings/queue save then persists EMPTY
  hosters — permanently wiping the accounts. Worse, the async _atomicWrite blindly
  copied the (now truncated) live file over .bak, so an empty live could clobber a
  good backup.

Hardening (lib/config-store.js + main.js; no behavior change in the happy path):
- fsync before rename in both write paths — _atomicWrite (openSync/writeSync/
  fsyncSync/closeSync) and the synchronous save-global-settings-sync on window close.
  A hard crash can no longer leave a truncated config.
- _atomicWrite only refreshes .bak when the current live file is non-trivial
  (trim length > 2), so an empty/truncated live can never overwrite a good backup
  (the sync-save path already did this).
- Wipe-guard (_guardHosters): save(), saveRotationCursors() and the sync close-save
  never intend to change hosters; if after a load() the hosters are all empty and
  the write did not explicitly provide hosters, recover them from disk
  (_recoverHostersFromDisk: live -> .bak -> .pre-history-split.bak) instead of
  persisting the wipe. An explicit save({hosters: {}}) (user deleted all accounts)
  is still allowed. Restored hosters are already-encrypted on disk and
  encryptCredentials skips already-encrypted fields, so re-serializing is safe.
- load() gained a third fallback tier — the permanent pre-history-split.bak snapshot
  (which still holds the accounts) — so load() itself recovers after corruption.

Recovery for the already-affected server: copy
%APPDATA%/multi-hoster-uploader/electron-config.json.pre-history-split.bak (or .bak)
over electron-config.json with the app closed.

2 new regression tests (post-wipe valid-empty live + .bak → guard restores accounts;
an explicit empty-hosters save is not blocked). 409 tests pass; clean boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 03:53:01 +02:00
Administrator
bad1c665f5 perf(history): gate the unconditional History-tab reload + fix the diagnostics history regression (v3.3.101)
v3.3.100's interaction instrument pinpointed the residual UI lag exactly: every
slow click was a tab switch into the History view (button.tab / nav.tab-bar,
200-840ms), each coupled to `ipc get-history wall=150-200ms sync` +
`main-longtask blocked=242-289ms lastIpc=get-history` + a 200-248ms renderer long
task. Uploads themselves are pristine (event-loop mean 11.7ms; the only spikes line
up with the get-history tab switches).

Root cause: the History tab handler called loadHistory() UNCONDITIONALLY on every
activation (renderer/app.js) even though it tracks a `_historyDirty` flag it never
checked. Each call synchronously readFileSync + JSON.parse's the ~185MB /
30000-entry electron-history.json (no cache), ships all 30000 batches across IPC,
and the renderer flattens ~120000 row objects before .slice(-2000) for the DOM (the
DOM is already capped at 2000).

Fix (the adversary-verified safe subset):
- Gate the History-tab load: only reload when `_historyDirty || !_historyEverLoaded`.
  Added `_historyEverLoaded`, and both flags are now set inside loadHistory() after
  the fetch succeeds (so a failed load retries). Dirty-coverage is complete — every
  history append routes through batch-done -> appendHistory and upload-batch-done ->
  handleBatchDone which sets _historyDirty=true. Result: repeat History tab switches
  with no new uploads do zero IPC/parse/flatten and are instant. (The first open
  after a new batch still parses once ~450ms; removing that needs a parse-cache or
  JSONL storage, deferred.)
- Diagnostics history regression: getHistory() read loadConfig().history, but since
  the v3.3.99 history split load() returns history:[] in packaged mode, so remote
  diagnostics reported totalBatches:0 despite 30000 real batches. It now reads
  loadHistory() (injected via the collector deps), with a backward-compatible
  fallback to loadConfig().history when not provided.

407 tests pass (2 new diagnostics regression tests); clean Electron boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:45:50 +02:00
Administrator
c3381d360f perf(config): split history out of the hot config file — the real 38.5MB main-thread thrash (v3.3.99)
The v3.3.98 instrument exposed the actual cause of the 1-2s UI button lag, and it
was none of the read-path suspects. electron-config.json had grown to 38.5MB and
was being load()/structuredClone()/JSON.stringify()'d ~137 times in a 73s window
(140-592ms each) on the Electron main thread — roughly 47% main-thread occupancy.
That is the lag: a button click lands while the thread is mid-clone of a 38.5MB
object. The 1MB read-ahead could not touch it.

The bulk is history, not the queue. Each batch-done appended the upload-manager
summary verbatim, including the full per-file result list (per-hoster URLs), into
config.history; default historyRetention='all' never prunes, so it grew unbounded
(75 batches ≈ 38.5MB). The "queue=undefined" in the perf log was a logging bug
(reading .length on the pendingQueue object), not an empty queue. Writes drove the
storm: queue-persistence (save-global-settings) did two loads + one 38.5MB
serialize per call, and _atomicWrite nulls the cache so the next load is a full
38.5MB reparse; even cache hits structuredCloned the whole 38.5MB. The per-job
upload path makes zero config calls, so pending=1280 was never the driver.

Fix — move history into its own file so it is never parsed/cloned/serialized on a
config op (a 5-agent investigation + adversarial review chose this over three
read-side half-fixes; it is the only change that removes the clone AND the
serialize AND the post-write reparse at once):

- History lives in electron-history.json. _migrateHistory() runs once at init
  (packaged only) and is fail-safe: it writes history.json (tmp → fsync → rename),
  re-reads and verifies the entry count, and keeps a permanent
  electron-config.json.pre-history-split.bak BEFORE the config is ever allowed to
  drop its history. If verification fails it leaves history in the config (retry
  next launch). _loadImpl returns history:[] once migrated, so the cached object is
  tiny (cheap clones); the config file shrinks to ~KB on the first save (cheap
  reparse) and _serializeForDisk writes ~KB (cheap serialize).
  loadHistory/appendHistory/pruneHistory/clearHistory go through history.json on
  their own write-queue with a no-clobber guard; the legacy config path stays as a
  fallback when migration did not run.
- Validated against the real 185MB / 30000-entry bench fixture: migrate 1.5s once,
  every entry preserved + .bak kept; load() 631ms cold once → 0.1ms after the first
  save strips the file (185MB → 2.1KB); loadHistory() still returns all 30000.

Dropped per review (one-variable + risk): loadShallow (moot after the split),
cache-repopulate (its gate can never fire), and a per-batch resolution cache (stale
account pools → the rotation/byse failover-regression class — the one thing that
could silently corrupt uploads).

Instrumentation (the user asked to measure everything; all additive, threshold-
gated, MHU_PERF=0 disables):
- ipcMain.handle/.on are centrally wrapped to log `ipc <channel> wall=Xms sync=Yms`
  over 50ms — the button-press→response latency — hardened (Promise.resolve(p)
  .finally + try/catch'd logging) so a logging failure can never break IPC.
- A 100ms main-process drift monitor logs `main-longtask blocked=Xms lastIpc=… gc=…`
  for any single main-thread turn over 100ms (catches GC, fs scans, serialize that
  the IPC timing structurally cannot see).
- config-store perf lines gain via=<caller> and wqDepth=, and the queue= logging
  bug is fixed (now reads pendingQueue.queueJobs.length).

405 tests pass (9 new migration tests: preserve-count, round-trip, save() never
loses history, crash-window fallback, idempotency). Clean Electron boot, no repo
pollution (migration is packaged-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:37:12 +02:00
Administrator
121eac5f14 perf(uploads): 1MB read-ahead to absorb read-bursts + instrument the config-persist/load path (v3.3.98)
v3.3.97 (UV_THREADPOOL_SIZE 64→8) was a decisive win — mean event-loop-delay at
70 active uploads dropped 200ms→~11ms (18×), rss 577→287MB, renderer healthy in
14/15 windows. But the user reports it is still not perfectly smooth. A focused
multi-agent investigation plus an adversarial review localized the residual to
TWO distinct, separately-measured spike sources:

1. Read-bursts. In the tail windows the file-read histogram inverts: FSReqCallback
   climbs to 66-70 against threadpool=8 (~8.75× queue depth) while SimpleWriteWrap
   (socket writes) collapses to 4-24 and mean delay rises to 30-42ms. GC is ruled
   out (gcMax ≤27ms in every window). The clean inversion at a stable active=70 /
   pending=1287 shows the reads are causal, not a symptom of a block elsewhere.

2. A suspected synchronous config-persist stall. save() → load() reparses the whole
   electron-config.json — which now carries the 1287-job pending queue nested in
   globalSettings plus full history — on every persist (because _atomicWrite nulls
   the read cache), then _serializeForDisk JSON.stringify(…, null, 2) of all of it.
   One tail sample (max 1021ms, heap spiking to 142MB) fits a large synchronous
   structuredClone+stringify, but it is a single confounded point, so this build
   only INSTRUMENTS the path rather than asserting the cause.

This release ships one behavioral change (kept to a single variable so the next
log attributes cleanly) plus measurement:

- highWaterMark 256KB→1MB in all five streaming read loops (lib/hosters.js,
  doodstream/voe/vidmoly CHUNK_SIZE consts, and the inline value in
  clouddrop-upload.js:108 — NOT the 16MB server chunk at clouddrop-upload.js:12).
  UV_THREADPOOL_SIZE stays 8. This deepens each stream's read-ahead cushion from
  ~0.43s to ~1.7s at the per-stream rate, so a stream tolerates the threadpool
  queue without starving its socket write, and cuts read-completion callbacks and
  per-chunk Buffer allocations ~4×. Byte-correctness is unaffected: Content-Length
  is preamble+fileSize+epilogue, independent of chunk size, and the chunk size
  never touches the multipart boundaries. Fully reversible; a dedicated read-
  concurrency semaphore is held in reserve if 1MB does not clear the bursts.

- config-store.js now times load() (the full reparse, which the account-failed
  handler also hits per failure) and the _commit serialize, logging
  `config-load …` / `config-serialize wall=…ms bytes=… hist=… queue=…` when the
  synchronous work exceeds 20ms. load() is split into a timing wrapper + _loadImpl;
  the timer is a no-op until main.js wires configStore.setPerfLog → logInfo.

The renderer batch-drain fix for the one observed 243ms longtask is intentionally
deferred: that jank is downstream of the main-thread read-burst flooding IPC, so
fix #1 should make it self-heal; bundling it would confound the measurement and
touch the progress hot path. All 397 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:55:34 +02:00
Administrator
a5b835f76a perf(uploads): threadpool 64→8 + GC/heap instrumentation (decisive high-concurrency lag build, v3.3.97)
The v3.3.96 eventloop-delay logs from a real 70-connection run pinpointed the
high-concurrency lag to the file-read path. At a CONSTANT active-job count the
process flips between two clean regimes:

  HEALTHY  ELD ~11ms,  rss 268-308MB:  SimpleWriteWrap ≈ active, FSReqCallback ≈ 0-1
  BLOCKED  ELD 49-217ms, rss 540-610MB: FSReqCallback ≈ active (62-71 reads in
                                        flight), SimpleWriteWrap ≈ 0-4

Both the ELD spike and the rss balloon track FSReqCallback (libuv-threadpool file
reads) exactly — not crypto, not the renderer, not GC alone. All five uploaders
read identically via fs.createReadStream({highWaterMark: 256KB}); byse/doodstream/
voe run through the generic uploadFile in hosters.js (no dedicated module).

This build is both a candidate fix and a discriminator, per advisor review:

- UV_THREADPOOL_SIZE 64→8 (main.js:1). One reversible line, NOT an upload cap —
  70 uploads still run. 8 concurrent 256KB reads sustain ~100MB/s, far above the
  41MB/s aggregate, so it cannot bottleneck throughput even on the slow VM disk.
  Strong suspicion that tp=64 made it worse: it removed the natural read-
  serialization (default 4 threads) and let all 70 streams' reads fire at once,
  flooding the loop with completion callbacks in lock-step bursts.

- ELD line now also logs heap=heapUsed ext=external ab=arrayBuffers and
  gc=/gcTotal=/gcMax=ms (PerformanceObserver entryTypes:['gc'], reset per window).
  The 70×256KB ≈ 18MB of read buffers cannot account for the ~300MB rss swing —
  that is heap/object churn, so GC must be measured directly.

Decision rule for the next real-run log:
  - ELD drops with tp=8           → read over-parallelism confirmed (keep 8 or add
                                     a dedicated read-semaphore).
  - ELD high + GC pauses align    → heap churn, hunt the allocator.
  - ELD high + GC flat            → causation was reversed, pivot.

No upload-behavior change; the concurrency cap the user explicitly rejected is
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:09:46 +02:00
Administrator
54dbba223d fix(diag): log the full eventloop-delay payload + harden tray icon load
The v3.3.94 measurement computed the main-process event-loop delay, CPU%, per-hoster connection distribution and transient error counts, but logged only '[INFO ] perf': logInfo(ctx, msg) treats a string first arg as the whole message and discards the second, so the payload was thrown away (confirmed in the user's upload-debug.log). Pass the line as a single argument so the real numbers reach the log.

Also: assets/ was missing from the electron-builder files list, so app_icon.ico was never packaged into the asar — new Tray() threw an unhandled rejection on every startup and left no tray icon. Package the icons and wrap createTray in try/catch with a nativeImage fallback so a missing/invalid icon can never crash tray creation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:47:48 +02:00
Administrator
8d1d641f97 fix(update): cancel active uploads before downloading + add download stall-timeout
Clicking 'install update' while many uploads were running left the update stuck at 'Update wird heruntergeladen...' forever, requiring a manual restart. Two causes, both fixed:

1) The 95 MB installer download competed with the active uploads for the (saturated) main process, CPU, network and connection pool, so it barely progressed. The install handler now calls uploadManager.cancel() first — the app quits and relaunches for the update anyway, and the queue is already persisted (non-terminal jobs restore as 'Bereit'), so freeing the resources is correct and makes the download fast.

2) The download read loop had no timeout, so a stalled stream hung indefinitely with no error. Added a 45 s stall-timeout: if no data arrives, the download aborts with a clear message ('Download haengt — seit 45 s keine Daten ... bitte erneut versuchen') instead of freezing, so the retry button works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:33:20 +02:00
Administrator
53c3448836 feat(diag): comprehensive high-concurrency measurement build
User asked to measure everything so we can pinpoint the lag/ECONNRESET at 70 concurrent uploads. All additive instrumentation, no upload-behavior change.

main process: the eventloop-delay log line now also reports cpu=X%core (process.cpuUsage delta over wall time; >100% means multiple cores), rss, per-hoster live connection distribution (active-by-hoster), cumulative transient (ECONNRESET-class) error count, and pending count — pulled from a new UploadManager.getDiagnostics(). This shows whether the main thread is CPU-bound and which hoster is oversubscribed.

renderer: a PerformanceObserver('longtask') plus a requestAnimationFrame frame-time monitor log 'renderer-perf' every 5s while uploading — fps, jankFrames (>33ms), worstFrame, longtask count and max. This is the direct renderer ground truth (real frame rate under load), which the component-timing harness could not capture. Low fps / high jank => renderer is blocked; ~60fps while it still feels laggy => the cpu/eld numbers in the same log decide CPU-bound vs IO-bound in the main process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:23:43 +02:00
Administrator
901cd823dd perf(diag): enrich event-loop-delay log with active-resource histogram
Make the single high-concurrency measurement decisive. The ELD line now appends process.getActiveResourcesInfo() as a compact type-histogram, so one run splits all three readings in a single line: high mean/p99 means the main thread is CPU-blocked (workers/cap justified); low delay with many TCPSocketWrap/FSReqCallback/GetAddrInfoReqWrap resources means IO-bound queueing (threadpool/socket tuning, not workers); low delay with few resources means it is not saturated at all. Pure metrics — no credential-redaction path touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:20:32 +02:00
Administrator
2fd26add1a perf(main): bump UV_THREADPOOL_SIZE to 64 + instrument event-loop delay
The user runs 50+ concurrent uploads (parallel counts raised deliberately). Every async uploader feeds undici from fs.createReadStream and resolves DNS via getaddrinfo — both go through the libuv threadpool, whose default size is 4. At 50 concurrent uploads, file reads and lookups serialize 4-at-a-time: a hard cliff at a small connection count that matches the 'lags from X connections onward' symptom. Raise the cap to 64 as the first statement (before require('electron'), so libuv reads it when it lazily inits the pool; an explicit env override still wins). Threads are created on demand, so a higher max costs nothing when unused — reversible, zero upload-core change.

Also enable perf_hooks.monitorEventLoopDelay and log mean/p99/max/stddev every ~5s while uploading. This is the ground-truth instrument that splits the two competing explanations for the lag: a high event-loop delay means the main thread is CPU-blocked (TLS/crypto) and only workers or a concurrency cap will help; a low delay while uploads stall means the work is IO-bound and the threadpool/socket config is the lever, not workers. The numbers are pure metrics — they never touch the credential-redaction path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:10:43 +02:00
Administrator
233952af8f perf(doodstream): gate per-upload _debugLog behind logVerbose (default off) — drop ~8-15 sync fs syscalls/upload off the main loop
doodstream-upload.js's _debugLog ran with NO verbose gate: every call did a
synchronous statSync (via maybeRotateLogFile) + appendFileSync directly on the
main-process event loop, ~8-15x per upload (server probe, response, redirect,
result page, filecode parse, hidden fields, submit/follow, plus retry branches).
DoodstreamUploader runs in the main process, so each pair of sync fs syscalls
blocked the event loop while uploading — delaying IPC, upload-progress-batch
forwarding, tray-tooltip and webhook handling for every other concurrent upload.
A constant per-upload main-thread tax (not history-scaling), surfaced by the
session-wide lag audit and confirmed as the one finding that bites in the real
upload scenario.

Fix: gate _debugLog behind the existing globalSettings.logVerbose setting
(default false), exactly mirroring main.js logDebug/_logVerbose. A module-level
_debugVerbose flag + setDebugVerbose() setter, an early-return at the top of
_debugLog, and one wire at main.js's single setLogVerbose chokepoint (covers
boot + save-config + the verbose toggle). When verbose is off the doodstream
trace simply isn't written — same contract as the main debug.log — and the
per-upload sync fs disappears. When a doodstream issue needs tracing, enabling
verbose restores the full trace.

The config-store audit findings (load() history-clone cost, per-write history
serialize) are real but scale only with history size — tens of microseconds at
this user's 8-batch config, and the safe fix is risky persistence surgery on
credential-bearing code for a latent micro-cost; deferred and documented in
tasks/todo.md rather than shipped.

397/397 tests pass, eslint clean, gate wiring verified (shared module instance,
default-off, toggles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 02:29:59 +02:00
Administrator
29d1944328 perf(config): cache parsed config + lean serialize + drop load() from log flush (long-run lag)
A 13-agent hunt pinned the "wird mit der Zeit laggy" symptom (CPU/RAM normal, UI
sluggish after many uploads) to the main process re-doing config I/O that scales
with the ever-growing history, stalling the synchronous main event loop so the
renderer's IPC round-trips feel laggy. The renderer render path was already
optimized (virtualized queue, capped panels) — confirmed clean.

This commit lands the two contained fixes (T1 + T3); the queue-persist rewrite (T2)
follows separately.

config-store (T1):
- load() now has an in-memory cache keyed on the file mtime+size. The processed
  config (merged + credential-decrypted) is re-read/re-parsed/re-DPAPI-decrypted
  ONLY when the file actually changes; our writes invalidate it, external edits
  change mtime/size so the cache misses. Eliminates a full disk read + JSON.parse of
  the whole growing history + per-credential decrypt on the vast majority of the ~38
  load() call sites. load() always returns a structuredClone so callers can mutate
  freely without corrupting the cache.
- _serializeForDisk clones ONLY the hosters subtree (the only thing encryptCredentials
  touches) instead of JSON.parse(JSON.stringify(whole config)) — no more deep-cloning
  an 8 MB history on every write.
- _atomicWrite refreshes the .bak with a raw fs.copyFileSync instead of
  read + JSON.parse + write (it was re-parsing the full config a 2nd time per write).

main.js (T3):
- The log-flush paths resolved the log file via configStore.load() ~8x/second during
  uploads (re-reading + cloning the whole config just to read logMode/logFilePath).
  Cache those two strings in module scope, invalidate on the settings-save handlers.

Verified: 26 config-store tests (incl. new cache-correctness: independent clones,
external-change invalidation, save invalidation) + full 394-test suite green, lint 0
errors. Benchmark (8000-batch / 4.6 MB history): log flush no longer calls load() at
all; the remaining per-write history serialize is what T2 removes from the hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:49:55 +02:00
Administrator
0c6c502aab feat(diagnostics): network bind + fail-closed IP allowlist + host-in-code (Tailscale, like rd-diagnostics-mcp)
Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.

- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
  ::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
  (fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
  wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
  via config.allowlist (the existing remote-control server, which passes none, is
  unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
  "network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
  loopback, fail-closed). The allowlist + token gate access; the tunnel
  (Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
  gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
  takes the host from the code (host arg is an optional override). Proven end-to-end:
  the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
  suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
  allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
  into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
  IP/MagicDNS in the code address — no SSH forward needed).

This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:32:29 +02:00
Administrator
d69e5c39bf feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box
Adds the connect-by-code side of remote diagnostics and closes two real
secret-leak vectors that an end-to-end gateway<->agent test surfaced.

Gateway (gateway/, local stdio MCP, Claude connects once):
- 14 read-only tools (server_health hub, read_log, list_logs, list_errors,
  get_queue_state, get_history, get_config_redacted, get_system_info,
  get_rotation_state, get_app_events + connect/disconnect/list/current).
- The HOST is always supplied by the operator, never taken from the code.
- TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the
  token is sent (wss opt-in); plain ws is loopback-only.
- registry.json (holds bearer tokens) is gitignored; only an empty example ships.

Security hardening (gates every off-box payload):
- redactLogText now scrubs opaque bearer/token-family secrets that are NOT
  stored config credentials (e.g. a session token a hoster returns inside an
  error string): bare token/auth_token/refresh_token/session_token + standalone
  "Bearer <opaque>". Benign "token bucket" prose is left intact.
- get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so
  the cookie/sess line patterns can't gobble across a compact-JSON field) and
  drops the history subtree (served by get_history with its own per-error
  redaction). This plugs leaks via globalSettings.pendingQueue[].error etc.

Bind-address safety:
- _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the
  0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until
  encrypted transport (wss) exists — remote access goes through an SSH/VPN
  tunnel to loopback. (Never plaintext ws:// on all interfaces.)

Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/
get_config_redacted, asserts zero secret leakage, rejects doodstream log, path
traversal and write ops); + redaction regression tests in the main suite.
385 app tests + 9 gateway tests pass; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:39:31 +02:00
Administrator
ab7313f32c feat(diagnostics): read-only remote diagnostics agent over the existing WS transport (app side)
Adds the server/app half of a remote-diagnostics system so Claude (via a local
MCP gateway, added separately) can read a server's full state to diagnose
problems: logs, errors, queue/app state, redacted config, system health. All
read-only; security review folded in as hard requirements.

Architecture (design-verified):
- A SECOND, independent RemoteServer instance (diagnosticMode) on its own port
  (default 9110, bind 127.0.0.1) with NO capture/input callbacks, so screen
  capture and sendInputEvent are structurally unreachable from the diagnostic
  path. The existing screen-share server (port 9100) is byte-for-byte unchanged.
- lib/remote-server.js: a `host` bind option, a post-auth `diag-request` branch
  that delegates to onDiagnosticRequest and replies with a reqId-correlated
  `diag-response`, a `diagnosticMode` guard (capture never spawns), a
  timing-safe token compare (length-guarded), and getLastAccess().
- lib/diagnostics-agent.js: handle(op,args) enforces a HARDCODED read-only op
  whitelist as the sole authority — no write/exec op, no run_health_check.
- lib/diagnostics-collectors.js: pure, dependency-injected collectors
  (server_health one-shot hub, read_log, list_errors, get_queue_state,
  get_history, get_config_redacted, get_system_info, list_logs, get_app_events,
  get_rotation_state, get_health) reusing support-bundle + stats.

Security (all mandatory, implemented):
- Redaction gates every off-box payload. support-bundle now adds webhookUrl +
  diagToken to CRED_KEYS, and exports redactLogText (value-scrub of live secret
  strings + pattern-scrub of Discord webhooks / Bearer / api_key= / cookies /
  sess ids) + valueScrub + collectSecretValues. read_log takes a logical NAME
  (no path traversal); doodstream-debug.log is excluded from the readable set
  (it logs live api-key-bearing HTML). grep is length-capped (ReDoS guard).
- diagnostics config subtree (enabled/port/token/label/codeIssuedAt/bindAddress)
  defaults OFF, bind 127.0.0.1; deep-merge makes it migration-free. The
  server-owned token is preserved against renderer clobber in both
  save-global-settings handlers; the diagnostics:* IPC is the only mutator.
- app.requestSingleInstanceLock() (also the approved fix #6) so a relaunch can't
  EADDRINUSE-kill the agent and two instances can't clobber the config.

main.js: buildDiagnosticCode (mhu1_ base64url, no host embedded), start/stop +
auto-start-on-launch + stop-on-quit, the four diagnostics:* IPC handlers.
renderer: a "Diagnose-Zugriff" settings subtab (toggle, port, bind-address,
copyable connection code + regenerate, status, read-only security note).

382 tests pass (incl. 11 new: collectors+redaction, agent whitelist, and a live
RemoteServer protocol test proving auth->diag-response correlation, that a
diagnostic client never triggers the capture window, and brute-force lockout).
Lint clean (0 errors). Smoke boots identically to baseline. The standalone MCP
gateway package + operator setup docs land in a follow-up commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:24:32 +02:00
Administrator
6b0c515a9b fix(queue): close two lost-work / sticky-ghost edge cases found by intensive testing
Follow-up hardening on the v3.3.80 queue-persistence fix after a deeper adversarial
sweep + reviewer pass over the WHOLE subsystem (not just the diff). Two real defects:

1. Basename-collision lost work (regression introduced by v3.3.80 FIX A).
   restoreQueueStateFromConfig collapses jobs on the FULL path while the ts-gate keys
   on basename|hoster. Two genuinely different files with the same basename queued to
   the same hoster from different folders therefore share a gate key: if one was logged
   after savedAt, the ts-rule dropped BOTH — silently losing the still-pending one.
   Pre-FIX-A only 'done' jobs were dropped, so a pending file was never at risk.
   Fix: an ambiguity guard in partitionRestoredJobsByLog — the ts-rule is suppressed
   when a basename|hoster key maps to more than one distinct file path (the log records
   only basenames, so it can't say which physical file completed). The done-in-log rule
   is unchanged. Fails safe: worst case a visible ghost survives, never silent data loss.

2. selectedFiles re-materialization with removeFromQueueOnDone=ON (second mechanism,
   independent of the stale snapshot). When that setting is on, a completed job is
   stripped from queueJobs but its path stays in selectedFiles (syncSelectedFilesFromQueue
   only runs at batch-done, never on a mid-upload close). On restart the ts-gate operates
   on queueJobs and never sees it, then the startup updateUploadView -> buildQueuePreview
   re-creates it as a preview ghost AFTER the gate ran, and it re-persists with a fresh
   savedAt — sticky. Fix: completedSelectionKeys() seeds _completedUploadKeys (the set
   buildQueuePreview already consults) from the log at startup, keyed on full path, with
   the same ambiguity guard. Log-based so it survives a hard kill, consistent with FIX A.

Also extracts the orphan-tmp sweep decision into lib/orphan-tmp.js (was untested inline
code in main.js; behavior-preserving) and adds executable coverage for the paths that
were previously only argued from logic: orphan-tmp sweep, config-store pendingQueue+savedAt
round-trip, an end-to-end scenario in the exact user-reported shape (300 queued / ~200
finished mid-session), and a 3000+500-iteration property fuzz of the gate invariant
including the lost-work guarantee. 359/359 green, ESLint clean, smoke-boot unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 06:51:48 +02:00
Administrator
eeec1d150c fix(queue): completed files no longer reappear in the queue after restart
Closing the app (especially during an active upload or on a hard kill) and
reopening sometimes left already-uploaded files sitting in the queue as if
still pending. Root cause is three layers stacked:

1. Persist-starvation: persistQueueStateSoon() reset a 10s debounce on every
   progress event, so during an upload the on-disk queue snapshot was never
   rewritten and stayed frozen at the pre-upload state (all jobs "preview").
2. The beforeunload sync flush only covers a clean close; a hard kill / crash
   leaves that stale snapshot on disk.
3. Startup auto-dedup only dropped jobs with status "done". The completed
   files were stored as "preview" in the stale snapshot, so they survived and
   reappeared.

Fix (mechanism-independent — holds whether the stale snapshot came from
starvation, a mid-upload close race, or a hard kill):

FIX A (core, durable): the upload log is the source of truth. Each persisted
snapshot is now stamped with savedAt; on restart any restored job whose newest
matching log entry is timestamped at/after floor(savedAt) is dropped regardless
of status — it provably completed after the snapshot, so a "preview" row for it
is a ghost. lib/queue-dedup.js gains an additive 3rd savedAt param; without
savedAt or without log timestamps it behaves exactly as before (the 5 canary
tests stay green, so intentional re-uploads of older files still survive).

FIX B: new lib/throttle-timer.js with a max-wait. During uploads the snapshot
is now written at most ~20s into a continuous progress burst instead of never;
idle stays a pure debounce. The fallback shim honors max-wait too, so a missing
library can never silently reintroduce the starvation.

FIX C: the synchronous close-write retries renameSync on EBUSY/EPERM/EACCES and
uses a pid-unique tmp (de-conflicts it from config-store._atomicWrite's fixed
.tmp). A startup sweep reclaims orphaned <config>.<pid>.tmp files left by a hard
kill between write and rename.

lib/upload-log.js extracts formatUploadLogLine + parseUploadLogLine from main.js
so the real writer -> reader -> gate seam is unit-tested (a future log-format or
epoch-basis change can no longer pass green while breaking the fix).

Verified: 334/334 tests green (incl. throttle fake-clock starvation/maxWait,
ts-gate multi-hoster partial-completion, and the real-format seam tests), ESLint
clean, smoke-boot identical to baseline, and an adversarial multi-agent review
(15 findings, 14 refuted, 1 low — the tmp orphan, now swept) on the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 04:35:27 +02:00
Administrator
644ed712e0 fix(rotation): persist the round-robin cursor so drip-fed uploads keep rotating
v3.3.74 created a fresh account picker inside every buildUploadTasks /
buildUploadTasksFromJobs call, so its in-memory rotation index reset to 0
on each call. That is correct for a one-shot batch (drag-drop N files at
once distributes fine), but it silently no-ops in exactly the pattern the
byse 80-uploads/30-days quota cares about: the folder monitor feeds new
files into a *running* batch one detection at a time via add-jobs-to-batch
(renderer -> add-jobs-to-batch IPC), and per-detection autoStart likewise
fires start-upload per file. Each of those calls rebuilt the picker from
index 0, so account 1 won every single time and the secondary accounts
never received anything.

The rotation cursor now lives outside the picker and survives across calls
and across app restarts (the quota spans a rolling 30-day window, so a
session-only counter would still starve secondaries for users who restart
between small uploads):

- account-rotation.js: createAccountPicker() accepts a seed { indices } map,
  resumes the per-hoster cursor from it, and exposes indices() (the advanced
  cursors) + dirty() (whether any rotation actually happened this call). The
  cursor is a monotonic counter taken mod the current enabled-account count,
  so it keeps wrapping correctly even if an account is later enabled/disabled.
- config-store.js: new top-level rotationCursors map (added to DEFAULTS, read
  back in load() which otherwise reconstructs the result and would drop
  unknown keys) plus a saveRotationCursors() method. The existing
  read-modify-write save() preserves it across unrelated settings/credential
  saves; secret-store never touches it.
- main.js: a module-level _rotationCursors is the authoritative source of
  truth (seeded once from disk on first use, updated synchronously per batch),
  with config as restart-survival backing. makeAccountPicker() seeds the
  picker from it; persistRotation() folds the advance back and flushes to
  config only when dirty. Authoritative in-memory state also closes the
  disk-read race two rapid batches would otherwise hit. Both task builders
  now receive the picker instead of constructing their own.

Composition with failover is unchanged and verified: the picker only chooses
each file's *initial* account and is never called on the failover path. The
pre-job-swap reroutes a task only when that task's own account is in
_failedAccounts, so a dead account's jobs reroute while healthy accounts keep
their rotation share. No double-advance of the cursor.

Tests: account-rotation gains seeded-resume, drip-feed-across-pickers (the
regression), dirty()-semantics, carry-forward, and count-shrink-wrap cases;
config-store gains rotationCursors default, save round-trip, no-clobber, and
credentials-undisturbed cases. Full suite 303/303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:33:20 +02:00
Administrator
3204629fef feat(rotation): opt-in per-hoster account rotation to keep all accounts active
byse.sx requires each account to upload >=80 videos in the last 30 days
(measured across a rolling 3-month window) or it goes dormant. With a
single primary account doing all the work, the other configured accounts
decay and eventually get suspended. This adds an opt-in "Accounts rotieren"
toggle per hoster that round-robins files across every enabled account that
has credentials: file 1 -> account 1, file 2 -> account 2, file 3 ->
account 3, then wraps. Off by default, so existing single-account behavior
is unchanged.

Mechanics:
- New lib/account-rotation.js: createAccountPicker() returns a stateful
  pick(hoster) closure holding a per-hoster round-robin index. It only
  rotates when rotateAccounts === true AND more than one usable account
  exists; otherwise it returns the first enabled account (the old primary).
  enabledAccountsFor() filters disabled + credential-less accounts while
  preserving configured order, so a disabled account is simply skipped in
  the cycle rather than leaving a gap.
- main.js: both buildUploadTasks() and buildUploadTasksFromJobs() now build
  one picker per call and use pick(hoster) instead of getPrimaryAccount(),
  which is now removed (dead code). A fresh picker per batch means each
  upload session starts the cycle at account 1, matching "die erste Datei
  auf Account eins".
- config-store.js: rotateAccounts: false added to HOSTER_SETTINGS_DEFAULTS.
- renderer/app.js: "Accounts rotieren" checkbox in the per-hoster upload
  settings (Accounts tab). Persisted by the existing generic checkbox path
  in saveHosterSettingsFromDom().

Composes with failover: rotation only chooses the *initial* account per
file. On a hard account failure the existing failover (_failedAccounts +
pre-job account swap) reroutes just that account's jobs; the healthy
accounts keep their rotation share.

Single enabled account (or rotation off) = no behavior change.

Tests: tests/account-rotation.test.js (10 cases) covers off=primary,
on=round-robin+wrap, single-account no-op, skip disabled, skip no-creds,
null when none usable, per-hoster index independence, byse-only rotation,
100-file 50/50 split, and the enabledAccountsFor filter. Full suite 294/294.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:20:50 +02:00
Administrator
497317dc32 fix(rotation): suspect byse rejections now reach every account instead of dying on the first
Root cause of "4 accounts configured, rotation only ever uses 1-3": byse
returns HTTP 200 + per-file status "Not video file format" for valid MKVs
above an account's size tier (proven from the live rotation log: account
4ha0 accepted 1158 files up to 2.62 GB and deterministically rejected all
6 files >= 2.81 GB with that status, each costing a full ~20 min upload).
The parser tagged this fileRejected, which (a) skipped the 30s recovery
poll that was originally BUILT for this misleading status (guard regression
from "byse skips 30s recovery poll on explicit reject") and (b) made the
upload manager skip rotation entirely - so the remaining accounts were
never tried and the files failed forever. Account selection is
primary-first failover, so a later account only ever runs when every
earlier one is hard-failed or disabled, which matches the reported
"account 4 only gets used when I disable the others".

The fix, layered:
- parseByseResult flags "Not video file format" as err.suspectReject
  (alongside fileRejected). Genuine rejections (Duplicate, too small,
  account-level disk-full) behave exactly as before.
- uploadFile runs the byse recovery poll again for suspect rejections
  (the file often registers asynchronously despite the status), but skips
  it when the caller's file probe says the upload is genuinely not a video
  (new opts.probeIsVideoLike, threaded from the upload manager's probe).
  Poll helpers now swallow an abort during their sleep and return null so
  a cancelled poll surfaces the original error instead of a bare
  AbortError.
- upload-manager: on a suspect rejection of a probe-verified video, the
  file gets ONE attempt on each remaining pool account (new accountPools
  from main.js, refreshed on save-config) - WITHOUT blacklisting the
  rejecting account, which keeps working for smaller files. A per-batch
  size memo (hoster:account -> smallest rejected size) plus a known-good
  account preference prevent re-uploading every following oversized file
  through the whole chain: the second file skips known-limited accounts
  outright and goes straight to the account that took the first one.
  Alternates that fail with a genuine account error (quota/ban/full) are
  marked failed in-batch so no later file burns a multi-GB upload on them;
  deliberately no account-failed emit there, as repointing the hoster-wide
  override would reroute normal-sized files away from a healthy primary.
- Rotation hardening from the adversarial review: the rotated-to retry
  loop now fast-breaks on file-rejected/hoster-transient errors instead of
  burning maxAttempts full uploads, and a file-class error on a rotated
  account no longer blacklists that account (it fails only the file).
  Cancelling mid-alternates or mid-rotation now records the job as
  aborted instead of error (this also stops the batch webhook from firing
  on fully user-cancelled batches). New upload-failure rot-logs are
  guarded against logging user aborts as failures; rotation retries are
  now visible in the rotation log at all (previously failures on a
  rotated-to account were never logged, which is why account 2's death
  was invisible in the support log).
- file-probe: the mpeg-ts signature required only a single leading 0x47
  byte, so GIFs and "G"-prefixed text classified as video and would have
  qualified junk for pool-wide alternate uploads; it now requires TS
  sync-byte periodicity (0x47 at offsets 0/188/376) and the probe head
  read grew from 64 to 512 bytes to make that check possible. GIF gets
  its own non-video signature.

Tests: 9 new/reworked across suspect-reject-alternates.test.js (alternate
walk, no blacklist, memo short-circuit, account-error marking, abort
labeling, probe gating) and byse-reject-recovery.test.js (suspect polls +
recovers, empty poll throws suspectReject, Duplicate still fast-fails,
probe-confirmed non-video skips the poll) plus a TS/GIF probe regression
test. Full suite 276 green.
2026-06-15 01:11:37 +02:00
Administrator
e5d7917cf3 feat(ui): downloader-style top menu bar (Datei / Einstellungen / Hilfe)
Adds a custom HTML menu bar above the tab bar, replicating the Real-Debrid
Downloader's look: click-to-open triggers, hover-switch between open menus,
click-outside / Escape to close, right-flyout submenus.

- Datei: Dateien hinzufügen, Ordner hinzufügen, Sicherung submenu
  (Export/Import backup), Neustart, Beenden.
- Einstellungen: "Einstellungen öffnen" (jumps to the settings tab) plus an
  inline live grid with the max-parallel-uploads spinner and a speed-limit
  checkbox + MB/s spinner, both two-way-synced with the Settings tab fields
  via saveGlobalSettings (parallelUploadCount / globalMaxSpeedKbs).
- Hilfe: Log-Ordner öffnen, Diagnose-Paket exportieren, Suche Aktualisierungen.

Wiring reuses existing handlers (addFiles/addFolder buttons, doBackupExport/
Import, openLogFolder, createSupportBundle, checkForUpdate) and two new tiny
IPC handlers app:restart (app.relaunch+quit) / app:quit. initMenuBar() is
wrapped in try/catch in setupListeners so a menu fault can never break core
app init. CSS maps the downloader's menu styles onto this app's theme vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:45:39 +02:00
Administrator
05ad08433c feat(history): configurable retention + render cap so the Verlauf tab loads fast
The Upload-Verlauf tab took 30s+ to open on large, never-cleared histories.
Measured on the live config: 18.9 MB config / 41 batches / 53,403 rendered rows.
Two independent problems, fixed together:

1. Render side (the 30s killer): renderHistoryTable built ALL rows into one
   innerHTML. Now the build is capped to the newest 2,000 rows (HISTORY_RENDER_CAP)
   after slicing the chronological tail, then sorted for display. A notice shows
   "Zeige neueste 2000 von N" so nothing looks deleted; full history stays on disk
   and "Verlauf exportieren" still emits everything (export reads loadHistory()).

2. Storage side: history grew unbounded because appendHistory only ever pushed.
   New globalSettings.historyRetention ('all' default, non-destructive) with
   policies: 7d / 30d / 90d (time-based) and 1000 / 100 (newest-uploads-based).
   appendHistory now prunes after each batch; a new prune-history IPC re-applies
   the policy immediately when the user changes it, gated behind a confirm() that
   shows the exact removal count (dry-run first).

Prune model keeps whole batches (atomic): count policies accumulate rendered rows
from the newest batch backward and always keep >= the newest batch even if it
alone exceeds the target; time policies keep batches whose timestamp is missing
or unparseable (old entries have none) so ambiguous data is never silently dropped.

New retention dropdown lives in the Verlauf header. 9 unit tests cover the prune
edge cases incl. a realistic 41-batch fixture. Full suite: 272 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:26:20 +02:00
Administrator
6df00a0948 revert(network): remove network-outage auto-pause entirely — false positives froze batches 2026-06-10 16:52:37 +02:00
Administrator
e69635bcf7 fix(backup): exclude upload history from export and import — Verlauf stays local 2026-06-10 15:42:09 +02:00
Administrator
0e5eaa89e6 fix(webhook): retry+429+status handling, await before shutdown, error-path notify, abort/auto-retry suppress, Discord limits 2026-06-10 00:08:02 +02:00
Administrator
e1d04d0838 feat(webhook): optional Discord ping (user-id / role / @here / @everyone) so batch-done actually notifies 2026-06-09 23:40:38 +02:00
Administrator
34aaa36571 feat(unattended): network outage auto-pause/resume, post-batch auto-retry rounds, webhook notifications 2026-06-09 20:39:59 +02:00
Administrator
d3fda31243 fix(ui): ETA includes waiting jobs — folder-added files now ship with bytesTotal 2026-06-08 23:03:29 +02:00
Administrator
6cd7498f70 fix(critical): safeSend infinite recursion + queueMicrotask, plus 6 audit findings 2026-06-08 22:03:19 +02:00
Administrator
0f57aef7c7 fix(stability): wrap hot timers/callbacks in try/catch, safeSend, updater waits for batch 2026-06-08 21:28:12 +02:00
Administrator
9b10a4356f feat(diagnostics): full crash instrumentation — never silently die again 2026-06-08 21:18:54 +02:00
Administrator
1418c2bc17 feat(backup): plain JSON export/import + clearer error when decrypt fails 2026-06-08 14:19:47 +02:00
Administrator
d9199f8aaf fix(perf): chunked startBatch + async rotLog — kill remaining 30s freeze on 5k+ jobs 2026-06-08 01:29:31 +02:00
Administrator
d59c5c1df8 perf: per-batch baseline cache, async folder walk, history-table fast path, progress IPC batching 2026-06-07 21:11:04 +02:00
Administrator
d280765feb fix(perf): freeze on Start with 2000+ jobs — gate probe + rot-log behind semaphore 2026-06-07 20:40:55 +02:00
Administrator
cf35f4401d feat(ui): per-hoster success rate, session-paused badge, post-batch retry, link export formats 2026-06-07 20:32:35 +02:00
Administrator
f42c55c521 feat(diagnostics): log levels, support bundle export, verbose toggle, log paths panel 2026-06-07 16:34:51 +02:00
Administrator
2e8e8a3819 fix(accounts): VOE CSRF burst-throttle + collapsible per-hoster groups
User reported two coupled issues in the accounts panel:
- "VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" fires
  intermittently across multiple VOE accounts when "Accounts prüfen" runs.
  Each retry "fixes" one and breaks another — classic anti-bot burst response.
- The flat badge strip becomes unreadable with many accounts; user wants
  collapsible per-hoster groups with "N/M" headers and green/red indicators,
  click to expand to per-account detail.

DISCRIMINATOR CHECK (cheap before serializing): grep'd lib/voe-upload.js for
module-level state — none. Each new VoeUploader() carries its own cookie Map.
Burst-throttle on VOE's side is the only plausible root cause.

CONCURRENCY FIX in main.js runHosterHealthCheck:
- Group checks by hoster, run each hoster's group SEQUENTIALLY, groups in
  parallel (Promise.all of sequential runners). Cross-hoster parallelism
  preserved; intra-hoster bursts eliminated.
- Result array preserves input order via a result-index map.
- Hardening per review: dedup duplicate {hoster, accountId} entries before
  grouping (no wasted API calls if a caller ever sends duplicates), and entries
  missing accountId now return a clean "Account-ID fehlt" error instead of
  silently calling per-hoster checker with null config.
- Validate-credentials and checkSingleAccount paths unchanged (single-check
  payloads run the same way regardless).
- Latency trade-off acknowledged: 5 VOE accounts ~5x faster path → up to 25s
  for that hoster's column. That's the cost for reliability; the user's
  alternative was 0/5 working on burst-failed runs.

UI FIX in renderer:
- New _buildAccountHosterGroupHtml emits a collapsible per-hoster group
  reusing the existing .hoster-panel-header / .panel-arrow CSS pattern.
- Header shows "VOE 4/5" (ok-count / total-accounts), a green/red/amber/gray
  status dot, plus pills for "N deaktiviert" and "N Fehler".
- Default: auto-expand any hoster with errors, checking, or unchecked
  accounts; collapse all-green.
- Open-state memory tracks user clicks. Per review: also tracks errorsAtClose
  snapshot so a NEW failure since the user's close forces re-expand once.
  Prevents the "I closed it once and now silent failures hide forever" risk.
- Single-card updates also refresh the parent group's header counter via
  _refreshHosterGroupHeader.
- Flat badge strip in renderHealthCheckResults is now a no-op stub — the
  per-hoster headers carry the same info, less duplication.

Three-lens review (workflow wch4p9ee9): concurrency PASS_WITH_NOTES, ui-state
PASS_WITH_NOTES, comment-policy PASS (zero new // or /* */ comments).
Latent concerns from review applied as hardenings.

210/210 tests green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:27:57 +02:00
Administrator
e26b7ea8ed fix(accounts): never persist unverified creds + dedupe-proof modal + label + perf
User reported three coupled bugs in account add/edit:
  (1) Invalid logins still create the account
  (2) Doodstream gets created multiple times when "Prüfen & Anlegen" is
      double-clicked or repeatedly OTP-retried
  (3) Add/Delete in the accounts panel feel laggy
Plus a UX/feature request: account label + two-step "Prüfen → Anlegen" flow.

Map (workflow wf44zpud4, 3 parallel subagents + adversarial verify) confirmed:
- saveAccount() persisted to disk BEFORE the health check (lines 3407-3409)
- saveBtn.disabled was set AFTER two awaited IPC roundtrips → 5-100ms race window
- OTP-retry path generated a new accountId on every click (editingAccountId
  stayed null in ADD mode) → DETERMINISTIC duplication on every OTP attempt
- runHealthCheck IPC required the account to be already persisted → that's
  why the old code wrote-first-check-second

Fix architecture (advisor: Option A — make the invariant real, not cleanup-based):
- main.js + preload.js: NEW `validate-credentials` IPC. Accepts ephemeral
  {hoster, authType, username, password, apiKey, otp} payload, builds an
  ephemeral hosterConfig, runs the same per-hoster checker via a shared
  _dispatchHealthCheck helper. Nothing touches config.hosters.
- renderer: two-step modal state machine.
    - "Prüfen" click → validateCredentials (ephemeral) → green flips button to
      "Anlegen"/"Speichern" AND caches a snapshot of the validated creds.
    - "Anlegen"/"Speichern" click → only fires if cached snapshot matches the
      currently-typed credential-identity (username+password or apiKey;
      label and OTP are not part of the snapshot key).
    - Input listeners on the identity fields drop the snapshot the moment any
      cred is edited post-green → user can't sneak unverified creds through.
    - _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler,
      before any await, so a double-click is a no-op.
    - _accountModalSession token bumps on every modal reset → a stale late
      response from a closed-and-reopened modal can't stomp the new session's
      busy flag or UI (lens-2 review fix).
    - Edit mode flows through the same path → bad edits never reach disk
      before being validated (fixes the silent good-creds clobber).
    - closeAccountModal cancels the auto-close timer + clears modal state so
      a stale 600 ms timer can't close a freshly-reopened modal.
- Label field (new): persisted on the account, shown in the card subtitle as
  "Label: XYZ • API: ABC… — API Key gültig" so identical-looking API accounts
  are disambiguable. Excluded from snapshot key on purpose — label is metadata.
- Perf: drop the redundant `await getConfig()` round-trip in commit+delete
  (in-memory state was already the source of truth and the old reload was the
  main lag source). deleteAccount fires-and-forgets the saveConfig and closes
  the modal synchronously. Commit path uses updateAccountCard for the
  single-card edit case instead of a 4-panel cascade.

Multi-lens review (workflow wyoc3iq4k, 3 reviewers): OTP-correctness SHIP,
race-guard SHIP-WITH-FIXES (session-id token + busy-inside-try applied),
edit-mode+label SHIP. No blockers.

Tests: 6 new regression tests (tests/validate-credentials.test.js) covering
the three reported bugs as executable spec:
  (a) failed validation persists nothing to config.hosters
  (b) second click with guard set persists exactly one entry
  (c) OTP-required persists nothing; OTP retry re-validates ephemerally
plus snapshot-key identity, post-validation edit invalidation, and the
ephemeral hosterConfig shape contract. 210/210 green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:11:13 +02:00
Administrator
ca35c2a6a4 fix(log): persist BARE log path (no compounded daily/session stamps)
User report: in session mode the upload-log lines split across two files — the
first few before the auto-persist fallback fired, the rest after into a path
with the session-stamp DOUBLED.

Root cause in main.js _persistFallbackLogPath:
1. The strip was gated on the legacy `sessionLog` boolean, which 3.3.35 retired
   in favour of `logMode`. So in session/daily mode the gate was false and the
   resolved path got persisted with its stamp intact.
2. Even when the gate triggered, its regex matched only the daily YYYY-MM-DD
   suffix, not the session "session-YYYY-MM-DD_HH-MM-SS-pid" suffix.

The next getLogFilePath() call read that saved path as the "base", treated the
already-stamped filename as the base name, and re-applied another stamp on top.
First flush hit the original session file; everything after hit a doubly-
stamped one — exactly the symptom (top file: 2 lines, bottom file: the rest).

- lib/log-mode.js: new pure stripModeStampFromFileName helper that removes both
  the daily and the session suffix patterns. Anchored to $, no nested
  quantifiers (linear).
- main.js: gate on logMode (not sessionLog) and call the helper for daily AND
  session, so logFilePath always persists as a bare base.
- Tests: 4 new — strip behaviour + an idempotence regression that locks in
  "resolve → strip → resolve = same path" so this can't silently come back. 204/200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:08:15 +02:00
Administrator
d720ba295a feat(log): add per-session log mode (one file per app launch)
Adds a third choice next to the existing single-file and per-day modes: a new
log file is created at every app start (process boot) and used until the app is
closed. A close → reopen of the app starts a new session, hence a new file.
File pattern: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log.

The boolean sessionLog field — misnamed: it actually toggled daily mode — is
replaced by a logMode enum: "single" | "daily" | "session". The misnomer made
the migration the trap to watch: existing users with sessionLog:true must land
on "daily", NOT "session". normalizeLogMode handles this and is unit-tested.

- lib/log-mode.js (new, pure, dual CJS/window export): normalizeLogMode +
  resolveLogFileName + format helpers. No fs, no Date.now() at call time.
- config-store.js: normalize at the single load() boundary so downstream
  readers consume logMode only. logMode is deliberately NOT seeded in DEFAULTS
  (would beat the legacy migration after merge).
- main.js: stamp SESSION_ID once at process start (with pid hedge against
  same-second restart collisions); getLogFilePath and buildFallbackLogName
  switch on mode via the lib. _resolveUploadLogTarget cache key is now just
  the primary path, which already encodes mode/date/session — self-invalidates.
- renderer: <select> with three German labels replaces the old checkbox;
  saveSettings writes logMode; index.html loads the lib so window.LogMode is
  available in renderSettings.
- Tests: 14 log-mode tests (incl. legacy-migration regression), 3 config-store
  tests (defaults, legacy migration, round-trip all three values). 200/200.

End-to-end simulated locally: two launches → two distinct session files; PID
hedge produces distinct names even within the same second.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:41:06 +02:00
Administrator
a8d81cbf0d fix(doodstream): upload via the doodapi API when an API key exists
Root cause of the recurring "kein Filecode — Server gab leeren Link zurueck":
the web-session upload flow gets the filecode back inside an XFileSharing HTML
form, and on long/large uploads that form comes back empty (no fn). Verified
research: doodstream's server-side file-registration callback times out under
large-file load, so the upload "succeeds" (bytes sent, HTTP 200) but no filecode
is minted — and because registration failed, the file is NOT in the file list
either, so polling can't recover it. The web path also rides a per-page-load
sess_id token that ages over the multi-minute upload.

The official doodapi.co JSON API has no such failure mode for result retrieval:
the upload response returns result[0].filecode directly, and it authenticates
with a persistent api_key (no aging sess_id). Git history confirms the API was
doodstream's ORIGINAL upload path (initial commit); web login was added later
only "as an alternative to API key" — so preferring the key restores the
intended primary path rather than fighting a deliberate choice.

- lib/account-auth.js (new, pure, unit-tested): selectUploadAuth() prefers the
  doodstream API key over username/password; all other hosters unchanged.
- main.js buildTaskFromAccount delegates to it → a doodstream account with an
  apiKey now routes through hosters.uploadFile (doodapi API) instead of the web
  uploader; keyless accounts keep using web login.
- hosters.js: drop the stale hardcoded fallback node from the doodstream API
  config (same dead tr1128ve host removed from the web path) so a failed server
  lookup throws cleanly instead of uploading into a dead end.
- Tests: 8 routing cases (doodstream key-preference, keyless fallback, voe
  unaffected, authType=api, null-safety). Full suite 173/173.

This eliminates the empty-form failure mode for result retrieval when a key is
configured. It does NOT change doodstream's backend — whether the large-file
timeout recurs (now as a structured JSON error, not a silent empty form) is for
the server run to confirm. Requires a doodstream API key on the account.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:42:19 +02:00
Administrator
57f8f0876e feat(log): per-hoster toggle for writing links to fileuploader.log
New per-hoster setting "Links in Log schreiben" (logToFile, default
on). When unchecked for a hoster, that hoster's successful upload
links are no longer written to fileuploader.log — other hosters keep
logging independently.

- lib/config-store.js: logToFile: true added to HOSTER_SETTINGS_DEFAULTS;
  merge-on-load gives every hoster the key (old configs included).
- renderer/app.js: checkbox per hoster panel + collection loop now
  handles type=checkbox (boolean) alongside the numeric fields. The
  autosave bind already special-cased checkboxes (change event).
- lib/log-policy.js (new): hosterLogToFileEnabled() — pure, opt-out
  semantics. Only an explicit logToFile===false disables; missing/
  malformed/non-true values all default ON so links are never
  silently dropped.
- main.js: shouldLogHosterToFile() reads the LIVE uploadManager
  .hosterSettings (so a mid-batch toggle takes effect at once), falls
  back to persisted config, then to enabled. Guards appendUploadLog
  in the done handler; skipped writes get a debugLog line.

Tests: 8 log-policy (defaults, opt-out, per-hoster independence,
malformed input) + 2 config-store (default true, persisted false
survives reload). 147/147 green, eslint clean.
2026-05-23 15:29:25 +02:00
Administrator
2208632154 ux(log): default fileuploader.log path is now the user's Desktop
In packaged builds path.dirname(process.execPath) resolves to
%LOCALAPPDATA%\Programs\Multi-Hoster-Upload — a hidden install
directory the user never visits and that NSIS may prune on
uninstall. Existing files written there were effectively invisible.

Change the unconfigured-default to app.getPath('desktop') instead.
If Desktop isn't available (rare), fall back to userData (Roaming),
and finally to the exe dir as a last resort. Dev mode (isPackaged
false) is unchanged — keeps the project dir for inspection.

Custom log paths set via the Settings UI override this and continue
to work as before. Existing users with old logs in the install dir
will just see a new fileuploader.log on the Desktop going forward;
the old file stays where it is (not auto-migrated).

137/137 tests still green.
2026-05-23 01:10:10 +02:00
Administrator
b1fe0cfefb fix(log): auto-rotate the other 3 internal log files (debug, rot, doodstream)
3.3.2 fixed fileuploader.log unbounded growth, but three siblings kept
growing without limit:

- upload-debug.log     (verbose, every IPC + progress event log line)
- account-rotation.log (every rotation decision)
- doodstream-debug.log (per-hoster trace from lib/doodstream-upload.js)

A multi-month dev install or a heavy production user could fill the
log dir with multi-GB files and slow every appendFile.

Wire all three through the same lib/log-rotation.js helper:
- upload-debug.log     → 25 MB cap, 2 numbered backups (~75 MB worst)
- account-rotation.log → 10 MB cap, 2 numbered backups (~30 MB worst)
- doodstream-debug.log → 10 MB cap, 1 numbered backup  (~20 MB worst)

The rotation check runs once per flush call (each is debounced or
already a once-per-event path), so the statSync overhead is
microscopic. _flushDebugLog passes a noop logger to avoid recursing
into itself; _flushRotLog and _debugLog (doodstream) use the normal
debugLog so any rotation surprises end up in upload-debug.log.

126/126 tests still green.
2026-04-28 11:11:24 +02:00
Administrator
a6958f1418 fix(persist): stop swallowing save errors + decouple .bak refresh from save
Two related fixes from the deep-audit pass:

HIGH-2: save-global-settings-sync used `try {} catch {}` and always
returned `event.returnValue = true`, so a disk-full / AV-lock /
permissions failure looked like success to the renderer's beforeunload
chain. The user closes the app, comes back, settings are gone —
without any indication. Now the catch sets returnValue=false and
debugLogs the error message, and the bak refresh is in its own
nested try so a transient lock there doesn't fail the whole save.

MED-4: lib/config-store.js _atomicWrite had the same TOCTOU on the
.bak refresh — fs.existsSync(...) then fs.readFileSync(...) without
guarding the read. Wrapped the read+write of the backup in its own
try/catch: a stale .bak is preferable to dropping the new write
entirely just because Windows Defender briefly locked the file mid-
operation. The rename of tmp → live still throws on real failure,
which is what the outer reject is for.

119/119 tests still green; both fixes are defensive guards on
already-tested write paths.
2026-04-28 09:40:08 +02:00
Administrator
04e535c709 fix(main): batch-done race could orphan a freshly-spawned UploadManager
The batch-done event handler awaits configStore.appendHistory(summary)
before nulling the global uploadManager reference. If the renderer
fires start-upload while that await is pending, the start-upload IPC
creates a fresh UploadManager and assigns it to the same global. The
old handler resumes, sets uploadManager = null, and orphans the new
manager: cancel-upload, add-jobs-to-batch, save-config re-resolve etc.
all see null and become no-ops, while the new batch keeps running
invisibly in the background.

Capture the manager identity at listener registration time and only
null the global if it still points at THIS manager. If a newer one
replaced it mid-await, leave it alone and log the near-miss for
diagnostics.

Found by deep-audit subagent. Tests still 119/119 (no test for this
because it needs a coordinated IPC + async-mock harness; the fix is
small and the diagnostic log will catch regressions).
2026-04-28 09:12:48 +02:00
Administrator
d9c3a00016 test(log): extract log-rotation into testable module + 10 unit tests
The fileuploader.log rotation introduced in 3.3.2 lived inline in
main.js — fine for the runtime path, but it required electron's `app`
to even reach the function under test. Pull the rotation logic into
lib/log-rotation.js (pure fs/path, no electron deps) and cover it
properly:

- ENOENT (file missing) → no-op
- Below cap → no-op
- Over cap → live → .1, returns true
- Existing backups shift up: .1 → .2, .2 → .3
- At maxBackups limit → oldest dropped, others shift, live becomes .1
- Idempotent: rotating twice keeps the chain consistent
- maxBackups=1: never grows past .1
- Invalid maxBytes (0/negative/NaN) → safe no-op
- Provided debug callback receives a "rotated" message
- File without extension still rotates correctly

main.js now imports `maybeRotateLogFile` and calls it directly. 97/97
tests pass.
2026-04-28 05:10:53 +02:00
Administrator
4575b5ac26 fix(main): cap _jobLogCollector at 1000 jobs (FIFO eviction)
The per-job log collector was only cleared at start-upload — across a
long session with many add-jobs-to-running-batch interactions (no new
start-upload), the Map grew unbounded. At ~5000 tracked jobs that's
1 MB × 5 = 5 MB+ of stale history hanging around in the main process,
bigger as ring buffers fill.

Add a cap: when a new jobId would push size past 1000, evict the
oldest entry (Map iteration order is insertion order per spec). 1000
× 200 entries/job × ~100 B/entry ≈ 20 MB worst case, properly bounded
no matter how long the session runs. Per-job ring buffer (200 entries)
unchanged; only the count of tracked jobs is now capped.

The "Log anzeigen" modal still works for any job in the most-recent
1000 — older jobs return an empty array, which the renderer already
displays as "Keine Log-Einträge".
2026-04-28 04:09:27 +02:00
Administrator
d96c6afce0 feat(log): auto-rotate fileuploader.log at 50 MB
A long-running install can otherwise grow the upload log into the
gigabyte range, eating disk and slowing every appendFile. Add a
size-checked rotation right before each flush:

- statSync the resolved log target (cheap, ENOENT skips silently).
- If size exceeds 50 MB, drop the oldest backup (.3), shift .2→.3
  and .1→.2, then rename the live file to .1 and let appendFile
  create a fresh primary on the next call.
- Max 3 backups (~200 MB worst case, bounded). debugLog records
  each rotation for diagnostics.
- Pure additive: skips when file is small or doesn't exist; no
  effect on the daily-log mode (already date-rotated).
2026-04-28 03:40:06 +02:00