Improve the account empty state and modal ergonomics, add complete keyboard focus handling and accessible labels, localize the core upload UI, and align the product version with the v2.0.2 release.
Keep the account submit lock independent of modal sessions, use the exact button labels, and separate persistence from renderer application so a post-save UI failure cannot invite a duplicate save.
Keep account validation and persistence inside one guarded submission. Recheck modal identity before committing, persist a copied hoster candidate, and publish renderer state only after saveConfig succeeds.
The v3.3.103 log (real 2464-job, 4-hoster batch ramping to 95 concurrent) confirmed
the statSync fix held (batch-start main spike 336→231ms with 10× more jobs) and the
whole 90s ramp to 95 active was pristine (event-loop mean ~11ms, fps=32,
longtasks=0). The one residual: rapidly clicking tabs DURING the 95-active upload
produced 210-221ms renderer long-tasks (proc=0ms → layout/paint, not JS).
Cause: the Recent-uploads panel rendered every sessionFilesData row (up to 2000)
into the DOM non-virtualized — the exact analog of the History table before it was
virtualized in v3.3.102. Switching to that view laid out ~2000 rows (~210ms on the
RDP VM).
Fix — virtualize renderRecentUploadsPanel, mirroring the queue/History pattern:
- The tbody gets only the visible rows plus top/bottom spacer <tr> sized from
VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll handler and a ResizeObserver on
.recent-files-table-wrap re-render the visible window (the ResizeObserver also
serves as the show-trigger when the hidden panel gains size). _recentWorking holds
the sorted set. The insertAdjacentHTML append-only fast path is dropped — a
~40-row window re-render is cheap, so every render just re-renders the window; on
prepend (date desc) the scroll position is preserved (scrollTop=0 at top, else
+= added*ROW_HEIGHT).
- Selection stays correct: _buildRecentRowHtml already stamps the selected class
from selectedRecentIds.has(row.order) per row, so an off-screen-selected row
renders selected when scrolled into view; selectedRecentIds remains the source of
truth and shift-select already reads the sort cache, not the DOM.
- styles.css gives .recent-file-row a fixed 28px height so the virtualization math
is exact (the table already had table-layout:fixed, so no column-jump fix needed).
- _renderRecentVirtualRows returns early when there are no rows, so it never wipes
the empty-state message.
Verified with Playwright at 2000 rows (bounded container): view-show layout drops
from ~118ms to ~2.4ms, the DOM stays at 29-39 rows, scrolling maps to the correct
rows, the scrollbar height is exact, an off-screen-selected row renders with the
selected class, and the row height is exactly 28. Every large table (Queue,
History, Recent) is now virtualized.
407 tests pass; clean Electron boot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v3.3.101's gate removed the get-history parse on tab switch, but the log still
showed tab clicks at ~216ms with a ~197ms renderer long-task and NO get-history
(proc=0ms → pure browser layout, not JS). Cause: `.view{display:none}` ->
`.active{display:flex}` and the History table built up to 2000 non-virtualized
<tr>, so making the view visible laid out 2000 rows (~197ms on the RDP VM). The
queue was already virtualized; the History table was the only large non-virtual
one.
Measured the options with Playwright on the real DOM (this machine; the user's VM
is ~1.7x slower):
- current 2000 rows (auto layout): 118ms
- content-visibility + table-layout:fixed: 117ms (no help — rows still laid out)
- cap to 300 rows: 16ms, but rejected: History rows are per-file-per-hoster, so a
single 1280-file batch is ~3840 rows and a small cap would hide a recent batch's
links
- virtualize (40 visible of 2000): 2.3ms
Fix — virtualize renderHistoryTable, mirroring the queue's _renderVirtualRows:
- The header is always rendered; tbody#historyBody gets only the visible rows plus
top/bottom spacer <tr> sized from VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll
handler and a ResizeObserver on #historyContainer re-render the visible window;
the ResizeObserver also serves as the show-trigger (a hidden 0x0 container that
gains size on tab activation re-renders at the correct height). Sorting resets
scrollTop and re-renders; the copy-link / sort-header click delegation is
unchanged. _historyWorking holds the sorted working set.
- styles.css: .history-table gets table-layout:fixed plus scoped column widths so
columns do not jump as rows scroll in and out. This is scoped to .history-table
and does not touch the .col-* classes the queue shares.
Verified end-to-end with Playwright at 6000 rows: show cost 1.1ms (was 118ms), DOM
stays 32-42 rows, scroll maps to the right rows (top/middle/bottom), scrollbar
height exact, column widths stable, rows update on scroll. All rows remain
scrollable (no UX loss); the show cost drops ~100x.
407 tests pass; clean Electron boot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Closes the last measurement gap. The main process was already fully instrumented
(every ipcMain handler timed at >=50ms, a 100ms main-thread long-task monitor with
channel attribution, config load/serialize timing). Account switches were already
covered too: switchAccount is a trivial synchronous Map-set and the rotation work
is async, so a switch cannot block the main thread, and any block that did occur
would surface in the long-task monitor.
The real gap was the renderer side: the renderer-perf line was gated on active
uploads (idle clicks were never logged) and only reported an aggregate longtask
count — no per-interaction latency and no element attribution. So a switch/sort/tab
that janked in the renderer (not main) was invisible.
renderer/app.js (additive, self-silencing, wrapped in try/catch):
- An Event Timing observer (PerformanceObserver type:'event', durationThreshold:50,
buffered) logs `renderer-interaction <type> dur=Xms proc=Yms target=<el>` for every
user interaction whose latency exceeds 50ms — always on, idle or under load — and
names the element (id / first class / data-action / aria-label / title). This is
the direct click->reaction latency the user feels.
- The longtask observer now also logs `renderer-longtask dur=Xms` immediately for any
single renderer long task >=100ms, regardless of upload state.
405 tests pass; clean Electron boot. Every action — main or renderer, idle or under
load — now names itself in the log if it is slow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
maybeAddSessionFile computed formatDateTime(new Date()) at the top, before the status==='done' guard that early-returns for every other status. formatDateTime runs two Intl locale formats (~83us measured), so it executed on every progress event — onUploadProgressBatch loops the M-item batch into handleProgress -> maybeAddSessionFile, i.e. 10xM times per second — and threw the result away for all non-done events (the vast majority while uploading).
That made each progress batch a synchronous main-thread block scaling with the active-upload count: ~2.4ms at 25 concurrent, ~5ms at 61, every 100ms, on top of render and sort — enough to blow the 16ms frame budget and stutter scrolling. It is per-event, not per-render, so it janks regardless of scroll position, matching the user's report that the lag appears above ~50 connections and when the uploading rows are scrolled out of view.
Move the formatDateTime call inside the dedup block so it runs once per genuinely-new completed upload. A faithful Blink benchmark at the user's regime (500 rows virtualized, dynamic progress sort, scrolling) shows the per-batch cost drop from 1.7/3.2/4.1ms at 25/50/61 concurrent to a flat 0ms, and frame P95 at 61 concurrent from 7.3ms to 4.2ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The recent-uploads panel had a cheap append-only fast path, but it was gated on
`rows.length > _recentLastRenderedLen`. maybeAddSessionFile caps sessionFilesData
by push-then-slice (2000 -> 2001 -> sliced back to 2000), so once a session
produces more than SESSION_FILES_CAP rows the length is pinned at the cap and the
gate is false forever. Every subsequent completion then fell through to the full
`tbody.innerHTML = rows.map(...).join('')` rebuild of all ~2000 rows.
The cap is per (link x file x hoster), so with 4-5 selected hosters the 2000 cap
is hit at only ~400-500 distinct files — very reachable in a long folder-monitor
session. Profiled in Chromium (same Blink engine as Electron, table-layout:fixed):
the full 2000-row rebuild costs ~80ms and ran on EVERY completion past the cap — a
repeating ~80ms main-thread freeze. That is the "fine on a fresh start, gets laggy
after many uploads while CPU (~40%) and RAM (~6GB, stable) stay normal" symptom: a
render-thread stall, not CPU saturation or a memory leak.
Fix: track newly-pushed rows in _recentPendingAppends (incremented in
maybeAddSessionFile, consumed every render) and gate the fast path on
`pendingAppends > 0` instead of length growth, so it survives the cap. Prepend the
new rows, then evict the same overflow count from the DOM bottom (oldest rows,
which is where the date-desc view places the front-of-array entries the cap slices
off). DOM work is O(added) again. The fast path is gated behind an explicit
`appendOnly` flag passed only by scheduleRecentRender's rAF, so selection / delete
/ clear / sort / batch-done renders stay full rebuilds and cannot wrong-evict or
double-prepend.
Verified in Blink over a simulated 5000-completion session (3/frame, far past the
cap): per-frame render 80ms -> median 7.4ms (>10x), and the DOM stays exactly equal
to the data (cap held at 2000, newest-on-top, oldest evicted, zero duplicates).
397/397 tests pass, eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
A 36-agent adversarial sweep over the queue-persistence context surfaced three
NEW, reachable defects (all renderer-only) that the v3.3.80-82 work missed:
#1 retrySelectedJobs (the reuploadBtn / "erneut hochladen" path) is a THIRD
re-add path that never cleared the completed-dedup guard. _completedUploadKeys
gains file|hoster when a job reaches done; retry of a done row reset the job to
pending and re-added its path to selectedFiles but kept the key. On a restart
before the re-upload re-completes, restoreQueueStateFromConfig re-seeds the key
and buildQueuePreview then refuses to recreate the row, so the deliberate
re-upload silently vanished (lost work). Retry now clears the exact file|hoster
keys it re-activates (per key, NOT per path, so a sibling hosters completed
ghost is not resurrected).
#7 The folder-monitor pre-selected-hosters branch re-adds a path and calls
buildQueuePreview WITHOUT going through applyHosterSelection, so the v3.3.82
key-clear never ran. With removeFromQueueOnDone ON, a re-encoded / re-dropped
file whose row was auto-removed was suppressed forever (silent no-upload that
even survived a restart). The branch now clears the dedup keys for the freshly
re-added paths, matching the manual modal flow.
#4 Deleting one hosters row of a multi-hoster file was silently undone. A
manually deleted non-completed file|hoster pair is recreated by buildQueuePreview
the next time it runs (adding another file, or a folder-monitor drop), because
the recreate guard only consulted _completedUploadKeys, never the per-cell
deletion. Result: the file got uploaded to a hoster the user explicitly
removed, burning quota and publishing an unwanted link. Introduces
_suppressedPreviewKeys: a manual delete of a non-done job whose file stays
selected (pinned by a sibling job) suppresses that exact file|hoster from
re-creation; the suppression is persisted in pendingQueue.suppressedKeys and
re-seeded on restore (mirroring completedKeys), and is cleared whenever the
user deliberately re-adds the file (modal or folder monitor) so a real re-add
still works.
The clear-on-re-add logic for both sets is unified into clearDedupKeysForPaths()
and reused by applyHosterSelection and the folder-monitor branch.
Held for a separate decision/round (surfaced to the user): single-instance lock,
the sync-close write-sequence guard (its ghost symptom is masked by the existing
microtask-ordered removal + restore-time ts-gate; its real residual is rotation/
history integrity), and the upload-log timezone/same-basename edges (need a log
format change). 362 tests pass, lint clean, smoke boots identically to baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The completed-upload dedup guard (_completedUploadKeys, "file|hoster") is the
single source of truth that keeps a finished job from being re-materialised as a
"Bereit" preview by buildQueuePreview(). Three independent paths leaked ghosts
back into the queue:
A) removeJobFromIndex() unconditionally DELETED the dedup key. The
removeFromQueueOnDone auto-remove path (handleProgress), the batch-done sweep,
and the terminal-job prune all call removeJobFromIndex on a *finished* job, so
the guard handleProgress had just added was immediately wiped and
buildQueuePreview re-created the job as a preview both mid-session and after
restart. removeJobFromIndex now takes keepCompletedKey; the three auto-removal
call sites pass true (keep the guard), while the manual-delete sites keep the
old behaviour (drop the guard so the user can re-add the file).
D/E) The dedup guard lived only in memory, so every restart began with an empty
set and buildQueuePreview rebuilt ghosts from the persisted selectedFiles.
buildPersistedQueueState() now serialises the keys whose file is still in the
snapshot (completedKeys) and restoreQueueStateFromConfig() re-seeds them. To
keep deliberate re-uploads working, applyHosterSelection() clears the
persisted key for every freshly (re-)added file: re-adding through the hoster
modal is the explicit "upload this again" signal. Both retry paths
(retrySelectedJobs, _retryFailedFromBuckets) mutate the existing job in place
instead of relying on buildQueuePreview, so they are unaffected.
H) buildQueuePreview() excluded error jobs from its existingKeys set, so a
file+hoster that already had an 'error' row got a second 'preview' row stacked
beside it. Error jobs now count as existing.
B/C) parseUploadLogLine() took the filename from a fixed field index and trimmed
it. A pipe inside the link shifted the field (entry lost from the log-based
dedup) and trimming broke matching against the untrimmed OS basename used as
the queue-job key. The filename is now the last non-empty field and is no
longer trimmed, so log lines with pipes in the URL and leading-space filenames
both match end-to-end.
362 tests pass, lint clean on all touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The byse suspect size-memo (softened in v3.3.73 to arm only after two
confirmed suspect rejections on the same account and only for strictly
larger files) still occasionally pre-skips a primary account with
"Bekanntes Größen-Limit auf diesem Account", which the user finds annoying
when they would rather just let every file attempt the upload for real.
Add a per-hoster opt-out: a new "Größen-Limit merken" checkbox (Accounts tab,
default ON so existing behavior is preserved for everyone) that, when
unchecked, disables the memo-based pre-skip entirely for that hoster.
- config-store.js / upload-manager.js DEFAULT_SETTINGS: sizeMemoEnabled: true.
- upload-manager.js _suspectMemoBlocks(): returns false up front when the
hoster has sizeMemoEnabled === false. That single guard covers BOTH consult
sites — the pre-job block (skips the guaranteed-fail upload) and the
alternate-account skip — since both route through _suspectMemoBlocks. The
memo is still recorded; it is simply never allowed to block. This moves the
flow toward "fail open": with the toggle off, every file always gets a real
attempt on its account.
- The genuine suspect-reject alternates walk is gated on err.suspectReject,
not the memo, so disabling the memo does not weaken real failover — a file
the account actually rejects still rolls over to the next account.
- renderer/app.js: checkbox data-hs="sizeMemoEnabled", checked when
hs.sizeMemoEnabled !== false; persisted by the existing generic checkbox
path in saveHosterSettingsFromDom(). The running manager picks it up on the
next batch (fresh UploadManager) and immediately on save (updateSettings).
Tests: suspect-reject-alternates gains a disabled-memo case proving the larger
third file still gets a real primary attempt (key1 tried 3x, no
suspect-memo-skip event); config-store gains a default-true + persist-false
case. Full suite 305/305. Reviewed by a 4-lens adversarial workflow
(completeness / composition / persistence-ui / conventions): 0 findings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Three changes, investigated and adversarially reviewed via multi-agent workflow.
1. Queue maximize bug: the virtual-scrolled upload queue derived its visible-row
window from #queueContainer.clientHeight but only recomputed it on 'scroll'.
Maximizing the window enlarges the container without a scroll event, so rows
below the old viewport stayed blank until you scrolled. Fix: a ResizeObserver
on #queueContainer reusing the existing rAF-coalesced _onQueueScroll. Strictly
stronger than a window 'resize' listener — it also covers the hidden->visible
view-switch case (container 0 -> real height). No feedback loop (container box
is flex-sized, not content-sized); early-returns for <200-row queues.
2. Einstellungen tab restructured from 4 dense stacked collapsible panels into
horizontal sub-tabs: Allgemein / Automatik / Logs & Diagnose / Fernsteuerung /
Backup. All sub-pages render into the DOM at once (only the active one is
shown), so every element id stays present and saveSettings keeps working. The
cluttered "Allgemein" block is split across Allgemein + Automatik + Logs.
Per-hoster settings already moved to Accounts in v3.3.69.
3. saveSettings hardened to be null-safe (elTxt/elChk/elInt helpers): when a
settings element is absent from the DOM it now keeps the current config value
instead of collapsing to a default. Behavior is identical when elements are
present; this is insurance against a future dropped id silently overwriting a
real setting (e.g. webhook URL, folder-monitor hoster pre-selection).
Verified: 43 sub-tab ids unique, all 26 saveSettings ids present, lint clean,
boot clean, 284 tests green, 0 confirmed defects from a 3-lens adversarial review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings section was nested inside the collapsible cards body, so you had to
expand a hoster group first before you could open its upload settings. Moved the
section out to the group level (sibling of the cards body), so the
"Upload-Einstellungen" toggle is always visible directly under each hoster header
— collapsed or not — for every hoster. Styled as a full-width row matching the
group header. Group-cards toggle and the settings toggle are now fully independent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Declutters the Einstellungen tab: the per-hoster panels (Retries, Max Speed,
Parallel Uploads, Restart-below, Interval, Max Size, log-to-file) are no longer
listed there. Each hoster group in the Accounts tab now has a collapsible
"Upload-Einstellungen" section with exactly those controls, so everything about
a hoster lives in one place. The Einstellungen tab shows a short pointer to where
they moved.
Per-hoster edits in Accounts save via a dedicated lightweight handler
(scheduleHosterSettingsSave → saveHosterSettings) that touches only
hosterSettings — it deliberately does NOT route through the global saveSettings,
so tweaking a hoster slider can't re-run the folder-monitor start/stop or clobber
global fields. The global saveSettings still reads .hs-input[data-hoster] wherever
they live, and global inputs (which have no data-hoster) are unaffected. The new
inputs drop the .settings-autosave class and bind via delegation on the persistent
accounts container so they survive re-renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adds a bottom-right button under the accounts list that expands every hoster
group at once (and collapses them again — the label flips based on current
state). Reuses the existing _hosterGroupOpenMemory so the per-group open/closed
state stays consistent with manual header clicks. The footer is hidden when no
accounts exist. Pure DOM toggle, no re-render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>