Previously, clicking 'Ausgewählte starten' on 'Wartet' jobs during an active upload just showed a toast. But the jobs might NOT actually be in the batch (skipped during task building). Now: ALL selected queued/error/aborted jobs are sent to addJobsToBatch. The upload-manager has duplicate protection (checks jobAbortControllers) so jobs already in the batch are skipped. Jobs NOT in the batch get added and start uploading immediately. Toast now shows exact counts: "X hinzugefügt, Y waren schon im Batch" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1417 lines
47 KiB
JavaScript
1417 lines
47 KiB
JavaScript
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;
|
|
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');
|
|
}
|
|
|
|
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: <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, 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);
|
|
|
|
// 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 };
|
|
|
|
// 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, skippedJobs };
|
|
});
|
|
|
|
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);
|
|
if (tasks.length === 0) return { added: 0 };
|
|
const added = uploadManager.addJobs(tasks);
|
|
debugLog(`add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${tasks.length - added} already in batch)`);
|
|
return { added };
|
|
});
|
|
|
|
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('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
|
|
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);
|
|
}
|
|
});
|