Multi-Hoster-Upload/main.js
Administrator b1fe0cfefb fix(log): auto-rotate the other 3 internal log files (debug, rot, doodstream)
3.3.2 fixed fileuploader.log unbounded growth, but three siblings kept
growing without limit:

- upload-debug.log     (verbose, every IPC + progress event log line)
- account-rotation.log (every rotation decision)
- doodstream-debug.log (per-hoster trace from lib/doodstream-upload.js)

A multi-month dev install or a heavy production user could fill the
log dir with multi-GB files and slow every appendFile.

Wire all three through the same lib/log-rotation.js helper:
- upload-debug.log     → 25 MB cap, 2 numbered backups (~75 MB worst)
- account-rotation.log → 10 MB cap, 2 numbered backups (~30 MB worst)
- doodstream-debug.log → 10 MB cap, 1 numbered backup  (~20 MB worst)

The rotation check runs once per flush call (each is debounced or
already a once-per-event path), so the statSync overhead is
microscopic. _flushDebugLog passes a noop logger to avoid recursing
into itself; _flushRotLog and _debugLog (doodstream) use the normal
debugLog so any rotation surprises end up in upload-debug.log.

126/126 tests still green.
2026-04-28 11:11:24 +02:00

2091 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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