Compare commits

...

6 Commits

Author SHA1 Message Date
Administrator
c58d9203bc docs(tasks): record v3.3.108 session-log 6-digit suffix release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:14:23 +02:00
Administrator
f0006f8003 feat(logs): append a 6-digit random suffix to session log filenames (v3.3.108)
Session-mode log files now end in a generated 6-digit number, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107
reintroduced same-minute collision risk on fast close/reopen; the random
suffix restores per-launch uniqueness without leaking the process id.

- formatSessionStamp(date, rand) appends -<rand> when supplied
- main.js stamps SESSION_ID with a 6-digit Math.random value
- stripModeStampFromFileName tolerates the optional -NNNNNN suffix
- tests cover stamp-with-rand and strip-with-suffix; 411 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:52:13 +02:00
Administrator
66ae240794 feat(logs): session log filename → DD-MM-YYYY-mdu-session-HH-MM (v3.3.107)
Per user request, the per-session log file is renamed from
fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour and minute only, no seconds or pid).

lib/log-mode.js:
- formatSessionStamp(date) returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}`
  (the pid argument is dropped; main.js still passes process.pid, harmlessly
  ignored). Same-minute restarts now share a session file, which is the intended
  human-readable trade.
- the session branch of resolveLogFileName returns `${sessionId}${ext}` — the stamp
  is the full app-defined stem and baseName is intentionally ignored for session
  mode (single/daily still use the 'fileuploader' base).
- stripModeStampFromFileName recognizes the new format and resets to the default
  'fileuploader' base (the new stem embeds no base, so the configured base is not
  recoverable from it); the existing daily and old-session strip regexes are kept
  for backward-compat with any persisted old paths, and the persist/re-resolve
  round-trip stays idempotent (no compounding stamps).

Tests updated for the new format (formatSessionStamp, session resolveLogFileName,
the new-format strip, and the idempotency regression). 410 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:56:43 +02:00
Administrator
8fed8669f3 fix(startup): disable HW acceleration on RDP/VM to kill the intermittent white screen + export filename (v3.3.106)
(A) Intermittent pure-white startup window. On a Windows VM viewed over
RemoteDesktop the app sometimes opened to a blank white window — no error banner,
not even the static menu bar. A multi-agent investigation + adversarial review
pinned it by one airtight deduction: the BrowserWindow is created with
backgroundColor '#16181c' (dark), so "white" can never be an un-painted, loading,
or failed state — all of those show dark. Pure white, with no banner and no static
menu bar, and silent in the logs, eliminates every DOM/init/CSS/load failure mode
(each would leave the dark styled shell, unstyled black-on-white menu text, or the
red init().catch banner) and leaves exactly one: a GPU/compositor surface failure
on the RDP virtual display adapter. The code ran hardware acceleration full-default
with no fallback (no disableHardwareAcceleration, no GPU switch anywhere), and the
GPU child-process-gone handler is log-only — matching the silent symptom.

Fix (the reviewed safe subset):
- app.disableHardwareAcceleration() at module top (before app.whenReady), gated on
  an RDP session (process.env.SESSIONNAME matches /^RDP/) OR a persisted
  gpu-disabled.flag in userData. The renderer has no WebGL/canvas/video, so software
  compositing costs effectively nothing here and does not undo the recent renderer
  perf work; local/console users keep hardware acceleration.
- Auto-heal: when a GPU child-process-gone fires, write gpu-disabled.flag so the next
  launch disables acceleration even if the RDP gate didn't match (covers a VM reached
  via console or a flaky virtual GPU). Self-heals after at most one white screen.
- The existing child-process-gone / render-process-gone / did-fail-load logging is
  kept so the affected server's next white-start log can confirm the cause
  (CHILD PROCESS GONE type=GPU). A webContents reload would not disrupt uploads
  (uploadManager lives in the main process and is torn down only on quit), but no
  watchdog is added because in this GPU mode init completes — an init-complete signal
  would not detect the blank surface.

(B) Backup export default filename changed from multi-hoster-backup-YYYY-MM-DD.mhu to
DD-MM-YYYY-multihoster-backup.mhu.

409 tests pass; clean boot (the guard is inert off-RDP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:05:07 +02:00
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
335f365497 perf(recent): virtualize the Recent-uploads panel — the last non-virtual table (v3.3.104)
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>
2026-06-22 03:15:44 +02:00
9 changed files with 301 additions and 76 deletions

View File

@ -357,8 +357,10 @@ class ConfigStore {
try { data = this._readAndParse(this.filePath); } catch {} try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt // Fallback to backup if main is empty/corrupt
if (!data) { if (!data) {
const backupPath = this.filePath + '.bak'; try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
try { data = this._readAndParse(backupPath); } catch {} }
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
} }
if (!data) { if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS)); const fresh = JSON.parse(JSON.stringify(DEFAULTS));
@ -481,12 +483,41 @@ class ConfigStore {
return this._writeQueue; return this._writeQueue;
} }
_anyHosters(cfg) {
const h = cfg && cfg.hosters;
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
}
_recoverHostersFromDisk() {
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
try {
const raw = fs.readFileSync(p, 'utf-8');
if (!raw || raw.trim().length < 2) continue;
const data = JSON.parse(raw);
if (this._anyHosters(data)) return data.hosters;
} catch {}
}
return null;
}
_guardHosters(current, hostersIntentional) {
if (!hostersIntentional && !this._anyHosters(current)) {
const recovered = this._recoverHostersFromDisk();
if (recovered) {
current.hosters = recovered;
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
}
}
return current;
}
save(config) { save(config) {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const current = this.load(); const current = this.load();
if (config.hosters) current.hosters = config.hosters; if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings; if (config.globalSettings) current.globalSettings = config.globalSettings;
this._guardHosters(current, !!config.hosters);
return this._commit(current); return this._commit(current);
}); });
} }
@ -503,18 +534,22 @@ class ConfigStore {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp'; const tmpPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak'; const backupPath = this.filePath + '.bak';
fs.writeFile(tmpPath, data, 'utf-8', (err) => { let fd;
if (err) return reject(err); try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
} catch (e) {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
return reject(e);
}
try { fs.closeSync(fd); } catch {}
Promise.resolve().then(() => {
try { try {
// Refresh .bak from the previous live file with a raw byte copy —
// no read+JSON.parse+write. The live file was itself written through
// this atomic path, so re-validating it by parsing the whole (growing)
// config on every write was pure waste. Wrapped in try/catch so an
// AV/indexer briefly locking the file doesn't fail the save — the
// rename to the live path is the part that matters.
try { try {
if (fs.existsSync(this.filePath)) { if (fs.existsSync(this.filePath)) {
fs.copyFileSync(this.filePath, backupPath); const cur = fs.readFileSync(this.filePath, 'utf-8');
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8');
} }
} catch {} } catch {}
fs.renameSync(tmpPath, this.filePath); fs.renameSync(tmpPath, this.filePath);
@ -604,6 +639,7 @@ class ConfigStore {
return this._enqueueWrite(() => { return this._enqueueWrite(() => {
const config = this.load(); const config = this.load();
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {}; config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
this._guardHosters(config, false);
return this._commit(config); return this._commit(config);
}); });
} }

View File

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

27
main.js
View File

@ -27,6 +27,16 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors'); const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent'); const { createAgent } = require('./lib/diagnostics-agent');
function _gpuDisableFlagPath() {
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
}
(function maybeDisableHardwareAcceleration() {
let disable = false;
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
})();
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable(); _eventLoopDelay.enable();
let _eldLastLog = 0; let _eldLastLog = 0;
@ -553,10 +563,10 @@ function getBaseLogFilePath() {
// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session. // Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session.
// The session-id is stamped ONCE at main-process startup so every write of a // The session-id is stamped ONCE at main-process startup so every write of a
// given session lands in the same file. A close→reopen of the app starts a new // given session lands in the same file. A close→reopen of the app starts a new
// main process, so a new SESSION_ID, so a new session file. PID is appended as // main process, so a new SESSION_ID, so a new session file. A 6-digit random is
// a cheap hedge against same-second restart collisions. // appended as a cheap hedge against same-minute restart collisions.
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode'); const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
const SESSION_ID = formatSessionStamp(new Date(), process.pid); const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000)));
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
let _activeLogPath = null; let _activeLogPath = null;
@ -1278,6 +1288,9 @@ function createWindow() {
app.on('child-process-gone', (_event, details) => { app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details); _writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`); debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
if (details && details.type === 'GPU') {
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
}
}); });
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
@ -2203,9 +2216,11 @@ ipcMain.handle('clear-history', async () => {
// --- Backup export / import --- // --- Backup export / import ---
ipcMain.handle('export-backup', async () => { ipcMain.handle('export-backup', async () => {
const _bd = new Date();
const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`;
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren', title: 'Backup exportieren',
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`, defaultPath: `${_bdate}-multihoster-backup.mhu`,
filters: [ filters: [
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] }, { name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] } { name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
@ -2476,10 +2491,12 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics; const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
current.globalSettings = globalSettings; current.globalSettings = globalSettings;
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag; if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
try { configStore._guardHosters(current, false); } catch {}
_invalidateLogSettings(); _invalidateLogSettings();
const data = configStore._serializeForDisk(current); const data = configStore._serializeForDisk(current);
const backupPath = configStore.filePath + '.bak'; const backupPath = configStore.filePath + '.bak';
fs.writeFileSync(tmpPath, data, 'utf-8'); const _fd = fs.openSync(tmpPath, 'w');
try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); }
if (fs.existsSync(configStore.filePath)) { if (fs.existsSync(configStore.filePath)) {
// Use try/catch around the read so an AV/lock race doesn't fail the // Use try/catch around the read so an AV/lock race doesn't fail the
// whole save just because we couldn't refresh the .bak — the write to // whole save just because we couldn't refresh the .bak — the write to

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.103", "version": "3.3.108",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -4738,54 +4738,67 @@ function _buildRecentRowHtml(row) {
let _recentLastRenderedSig = ''; let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0; let _recentLastRenderedLen = 0;
let _recentPendingAppends = 0; let _recentPendingAppends = 0;
let _recentWorking = [];
let _recentLastRange = { start: -1, end: -1 };
let _recentScrollQueued = false;
function _onRecentScroll() {
if (_recentScrollQueued) return;
_recentScrollQueued = true;
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
}
function _renderRecentVirtualRows() {
const wrap = document.querySelector('.recent-files-table-wrap');
const tbody = document.getElementById('recentFilesBody');
if (!wrap || !tbody) return;
const total = _recentWorking.length;
if (!total) return;
const scrollTop = wrap.scrollTop;
const viewportHeight = Math.max(wrap.clientHeight, 400);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
_recentLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
tbody.innerHTML = parts.join('');
}
function renderRecentUploadsPanel(appendOnly = false) { function renderRecentUploadsPanel(appendOnly = false) {
const tbody = document.getElementById('recentFilesBody'); const tbody = document.getElementById('recentFilesBody');
if (!tbody) return; if (!tbody) return;
const pendingAppends = _recentPendingAppends;
_recentPendingAppends = 0; _recentPendingAppends = 0;
const wrap = tbody.closest('.recent-files-table-wrap');
if (!sessionFilesData.length) { if (!sessionFilesData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
_recentLastRenderedSig = ''; _recentWorking = [];
_recentLastRenderedLen = 0; _recentLastRange = { start: -1, end: -1 };
return;
}
const rows = sortRecentFiles(sessionFilesData);
const sig = `${recentSortState.key}|${recentSortState.direction}`;
const dateDescAppendOnly = appendOnly
&& pendingAppends > 0
&& sig === 'date|desc'
&& _recentLastRenderedSig === sig
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
const wrap = tbody.closest('.recent-files-table-wrap');
const wasAtTop = !wrap || wrap.scrollTop <= 48;
let wasAppendOnly = false;
if (dateDescAppendOnly) {
const added = Math.min(pendingAppends, rows.length);
let html = '';
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
tbody.insertAdjacentHTML('afterbegin', html);
let evict = (_recentLastRenderedLen + added) - rows.length;
while (evict > 0) {
const last = tbody.lastElementChild;
if (!last) break;
last.remove();
evict--;
}
wasAppendOnly = true;
} else { } else {
tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); const prevLen = _recentWorking.length;
_recentWorking = sortRecentFiles(sessionFilesData);
_recentLastRange = { start: -1, end: -1 };
const sig = `${recentSortState.key}|${recentSortState.direction}`;
if (wrap) {
const added = _recentWorking.length - prevLen;
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
}
_renderRecentVirtualRows();
} }
if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
_recentLastRenderedSig = sig;
_recentLastRenderedLen = rows.length;
// Event delegation bind once, not per-row // Event delegation bind once, not per-row
if (!_recentListenersBound) { if (!_recentListenersBound) {
_recentListenersBound = true; _recentListenersBound = true;
if (wrap) {
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
}
tbody.addEventListener('click', (e) => { tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row'); const tr = e.target.closest('.recent-file-row');
if (!tr) return; if (!tr) return;
@ -4824,8 +4837,7 @@ function renderRecentUploadsPanel(appendOnly = false) {
}); });
} }
// Sort headers only change when the sort state changes — skip on appends. updateRecentSortHeaders();
if (!wasAppendOnly) updateRecentSortHeaders();
} }
const HISTORY_RENDER_CAP = 2000; const HISTORY_RENDER_CAP = 2000;

View File

@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.recent-file-row { .recent-file-row {
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
height: 28px;
} }
.recent-file-row:hover { .recent-file-row:hover {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);

View File

@ -1,3 +1,129 @@
# v3.3.108 — session log filename: 6-digit uniqueness suffix
User: append a generated 6-digit number to the session log filename, e.g.
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute
collision risk on a fast close/reopen.
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged.
- main.js stamps SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random()*900000))).
- stripModeStampFromFileName newSessionRe gains an optional `(?:-\d+)?` so the suffix strips back to the base.
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass.
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json,
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit).
---
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
DD-MM-YYYY-mdu-session-HH-MM.log (hour-minute, no seconds/pid). lib/log-mode.js:
- formatSessionStamp(date) now returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}` (pid arg dropped; main.js
still passes process.pid, harmlessly ignored).
- resolveLogFileName session branch returns `${sid}${ext}` (the stamp is the full app-defined stem, baseName
ignored — single/daily still use baseName 'fileuploader').
- stripModeStampFromFileName recognizes the new format (^DD-MM-YYYY-mdu-session-HH-MM(.ext)$) and resets to the
default 'fileuploader' base (the new format embeds no base); the old daily + old-session strip regexes stay
for backward-compat with any persisted old paths. The compounding round-trip stays idempotent.
Tests updated (formatSessionStamp, resolveLogFileName session, strip new-format, idempotency). 410 pass.
---
# v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename
Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the
static menu bar) on a 12-vCPU Windows VM over RemoteDesktop. Workflow wn2k04x5t (2 agents + adversarial verify)
nailed it by one airtight deduction: the BrowserWindow backgroundColor is DARK (#16181c, main.js:1228), so
"white" can NEVER be an un-painted/loading/failed state — those all show DARK. Pure-white + no menu bar + no
banner + SILENT eliminates every DOM/init/CSS/load mode (each leaves the dark styled shell, unstyled-black-on-
white menu text, or the red init().catch banner) → the ONLY match is a GPU/compositor surface failure on the
RDP virtual display adapter. Confirmed: NO disableHardwareAcceleration / disable-gpu / appendSwitch ANYWHERE,
and child-process-gone (GPU) is log-only (matches the silent symptom) while render-process-gone pops a dialog.
Renderer has ZERO WebGL/canvas/video (audited) → software compositing costs ~nothing and doesn't undo the perf
work; a webContents.reload() doesn't disrupt uploads (uploadManager lives in main, torn down only on quit).
Watchdog REJECTED: the GPU mode lets init complete, so an init-complete signal wouldn't detect the white screen.
SHIPPED v3.3.106 (main.js — adversary's safe subset):
- app.disableHardwareAcceleration() at module top (before app.whenReady) GATED on RDP (process.env.SESSIONNAME
matches /^RDP/) OR a persisted gpu-disabled.flag (in userData). Zero-regression for local/console users.
- Auto-heal: on a GPU child-process-gone, write gpu-disabled.flag → next launch disables HW accel even if the
RDP gate missed (covers VM-via-console / bad virtual GPU). Self-healing after at most one white screen.
- Kept the child-process-gone/render-process-gone/did-fail-load instrumentation to CONFIRM on the server log
(caveat: root cause is the standard RDP-GPU bet, not yet confirmed on the affected machine — next white-start
log shows CHILD PROCESS GONE type=GPU = confirmed).
- (B) export-backup defaultPath: multi-hoster-backup-YYYY-MM-DD.mhu → DD-MM-YYYY-multihoster-backup.mhu.
409 tests pass, clean boot (guard inert on non-RDP dev machine).
DEFERRED (perf polish, workflow w5o2rpffx design ready): history JSONL + batch-start residual.
---
# v3.3.105 — URGENT data-safety: config write fsync + account-wipe guard
User report: after a server CRASHED during upload (NOT the v3.3.104 update — the other server updated fine and
kept its accounts), the accounts/credentials were gone. Root-cause chain (in code): config writes were atomic
(tmp+rename) but had NO fsync — a hard crash can leave electron-config.json truncated/unflushed → on restart
load() reads the corrupt/empty file, falls to .bak, and if that's also bad returns empty DEFAULTS → the next
settings/queue save persists EMPTY hosters → accounts permanently wiped (and the async _atomicWrite blindly
copyFileSync'd the live → .bak, so an empty live could clobber a good .bak).
SHIPPED v3.3.105 (lib/config-store.js + main.js — data-safety, no behavior change):
- fsync before rename in BOTH write paths: _atomicWrite (openSync+writeSync+fsyncSync+closeSync, then
guarded-.bak + rename) and main.js save-global-settings-sync (openSync+writeSync+fsyncSync+closeSync). A hard
crash can no longer leave a truncated config.
- _atomicWrite .bak is now GUARDED: read the live file and only refresh .bak if it's non-trivial (trim>2) —
an empty/truncated live can never clobber a good .bak (matches what the sync-save already did).
- WIPE-GUARD (_guardHosters): in save()/saveRotationCursors()/the sync-save, when the write does NOT
intentionally set hosters (config.hosters absent) AND the resulting hosters are all-empty, recover the
hosters from disk (_recoverHostersFromDisk tries live → .bak → .pre-history-split.bak) instead of persisting
the wipe. An EXPLICIT save({hosters:{}}) (user deleted all) is still allowed (hostersIntentional=true).
- load() gained a 3rd fallback tier: .pre-history-split.bak (the permanent v3.3.99 snapshot with accounts) so
load() itself recovers after corruption.
2 new tests (post-wipe valid-empty live + .bak → guard restores; explicit empty NOT blocked). 409 tests pass.
RECOVERY for the affected server: %APPDATA%\multi-hoster-uploader\electron-config.json.pre-history-split.bak
(or .bak) → copy over electron-config.json with the app closed.
DEFERRED (perf polish, workflow w5o2rpffx designs ready): history JSONL (append-only, kills per-batch 185MB
rewrite + first-open parse via loadHistoryRecent tail-read + meta sidecar) and the batch-start one-time
render/231ms residual. Do these AFTER the data-safety fix is confirmed stable.
---
# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
that view laid out ~2000 rows.
Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
SHIPPED v3.3.104 (renderer/app.js + styles.css):
- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
~visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
top, else += added*ROW_HEIGHT).
- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
rows is correct.
- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
table-layout:fixed, so no column-jump fix needed unlike History).
- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
Uploads" message.
- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
row height exactly 28. 407 tests pass, clean boot.
Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
batch parse-cache/JSONL.
---
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm) # v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch), v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),

View File

@ -293,6 +293,27 @@ describe('ConfigStore', () => {
const config = store.load(); const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup'); assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
}); });
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
hosterSettings: {}, globalSettings: {}, history: []
}), 'utf-8');
await store.save({ globalSettings: { alwaysOnTop: true } });
const cfg = store.load();
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
assert.equal(cfg.globalSettings.alwaysOnTop, true);
});
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
await store.save({ hosters: {} });
const cfg = store.load();
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
});
}); });
describe('ConfigStore history split (electron-history.json)', () => { describe('ConfigStore history split (electron-history.json)', () => {

View File

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