Compare commits

...

12 Commits

Author SHA1 Message Date
Administrator
ddf2710fc6 release: v3.3.53 2026-06-08 21:28:34 +02:00
Administrator
0f57aef7c7 fix(stability): wrap hot timers/callbacks in try/catch, safeSend, updater waits for batch 2026-06-08 21:28:12 +02:00
Administrator
f0608dcda1 release: v3.3.52 2026-06-08 21:19:19 +02:00
Administrator
9b10a4356f feat(diagnostics): full crash instrumentation — never silently die again 2026-06-08 21:18:54 +02:00
Administrator
d159ac484a release: v3.3.51 2026-06-08 19:22:54 +02:00
Administrator
f4b5fadc5f fix(ui): first click on sort header sets default direction instead of toggling 2026-06-08 19:22:29 +02:00
Administrator
169817f707 release: v3.3.50 2026-06-08 14:20:16 +02:00
Administrator
1418c2bc17 feat(backup): plain JSON export/import + clearer error when decrypt fails 2026-06-08 14:19:47 +02:00
Administrator
8d33141294 release: v3.3.49 2026-06-08 03:04:25 +02:00
Administrator
35341b522a fix(accounts): allow health check during active uploads + toast when already running 2026-06-08 03:04:00 +02:00
Administrator
f9aa7f4168 release: v3.3.48 2026-06-08 01:30:19 +02:00
Administrator
d9199f8aaf fix(perf): chunked startBatch + async rotLog — kill remaining 30s freeze on 5k+ jobs 2026-06-08 01:29:31 +02:00
5 changed files with 325 additions and 146 deletions

View File

@ -233,7 +233,20 @@ async function installUpdate(onProgress) {
// Stage: done // Stage: done
if (onProgress) onProgress({ stage: 'done', percent: 100 }); if (onProgress) onProgress({ stage: 'done', percent: 100 });
setTimeout(() => app.quit(), 900); const _doQuit = () => setTimeout(() => app.quit(), 900);
const _getActive = () => {
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
catch { return 0; }
};
if (_getActive() > 0) {
const POLL_MS = 3000;
const poller = setInterval(() => {
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
}, POLL_MS);
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
} else {
_doQuit();
}
} catch (err) { } catch (err) {
if (onProgress) onProgress({ stage: 'error', error: err.message }); if (onProgress) onProgress({ stage: 'error', error: err.message });

View File

@ -67,6 +67,10 @@ class UploadManager extends EventEmitter {
return this._accountOverrides.get(hoster) || null; return this._accountOverrides.get(hoster) || null;
} }
getActiveJobCount() {
return this.activeJobs.size;
}
clearFailedAccount(hoster, accountId) { clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`); return this._failedAccounts.delete(`${hoster}:${accountId}`);
} }
@ -310,18 +314,30 @@ class UploadManager extends EventEmitter {
this._batchResults = results; this._batchResults = results;
this._additionalPromises = []; // Track jobs added mid-batch via addJobs() this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
for (const task of tasks) { const DEDUP_CHUNK = 200;
const fileName = path.basename(task.file); for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
if (!results.has(task.file)) { const end = Math.min(i + DEDUP_CHUNK, tasks.length);
let size = 0; for (let j = i; j < end; j++) {
try { size = fs.statSync(task.file).size; } catch {} const task = tasks[j];
results.set(task.file, { name: fileName, size, results: [] }); if (!results.has(task.file)) {
const fileName = path.basename(task.file);
let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] });
}
} }
if (end < tasks.length) await new Promise(setImmediate);
} }
this._startStatsTimer(); this._startStatsTimer();
const promises = tasks.map((task) => this._runJob(task, results, signal)); const SPAWN_CHUNK = 100;
const promises = [];
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
if (end < tasks.length) await new Promise(setImmediate);
}
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Wait for any jobs added mid-batch via addJobs() // Wait for any jobs added mid-batch via addJobs()
while (this._additionalPromises.length > 0) { while (this._additionalPromises.length > 0) {
@ -542,14 +558,16 @@ class UploadManager extends EventEmitter {
speedAbort = new AbortController(); speedAbort = new AbortController();
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]); uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
speedMonitor = setInterval(() => { speedMonitor = setInterval(() => {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { try {
if (!lowSpeedSince) lowSpeedSince = Date.now(); if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
if (Date.now() - lowSpeedSince > 6000) { if (!lowSpeedSince) lowSpeedSince = Date.now();
speedAbort.abort(); if (Date.now() - lowSpeedSince > 6000) {
speedAbort.abort();
}
} else {
lowSpeedSince = 0;
} }
} else { } catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); }
lowSpeedSince = 0;
}
}, 2000); }, 2000);
} }
@ -563,41 +581,42 @@ class UploadManager extends EventEmitter {
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => { const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now(); try {
const elapsed = Math.round((now - jobStart) / 1000); const now = Date.now();
const timeDelta = (now - lastSpeedTime) / 1000; const elapsed = Math.round((now - jobStart) / 1000);
if (timeDelta >= 1) { const timeDelta = (now - lastSpeedTime) / 1000;
const bytesDelta = bytesUploaded - lastBytes; if (Number.isFinite(timeDelta) && timeDelta >= 1) {
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); const bytesDelta = bytesUploaded - lastBytes;
lastBytes = bytesUploaded; currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
lastSpeedTime = now; lastBytes = bytesUploaded;
} lastSpeedTime = now;
}
activeEntry.speedKbs = currentSpeedKbs; activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded; activeEntry.bytesUploaded = bytesUploaded;
// Throttle progress emissions to reduce IPC + rendering overhead if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; lastEmitTime = now;
lastEmitTime = now;
const remaining = currentSpeedKbs > 0 const remaining = currentSpeedKbs > 0
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
: 0; : 0;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId, jobId,
status: 'uploading', status: 'uploading',
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
bytesUploaded, bytesUploaded,
bytesTotal, bytesTotal,
speedKbs: currentSpeedKbs, speedKbs: currentSpeedKbs,
elapsed, elapsed,
remaining, remaining,
error: null, error: null,
result: null, result: null,
attempt, attempt,
maxAttempts maxAttempts
}); });
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
}; };
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle); const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
@ -979,7 +998,7 @@ class UploadManager extends EventEmitter {
_startStatsTimer() { _startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval); if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => { this.statsInterval = setInterval(() => {
// Single pass over active jobs instead of two. try {
let globalSpeedKbs = 0; let globalSpeedKbs = 0;
let activeCount = 0; let activeCount = 0;
let inProgressBytes = 0; let inProgressBytes = 0;
@ -999,6 +1018,7 @@ class UploadManager extends EventEmitter {
activeJobs: activeCount, activeJobs: activeCount,
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
}); });
} catch { /* never let a stats tick crash the timer + caller */ }
}, 1000); }, 1000);
} }

269
main.js
View File

@ -208,6 +208,7 @@ function getAllLogPaths() {
debug: debugPath, debug: debugPath,
accountRotation: rot, accountRotation: rot,
doodstreamDebug: path.join(dir, 'doodstream-debug.log'), doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir logDir: dir
}; };
} }
@ -216,23 +217,10 @@ function rotLog(msg, ts) {
try { try {
const iso = new Date(ts || Date.now()).toISOString(); const iso = new Date(ts || Date.now()).toISOString();
const line = `[${iso}] ${msg}\n`; const line = `[${iso}] ${msg}\n`;
// Write synchronously. Rotation events are rare (a handful per batch) so _rotLogBuffer.push(line);
// the batching optimization from debugLog doesn't buy us anything, and if (!_rotLogFlushTimer) {
// syncing guarantees the user can refresh the file and see fresh entries _rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500);
// without waiting on a flush timer.
const candidates = [
getRotLogPath(),
path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'),
path.join(app.getPath('userData'), 'account-rotation.log')
];
for (const target of candidates) {
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.appendFileSync(target, line, 'utf-8');
break;
} catch {}
} }
// Mirror into the main debug log for single-file-grep convenience.
_debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`); _debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`);
if (!_debugLogFlushTimer) { if (!_debugLogFlushTimer) {
_debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500); _debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500);
@ -240,11 +228,62 @@ function rotLog(msg, ts) {
} catch {} } catch {}
} }
// Catch unhandled rejections from fire-and-forget async calls function safeSend(channel, data) {
if (!mainWindow || mainWindow.isDestroyed()) return false;
try {
safeSend(channel, data);
return true;
} catch (err) {
debugLog(`safeSend(${channel}) failed: ${err && err.message ? err.message : err}`);
return false;
}
}
function _writeCrashLog(prefix, err, extra) {
try {
const ts = new Date().toISOString();
const line = `[${ts}] ${prefix} ${err && err.stack ? err.stack : (err && err.message) || String(err)}${extra ? ' :: ' + JSON.stringify(extra) : ''}\n`;
try {
const target = getDebugLogPath();
fs.appendFileSync(target, line, 'utf-8');
} catch {}
try {
const crashDir = path.dirname(getDebugLogPath());
fs.appendFileSync(path.join(crashDir, 'crash.log'), line, 'utf-8');
} catch {}
} catch {}
}
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`); debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`);
_writeCrashLog('UNHANDLED REJECTION', reason);
}); });
process.on('uncaughtException', (err, origin) => {
_writeCrashLog('UNCAUGHT EXCEPTION (' + origin + ')', err);
debugLog(`UNCAUGHT EXCEPTION (${origin}): ${err && err.stack ? err.stack : err}`);
});
process.on('exit', (code) => {
try { _writeCrashLog('PROCESS EXIT', new Error('code=' + code)); } catch {}
});
process.on('warning', (warning) => {
debugLog(`PROCESS WARNING: ${warning.name} ${warning.message}`);
});
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
try {
process.on(sig, () => {
_writeCrashLog('SIGNAL ' + sig, new Error('process received ' + sig));
try {
if (_debugLogBuffer.length) fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8');
} catch {}
process.exit(0);
});
} catch {}
}
function withTimeout(promise, timeoutMs, label) { function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -444,9 +483,7 @@ function _flushUploadLog() {
// next session writes here directly (no more fallback ladder) and // next session writes here directly (no more fallback ladder) and
// the Settings input reflects reality. // the Settings input reflects reality.
_persistFallbackLogPath(target.path); _persistFallbackLogPath(target.path);
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('upload-log-fallback', { fallbackPath: target.path });
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
} }
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog); if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
}); });
@ -474,9 +511,7 @@ function _persistFallbackLogPath(workingPath) {
cfg.globalSettings = gs; cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {}); configStore.save({ globalSettings: gs }).catch(() => {});
_invalidateUploadLogTargetCache(); _invalidateUploadLogTargetCache();
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('log-path-auto-updated', { logFilePath: toSave });
mainWindow.webContents.send('log-path-auto-updated', { logFilePath: toSave });
}
} catch (err) { } catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`); debugLog(`persist fallback logpath failed: ${err.message}`);
} }
@ -960,6 +995,51 @@ function createWindow() {
}); });
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
mainWindow.webContents.on('render-process-gone', (_event, details) => {
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
if (mainWindow && !mainWindow.isDestroyed()) {
try {
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'error',
title: 'Renderer abgestürzt',
message: `Der Renderer-Prozess ist abgestürzt (${details.reason}).`,
detail: 'Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.',
buttons: ['Neu laden', 'Beenden'],
defaultId: 0,
cancelId: 1
});
if (choice === 0) {
mainWindow.webContents.reload();
} else {
app.exit(1);
}
} catch {
try { mainWindow.webContents.reload(); } catch {}
}
}
});
mainWindow.webContents.on('unresponsive', () => {
_writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive'));
debugLog('RENDERER UNRESPONSIVE');
});
mainWindow.webContents.on('responsive', () => {
debugLog('RENDERER RESPONSIVE AGAIN');
});
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
_writeCrashLog('DID-FAIL-LOAD', new Error(errorDescription), { errorCode, validatedURL });
debugLog(`DID-FAIL-LOAD: ${errorCode} ${errorDescription} url=${validatedURL}`);
});
app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
} }
@ -1053,7 +1133,7 @@ app.whenReady().then(() => {
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`); logDebug(`update-check result: ${JSON.stringify(result)}`);
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) { if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-available', result); safeSend('app:update-available', result);
} }
} catch (err) { } catch (err) {
logError('update-check failed', err); logError('update-check failed', err);
@ -1062,6 +1142,9 @@ app.whenReady().then(() => {
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
const activeJobs = uploadManager && typeof uploadManager.getActiveJobCount === 'function' ? uploadManager.getActiveJobCount() : 0;
debugLog(`window-all-closed: activeJobs=${activeJobs}, uploadManager=${!!uploadManager}`);
_writeCrashLog('WINDOW-ALL-CLOSED', new Error('all windows closed'), { activeJobs, uploadManager: !!uploadManager });
app.quit(); app.quit();
}); });
@ -1133,11 +1216,9 @@ ipcMain.handle('save-config', async (_event, config) => {
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`); rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
uploadManager.switchAccount(hoster, fallback); uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('account-switched', {
mainWindow.webContents.send('account-switched', {
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
}); });
}
} }
} }
} catch (err) { } catch (err) {
@ -1381,6 +1462,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
// Pass hoster settings to the upload manager // Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
globalThis._mhuUploadManagerRef = uploadManager;
const _progressByJob = new Map(); const _progressByJob = new Map();
const _progressTerminalQueue = []; const _progressTerminalQueue = [];
@ -1398,7 +1480,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
const batch = _progressTerminalQueue.splice(0); const batch = _progressTerminalQueue.splice(0);
for (const v of _progressByJob.values()) batch.push(v); for (const v of _progressByJob.values()) batch.push(v);
_progressByJob.clear(); _progressByJob.clear();
if (batch.length) mainWindow.webContents.send('upload-progress-batch', batch); if (batch.length) safeSend('upload-progress-batch', batch);
}, PROGRESS_BATCH_INTERVAL_MS); }, PROGRESS_BATCH_INTERVAL_MS);
} }
@ -1436,16 +1518,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
}); });
uploadManager.on('stats', (data) => { uploadManager.on('stats', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) { try {
mainWindow.webContents.send('upload-stats', data); if (!data || typeof data !== 'object') return;
} safeSend('upload-stats', data);
// Update tray tooltip with upload progress if (data.state === 'uploading' && data.activeJobs > 0) {
if (data.state === 'uploading' && data.activeJobs > 0) { const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1);
const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1); updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`); } else {
} else { updateTrayTooltip('Multi-Hoster-Upload');
updateTrayTooltip('Multi-Hoster-Upload'); }
} } catch (e) { debugLog(`stats listener error: ${e && e.message}`); }
}); });
uploadManager.on('account-failed', ({ hoster, accountId }) => { uploadManager.on('account-failed', ({ hoster, accountId }) => {
@ -1458,9 +1540,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`); rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
uploadManager.switchAccount(hoster, fallback); uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
}
} else { } else {
rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`); rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`);
} }
@ -1476,17 +1556,25 @@ ipcMain.handle('start-upload', (_event, payload) => {
'doodstream-via-web' 'doodstream-via-web'
]); ]);
uploadManager.on('rot-log', (entry) => { uploadManager.on('rot-log', (entry) => {
const { ts, event, ...rest } = entry; try {
const pairs = Object.entries(rest) if (!entry || typeof entry !== 'object') return;
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) const { ts, event, ...rest } = entry;
.join(' '); const pairs = Object.entries(rest)
rotLog(`[${event}] ${pairs}`, ts); .map(([k, v]) => {
if (entry.jobId) { let sv;
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest }); try { sv = typeof v === 'string' ? v : JSON.stringify(v); }
} catch { sv = '<unserializable>'; }
if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) { return `${k}=${sv}`;
mainWindow.webContents.send('account-rotation-log', entry); })
} .join(' ');
rotLog(`[${event}] ${pairs}`, ts);
if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
}
if (ROT_LOG_RENDERER_EVENTS.has(event)) {
safeSend('account-rotation-log', entry);
}
} catch (e) { debugLog(`rot-log listener error: ${e && e.message}`); }
}); });
// Capture the manager identity at listener-registration time so the post- // Capture the manager identity at listener-registration time so the post-
@ -1503,13 +1591,11 @@ ipcMain.handle('start-upload', (_event, payload) => {
try { await configStore.appendHistory(summary); } catch (err) { try { await configStore.appendHistory(summary); } catch (err) {
debugLog(`appendHistory failed: ${err.message}`); debugLog(`appendHistory failed: ${err.message}`);
} }
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('upload-batch-done', summary);
mainWindow.webContents.send('upload-batch-done', summary);
}
// Shutdown after finish // Shutdown after finish
handleShutdownAfterFinish(); handleShutdownAfterFinish();
if (uploadManager === _thisManager) uploadManager = null; if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await'); else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
}); });
@ -1525,8 +1611,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
}).catch((err) => { }).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
// Forward error to renderer as batch-done with failure // Forward error to renderer as batch-done with failure
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('upload-batch-done', {
mainWindow.webContents.send('upload-batch-done', {
id: 'error', id: 'error',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
total: tasks.length, total: tasks.length,
@ -1535,7 +1620,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
files: [], files: [],
error: err ? err.message : 'Unbekannter Fehler' error: err ? err.message : 'Unbekannter Fehler'
}); });
}
}); });
}); });
@ -1722,6 +1806,7 @@ ipcMain.handle('create-support-bundle', async () => {
{ label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 }, { label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 },
{ label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 }, { label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 },
{ label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 }, { label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 },
{ label: 'crash.log', path: path.join(paths.logDir || path.dirname(paths.debug), 'crash.log'), maxBytes: 1 * 1024 * 1024 },
{ label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 } { label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 }
] ]
}); });
@ -1763,12 +1848,19 @@ ipcMain.handle('export-backup', async () => {
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: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] filters: [
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
]
}); });
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const config = configStore.load(); const config = configStore.load();
const encrypted = backupCrypto.encrypt(config); if (filePath.toLowerCase().endsWith('.json')) {
fs.writeFileSync(filePath, encrypted); fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
} else {
const encrypted = backupCrypto.encrypt(config);
fs.writeFileSync(filePath, encrypted);
}
return { ok: true, path: filePath }; return { ok: true, path: filePath };
}); });
@ -1780,7 +1872,11 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
} else { } else {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Backup importieren', title: 'Backup importieren',
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }], filters: [
{ name: 'Multi-Hoster Backup', extensions: ['mhu', 'json'] },
{ name: 'Verschlüsselt (.mhu)', extensions: ['mhu'] },
{ name: 'Klartext (.json)', extensions: ['json'] }
],
properties: ['openFile'] properties: ['openFile']
}); });
if (canceled || !filePaths.length) return { ok: false, canceled: true }; if (canceled || !filePaths.length) return { ok: false, canceled: true };
@ -1789,14 +1885,25 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
_lastImportPath = sourcePath; _lastImportPath = sourcePath;
} }
let imported; let imported;
try { const looksLikeJson = buffer.length >= 1 && (buffer[0] === 0x7B || buffer[0] === 0x20 || buffer[0] === 0x0A || buffer[0] === 0x0D || buffer[0] === 0x09 || buffer[0] === 0xEF);
imported = backupCrypto.decrypt(buffer, legacyPassword); if (looksLikeJson) {
} catch (err) { try {
if (err && err.needsPassword) { const text = buffer.toString('utf-8').replace(/^\uFEFF/, '');
return { ok: false, needsPassword: true }; imported = JSON.parse(text);
} catch (err) {
_lastImportPath = null;
return { ok: false, error: 'Klartext-Backup ist kein gültiges JSON: ' + (err.message || err) };
}
} else {
try {
imported = backupCrypto.decrypt(buffer, legacyPassword);
} catch (err) {
if (err && err.needsPassword) {
return { ok: false, needsPassword: true, hint: 'Falls dieses Backup mit der aktuellen Version erzeugt wurde, ist die Datei vermutlich beim Transfer beschädigt worden (z. B. FTP-Text-Modus). Versuch es mit einem Klartext-JSON-Export.' };
}
_lastImportPath = null;
throw err;
} }
_lastImportPath = null;
throw err;
} }
_lastImportPath = null; _lastImportPath = null;
// Validate imported data has required structure // Validate imported data has required structure
@ -1914,13 +2021,9 @@ ipcMain.handle('app:check-updates', async () => {
ipcMain.handle('app:install-update', () => { ipcMain.handle('app:install-update', () => {
installUpdate((progress) => { installUpdate((progress) => {
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('app:update-progress', progress);
mainWindow.webContents.send('app:update-progress', progress);
}
}).catch((err) => { }).catch((err) => {
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('app:update-progress', { stage: 'error', error: err.message });
mainWindow.webContents.send('app:update-progress', { stage: 'error', error: err.message });
}
}); });
return { started: true }; return { started: true };
}); });
@ -1999,9 +2102,7 @@ function startFolderMonitor(settings) {
folderMonitor.removeAllListeners(); folderMonitor.removeAllListeners();
folderMonitor.on('new-files', (files) => { folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`); debugLog(`folder-monitor: ${files.length} new file(s)`);
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('folder-monitor:new-files', files);
mainWindow.webContents.send('folder-monitor:new-files', files);
}
}); });
folderMonitor.on('error', (err) => { folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`); debugLog(`folder-monitor error: ${err.message}`);
@ -2245,9 +2346,7 @@ ipcMain.handle('remote:get-capture-source-id', async () => {
// IPC: Client count updates from capture window // IPC: Client count updates from capture window
ipcMain.on('remote:client-count', (_event, count) => { ipcMain.on('remote:client-count', (_event, count) => {
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('remote:client-count', count);
mainWindow.webContents.send('remote:client-count', count);
}
}); });
// IPC: Remote settings // IPC: Remote settings
@ -2352,7 +2451,7 @@ ipcMain.on('drop-target:files', (_event, paths) => {
mainWindow.show(); mainWindow.show();
mainWindow.focus(); mainWindow.focus();
} }
mainWindow.webContents.send('drop-target:files', paths); safeSend('drop-target:files', paths);
} }
}); });
@ -2389,9 +2488,7 @@ function handleShutdownAfterFinish() {
const { exec } = require('child_process'); const { exec } = require('child_process');
// Notify renderer // Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) { safeSend('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
mainWindow.webContents.send('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
}
// Clear any previous countdown to prevent orphaned timers // Clear any previous countdown to prevent orphaned timers
if (shutdownTimer) clearTimeout(shutdownTimer); if (shutdownTimer) clearTimeout(shutdownTimer);

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.47", "version": "3.3.53",
"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

@ -63,10 +63,12 @@ const queueSortState = { key: 'filename', direction: 'asc' };
// History state // History state
let historyRowsData = []; let historyRowsData = [];
let historySortState = { key: 'date', direction: 'desc' }; let historySortState = { key: 'date', direction: 'desc' };
let _historySortClicked = false;
// Session-specific files for the "Files" panel (resets each session) // Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = []; let sessionFilesData = [];
const recentSortState = { key: 'date', direction: 'desc' }; const recentSortState = { key: 'date', direction: 'desc' };
let _recentSortClicked = false;
const selectedRecentIds = new Set(); const selectedRecentIds = new Set();
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. // Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
let _sessionDoneCount = 0; let _sessionDoneCount = 0;
@ -75,6 +77,19 @@ let _sessionErrorCount = 0;
// Huge with thousands of rows × thousands of incoming results. // Huge with thousands of rows × thousands of incoming results.
const _sessionFileKeys = new Set(); const _sessionFileKeys = new Set();
window.addEventListener('error', (e) => {
try {
const msg = `RENDERER ERROR: ${e.message} at ${e.filename}:${e.lineno}:${e.colno}${e.error && e.error.stack ? '\n' + e.error.stack : ''}`;
if (window.api && window.api.debugLog) window.api.debugLog(msg);
} catch {}
});
window.addEventListener('unhandledrejection', (e) => {
try {
const reason = e.reason && e.reason.stack ? e.reason.stack : (e.reason && e.reason.message) || String(e.reason);
if (window.api && window.api.debugLog) window.api.debugLog(`RENDERER UNHANDLED REJECTION: ${reason}`);
} catch {}
});
// --- Init --- // --- Init ---
async function init() { async function init() {
config = await window.api.getConfig(); config = await window.api.getConfig();
@ -1443,7 +1458,7 @@ async function doBackupExport() {
} }
} }
function askLegacyBackupPassword() { function askLegacyBackupPassword(hint) {
return new Promise((resolve) => { return new Promise((resolve) => {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
@ -1456,7 +1471,7 @@ function askLegacyBackupPassword() {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'modal-header'; header.className = 'modal-header';
const h3 = document.createElement('h3'); const h3 = document.createElement('h3');
h3.textContent = 'Passwort erforderlich'; h3.textContent = 'Backup nicht entschlüsselbar';
header.appendChild(h3); header.appendChild(h3);
const body = document.createElement('div'); const body = document.createElement('div');
@ -1464,7 +1479,15 @@ function askLegacyBackupPassword() {
const p = document.createElement('p'); const p = document.createElement('p');
p.style.margin = '0 0 10px'; p.style.margin = '0 0 10px';
p.style.fontSize = '13px'; p.style.fontSize = '13px';
p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.'; p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
if (hint) {
const p2 = document.createElement('p');
p2.style.margin = '0 0 10px';
p2.style.fontSize = '12px';
p2.style.color = 'var(--text-dim)';
p2.textContent = hint;
body.appendChild(p2);
}
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'password'; input.type = 'password';
input.className = 'key-input'; input.className = 'key-input';
@ -1508,7 +1531,7 @@ async function doBackupImport(legacyPassword) {
const result = await window.api.importBackup(pw); const result = await window.api.importBackup(pw);
if (!result || result.canceled) return; if (!result || result.canceled) return;
if (result.needsPassword) { if (result.needsPassword) {
const entered = await askLegacyBackupPassword(); const entered = await askLegacyBackupPassword(result.hint);
if (entered) doBackupImport(entered); if (entered) doBackupImport(entered);
return; return;
} }
@ -1873,6 +1896,14 @@ async function cancelUpload() {
// --- Progress handling --- // --- Progress handling ---
function handleProgress(data) { function handleProgress(data) {
try {
if (!data || typeof data !== 'object') return;
_handleProgressImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleProgress error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleProgressImpl(data) {
let job = data.jobId ? _jobIndexById.get(data.jobId) : null; let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
if (!job) { if (!job) {
@ -2134,6 +2165,14 @@ function _retryFailedFromBuckets(buckets, transientOnly) {
} }
function handleStats(data) { function handleStats(data) {
try {
if (!data || typeof data !== 'object') return;
_handleStatsImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleStats error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleStatsImpl(data) {
lastUploadStats = { lastUploadStats = {
state: data.state || 'idle', state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0, globalSpeedKbs: data.globalSpeedKbs || 0,
@ -2522,8 +2561,10 @@ async function executeHealthCheck(hosters, _mode) {
} }
async function runHealthCheck(mode = 'manual', requestedHosters = null) { async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning || (uploading && mode === 'manual')) return []; if (healthCheckRunning) {
// Build check list: all enabled accounts with creds if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
return [];
}
let hosters; let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters; hosters = requestedHosters;
@ -4140,8 +4181,14 @@ function renderHistoryTable(container) {
const th = e.target.closest('th.sortable'); const th = e.target.closest('th.sortable');
if (th && container.contains(th)) { if (th && container.contains(th)) {
const key = th.dataset.historySort; const key = th.dataset.historySort;
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; const defaultDir = key === 'date' ? 'desc' : 'asc';
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; } if (!_historySortClicked || historySortState.key !== key) {
_historySortClicked = true;
historySortState.key = key;
historySortState.direction = defaultDir;
} else {
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
}
renderHistoryTable(container); renderHistoryTable(container);
return; return;
} }
@ -4197,11 +4244,13 @@ function setupListeners() {
const th = e.target.closest('th[data-recent-sort]'); const th = e.target.closest('th[data-recent-sort]');
if (!th) return; if (!th) return;
const key = th.dataset.recentSort; const key = th.dataset.recentSort;
if (recentSortState.key === key) { const defaultDir = key === 'date' ? 'desc' : 'asc';
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; if (!_recentSortClicked || recentSortState.key !== key) {
} else { _recentSortClicked = true;
recentSortState.key = key; recentSortState.key = key;
recentSortState.direction = key === 'date' ? 'desc' : 'asc'; recentSortState.direction = defaultDir;
} else {
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc';
} }
renderRecentUploadsPanel(); renderRecentUploadsPanel();
}); });