Multi-Hoster-Upload/main.js
Administrator b4211a7d50 fix: use getMediaSourceId() for exact window capture
Instead of enumerating all sources and matching by title (which falls
back to full screen capture), use BrowserWindow.getMediaSourceId() to
get the exact media source ID for the app window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:52:22 +01:00

1270 lines
41 KiB
JavaScript

const { app, BrowserWindow, ipcMain, dialog, clipboard, powerSaveBlocker, 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) {
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();
await uploader.login(username, password);
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: <server-url> } 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 }) => {
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), 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 remoteConfig = configStore.load().globalSettings && configStore.load().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', () => {
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}`);
await configStore.appendHistory(summary);
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', () => {
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);
// 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;
});
// --- 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;
const bounds = mainWindow.getContentBounds();
const x = Math.round((data.x || 0) * bounds.width);
const y = Math.round((data.y || 0) * bounds.height);
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()) {
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';
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');
const mode = shutdownMode;
// Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shutdown-countdown', { mode, seconds: 60 });
}
shutdownTimer = setTimeout(() => {
if (mode === 'shutdown') {
exec('shutdown /s /t 0');
} else if (mode === 'restart') {
exec('shutdown /r /t 0');
} else if (mode === 'sleep') {
exec('rundll32.exe powrprof.dll,SetSuspendState 0,1,0');
}
}, 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);
}
});