Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c58d9203bc | ||
|
|
f0006f8003 | ||
|
|
66ae240794 | ||
|
|
8fed8669f3 | ||
|
|
7a40afbe7e |
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
27
main.js
@ -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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.104",
|
"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": {
|
||||||
|
|||||||
@ -1,3 +1,92 @@
|
|||||||
|
# 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.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
|
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
|
||||||
|
|||||||
@ -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)', () => {
|
||||||
|
|||||||
@ -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');
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user