const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu } = require('electron'); nativeTheme.themeSource = 'dark'; const path = require('path'); const fs = require('fs'); const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); const DoodstreamUploader = require('./lib/doodstream-upload'); const { selectUploadAuth } = require('./lib/account-auth'); const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const FolderMonitor = require('./lib/folder-monitor'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); let mainWindow; let _lastImportPath = null; let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); let uploadManager = null; // Rotation memory that survives batch-done → new UploadManager within the // same app session. Without this, clicking "Retry failed" after a batch // ended would burn the full retry budget on accounts we already know are // dead. Cleared on app restart (which is the user's signal for "try fresh"). const _sessionFailedAccounts = new Map(); // "hoster:accountId" -> true const _sessionAccountOverrides = new Map(); // hoster -> account object // Per-job log collector: backs the right-click "Log anzeigen" modal so the // user can see the full rot-log + status trail for a single file without // grepping account-rotation.log. Ring buffer per job keeps memory bounded. const _jobLogCollector = new Map(); // jobId -> Array const _MAX_LOG_ENTRIES_PER_JOB = 200; // Cap the total number of jobs we keep history for — without this the Map // keeps growing across batch-done boundaries (only start-upload clears it). // 1000 jobs × 200 entries × ~100 bytes ≈ 20 MB worst case, bounded. const _MAX_TRACKED_JOBS = 1000; function _appendJobLog(jobId, entry) { if (!jobId) return; let arr = _jobLogCollector.get(jobId); if (!arr) { arr = []; _jobLogCollector.set(jobId, arr); // Evict oldest tracked job (insertion order) once we're past the cap. // Map iteration is insertion-ordered in spec, so .keys().next() is FIFO. if (_jobLogCollector.size > _MAX_TRACKED_JOBS) { const oldestId = _jobLogCollector.keys().next().value; if (oldestId !== undefined) _jobLogCollector.delete(oldestId); } } if (arr.length >= _MAX_LOG_ENTRIES_PER_JOB) arr.shift(); arr.push(entry); } const folderMonitor = new FolderMonitor(); let remoteServer = null; let captureWindow = null; let captureWindowReady = false; let signalingQueue = []; const HEALTH_CHECK_TIMEOUT = 25000; // --- Debug logging (writes to upload-debug.log next to the app) --- function getDebugLogPath() { const baseDir = app.isPackaged ? path.dirname(process.execPath) : __dirname; return path.join(baseDir, 'upload-debug.log'); } // Buffered async writer: debugLog is called hundreds of times per second during // busy uploads (unhandledRejection traces, progress transitions, folder-monitor // events). Sync appendFileSync per call blocked the main event loop. We now // queue lines in memory and flush on a short interval / on process exit. const _debugLogBuffer = []; let _debugLogFlushTimer = null; let _debugLogWriting = false; // 25 MB cap for upload-debug.log + 10 MB for account-rotation.log. Each // keeps 2 numbered backups, so the on-disk worst case is bounded: // upload-debug ~75 MB, account-rotation ~30 MB. Reuses the same // lib/log-rotation.js helper that fileuploader.log already uses. const DEBUG_LOG_MAX_BYTES = 25 * 1024 * 1024; const ROT_LOG_MAX_BYTES = 10 * 1024 * 1024; const INTERNAL_LOG_MAX_BACKUPS = 2; function _flushDebugLog() { if (_debugLogWriting || _debugLogBuffer.length === 0) return; const chunk = _debugLogBuffer.join(''); _debugLogBuffer.length = 0; _debugLogWriting = true; const target = getDebugLogPath(); // Pass a noop logger here — debugLog() is THIS file's writer, recursing // into it would deadlock the buffer/timer state. maybeRotateLogFile(target, DEBUG_LOG_MAX_BYTES, INTERNAL_LOG_MAX_BACKUPS, () => {}); fs.appendFile(target, chunk, 'utf-8', () => { _debugLogWriting = false; // If more lines arrived during the write, flush them next tick. if (_debugLogBuffer.length) setImmediate(_flushDebugLog); }); } function debugLog(msg) { try { const ts = new Date().toISOString(); _debugLogBuffer.push(`[${ts}] ${msg}\n`); if (!_debugLogFlushTimer) { _debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500); } } catch {} } // Dedicated account-rotation log so users can trace fallback decisions // without wading through general debug output. Writes to account-rotation.log // in the same directory as fileuploader.log (honors user's configured path). function getRotLogPath() { const base = getLogFilePath(); const dir = path.dirname(base); return path.join(dir, 'account-rotation.log'); } const _rotLogBuffer = []; let _rotLogFlushTimer = null; let _rotLogWriting = false; function _flushRotLog() { if (_rotLogWriting || _rotLogBuffer.length === 0) return; const chunk = _rotLogBuffer.join(''); _rotLogBuffer.length = 0; _rotLogWriting = true; const tryTargets = [ getRotLogPath(), path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'), path.join(app.getPath('userData'), 'account-rotation.log') ]; const write = (i) => { if (i >= tryTargets.length) { _rotLogWriting = false; return; } try { fs.mkdirSync(path.dirname(tryTargets[i]), { recursive: true }); } catch {} // Cap account-rotation.log so a long-running install can't keep // growing it indefinitely (rotation events fire on every account-fail). maybeRotateLogFile(tryTargets[i], ROT_LOG_MAX_BYTES, INTERNAL_LOG_MAX_BACKUPS, debugLog); fs.appendFile(tryTargets[i], chunk, 'utf-8', (err) => { if (err) return write(i + 1); _rotLogWriting = false; if (_rotLogBuffer.length) setImmediate(_flushRotLog); }); }; write(0); } function rotLog(msg, ts) { try { const iso = new Date(ts || Date.now()).toISOString(); const line = `[${iso}] ${msg}\n`; // Write synchronously. Rotation events are rare (a handful per batch) so // the batching optimization from debugLog doesn't buy us anything, and // syncing guarantees the user can refresh the file and see fresh entries // 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`); if (!_debugLogFlushTimer) { _debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500); } } catch {} } // Catch unhandled rejections from fire-and-forget async calls process.on('unhandledRejection', (reason) => { debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`); }); function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`${label} Timeout`)); }, timeoutMs); promise .then((result) => { clearTimeout(timer); resolve(result); }) .catch((err) => { clearTimeout(timer); reject(err); }); }); } function normalizeApiError(payload, fallback) { if (!payload || typeof payload !== 'object') return fallback; const msg = String(payload.msg || payload.message || '').trim(); if (msg) return msg; if (payload.status) return `API Status ${payload.status}`; return fallback; } function getDefaultLogFilePath() { // In packaged builds the exe dir is %LOCALAPPDATA%\Programs\Multi-Hoster-Upload // — a hidden, install-managed location that NSIS may even prune on // uninstall. Default to the user's Desktop so the file is actually // findable; fall back to userData if Desktop isn't available, and // finally to the project dir in dev mode. if (app.isPackaged) { try { const desktop = app.getPath('desktop'); if (desktop) return path.join(desktop, 'fileuploader.log'); } catch {} try { return path.join(app.getPath('userData'), 'fileuploader.log'); } catch {} return path.join(path.dirname(process.execPath), 'fileuploader.log'); } return path.join(__dirname, 'fileuploader.log'); } function getBaseLogFilePath() { const config = configStore.load(); const customPath = config && config.globalSettings ? String(config.globalSettings.logFilePath || '').trim() : ''; return customPath || getDefaultLogFilePath(); } // Daily log: one file per day, reused across sessions on the same day let _dailyLogPath = null; let _dailyLogDate = null; function getLogFilePath() { const config = configStore.load(); const useDailyLog = config && config.globalSettings && config.globalSettings.sessionLog; if (!useDailyLog) return getBaseLogFilePath(); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; // Reuse path if same day, otherwise generate new if (_dailyLogDate !== today) { const base = getBaseLogFilePath(); const dir = path.dirname(base); const ext = path.extname(base); const name = path.basename(base, ext); _dailyLogPath = path.join(dir, `${name}-${today}${ext}`); _dailyLogDate = today; } return _dailyLogPath; } function buildFallbackLogName(dir) { // Match the daily-log naming when enabled, so fallback files stay consistent. const config = configStore.load(); const useDailyLog = config && config.globalSettings && config.globalSettings.sessionLog; if (!useDailyLog) return path.join(dir, 'fileuploader.log'); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return path.join(dir, `fileuploader-${today}.log`); } function getSafeDesktopDir() { try { const desktop = app.getPath('desktop'); if (desktop && fs.existsSync(desktop)) return desktop; } catch {} return null; } let _uploadLogFallbackWarned = false; // Buffer upload-log lines so a burst of completing jobs (e.g. 20 files finishing // within a second) becomes one file write instead of 20 sync writes. const _uploadLogBuffer = []; let _uploadLogFlushTimer = null; let _uploadLogWriting = false; // Cache the resolved upload-log target across flushes — mkdirSync + path // assembly on every 500ms flush during uploads is wasted work once we've // confirmed a writable directory. Invalidated when the user changes the log // path or when the daily-log date rolls over. let _cachedUploadLogTarget = null; let _cachedUploadLogKey = ''; function _invalidateUploadLogTargetCache() { _cachedUploadLogTarget = null; _cachedUploadLogKey = ''; } function _resolveUploadLogTarget() { const primary = getLogFilePath(); const key = `${primary}|${_dailyLogDate || ''}`; if (_cachedUploadLogKey === key && _cachedUploadLogTarget) return _cachedUploadLogTarget; const commit = (t) => { _cachedUploadLogTarget = t; _cachedUploadLogKey = key; return t; }; // Try primary → desktop → userData, mirror the original fallback ladder. try { fs.mkdirSync(path.dirname(primary), { recursive: true }); return commit({ path: primary, isFallback: false }); } catch (err) { debugLog(`uploadLog primary dir unavailable (${err.message})`); } const desktop = getSafeDesktopDir(); if (desktop) { try { const p = buildFallbackLogName(desktop); fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } catch {} } try { const p = buildFallbackLogName(app.getPath('userData')); fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } catch (err) { debugLog(`uploadLog: no writable target (${err.message})`); return null; } } // Cap the upload log file size. Beyond this we rotate to .1 (and shift // older numbered backups up) so a multi-month-running install can't fill // the disk. 50 MB ≈ ~600k log lines, plenty for human inspection. const UPLOAD_LOG_MAX_BYTES = 50 * 1024 * 1024; const UPLOAD_LOG_MAX_BACKUPS = 3; function _flushUploadLog() { if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; const target = _resolveUploadLogTarget(); if (!target) { _uploadLogBuffer.length = 0; return; } // Guard against the file's parent directory having been deleted/moved // since the cache was filled. mkdirSync(recursive:true) is a no-op when // the dir already exists; recreates it otherwise. Without this, every // subsequent flush silently fails with ENOENT and entries are lost. try { fs.mkdirSync(path.dirname(target.path), { recursive: true }); } catch {} // Cheap size check + rotation right before the append, so we never grow // a single log file beyond the cap regardless of session length. maybeRotateLogFile(target.path, UPLOAD_LOG_MAX_BYTES, UPLOAD_LOG_MAX_BACKUPS, debugLog); const chunk = _uploadLogBuffer.join(''); _uploadLogBuffer.length = 0; _uploadLogWriting = true; fs.appendFile(target.path, chunk, 'utf-8', (err) => { _uploadLogWriting = false; if (err) { debugLog(`uploadLog append failed: ${err.message}`); // Recovery: drop the cached target so the next flush re-resolves // (could be ENOENT after dir delete, ENOSPC, EBUSY etc.) and // restore the chunk so we don't lose entries on the retry. _invalidateUploadLogTargetCache(); _uploadLogBuffer.unshift(chunk); // Retry on the next event-loop tick rather than tight-looping. if (!_uploadLogFlushTimer) { _uploadLogFlushTimer = setTimeout(() => { _uploadLogFlushTimer = null; _flushUploadLog(); }, 1000); } } else if (target.isFallback && !_uploadLogFallbackWarned) { _uploadLogFallbackWarned = true; // Auto-persist the working fallback into the user's config so the // next session writes here directly (no more fallback ladder) and // the Settings input reflects reality. _persistFallbackLogPath(target.path); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path }); } } if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog); }); } function _persistFallbackLogPath(workingPath) { try { const cfg = configStore.load(); const gs = cfg.globalSettings || {}; // If daily-log is on, workingPath has a date suffix (fileuploader-YYYY-MM-DD.log). // Strip that before saving so the base path rolls forward to tomorrow's // file correctly — otherwise the next day's getLogFilePath would append // another date onto the already-dated base. let toSave = workingPath; if (gs.sessionLog) { const dir = path.dirname(workingPath); const base = path.basename(workingPath); const stripped = base.replace(/-\d{4}-\d{2}-\d{2}(\.[^.]+)$/, '$1'); toSave = path.join(dir, stripped); } if (gs.logFilePath === toSave) return; gs.logFilePath = toSave; cfg.globalSettings = gs; configStore.save({ globalSettings: gs }).catch(() => {}); _invalidateUploadLogTargetCache(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('log-path-auto-updated', { logFilePath: toSave }); } } catch (err) { debugLog(`persist fallback logpath failed: ${err.message}`); } } // Whether this hoster's successful links should land in fileuploader.log. // Reads the LIVE uploadManager.hosterSettings (kept current via // updateSettings) so a mid-batch toggle takes effect immediately. Falls back // to the persisted config if no batch is active, then defaults to enabled. function shouldLogHosterToFile(hoster) { const live = uploadManager && uploadManager.hosterSettings ? uploadManager.hosterSettings : null; if (live) return hosterLogToFileEnabled(live, hoster); try { return hosterLogToFileEnabled(configStore.load().hosterSettings, hoster); } catch { return true; } } function appendUploadLog(hoster, link, fileName) { const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; _uploadLogBuffer.push(`${dateStr}|${hoster}|${link}||${fileName}|\n`); if (!_uploadLogFlushTimer) { _uploadLogFlushTimer = setTimeout(() => { _uploadLogFlushTimer = null; _flushUploadLog(); }, 500); } } function flattenHistoryForExport(history) { const rows = []; const list = Array.isArray(history) ? history : []; for (const batch of list) { const batchId = batch && batch.id ? String(batch.id) : ''; const rawTs = batch && batch.timestamp ? String(batch.timestamp) : ''; const parsedTs = rawTs ? new Date(rawTs) : null; const batchTimestamp = parsedTs && !Number.isNaN(parsedTs.getTime()) ? parsedTs.toISOString() : rawTs; const files = Array.isArray(batch && batch.files) ? batch.files : []; for (const file of files) { const fileName = file && file.name ? String(file.name) : ''; const filePath = file && file.path ? String(file.path) : ''; const fileSize = Number.isFinite(Number(file && file.size)) ? Number(file.size) : ''; const results = Array.isArray(file && file.results) ? file.results : []; if (results.length === 0) { rows.push({ batchId, batchTimestamp, fileName, filePath, fileSize, hoster: '', status: '', link: '', error: '' }); continue; } for (const result of results) { // Only accept real URLs. file_code alone is just an opaque ID and // ends up looking like "nur sone Nummerierung" in the CSV. const rawLink = result && (result.download_url || result.embed_url) || ''; const link = /^https?:\/\//i.test(String(rawLink)) ? String(rawLink) : ''; rows.push({ batchId, batchTimestamp, fileName, filePath, fileSize, hoster: result && result.hoster ? String(result.hoster) : '', status: result && result.status ? String(result.status) : '', link, error: result && (result.error || result.message) ? String(result.error || result.message) : '' }); } } } return rows; } function toCsvCell(value) { const text = value === null || value === undefined ? '' : String(value); if (!/[",\r\n]/.test(text)) return text; return `"${text.replace(/"/g, '""')}"`; } function buildHistoryCsv(rows) { const header = [ 'Batch ID', 'Batch Timestamp', 'File Name', 'File Path', 'File Size Bytes', 'Hoster', 'Status', 'Link', 'Error' ]; const lines = [header.map(toCsvCell).join(',')]; for (const row of rows) { lines.push([ row.batchId, row.batchTimestamp, row.fileName, row.filePath, row.fileSize, row.hoster, row.status, row.link, row.error ].map(toCsvCell).join(',')); } return `${lines.join('\n')}\n`; } // --- Multi-account helpers --- function hosterAccountHasCreds(name, account) { if (!account) return false; if (account.authType === 'api') return !!account.apiKey; if (account.authType === 'login') return !!(account.username && account.password); // Fallback for old format if (name === 'vidmoly.me') return !!(account.username && account.password); if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; if (name === 'clouddrop.cc') return !!account.apiKey; return !!account.apiKey; } function getPrimaryAccount(config, hosterName) { const accounts = config.hosters[hosterName]; if (!Array.isArray(accounts)) return null; return accounts.find(a => a.enabled !== false && hosterAccountHasCreds(hosterName, a)) || null; } function getNextFallbackAccount(config, hosterName, failedAccountId) { const accounts = config.hosters[hosterName]; if (!Array.isArray(accounts)) return null; const failedIndex = accounts.findIndex(a => a.id === failedAccountId); if (failedIndex < 0) return null; for (let i = failedIndex + 1; i < accounts.length; i++) { if (accounts[i].enabled !== false && hosterAccountHasCreds(hosterName, accounts[i])) { return accounts[i]; } } return null; } function buildTaskFromAccount(hoster, account, extra) { const task = { ...extra, hoster, accountId: account.id, ...selectUploadAuth(hoster, account) }; return task; } function buildUploadTasks(config, files, hosters) { const tasks = []; for (const file of files) { for (const hoster of hosters) { const account = getPrimaryAccount(config, hoster); if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; } tasks.push(buildTaskFromAccount(hoster, account, { file })); } } return tasks; } function buildUploadTasksFromJobs(config, jobs) { if (!Array.isArray(jobs)) return []; return jobs.flatMap((job) => { if (!job || !job.file || !job.hoster) return []; const account = getPrimaryAccount(config, job.hoster); if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); return []; } return [buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })]; }); } async function checkDoodstreamHealth(hosterConfig, otp) { const username = hosterConfig && hosterConfig.username ? String(hosterConfig.username).trim() : ''; const password = hosterConfig && hosterConfig.password ? String(hosterConfig.password).trim() : ''; // Login-based check (preferred) if (username && password) { const uploader = new DoodstreamUploader(); try { await uploader.login(username, password, otp || undefined); } catch (err) { if (err.otpRequired) { return { status: 'otp_required', message: err.message || 'OTP erforderlich' }; } throw err; } return { status: 'ok', message: 'Login ok, Upload-Seite bereit' }; } // Fall back to API key check const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() : ''; if (!apiKey) { return { status: 'error', message: 'Login oder API Key fehlt' }; } const apiBase = HOSTER_CONFIGS['doodstream.com'].apiBase; const accountRes = await fetch(`${apiBase}/api/account/info?key=${encodeURIComponent(apiKey)}`, { method: 'GET', redirect: 'follow' }); const accountPayload = await accountRes.json().catch(() => null); if (!accountPayload || typeof accountPayload !== 'object') { return { status: 'error', message: 'Account-Check lieferte kein gültiges JSON' }; } if (Number(accountPayload.status || 0) !== 200) { return { status: 'error', message: normalizeApiError(accountPayload, 'Account-Check fehlgeschlagen') }; } const serverRes = await fetch(`${apiBase}/api/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET', redirect: 'follow' }); const serverPayload = await serverRes.json().catch(() => null); if (!serverPayload || typeof serverPayload !== 'object') { return { status: 'warn', message: 'Upload-Server-Check lieferte kein gültiges JSON' }; } const serverResult = serverPayload.result; if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) { return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim(); if (/no servers available/i.test(serverMsg)) { return { status: 'warn', message: 'API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)' }; } return { status: 'warn', message: serverMsg || 'API Key gültig, Upload-Server aktuell nicht geliefert' }; } async function checkVidmolyHealth(hosterConfig) { const username = hosterConfig && hosterConfig.username ? String(hosterConfig.username).trim() : ''; const password = hosterConfig && hosterConfig.password ? String(hosterConfig.password).trim() : ''; if (!username || !password) { return { status: 'error', message: 'Username oder Passwort fehlt' }; } const uploader = new VidmolyUploader(); await uploader.login(username, password); const { uploadUrl, fileFieldName } = await uploader.getUploadParams(); if (!uploadUrl || !/^https?:\/\//i.test(uploadUrl)) { return { status: 'error', message: 'Upload-URL wurde nicht erkannt' }; } return { status: 'ok', message: `Login ok, Upload-Form bereit (Dateifeld: ${fileFieldName || 'file'})` }; } async function checkVoeHealth(hosterConfig) { const username = hosterConfig && hosterConfig.username ? String(hosterConfig.username).trim() : ''; const password = hosterConfig && hosterConfig.password ? String(hosterConfig.password).trim() : ''; if (!username || !password) { // Fall back to API key check if no login const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() : ''; if (!apiKey) { return { status: 'error', message: 'Login oder API Key fehlt' }; } // Quick API check const res = await fetch(`https://voe.sx/api/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET' }); const data = await res.json().catch(() => null); if (data && data.result && typeof data.result === 'string' && /^https?:\/\//i.test(data.result.trim())) { return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const msg = data && (data.msg || data.message) ? String(data.msg || data.message).trim() : ''; if (/no servers/i.test(msg)) { return { status: 'warn', message: 'API Key gültig, aktuell kein Server verfügbar' }; } return { status: 'error', message: msg || 'API Key ungültig oder Server nicht erreichbar' }; } const uploader = new VoeUploader(); await uploader.login(username, password); const { csrfToken } = await uploader._getUploadParams(); if (!csrfToken) { return { status: 'error', message: 'Login ok, aber Upload-Seite liefert kein CSRF-Token' }; } return { status: 'ok', message: 'Login ok, Upload-Seite bereit' }; } async function checkByseHealth(hosterConfig) { const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() : ''; if (!apiKey) { return { status: 'error', message: 'API Key fehlt' }; } const apiBase = 'https://api.byse.sx'; const serverRes = await fetch(`${apiBase}/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET', redirect: 'follow' }); const serverPayload = await serverRes.json().catch(() => null); if (!serverPayload || typeof serverPayload !== 'object') { return { status: 'error', message: 'API lieferte kein gültiges JSON' }; } const serverResult = serverPayload.result; if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) { return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' }; } const msg = String(serverPayload.msg || serverPayload.message || '').trim(); // Byse API returns { msg: "OK", result: } on success. // If msg is "OK" but result wasn't a valid URL, treat as success with warning. if (/^ok$/i.test(msg)) { return { status: 'ok', message: 'API Key gültig' }; } if (msg) { return { status: 'error', message: msg }; } return { status: 'error', message: 'API Key ungültig oder Server nicht erreichbar' }; } async function checkClouddropHealth(hosterConfig) { const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() : ''; if (!apiKey) return { status: 'error', message: 'API Key fehlt' }; try { const uploader = new ClouddropUploader(apiKey); await uploader.checkAuth(); return { status: 'ok', message: 'API Key gültig' }; } catch (err) { return { status: 'error', message: err && err.message ? err.message : 'Clouddrop Auth fehlgeschlagen' }; } } // requestedChecks can be: // - array of strings (hoster names) for legacy/all-accounts check // - array of { hoster, accountId } for specific account checks async function runHosterHealthCheck(config, requestedChecks) { const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx', 'clouddrop.cc']; // Normalize input to [{ hoster, accountId? }] let checks; if (!Array.isArray(requestedChecks) || requestedChecks.length === 0) { // Check all accounts for all hosters checks = []; for (const name of allowed) { const accounts = config.hosters[name]; if (Array.isArray(accounts)) { for (const acc of accounts) { if (hosterAccountHasCreds(name, acc)) checks.push({ hoster: name, accountId: acc.id }); } } } } else if (typeof requestedChecks[0] === 'string') { // Legacy: array of hoster names — check all accounts for each checks = []; for (const name of requestedChecks) { const accounts = config.hosters[name]; if (Array.isArray(accounts)) { for (const acc of accounts) { if (hosterAccountHasCreds(name, acc)) checks.push({ hoster: name, accountId: acc.id }); } } } } else { checks = requestedChecks; } const results = await Promise.all(checks.map(async ({ hoster, accountId, otp }) => { if (!allowed.includes(hoster)) { return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } // Find specific account const accounts = config.hosters[hoster]; const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; try { let result; if (hoster === 'doodstream.com') { result = await withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check'); } else if (hoster === 'vidmoly.me') { result = await withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check'); } else if (hoster === 'voe.sx') { result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check'); } else if (hoster === 'byse.sx') { result = await withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check'); } else if (hoster === 'clouddrop.cc') { result = await withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check'); } else { return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } return { hoster, accountId, ...result }; } catch (err) { return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' }; } })); return { checkedAt: new Date().toISOString(), results }; } function createWindow() { mainWindow = new BrowserWindow({ width: 1100, height: 750, minWidth: 800, minHeight: 550, backgroundColor: '#16181c', autoHideMenuBar: true, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'preload.js') } }); mainWindow.webContents.setBackgroundThrottling(false); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); } function createTray() { const iconPath = path.join(__dirname, 'assets', 'app_icon.ico'); tray = new Tray(iconPath); tray.setToolTip('Multi-Hoster-Upload'); const contextMenu = Menu.buildFromTemplate([ { label: 'Öffnen', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, { type: 'separator' }, { label: 'Beenden', click: () => { app.quit(); } } ]); tray.setContextMenu(contextMenu); tray.on('click', () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); } function updateTrayTooltip(text) { if (tray && !tray.isDestroyed()) tray.setToolTip(text); } app.whenReady().then(() => { createWindow(); createTray(); // Minimize to tray instead of taskbar mainWindow.on('minimize', () => { mainWindow.hide(); }); // Auto-start folder monitor if enabled try { const launchConfig = configStore.load(); const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; if (fm && fm.enabled && fm.folderPath) { if (fs.existsSync(fm.folderPath)) { startFolderMonitor(fm); } else { debugLog(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`); // Persist the disable so the user gets a clean state on next launch const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } }; configStore.save({ globalSettings: gs }).catch(() => {}); } } } catch (err) { debugLog(`folder-monitor auto-start failed: ${err.message}`); } // Auto-start remote server if enabled try { const _remCfg = configStore.load(); const remoteConfig = _remCfg.globalSettings && _remCfg.globalSettings.remote; if (remoteConfig && remoteConfig.enabled) { startRemoteServer().catch(err => { debugLog(`remote-server auto-start failed: ${err.message}`); }); } } catch (err) { debugLog(`remote-server auto-start failed: ${err.message}`); } // Auto-show drop target if enabled try { const dtConfig = configStore.load(); if (dtConfig.globalSettings && dtConfig.globalSettings.showDropTarget) { createDropTargetWindow(); } } catch {} // Auto-check for updates after 3 seconds setTimeout(async () => { try { debugLog('update-check: starting'); const result = await checkForUpdate(); debugLog(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); if (result && result.available && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('app:update-available', result); } } catch (err) { debugLog(`update-check failed: ${err && err.message || err}`); } }, 3000); }); app.on('window-all-closed', () => { app.quit(); }); app.on('before-quit', () => { if (uploadManager) try { uploadManager.cancel(); } catch {} try { folderMonitor.stop(); } catch {} try { if (remoteServer) { remoteServer.stop(); remoteServer = null; } destroyCaptureWindow(); } catch {} destroyDropTargetWindow(); // Flush pending log buffers synchronously so no lines are lost. try { if (_debugLogBuffer.length) { fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8'); _debugLogBuffer.length = 0; } } catch {} try { if (_uploadLogBuffer.length) { const target = _resolveUploadLogTarget(); if (target) fs.appendFileSync(target.path, _uploadLogBuffer.join(''), 'utf-8'); _uploadLogBuffer.length = 0; } } catch {} try { if (_rotLogBuffer.length) { fs.appendFileSync(getRotLogPath(), _rotLogBuffer.join(''), 'utf-8'); _rotLogBuffer.length = 0; } } catch {} }); // --- IPC Handlers --- // Debug log from renderer ipcMain.handle('debug-log', (_event, msg) => { debugLog(`[RENDERER] ${msg}`); return true; }); ipcMain.handle('get-config', () => { return configStore.load(); }); ipcMain.handle('save-config', async (_event, config) => { await configStore.save(config); // If a batch is running and some accounts got marked failed before any // fallback existed, re-resolve now — the user may have just added one. // Without this re-probe, those accounts stay stuck with no override until // the app restarts, and every subsequent job wastes an attempt on them. if (uploadManager && typeof uploadManager.getFailedAccountKeys === 'function') { try { const cfg = configStore.load(); const keys = uploadManager.getFailedAccountKeys(); for (const key of keys) { const sep = key.indexOf(':'); if (sep < 0) continue; const hoster = key.slice(0, sep); const failedAccountId = key.slice(sep + 1); if (uploadManager.getOverride(hoster)) continue; // already has a fallback const fallback = getNextFallbackAccount(cfg, hoster, failedAccountId); if (fallback) { rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`); uploadManager.switchAccount(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-switched', { hoster, fromAccountId: failedAccountId, toAccountId: fallback.id }); } } } } catch (err) { debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`); } } return true; }); ipcMain.handle('get-history', () => { return configStore.loadHistory(); }); ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) => { const safeName = String(defaultName || `export-${new Date().toISOString().slice(0, 10)}.txt`); const safeFilters = Array.isArray(filters) && filters.length ? filters : [{ name: 'Textdatei', extensions: ['txt', 'csv', 'log'] }]; const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { title: 'Speichern unter', defaultPath: safeName, filters: safeFilters }); if (canceled || !filePath) return { ok: false, canceled: true }; fs.writeFileSync(filePath, String(content === null || content === undefined ? '' : content), 'utf-8'); return { ok: true, path: filePath }; }); ipcMain.handle('export-history', async (_event, format) => { const normalizedFormat = String(format || 'csv').toLowerCase() === 'json' ? 'json' : 'csv'; const history = configStore.loadHistory(); const rows = flattenHistoryForExport(history); const datePrefix = new Date().toISOString().slice(0, 10); const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { title: 'Upload-Verlauf exportieren', defaultPath: `upload-history-${datePrefix}.${normalizedFormat}`, filters: normalizedFormat === 'json' ? [{ name: 'JSON-Datei', extensions: ['json'] }] : [{ name: 'CSV-Datei', extensions: ['csv'] }] }); if (canceled || !filePath) return { ok: false, canceled: true }; if (normalizedFormat === 'json') { const payload = { exportedAt: new Date().toISOString(), totalBatches: Array.isArray(history) ? history.length : 0, totalRows: rows.length, history }; fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); } else { fs.writeFileSync(filePath, buildHistoryCsv(rows), 'utf-8'); } return { ok: true, path: filePath, format: normalizedFormat, totalBatches: Array.isArray(history) ? history.length : 0, totalRows: rows.length }; }); ipcMain.handle('run-health-check', async (_event, payload) => { const config = configStore.load(); const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; return runHosterHealthCheck(config, hosters); }); ipcMain.handle('select-files', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile', 'multiSelections'], filters: [ { name: 'Alle Dateien', extensions: ['*'] }, { name: 'Videos', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] } ] }); return result.canceled ? null : result.filePaths; }); // Debug self-test: runs a minimal upload in the main process to verify events work ipcMain.handle('debug-test-upload', async () => { const testFile = path.join(__dirname, 'test-self-check.txt'); try { fs.writeFileSync(testFile, 'selftest ' + Date.now(), 'utf-8'); const mgr = new UploadManager({ 'voe.sx': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); const events = []; return new Promise((resolve) => { mgr.on('progress', (data) => { events.push({ s: data.status, e: data.error || null }); }); mgr.on('batch-done', (summary) => { try { fs.unlinkSync(testFile); } catch {} resolve({ ok: true, events, summary: { ok: summary.succeeded, fail: summary.failed } }); }); mgr.startBatch([{ file: testFile, hoster: 'voe.sx', apiKey: 'invalid-test-key' }]); setTimeout(() => { try { fs.unlinkSync(testFile); } catch {} resolve({ ok: false, events, timeout: true }); }, 20000); }); } catch (err) { try { fs.unlinkSync(testFile); } catch {} return { ok: false, error: err.message }; } }); ipcMain.handle('select-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'multiSelections'] }); if (result.canceled || !result.filePaths.length) return null; // Recursively collect all files from selected folders const files = []; const walk = (dir) => { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (entry.isFile()) files.push(full); } } catch {} }; for (const folder of result.filePaths) walk(folder); return files.length > 0 ? files : null; }); ipcMain.handle('resolve-folder-files', async (_event, folderPath) => { const files = []; const walk = (dir) => { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (entry.isFile()) files.push(full); } } catch {} }; walk(folderPath); return files; }); ipcMain.handle('start-upload', (_event, payload) => { const config = configStore.load(); const files = payload && Array.isArray(payload.files) ? payload.files : []; const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // per start-upload and added noticeable delay — log counts only. debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`); const tasks = jobs.length > 0 ? buildUploadTasksFromJobs(config, jobs) : buildUploadTasks(config, files, hosters); // Identify jobs that were skipped (no account/credentials) const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const skippedJobs = jobs.filter(j => j.id && !taskJobIds.has(j.id)).map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); if (skippedJobs.length > 0) { debugLog(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`); } debugLog(` tasks built: ${tasks.length}`); if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs }; // Pre-resolve a fallback for every hoster that has one. Lets the upload // manager break out of the retry loop after a single generic failure and // try the alternate account immediately, instead of hammering a probably- // dead primary 5× before the account-failed event even fires. Doesn't // trigger pre-job-swap (which only fires when the current account is in // _failedAccounts), so jobs still start on the primary as expected. const hostersInBatch = new Set(tasks.map(t => t.hoster).filter(Boolean)); for (const hoster of hostersInBatch) { if (_sessionAccountOverrides.has(hoster)) continue; // already learned from past batch const accounts = config.hosters && config.hosters[hoster]; if (!Array.isArray(accounts) || accounts.length < 2) continue; const primary = accounts.find(a => a && a.enabled !== false && hosterAccountHasCreds(hoster, a)); if (!primary) continue; const next = getNextFallbackAccount(config, hoster, primary.id); if (next) { _sessionAccountOverrides.set(hoster, next); rotLog(`main: pre-resolved fallback for ${hoster} → ${next.id} (primary ${primary.id} will try acc2 on first failure)`); } } // Fresh collector for this new batch — old entries from the previous // batch's jobs are dropped (user's signal for "fresh log" is starting a // new upload; addJobs during a running batch keeps them). _jobLogCollector.clear(); // Pass hoster settings to the upload manager uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); uploadManager.on('progress', (data) => { // Only log state changes, not continuous progress updates if (data.status !== 'uploading') { debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); _appendJobLog(data.jobId, { ts: Date.now(), kind: 'progress', status: data.status, hoster: data.hoster, accountId: data.accountId || null, error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0 }); } // Write to fileuploader.log immediately when a single upload finishes — // unless the user disabled logging for this hoster (per-hoster toggle). // Read from the live uploadManager.hosterSettings so a mid-batch toggle // (which calls updateSettings) takes effect immediately. if (data.status === 'done' && data.result) { const link = data.result.download_url || data.result.embed_url || data.result.file_code || ''; if (link) { if (shouldLogHosterToFile(data.hoster)) { appendUploadLog(data.hoster || '', link, data.fileName || ''); } else { debugLog(`upload-log: skip ${data.fileName} @ ${data.hoster} (logToFile disabled for hoster)`); } } else { debugLog(`WARNING: done but no link for ${data.fileName} @ ${data.hoster}: ${JSON.stringify(data.result)}`); } } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-progress', data); } }); uploadManager.on('stats', (data) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-stats', data); } // Update tray tooltip with upload progress if (data.state === 'uploading' && data.activeJobs > 0) { const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1); updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`); } else { updateTrayTooltip('Multi-Hoster-Upload'); } }); uploadManager.on('account-failed', ({ hoster, accountId }) => { // Persist to session cache so a subsequent batch (after batch-done) // gets primed and won't burn retries on this account again. _sessionFailedAccounts.set(hoster + ':' + accountId, true); const cfg = configStore.load(); const fallback = getNextFallbackAccount(cfg, hoster, accountId); if (fallback) { rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`); uploadManager.switchAccount(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); } } else { rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`); } }); uploadManager.on('rot-log', (entry) => { const { ts, event, ...rest } = entry; const pairs = Object.entries(rest) .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) .join(' '); rotLog(`[${event}] ${pairs}`, ts); if (entry.jobId) { _appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest }); } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-rotation-log', entry); } }); // Capture the manager identity at listener-registration time so the post- // batch null-out can compare against IT — not against whatever the global // happens to point at after an `await`. Without this, a renderer that // fires start-upload while we're still awaiting appendHistory would // create a fresh manager which the trailing `uploadManager = null` then // orphans (cancel/addJobs see null, the new batch keeps running invisibly). const _thisManager = uploadManager; uploadManager.on('batch-done', async (summary) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); logMemorySnapshot('batch-done'); try { await configStore.appendHistory(summary); } catch (err) { debugLog(`appendHistory failed: ${err.message}`); } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-batch-done', summary); } // Shutdown after finish handleShutdownAfterFinish(); if (uploadManager === _thisManager) uploadManager = null; else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await'); }); // Defer startBatch to next tick so the IPC response is sent first. // This ensures webContents.send() calls from upload events // are not interleaved with the handle() response. process.nextTick(() => { if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; } debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); uploadManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) }).catch((err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); // Forward error to renderer as batch-done with failure if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-batch-done', { id: 'error', timestamp: new Date().toISOString(), total: tasks.length, succeeded: 0, failed: tasks.length, files: [], error: err ? err.message : 'Unbekannter Fehler' }); } }); }); logMemorySnapshot('batch-start'); debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`); return { started: true, taskCount: tasks.length, skippedJobs }; }); // Logged at batch boundaries so we can spot memory growth between batches // across long sessions (main process side only — the renderer's live view // still uses DevTools for profiling). Non-invasive: single line per boundary. function logMemorySnapshot(label) { try { const m = process.memoryUsage(); const mb = (n) => (n / 1024 / 1024).toFixed(1); debugLog(`memory[${label}]: rss=${mb(m.rss)}MB heap=${mb(m.heapUsed)}/${mb(m.heapTotal)}MB external=${mb(m.external)}MB arrayBuffers=${mb(m.arrayBuffers)}MB`); } catch {} } ipcMain.handle('cancel-upload', () => { if (uploadManager) { uploadManager.cancel(); } return true; }); ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => { if (uploadManager) { uploadManager.cancelJobs(Array.isArray(jobIds) ? jobIds : []); } return true; }); ipcMain.handle('add-jobs-to-batch', (_event, payload) => { if (!uploadManager || !uploadManager.running) { return { error: 'Kein Upload aktiv' }; } const config = configStore.load(); const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const tasks = buildUploadTasksFromJobs(config, jobs); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const skippedJobs = jobs .filter(j => j && j.id && !taskJobIds.has(j.id)) .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); if (tasks.length === 0) { debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); return { added: 0, skippedJobs, alreadyInBatchJobIds: [] }; } const addResult = uploadManager.addJobs(tasks); const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0; const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds)) ? addResult.alreadyInBatchJobIds : []; debugLog( `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` ); return { added, skippedJobs, alreadyInBatchJobIds }; }); ipcMain.handle('finish-after-active', () => { if (uploadManager) { uploadManager.finishAfterActive(); } return true; }); ipcMain.handle('get-job-log', (_event, jobId) => { if (!jobId || typeof jobId !== 'string') return []; const arr = _jobLogCollector.get(jobId); return Array.isArray(arr) ? arr.slice() : []; }); ipcMain.handle('open-log-folder', async () => { // Reveal the active log file (or its directory) in the OS file manager. // Prefers the configured log path, then the rotation log, then just the // parent dir. const { shell } = require('electron'); const primary = getLogFilePath(); if (fs.existsSync(primary)) { shell.showItemInFolder(primary); return { ok: true, path: primary }; } const rot = getRotLogPath(); if (fs.existsSync(rot)) { shell.showItemInFolder(rot); return { ok: true, path: rot }; } try { const dir = path.dirname(primary); fs.mkdirSync(dir, { recursive: true }); shell.openPath(dir); return { ok: true, path: dir }; } catch (err) { return { ok: false, error: err.message }; } }); ipcMain.handle('clear-history', async () => { await configStore.clearHistory(); return true; }); // --- Backup export / import --- ipcMain.handle('export-backup', async () => { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { title: 'Backup exportieren', defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`, filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] }); if (canceled || !filePath) return { ok: false, canceled: true }; const config = configStore.load(); const encrypted = backupCrypto.encrypt(config); fs.writeFileSync(filePath, encrypted); return { ok: true, path: filePath }; }); ipcMain.handle('import-backup', async (_event, legacyPassword) => { let buffer; let sourcePath = _lastImportPath; if (legacyPassword && sourcePath) { buffer = fs.readFileSync(sourcePath); } else { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { title: 'Backup importieren', filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }], properties: ['openFile'] }); if (canceled || !filePaths.length) return { ok: false, canceled: true }; sourcePath = filePaths[0]; buffer = fs.readFileSync(sourcePath); _lastImportPath = sourcePath; } let imported; try { imported = backupCrypto.decrypt(buffer, legacyPassword); } catch (err) { if (err && err.needsPassword) { return { ok: false, needsPassword: true }; } _lastImportPath = null; throw err; } _lastImportPath = null; // Validate imported data has required structure if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) { return { ok: false, error: 'Backup-Datei hat ungültige Struktur (hosters, hosterSettings oder globalSettings fehlt).' }; } // Safety net: timestamped backup so multiple imports don't overwrite each other const ts = new Date().toISOString().replace(/[:.]/g, '-'); const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`); try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {} // Strip machine-specific state: absolute paths from the source machine will // not exist on this one (e.g. C:\Users\Administrator\... vs \bakeredwin318\...). // Any path that does not resolve locally is cleared so the user can re-set it // instead of hitting silent failures later. const importedGlobal = imported.globalSettings || {}; if (importedGlobal.logFilePath && !fs.existsSync(path.dirname(importedGlobal.logFilePath))) { importedGlobal.logFilePath = ''; } if (importedGlobal.folderMonitor && typeof importedGlobal.folderMonitor === 'object') { const fm = importedGlobal.folderMonitor; if (fm.folderPath && !fs.existsSync(fm.folderPath)) { fm.folderPath = ''; fm.enabled = false; } } importedGlobal.pendingQueue = null; // Single atomic write — no split state, no TOCTOU race const merged = { hosters: imported.hosters, hosterSettings: imported.hosterSettings, globalSettings: importedGlobal, history: imported.history || [] }; await configStore._atomicWrite(configStore._serializeForDisk(merged)); return { ok: true, config: configStore.load() }; }); ipcMain.handle('read-own-upload-log', () => { // Read all log files (base + daily logs) and return parsed entries const entries = []; const basePath = getBaseLogFilePath(); const dir = path.dirname(basePath); const ext = path.extname(basePath); const name = path.basename(basePath, ext); // Collect all matching log files (base + daily variants) const logFiles = []; try { for (const file of fs.readdirSync(dir)) { if (file.startsWith(name) && file.endsWith(ext)) { logFiles.push(path.join(dir, file)); } } } catch {} if (logFiles.length === 0 && fs.existsSync(basePath)) { logFiles.push(basePath); } for (const logPath of logFiles) { try { const content = fs.readFileSync(logPath, 'utf-8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const parts = trimmed.split('|'); if (parts.length >= 5) { const hoster = (parts[1] || '').trim(); const fileName = (parts[4] || '').trim(); if (hoster && fileName) entries.push({ hoster, fileName }); } } } catch {} } return entries; }); ipcMain.handle('import-upload-log', async () => { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { title: 'Upload-Log importieren', filters: [ { name: 'Log-Dateien', extensions: ['log', 'txt'] }, { name: 'Alle Dateien', extensions: ['*'] } ], properties: ['openFile'] }); if (canceled || !filePaths.length) return { canceled: true }; const content = fs.readFileSync(filePaths[0], 'utf-8'); // Parse log format: date|hoster|link||filename| const entries = []; for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const parts = trimmed.split('|'); if (parts.length >= 5) { const hoster = (parts[1] || '').trim(); const fileName = (parts[4] || '').trim(); if (hoster && fileName) entries.push({ hoster, fileName }); } } return { entries, path: filePaths[0] }; }); ipcMain.handle('copy-to-clipboard', (_event, text) => { clipboard.writeText(text); return true; }); ipcMain.handle('app:check-updates', async () => { try { return await checkForUpdate(); } catch (err) { return { available: false, error: err.message }; } }); ipcMain.handle('app:install-update', () => { installUpdate((progress) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('app:update-progress', progress); } }).catch((err) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('app:update-progress', { stage: 'error', error: err.message }); } }); return { started: true }; }); ipcMain.handle('app:abort-update', () => { abortUpdate(); return true; }); ipcMain.handle('app:get-version', () => { return app.getVersion(); }); // --- Hoster settings --- ipcMain.handle('get-hoster-settings', () => { const config = configStore.load(); return config.hosterSettings || {}; }); ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => { await configStore.save({ hosterSettings }); if (uploadManager) uploadManager.updateSettings(hosterSettings, null); return true; }); // --- Global settings --- ipcMain.handle('get-global-settings', () => { const config = configStore.load(); return config.globalSettings || {}; }); ipcMain.handle('save-global-settings', async (_event, globalSettings) => { await configStore.save({ globalSettings }); if (uploadManager) uploadManager.updateSettings(null, globalSettings); return true; }); // Synchronous save for beforeunload — blocks renderer until write completes // Uses atomic write pattern (tmp + backup + rename) to prevent corruption. // Returns false on any failure so the renderer (which surfaces this via the // beforeunload chain) doesn't quietly think queue + settings persisted when // they didn't. Errors are logged for diagnostics regardless. ipcMain.on('save-global-settings-sync', (event, globalSettings) => { try { const current = configStore.load(); current.globalSettings = globalSettings; const data = configStore._serializeForDisk(current); const tmpPath = configStore.filePath + '.tmp'; const backupPath = configStore.filePath + '.bak'; fs.writeFileSync(tmpPath, data, 'utf-8'); if (fs.existsSync(configStore.filePath)) { // 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 // the live file via rename is what matters. try { const existing = fs.readFileSync(configStore.filePath, 'utf-8'); if (existing && existing.trim().length > 2) { fs.writeFileSync(backupPath, existing, 'utf-8'); } } catch (bakErr) { debugLog(`save-global-settings-sync: backup read/write skipped: ${bakErr.message}`); } } fs.renameSync(tmpPath, configStore.filePath); event.returnValue = true; } catch (err) { debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`); event.returnValue = false; } }); // --- Folder Monitor --- function startFolderMonitor(settings) { try { folderMonitor.stop(); folderMonitor.removeAllListeners(); folderMonitor.on('new-files', (files) => { debugLog(`folder-monitor: ${files.length} new file(s)`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('folder-monitor:new-files', files); } }); folderMonitor.on('error', (err) => { debugLog(`folder-monitor error: ${err.message}`); }); folderMonitor.start(settings); debugLog(`folder-monitor started: ${settings.folderPath}`); } catch (err) { debugLog(`folder-monitor start failed: ${err.message}`); throw err; } } ipcMain.handle('folder-monitor:start', (_event, settings) => { startFolderMonitor(settings); return { ok: true }; }); ipcMain.handle('folder-monitor:stop', () => { folderMonitor.stop(); debugLog('folder-monitor stopped'); return { ok: true }; }); ipcMain.handle('folder-monitor:status', () => { return folderMonitor.status(); }); ipcMain.handle('folder-monitor:select-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }); if (result.canceled || !result.filePaths.length) return null; return result.filePaths[0]; }); // --- Remote Control --- function generateToken() { const crypto = require('crypto'); return crypto.randomBytes(32).toString('hex'); } function createCaptureWindow() { if (captureWindow && !captureWindow.isDestroyed()) return; captureWindowReady = false; captureWindow = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'lib', 'remote-capture-preload.js') } }); captureWindow.loadFile(path.join(__dirname, 'lib', 'remote-capture.html')); // Wait for window to be fully loaded before sending signaling messages captureWindow.webContents.on('dom-ready', () => { debugLog('remote: capture window ready, draining', signalingQueue.length, 'queued messages'); captureWindowReady = true; for (const msg of signalingQueue) { captureWindow.webContents.send('remote:signaling-to-capture', msg); } signalingQueue = []; }); // Crash recovery: if hidden window closes unexpectedly while clients connected, recreate it captureWindow.on('closed', () => { captureWindow = null; captureWindowReady = false; signalingQueue = []; if (remoteServer && remoteServer.getClientCount() > 0) { debugLog('remote: capture window crashed, recreating...'); createCaptureWindow(); } }); } function destroyCaptureWindow() { if (captureWindow && !captureWindow.isDestroyed()) { captureWindow.close(); captureWindow = null; } } async function startRemoteServer() { if (remoteServer) { remoteServer.stop(); remoteServer = null; } const config = configStore.load(); const remote = config.globalSettings && config.globalSettings.remote; if (!remote || !remote.enabled) return; let token = remote.token; if (!token) { token = generateToken(); const gs = { ...config.globalSettings, remote: { ...remote, token } }; await configStore.save({ globalSettings: gs }); } remoteServer = new RemoteServer(); await remoteServer.start({ port: remote.port || 9100, token, allowInput: remote.allowInput !== false, mainWindow, onSignalingToCapture: (data) => { if (!captureWindow || captureWindow.isDestroyed()) { debugLog('remote: signaling dropped, no capture window'); return; } if (captureWindowReady) { captureWindow.webContents.send('remote:signaling-to-capture', data); } else { debugLog('remote: capture window not ready, queuing', data.type, 'message'); signalingQueue.push(data); } }, onCreateCaptureWindow: () => createCaptureWindow(), onDestroyCaptureWindow: () => destroyCaptureWindow() }); debugLog(`remote-server started on port ${remoteServer.getPort()}`); } // IPC: Signaling from capture window back to dashboard client ipcMain.on('remote:signaling-from-capture', (_event, data) => { if (remoteServer && data.clientId) { remoteServer.sendToClient(data.clientId, data); } }); // IPC: Debug logging from capture window ipcMain.on('remote:capture-log', (_event, msg) => { debugLog('remote-capture:', msg); }); // IPC: Input events from capture window ipcMain.on('remote:input-event', (_event, data) => { if (!mainWindow || mainWindow.isDestroyed()) return; const config = configStore.load(); const remote = config.globalSettings && config.globalSettings.remote; if (!remote || !remote.allowInput) return; if (data.role !== 'admin') return; // Capture includes window frame (title bar) but NOT invisible DWM borders // sendInputEvent coordinates are relative to web content area const winBounds = mainWindow.getBounds(); const contentBounds = mainWindow.getContentBounds(); // Windows 10/11: getBounds() includes ~7px invisible resize borders not in capture const dwm = process.platform === 'win32' ? 7 : 0; const capturedW = winBounds.width - 2 * dwm; const capturedH = winBounds.height - dwm; // only bottom has invisible border const contentOffsetX = contentBounds.x - (winBounds.x + dwm); const contentOffsetY = contentBounds.y - winBounds.y; const rawX = typeof data.x === 'number' && isFinite(data.x) ? data.x : 0; const rawY = typeof data.y === 'number' && isFinite(data.y) ? data.y : 0; const x = Math.round(rawX * capturedW - contentOffsetX); const y = Math.round(rawY * capturedH - contentOffsetY); switch (data.type) { case 'mousemove': mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x, y }); break; case 'mousedown': mainWindow.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: data.button === 'right' ? 'right' : 'left', clickCount: 1 }); break; case 'mouseup': mainWindow.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: data.button === 'right' ? 'right' : 'left', clickCount: 1 }); break; case 'scroll': mainWindow.webContents.sendInputEvent({ type: 'mouseWheel', x, y, deltaX: data.deltaX || 0, deltaY: data.deltaY || 0 }); break; case 'keydown': mainWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: data.key, modifiers: buildModifiers(data) }); if (data.key.length === 1) { mainWindow.webContents.sendInputEvent({ type: 'char', keyCode: data.key, modifiers: buildModifiers(data) }); } break; case 'keyup': mainWindow.webContents.sendInputEvent({ type: 'keyUp', keyCode: data.key, modifiers: buildModifiers(data) }); break; } }); function buildModifiers(data) { const mods = []; if (data.shift) mods.push('shift'); if (data.ctrl) mods.push('control'); if (data.alt) mods.push('alt'); return mods; } // IPC: Get capture source ID (desktopCapturer must run in main process in Electron 33+) ipcMain.handle('remote:get-capture-source-id', async () => { if (!mainWindow || mainWindow.isDestroyed()) { debugLog('remote: capture source - mainWindow not available'); return null; } // Use getMediaSourceId() for exact window capture without enumeration const sourceId = mainWindow.getMediaSourceId(); debugLog('remote: capture source - getMediaSourceId:', sourceId); if (sourceId) return sourceId; // Fallback: enumerate sources const { desktopCapturer } = require('electron'); const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] }); const title = mainWindow.getTitle(); debugLog('remote: capture source - fallback, looking for title:', title); let source = sources.find(s => s.name === title); if (!source) source = sources.find(s => s.name.includes('Multi-Hoster')); if (!source) source = sources.find(s => s.id.startsWith('screen:')); debugLog('remote: capture source -', source ? `found: ${source.name} (${source.id})` : 'NONE FOUND'); return source ? source.id : null; }); // IPC: Client count updates from capture window ipcMain.on('remote:client-count', (_event, count) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('remote:client-count', count); } }); // IPC: Remote settings ipcMain.handle('remote:get-settings', () => { const config = configStore.load(); return config.globalSettings && config.globalSettings.remote || {}; }); ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => { const config = configStore.load(); const gs = { ...config.globalSettings, remote: remoteSettings }; await configStore.save({ globalSettings: gs }); if (remoteSettings.enabled) { await startRemoteServer(); } else if (remoteServer) { remoteServer.stop(); remoteServer = null; destroyCaptureWindow(); debugLog('remote-server stopped'); } return true; }); ipcMain.handle('remote:generate-token', () => { return generateToken(); }); ipcMain.handle('remote:status', () => { return { running: !!remoteServer, port: remoteServer ? remoteServer.getPort() : null, clientCount: remoteServer ? remoteServer.getClientCount() : 0 }; }); // --- Always on top --- ipcMain.handle('set-always-on-top', async (_event, value) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setAlwaysOnTop(!!value); } await configStore.save({ globalSettings: { ...configStore.load().globalSettings, alwaysOnTop: !!value } }); return true; }); ipcMain.handle('get-always-on-top', () => { if (mainWindow && !mainWindow.isDestroyed()) { return mainWindow.isAlwaysOnTop(); } return false; }); // --- Drop Target Window --- function createDropTargetWindow() { if (dropTargetWindow && !dropTargetWindow.isDestroyed()) return; const { screen } = require('electron'); const display = screen.getPrimaryDisplay(); const { width, height } = display.workAreaSize; dropTargetWindow = new BrowserWindow({ width: 120, height: 120, x: width - 140, y: height - 140, frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false, minimizable: false, maximizable: false, focusable: false, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'preload-drop-target.js') } }); dropTargetWindow.loadFile('renderer/drop-target.html'); dropTargetWindow.on('closed', () => { dropTargetWindow = null; }); } function destroyDropTargetWindow() { if (dropTargetWindow && !dropTargetWindow.isDestroyed()) { dropTargetWindow.close(); dropTargetWindow = null; } } ipcMain.handle('show-drop-target', () => { createDropTargetWindow(); return true; }); ipcMain.handle('hide-drop-target', () => { destroyDropTargetWindow(); return true; }); ipcMain.on('drop-target:files', (_event, paths) => { if (mainWindow && !mainWindow.isDestroyed()) { if (!mainWindow.isVisible() || mainWindow.isMinimized()) { mainWindow.show(); mainWindow.focus(); } mainWindow.webContents.send('drop-target:files', paths); } }); // --- Shutdown after finish --- let shutdownMode = 'nothing'; let shutdownTimer = null; ipcMain.handle('set-shutdown-after-finish', (_event, mode) => { shutdownMode = mode || 'nothing'; // Cancel active countdown if mode changed to 'nothing' if (shutdownMode === 'nothing' && shutdownTimer) { clearTimeout(shutdownTimer); shutdownTimer = null; } return true; }); ipcMain.handle('get-shutdown-after-finish', () => { return shutdownMode; }); ipcMain.handle('cancel-shutdown', () => { if (shutdownTimer) { clearTimeout(shutdownTimer); shutdownTimer = null; } shutdownMode = 'nothing'; return true; }); function handleShutdownAfterFinish() { if (shutdownMode === 'nothing') return; const { exec } = require('child_process'); // Notify renderer if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('shutdown-countdown', { mode: shutdownMode, seconds: 60 }); } // Clear any previous countdown to prevent orphaned timers if (shutdownTimer) clearTimeout(shutdownTimer); shutdownTimer = setTimeout(() => { // Read current mode at execution time (not captured at scheduling time) if (shutdownMode === 'shutdown') { exec('shutdown /s /t 0', (err) => { if (err) debugLog(`shutdown failed: ${err.message}`); }); } else if (shutdownMode === 'restart') { exec('shutdown /r /t 0', (err) => { if (err) debugLog(`restart failed: ${err.message}`); }); } else if (shutdownMode === 'sleep') { exec('rundll32.exe powrprof.dll,SetSuspendState 0,1,0', (err) => { if (err) debugLog(`sleep failed: ${err.message}`); }); } // else: mode was changed to 'nothing' during countdown — do nothing }, 60000); } // Restore always-on-top from config on window creation app.on('browser-window-created', () => { const config = configStore.load(); if (config.globalSettings && config.globalSettings.alwaysOnTop && mainWindow) { mainWindow.setAlwaysOnTop(true); } });