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 { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const FolderMonitor = require('./lib/folder-monitor'); const RemoteServer = require('./lib/remote-server'); let mainWindow; let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); let uploadManager = null; let 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'); } function debugLog(msg) { try { const ts = new Date().toISOString(); fs.appendFileSync(getDebugLogPath(), `[${ts}] ${msg}\n`, 'utf-8'); } 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() { const baseDir = app.isPackaged ? path.dirname(process.execPath) : path.join(__dirname); return path.join(baseDir, '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 appendUploadLog(hoster, link, fileName) { try { const logPath = getLogFilePath(); fs.mkdirSync(path.dirname(logPath), { recursive: true }); 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())}`; const line = `${dateStr}|${hoster}|${link}||${fileName}|\n`; fs.appendFileSync(logPath, line, 'utf-8'); } catch {} } // --- 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; 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 }; if (account.authType === 'api' && account.apiKey) { task.apiKey = account.apiKey; } else if (account.username && account.password) { task.username = account.username; task.password = account.password; } else if (account.apiKey) { task.apiKey = account.apiKey; } 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' }; } // 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']; // 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 { 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) { startFolderMonitor(fm); } } 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(); }); // --- 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); return true; }); ipcMain.handle('get-history', () => { return configStore.loadHistory(); }); 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 : []; debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}, jobs=${jobs.length}`); const tasks = jobs.length > 0 ? buildUploadTasksFromJobs(config, jobs) : buildUploadTasks(config, files, hosters); debugLog(` tasks built: ${tasks.length}`); if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.' }; // 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 || ''}`); } // Write to fileuploader.log immediately when a single upload finishes if (data.status === 'done' && data.result) { const link = data.result.download_url || data.result.embed_url || data.result.file_code || ''; if (link) { appendUploadLog(data.hoster || '', link, data.fileName || ''); } 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 }) => { const cfg = configStore.load(); const fallback = getNextFallbackAccount(cfg, hoster, accountId); if (fallback) { debugLog(`account-failed: ${hoster} ${accountId} → fallback to ${fallback.id}`); uploadManager.switchAccount(hoster, fallback); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); } } else { debugLog(`account-failed: ${hoster} ${accountId} → no fallback available`); } }); uploadManager.on('batch-done', async (summary) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); 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(); uploadManager = null; }); // 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'); uploadManager.startBatch(tasks).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' }); } }); }); debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`); return { started: true, taskCount: tasks.length }; }); 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('finish-after-active', () => { if (uploadManager) { uploadManager.finishAfterActive(); } return true; }); ipcMain.handle('clear-history', async () => { await configStore.clearHistory(); return true; }); // --- Backup export / import --- ipcMain.handle('export-backup', async (_event, password) => { const config = configStore.load(); const encrypted = backupCrypto.encrypt(config, password); 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 }; fs.writeFileSync(filePath, encrypted); return { ok: true, path: filePath }; }); ipcMain.handle('import-backup', async (_event, password) => { 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 }; const buffer = fs.readFileSync(filePaths[0]); const imported = backupCrypto.decrypt(buffer, password); // 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 {} // Single atomic write — no split state, no TOCTOU race const merged = { hosters: imported.hosters, hosterSettings: imported.hosterSettings, globalSettings: imported.globalSettings, history: imported.history || [] }; await configStore._atomicWrite(JSON.stringify(merged, null, 2)); return { ok: true, config: configStore.load() }; }); 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 ipcMain.on('save-global-settings-sync', (event, globalSettings) => { try { const current = configStore.load(); current.globalSettings = globalSettings; const data = JSON.stringify(current, null, 2); const tmpPath = configStore.filePath + '.tmp'; const backupPath = configStore.filePath + '.bak'; fs.writeFileSync(tmpPath, data, 'utf-8'); if (fs.existsSync(configStore.filePath)) { const existing = fs.readFileSync(configStore.filePath, 'utf-8'); if (existing && existing.trim().length > 2) { fs.writeFileSync(backupPath, existing, 'utf-8'); } } fs.renameSync(tmpPath, configStore.filePath); } catch {} event.returnValue = true; }); // --- 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); } });