diff --git a/.gitignore b/.gitignore index 42a6a46..d45a107 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ release/ -.worktrees/ +.artifacts/ __pycache__/ *.pyc diff --git a/README.md b/README.md index c5c790d..f2142db 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr ## Capabilities - Upload one batch to several supported hosters in parallel. -- Manage multiple accounts per hoster with validation, health checks, and automatic rotation. +- Manage multiple accounts per hoster with validation, health checks, automatic rotation, and inline OTP completion. +- Filter uploads, accounts, and history from task-focused sidebars without changing the underlying queue. - Add files by drag and drop or file selection and monitor live queue progress. -- Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring. +- Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup. - Keep local upload history and copy completed links in bulk. - Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client. +- Check for updates from the header and install available releases from an accessible update dialog. ## Supported hosters @@ -29,7 +31,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr 1. Download the Setup or portable executable from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). 2. Run the installer, or launch the portable executable directly. -3. Add and validate at least one hoster account in Settings, then select files and start the queue. +3. Add and validate at least one hoster account in Accounts, then select files and start the queue. ## Local data and credentials diff --git a/assets/product-overview.png b/assets/product-overview.png index e719b86..4e7db08 100644 Binary files a/assets/product-overview.png and b/assets/product-overview.png differ diff --git a/lib/config-store.js b/lib/config-store.js index c24bbf8..99df744 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -137,10 +137,7 @@ function batchRowCount(batch) { let n = 0; const files = (batch && batch.files) || []; for (const file of files) { - for (const result of (file.results || [])) { - if (result.status === 'aborted' || result.status === 'error') continue; - n++; - } + n += (file.results || []).length; } return n; } @@ -180,13 +177,19 @@ function applyHistoryRetention(history, retention, nowMs) { class ConfigStore { constructor(app) { - const dir = app && app.isPackaged + const useUserDataDir = app && ( + app.isPackaged || + (app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir')) + ); + const dir = useUserDataDir ? app.getPath('userData') : path.join(__dirname, '..'); this.filePath = path.join(dir, 'electron-config.json'); this.historyPath = path.join(dir, 'electron-history.json'); this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions this._historyWriteQueue = Promise.resolve(); + this._pendingWriteOperations = new Set(); + this._writesQuiesced = false; this._historyMigrated = false; this._cache = null; this._cacheKey = ''; @@ -238,9 +241,26 @@ class ConfigStore { }); } - _enqueueHistoryWrite(fn) { - this._historyWriteQueue = this._historyWriteQueue.then(fn, fn); - return this._historyWriteQueue; + _quiescedWriteError() { + const error = new Error('Die Anwendung wird gerade beendet'); + error.code = 'CONFIG_WRITES_QUIESCED'; + return error; + } + + setWritesQuiesced(quiesced) { + this._writesQuiesced = !!quiesced; + } + + _enqueueHistoryWrite(fn, options = {}) { + if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError()); + const operation = this._historyWriteQueue.then(fn, fn); + this._pendingWriteOperations.add(operation); + this._historyWriteQueue = operation.then(() => undefined, () => undefined); + operation.then( + () => this._pendingWriteOperations.delete(operation), + () => this._pendingWriteOperations.delete(operation) + ); + return operation; } _migrateHistory() { @@ -476,11 +496,31 @@ class ConfigStore { return this._atomicWrite(data); } - _enqueueWrite(fn) { + _enqueueWrite(fn, options = {}) { + if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError()); this._wqDepth++; - const done = () => { this._wqDepth--; }; - this._writeQueue = this._writeQueue.then(fn, fn).then(done, done); - return this._writeQueue; + const operation = this._writeQueue.then(fn, fn); + this._pendingWriteOperations.add(operation); + this._writeQueue = operation.then( + () => { this._wqDepth--; }, + () => { + this._wqDepth--; + } + ); + operation.then( + () => this._pendingWriteOperations.delete(operation), + () => this._pendingWriteOperations.delete(operation) + ); + return operation; + } + + async drainWrites() { + while (this._pendingWriteOperations.size > 0) { + const pending = Array.from(this._pendingWriteOperations); + const results = await Promise.allSettled(pending); + const failed = results.find(result => result.status === 'rejected'); + if (failed) throw failed.reason; + } } _anyHosters(cfg) { @@ -522,6 +562,56 @@ class ConfigStore { }); } + savePendingQueue(pendingQueue, options = {}) { + const snapshot = pendingQueue === null || pendingQueue === undefined ? null : this._clone(pendingQueue); + return this._enqueueWrite(() => { + const current = this.load(); + current.globalSettings = { + ...(current.globalSettings || {}), + pendingQueue: snapshot + }; + this._guardHosters(current, false); + return this._commit(current); + }, options); + } + + saveRendererGlobalSettings(globalSettings) { + const snapshot = this._clone(globalSettings || {}); + return this._enqueueWrite(() => { + const current = this.load(); + const currentGlobalSettings = current.globalSettings || {}; + const currentRemote = currentGlobalSettings.remote || {}; + const incomingRemote = snapshot.remote || {}; + current.globalSettings = { + ...snapshot, + pendingQueue: currentGlobalSettings.pendingQueue ?? null, + diagnostics: this._clone(currentGlobalSettings.diagnostics || {}), + historyRetention: currentGlobalSettings.historyRetention || 'all', + remote: { + ...incomingRemote, + token: incomingRemote.token || currentRemote.token || '' + } + }; + this._guardHosters(current, false); + return this._commit(current); + }); + } + + saveRemoteSettings(remoteSettings, createToken) { + const incoming = this._clone(remoteSettings || {}); + return this._enqueueWrite(async () => { + const current = this.load(); + const currentGlobalSettings = current.globalSettings || {}; + const currentRemote = currentGlobalSettings.remote || {}; + const token = incoming.token || currentRemote.token || (incoming.enabled && typeof createToken === 'function' ? createToken() : ''); + const canonical = { ...incoming, token }; + current.globalSettings = { ...currentGlobalSettings, remote: canonical }; + this._guardHosters(current, false); + await this._commit(current); + return this._clone(canonical); + }); + } + replaceSettings(config) { return this._enqueueWrite(() => { const current = this.load(); @@ -604,8 +694,12 @@ class ConfigStore { pruneHistory(retention, opts = {}) { const dryRun = !!opts.dryRun; if (this._historyMigrated) { - return this._enqueueHistoryWrite(() => { - const current = this._readHistoryFile() || []; + return this._enqueueHistoryWrite(async () => { + const storedHistory = this._readHistoryFile(); + if (storedHistory === null && fs.existsSync(this.historyPath)) { + throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert'); + } + const current = storedHistory || []; const beforeBatches = current.length; const beforeRows = countHistoryRows(current); const pruned = applyHistoryRetention(current, retention, Date.now()); @@ -616,9 +710,25 @@ class ConfigStore { keptRows: countHistoryRows(pruned) }; if (dryRun) return result; - return this._writeHistoryFileAtomic(pruned) - .then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } })) - .then(() => result); + return this._enqueueWrite(async () => { + const config = this.load(); + const previousGlobalSettings = this._clone(config.globalSettings || {}); + config.globalSettings = { ...previousGlobalSettings, historyRetention: String(retention || 'all') }; + this._guardHosters(config, false); + await this._commit(config); + try { + await this._writeHistoryFileAtomic(pruned); + } catch (historyError) { + config.globalSettings = previousGlobalSettings; + try { + await this._commit(config); + } catch (rollbackError) { + throw new AggregateError([historyError, rollbackError], 'Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden'); + } + throw historyError; + } + return result; + }); }); } return this._enqueueWrite(() => { diff --git a/lib/updater.js b/lib/updater.js index 722f5f6..62745c9 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -14,6 +14,7 @@ let cachedCheckTs = 0; const CACHE_TTL = 10 * 60 * 1000; // 10 min let activeAbort = null; +const launchedInstallerPaths = new Set(); function getCurrentVersion() { return app.getVersion(); @@ -131,10 +132,10 @@ async function checkForUpdate() { return cachedCheck; } -async function parseLatestYml(url) { +async function parseLatestYml(url, fetchImpl = fetch) { if (!url) return null; try { - const res = await fetch(url, { redirect: 'follow' }); + const res = await fetchImpl(url, { redirect: 'follow' }); const text = await res.text(); // Extract sha512 from latest.yml const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/); @@ -150,17 +151,18 @@ function verifyExeHeader(buf) { return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ' } -async function installUpdate(onProgress) { +async function prepareUpdate(onProgress, options = {}) { if (activeAbort) activeAbort.abort(); activeAbort = new AbortController(); const signal = activeAbort.signal; + const fetchImpl = options.fetchImpl || fetch; try { // Stage: starting if (onProgress) onProgress({ stage: 'starting', percent: 0 }); // Check or use cached - let check = cachedCheck; + let check = options.checkResult || cachedCheck; if (!check || !check.available) { check = await checkForUpdate(); } @@ -172,10 +174,10 @@ async function installUpdate(onProgress) { } // Stage: downloading - const tmpDir = app.getPath('temp'); + const tmpDir = options.tempDir || app.getPath('temp'); const installerPath = path.join(tmpDir, check.assetName); - const res = await fetch(check.assetUrl, { + const res = await fetchImpl(check.assetUrl, { method: 'GET', signal, redirect: 'follow' @@ -233,7 +235,7 @@ async function installUpdate(onProgress) { } // Optional SHA-512 verification from latest.yml - const expectedSha = await parseLatestYml(check.latestYmlUrl); + const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl); if (expectedSha) { const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64'); if (actualSha !== expectedSha) { @@ -248,32 +250,14 @@ async function installUpdate(onProgress) { // Write to disk fs.writeFileSync(installerPath, fileBuffer); - // Stage: launching - if (onProgress) onProgress({ stage: 'launching', percent: 100 }); - - const { spawn } = require('child_process'); - spawn(installerPath, ['/S', '--updated', '--force-run'], { - detached: true, - stdio: 'ignore' - }).unref(); - - // Stage: done - if (onProgress) onProgress({ stage: 'done', percent: 100 }); - - const _doQuit = () => setTimeout(() => app.quit(), 900); - const _getActive = () => { - try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; } - catch { return 0; } + const prepared = { + installerPath, + assetName: check.assetName, + remoteVersion: check.remoteVersion || '', + transportTag: check.transportTag || '' }; - if (_getActive() > 0) { - const POLL_MS = 3000; - const poller = setInterval(() => { - if (_getActive() === 0) { clearInterval(poller); _doQuit(); } - }, POLL_MS); - setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000); - } else { - _doQuit(); - } + if (onProgress) onProgress({ stage: 'prepared', percent: 100 }); + return prepared; } catch (err) { if (onProgress) onProgress({ stage: 'error', error: err.message }); @@ -283,6 +267,25 @@ async function installUpdate(onProgress) { } } +function launchPreparedUpdate(prepared, options = {}) { + const installerPath = prepared && typeof prepared.installerPath === 'string' ? prepared.installerPath : ''; + if (!installerPath) throw new Error('Vorbereitetes Update ist unvollständig'); + const key = path.resolve(installerPath).toLowerCase(); + if (launchedInstallerPaths.has(key)) return false; + const spawnImpl = options.spawnImpl || require('child_process').spawn; + launchedInstallerPaths.add(key); + try { + spawnImpl(installerPath, ['/S', '--updated', '--force-run'], { + detached: true, + stdio: 'ignore' + }).unref(); + return true; + } catch (error) { + launchedInstallerPaths.delete(key); + throw error; + } +} + function abortUpdate() { if (activeAbort) { activeAbort.abort(); @@ -290,4 +293,4 @@ function abortUpdate() { } } -module.exports = { checkForUpdate, installUpdate, abortUpdate, isNewer, resolveReleaseVersion }; +module.exports = { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion }; diff --git a/main.js b/main.js index 227bddd..c2c63ba 100644 --- a/main.js +++ b/main.js @@ -15,7 +15,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload'); const { selectUploadAuth } = require('./lib/account-auth'); const { createAccountPicker } = require('./lib/account-rotation'); const ClouddropUploader = require('./lib/clouddrop-upload'); -const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); +const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup'); const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup'); @@ -93,16 +93,146 @@ if (_perfOn) { } let mainWindow; +let closeFlushApproved = false; +let closeFlushRequested = false; +let closeHandshakeReady = false; +let closeFlushTimer = null; +let restartAfterClosePreparation = false; +let quitTeardownStarted = false; +let closePreparationAttempt = 0; +let closeQuiesceOwnerAttempt = null; +let lastRestoredCloseAttempt = null; +let closeFolderMonitorWasRunning = false; +let preparedUpdate = null; +let updatePreparationPromise = null; +let updateQuitPending = false; +let preparedUpdateLaunchStarted = false; let _lastImportPath = null; let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; +const activeUploadProducerTrackers = new Set(); const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running)); let diagnosticAgent = null; let _diagHandler = null; +function assertConfigWriteAllowed() { + if (!settingsImportGate.canStartUpload()) throw new Error('Einstellungen werden gerade importiert'); +} + +async function waitForConfigStoreWrites() { + await configStore.drainWrites(); +} + +function clearCloseFlushTimer() { + if (closeFlushTimer) clearTimeout(closeFlushTimer); + closeFlushTimer = null; +} + +function waitForCloseOperation(promise, timeoutMs) { + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => setTimeout(() => reject(new Error('Speichern vor dem Beenden hat zu lange gedauert')), timeoutMs)) + ]); +} + +function trackUploadProducer(manager) { + let settled = false; + let resolveProducer; + const promise = new Promise(resolve => { resolveProducer = resolve; }); + const tracker = { + manager, + promise, + finish() { + if (settled) return; + settled = true; + activeUploadProducerTrackers.delete(tracker); + resolveProducer(); + } + }; + activeUploadProducerTrackers.add(tracker); + return tracker; +} + +function isClosePreparationActive(attempt) { + return closeFlushRequested && !closeFlushApproved && attempt === closePreparationAttempt; +} + +function acquireCloseQuiesce(attempt) { + if (!isClosePreparationActive(attempt)) return false; + if (closeQuiesceOwnerAttempt !== null && closeQuiesceOwnerAttempt !== attempt) return false; + closeQuiesceOwnerAttempt = attempt; + configStore.setWritesQuiesced(true); + return true; +} + +function releaseCloseQuiesce(attempt) { + if (closeQuiesceOwnerAttempt !== attempt) return false; + closeQuiesceOwnerAttempt = null; + configStore.setWritesQuiesced(false); + return true; +} + +function rejectPendingUpdate(error) { + if (!preparedUpdate && !updateQuitPending) return false; + preparedUpdate = null; + updateQuitPending = false; + preparedUpdateLaunchStarted = false; + safeSend('app:update-progress', { + stage: 'error', + error: error && error.message ? error.message : String(error || 'Einstellungen konnten vor dem Update nicht gespeichert werden') + }); + return true; +} + +function restoreClosePreparation(attempt, clearRestart = true) { + if (!isClosePreparationActive(attempt)) return lastRestoredCloseAttempt === attempt; + closeFlushApproved = false; + closeFlushRequested = false; + clearCloseFlushTimer(); + releaseCloseQuiesce(attempt); + lastRestoredCloseAttempt = attempt; + if (clearRestart) restartAfterClosePreparation = false; + if (closeFolderMonitorWasRunning) { + closeFolderMonitorWasRunning = false; + const settings = configStore.load().globalSettings?.folderMonitor; + if (settings?.enabled && settings.folderPath) startFolderMonitor(settings); + } + rejectPendingUpdate(new Error('Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten')); + return true; +} + +function armCloseFlushTimer(attempt, timeoutMs) { + clearCloseFlushTimer(); + closeFlushTimer = setTimeout(() => { + closeFlushTimer = null; + restoreClosePreparation(attempt); + }, timeoutMs); +} + +function requestClosePreparation() { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed() || !closeHandshakeReady) return false; + if (closeFlushRequested) return true; + const attempt = ++closePreparationAttempt; + closeFlushRequested = true; + closeFolderMonitorWasRunning = !!folderMonitor.running; + try { folderMonitor.stop(); } catch {} + try { if (uploadManager) uploadManager.cancel(); } catch {} + (async () => { + try { + await waitForCloseOperation(Promise.all(Array.from(activeUploadProducerTrackers, tracker => tracker.promise)), 2500); + if (attempt !== closePreparationAttempt || !closeFlushRequested) return; + safeSend('app:prepare-close', attempt); + armCloseFlushTimer(attempt, 1500); + } catch { + restoreClosePreparation(attempt); + } + })(); + return true; +} + const _hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!_hasSingleInstanceLock) { app.quit(); @@ -708,6 +838,7 @@ function _flushUploadLog() { function _persistFallbackLogPath(workingPath) { try { + if (!settingsImportGate.canStartUpload()) return; const cfg = configStore.load(); const gs = cfg.globalSettings || {}; const mode = gs.logMode || 'single'; @@ -913,7 +1044,7 @@ function makeAccountPicker(config) { function persistRotation(pick) { if (!pick.dirty()) return; _rotationCursors = { ...rotationCursors(), ...pick.indices() }; - configStore.saveRotationCursors(_rotationCursors); + configStore.saveRotationCursors(_rotationCursors).catch(error => debugLog(`rotation cursor save failed: ${error.message}`)); } function buildUploadTasks(config, files, hosters, pick) { @@ -1231,7 +1362,7 @@ function createWindow() { height: 750, minWidth: 800, minHeight: 550, - backgroundColor: '#16181c', + backgroundColor: '#0f0f0f', autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -1240,9 +1371,28 @@ function createWindow() { } }); mainWindow = startupWindow.window; + closePreparationAttempt++; + closeFlushApproved = false; + closeFlushRequested = false; + closeHandshakeReady = false; + closeQuiesceOwnerAttempt = null; + lastRestoredCloseAttempt = null; + configStore.setWritesQuiesced(false); + clearCloseFlushTimer(); + + mainWindow.on('close', (event) => { + if (closeFlushApproved || !closeHandshakeReady || mainWindow.webContents.isDestroyed()) return; + event.preventDefault(); + requestClosePreparation(); + }); mainWindow.webContents.setBackgroundThrottling(false); + mainWindow.webContents.on('did-start-loading', () => { + closeHandshakeReady = false; + restoreClosePreparation(closePreparationAttempt); + }); + mainWindow.webContents.on('render-process-gone', (_event, details) => { _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); @@ -1422,7 +1572,27 @@ app.on('window-all-closed', () => { app.quit(); }); -app.on('before-quit', () => { +app.on('before-quit', (event) => { + if (!closeFlushApproved && mainWindow && !mainWindow.isDestroyed() && closeHandshakeReady) { + event.preventDefault(); + requestClosePreparation(); + } +}); + +app.on('will-quit', () => { + if (quitTeardownStarted) return; + quitTeardownStarted = true; + if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) { + preparedUpdateLaunchStarted = true; + try { + launchPreparedUpdate(preparedUpdate); + } catch (error) { + logError('prepared update launch failed', error); + } + } + preparedUpdate = null; + updateQuitPending = false; + if (restartAfterClosePreparation) app.relaunch(); if (uploadManager) try { uploadManager.cancel(); } catch {} try { folderMonitor.stop(); } catch {} try { @@ -1467,6 +1637,7 @@ ipcMain.handle('get-config', () => { }); ipcMain.handle('save-config', async (_event, config) => { + assertConfigWriteAllowed(); await configStore.save(config); if (config && config.globalSettings) _invalidateLogSettings(); try { @@ -1519,6 +1690,7 @@ ipcMain.handle('get-history', () => { ipcMain.handle('prune-history', async (_event, payload) => { const retention = payload && payload.retention; const dryRun = !!(payload && payload.dryRun); + if (!dryRun) assertConfigWriteAllowed(); return configStore.pruneHistory(retention, { dryRun }); }); @@ -1725,6 +1897,7 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => { }); ipcMain.handle('start-upload', (_event, payload) => { + if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' }; const config = configStore.load(); const files = payload && Array.isArray(payload.files) ? payload.files : []; @@ -1784,6 +1957,8 @@ ipcMain.handle('start-upload', (_event, payload) => { // Pass hoster settings to the upload manager uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); globalThis._mhuUploadManagerRef = uploadManager; + const _thisManager = uploadManager; + const _producerTracker = trackUploadProducer(_thisManager); const _progressByJob = new Map(); const _progressTerminalQueue = []; @@ -1907,7 +2082,6 @@ ipcMain.handle('start-upload', (_event, payload) => { // 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}`); logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); @@ -1918,7 +2092,16 @@ ipcMain.handle('start-upload', (_event, payload) => { try { await configStore.appendHistory(summary); } catch (err) { debugLog(`appendHistory failed: ${err.message}`); } + if (_progressFlushTimer) { + clearTimeout(_progressFlushTimer); + _progressFlushTimer = null; + } + const finalProgressBatch = _progressTerminalQueue.splice(0); + for (const value of _progressByJob.values()) finalProgressBatch.push(value); + _progressByJob.clear(); + if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); safeSend('upload-batch-done', summary); + _producerTracker.finish(); const fullyAborted = isAllAborted(summary); if (isAutoRetry) { @@ -1939,9 +2122,19 @@ ipcMain.handle('start-upload', (_event, payload) => { // 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; } + if (uploadManager !== _thisManager) { + debugLog('nextTick: uploadManager was replaced before startBatch'); + _producerTracker.finish(); + return; + } + if (closeFlushRequested) { + try { _thisManager.cancel(); } catch {} + if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } + _producerTracker.finish(); + return; + } debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); - uploadManager.startBatch(tasks, { + _thisManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) }).catch((err) => { @@ -1956,6 +2149,7 @@ ipcMain.handle('start-upload', (_event, payload) => { error: err ? err.message : 'Unbekannter Fehler' }; safeSend('upload-batch-done', errorSummary); + _producerTracker.finish(); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } }); @@ -1992,6 +2186,7 @@ ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => { }); ipcMain.handle('add-jobs-to-batch', (_event, payload) => { + if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!uploadManager || !uploadManager.running) { return { error: 'Kein Upload aktiv' }; } @@ -2208,6 +2403,7 @@ ipcMain.handle('open-log-folder', async () => { }); ipcMain.handle('clear-history', async () => { + assertConfigWriteAllowed(); await configStore.clearHistory(); return true; }); @@ -2266,6 +2462,7 @@ async function syncImportedRuntime(config) { async function applyImportedSettings(imported) { settingsImportGate.begin(); try { + await waitForConfigStoreWrites(); const prepared = prepareImportedSettings(imported); const ts = new Date().toISOString().replace(/[:.]/g, '-'); const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`); @@ -2302,6 +2499,7 @@ ipcMain.handle('export-backup', async () => { ] }); if (canceled || !filePath) return { ok: false, canceled: true }; + await waitForConfigStoreWrites(); const config = createPortableSettingsSnapshot(configStore.load()); if (filePath.toLowerCase().endsWith('.json')) { fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8'); @@ -2363,6 +2561,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { ipcMain.handle('online-backup:create', async () => { try { + await waitForConfigStoreWrites(); const snapshot = createPortableSettingsSnapshot(configStore.load()); const created = createOnlineBackup(snapshot, app.getVersion()); await uploadOnlineBackup(created.record); @@ -2455,18 +2654,37 @@ ipcMain.handle('app:check-updates', async () => { } }); -ipcMain.handle('app:install-update', () => { - try { if (uploadManager) uploadManager.cancel(); } catch {} - installUpdate((progress) => { - safeSend('app:update-progress', progress); - }).catch((err) => { - safeSend('app:update-progress', { stage: 'error', error: err.message }); - }); - return { started: true }; +ipcMain.handle('app:install-update', async () => { + if (updatePreparationPromise || preparedUpdate || updateQuitPending) { + return { started: false, error: 'Ein Update wird bereits vorbereitet' }; + } + updatePreparationPromise = (async () => { + const prepared = await prepareUpdate((progress) => { + safeSend('app:update-progress', progress); + }); + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed() || !closeHandshakeReady) { + throw new Error('Die Anwendung ist noch nicht bereit, das Update sicher zu installieren'); + } + preparedUpdate = prepared; + updateQuitPending = true; + preparedUpdateLaunchStarted = false; + setImmediate(() => app.quit()); + return { started: true }; + })(); + try { + return await updatePreparationPromise; + } catch (error) { + rejectPendingUpdate(error); + safeSend('app:update-progress', { stage: 'error', error: error.message }); + return { started: false, error: error.message }; + } finally { + updatePreparationPromise = null; + } }); ipcMain.handle('app:abort-update', () => { abortUpdate(); + rejectPendingUpdate(new Error('Update abgebrochen')); return true; }); @@ -2475,7 +2693,7 @@ ipcMain.handle('app:get-version', () => { }); ipcMain.handle('app:restart', () => { - app.relaunch(); + restartAfterClosePreparation = true; app.quit(); }); @@ -2483,6 +2701,53 @@ ipcMain.handle('app:quit', () => { app.quit(); }); +ipcMain.on('app:close-handshake-ready', (event) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true; +}); + +ipcMain.on('app:close-preparation-started', (event, attempt) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { + armCloseFlushTimer(attempt, 3000); + } +}); + +ipcMain.handle('app:finish-close', async (event, payload = true) => { + if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return false; + const attempt = payload && typeof payload === 'object' && Number.isInteger(payload.attempt) ? payload.attempt : null; + if (attempt === null) return false; + const ready = payload && typeof payload === 'object' ? payload.ready !== false : payload !== false; + if (!ready) { + if (isClosePreparationActive(attempt)) { + clearCloseFlushTimer(); + return restoreClosePreparation(attempt); + } + return lastRestoredCloseAttempt === attempt; + } + if (!isClosePreparationActive(attempt)) return false; + clearCloseFlushTimer(); + let approved = false; + try { + if (!acquireCloseQuiesce(attempt)) return false; + await waitForCloseOperation(waitForConfigStoreWrites(), 1000); + if (!isClosePreparationActive(attempt)) return false; + if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'pendingQueue')) { + await waitForCloseOperation(configStore.savePendingQueue(payload.pendingQueue, { allowDuringQuiesce: true }), 1000); + } + if (!isClosePreparationActive(attempt)) return false; + closeFlushApproved = true; + approved = true; + setImmediate(() => { + app.quit(); + }); + return true; + } catch { + restoreClosePreparation(attempt); + return false; + } finally { + if (!approved) releaseCloseQuiesce(attempt); + } +}); + // --- Hoster settings --- ipcMain.handle('get-hoster-settings', () => { const config = configStore.load(); @@ -2490,8 +2755,11 @@ ipcMain.handle('get-hoster-settings', () => { }); ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => { + assertConfigWriteAllowed(); await configStore.save({ hosterSettings }); - if (uploadManager) uploadManager.updateSettings(hosterSettings, null); + if (uploadManager) { + try { uploadManager.updateSettings(hosterSettings, null); } catch (error) { debugLog(`hoster settings runtime update failed: ${error.message}`); } + } return true; }); @@ -2501,22 +2769,19 @@ ipcMain.handle('get-global-settings', () => { return config.globalSettings || {}; }); -function _preserveDiagSubtree(globalSettings) { - if (!globalSettings || typeof globalSettings !== 'object') return globalSettings; - try { - const cur = configStore.load(); - if (cur.globalSettings && cur.globalSettings.diagnostics) { - globalSettings.diagnostics = cur.globalSettings.diagnostics; - } - } catch {} - return globalSettings; -} +ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => { + await configStore.savePendingQueue(pendingQueue); + return true; +}); ipcMain.handle('save-global-settings', async (_event, globalSettings) => { - globalSettings = _preserveDiagSubtree(globalSettings); - await configStore.save({ globalSettings }); + assertConfigWriteAllowed(); + await configStore.saveRendererGlobalSettings(globalSettings); + globalSettings = configStore.load().globalSettings; _invalidateLogSettings(); - if (uploadManager) uploadManager.updateSettings(null, globalSettings); + if (uploadManager) { + try { uploadManager.updateSettings(null, globalSettings); } catch (error) { debugLog(`global settings runtime update failed: ${error.message}`); } + } return true; }); @@ -2546,62 +2811,6 @@ function _sweepOrphanConfigTmps() { } catch {} } -// 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) => { - const tmpPath = configStore.filePath + '.' + process.pid + '.tmp'; - try { - const current = configStore.load(); - const _diskDiag = current.globalSettings && current.globalSettings.diagnostics; - current.globalSettings = globalSettings; - if (_diskDiag) current.globalSettings.diagnostics = _diskDiag; - try { configStore._guardHosters(current, false); } catch {} - _invalidateLogSettings(); - const data = configStore._serializeForDisk(current); - const backupPath = configStore.filePath + '.bak'; - const _fd = fs.openSync(tmpPath, 'w'); - try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); } - 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}`); - } - } - let renamed = false; - let lastErr = null; - for (let attempt = 0; attempt < 5 && !renamed; attempt++) { - try { - fs.renameSync(tmpPath, configStore.filePath); - renamed = true; - } catch (renameErr) { - lastErr = renameErr; - const code = renameErr && renameErr.code; - if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') { - _sleepSyncMs(40); - } else { - throw renameErr; - } - } - } - if (!renamed) throw lastErr || new Error('renameSync failed'); - event.returnValue = true; - } catch (err) { - try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {} - debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`); - event.returnValue = false; - } -}); - // --- Folder Monitor --- function startFolderMonitor(settings) { try { @@ -2809,6 +3018,7 @@ ipcMain.handle('diagnostics:get-settings', () => { }); ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => { + assertConfigWriteAllowed(); const cfg = configStore.load(); const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {}; const next = { @@ -2830,6 +3040,7 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => { }); ipcMain.handle('diagnostics:regenerate', async () => { + assertConfigWriteAllowed(); const cfg = configStore.load(); const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {}; const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() }; @@ -2908,32 +3119,38 @@ async function startRemoteServer() { let token = remote.token; if (!token) { - token = generateToken(); - const gs = { ...config.globalSettings, remote: { ...remote, token } }; - await configStore.save({ globalSettings: gs }); + const canonical = await configStore.saveRemoteSettings(remote, generateToken); + token = canonical.token; } 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() - }); + try { + 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() + }); + } catch (error) { + try { remoteServer.stop(); } catch {} + remoteServer = null; + destroyCaptureWindow(); + throw error; + } debugLog(`remote-server started on port ${remoteServer.getPort()}`); } @@ -3066,19 +3283,27 @@ ipcMain.handle('remote:get-settings', () => { }); ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => { - const config = configStore.load(); - const gs = { ...config.globalSettings, remote: remoteSettings }; - await configStore.save({ globalSettings: gs }); + assertConfigWriteAllowed(); + const canonicalSettings = await configStore.saveRemoteSettings(remoteSettings, generateToken); - if (remoteSettings.enabled) { - await startRemoteServer(); - } else if (remoteServer) { - remoteServer.stop(); - remoteServer = null; - destroyCaptureWindow(); - debugLog('remote-server stopped'); + let runtimeError = ''; + try { + if (canonicalSettings.enabled) { + await startRemoteServer(); + } else if (remoteServer) { + remoteServer.stop(); + remoteServer = null; + destroyCaptureWindow(); + debugLog('remote-server stopped'); + } + } catch (error) { + runtimeError = error && error.message ? error.message : String(error); } - return true; + return { + saved: true, + runtimeError, + settings: canonicalSettings + }; }); ipcMain.handle('remote:generate-token', () => { @@ -3095,6 +3320,7 @@ ipcMain.handle('remote:status', () => { // --- Always on top --- ipcMain.handle('set-always-on-top', async (_event, value) => { + assertConfigWriteAllowed(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setAlwaysOnTop(!!value); } diff --git a/package-lock.json b/package-lock.json index 5f88ad2..e77fd5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.0.5", + "version": "2.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.0.5", + "version": "2.0.6", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 7565aa1..36ca3d8 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "multi-hoster-uploader", - "version": "2.0.5", + "version": "2.0.6", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { "start": "electron .", "test": "node --test tests/*.test.js tests/ui-smoke.js", "test:backup-api": "npm --prefix services/backup-api test", + "lint": "eslint .", "dist": "electron-builder --win", - "release:win": "electron-builder --publish never --win nsis portable", - "release:gitea": "node scripts/release_gitea.mjs" + "release:win": "electron-builder --publish never --win nsis portable" }, "dependencies": { "chokidar": "^3.6.0", diff --git a/preload.js b/preload.js index 2557a48..39c2349 100644 --- a/preload.js +++ b/preload.js @@ -17,7 +17,15 @@ contextBridge.exposeInMainWorld('api', { // Global settings getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'), saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings), - saveGlobalSettingsSync: (settings) => ipcRenderer.sendSync('save-global-settings-sync', settings), + savePendingQueue: (pendingQueue) => ipcRenderer.invoke('save-pending-queue', pendingQueue), + finishClosePreparation: (payload = true) => ipcRenderer.invoke('app:finish-close', payload), + onPrepareClose: (callback) => { + ipcRenderer.on('app:prepare-close', (_event, attempt) => { + ipcRenderer.send('app:close-preparation-started', attempt); + callback(attempt); + }); + }, + signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'), // Always on top setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value), @@ -155,6 +163,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.removeAllListeners('upload-stats'); ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-progress'); + ipcRenderer.removeAllListeners('app:prepare-close'); ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('folder-monitor:new-files'); ipcRenderer.removeAllListeners('drop-target:files'); diff --git a/renderer/app.js b/renderer/app.js index 75cf869..5b63296 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -102,6 +102,161 @@ const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeT isPending() { return h !== null; } }; })(); +let configWriteQueue = Promise.resolve(); +const failedConfigWriteOperations = []; +let configFlushPromise = null; +let configWriteEpoch = 0; +let configImportInProgress = false; +let closePreparationState = 'open'; +let closeWriteAccessDepth = 0; + +function createSupersededConfigWriteError() { + const error = new Error('Einstellungen wurden durch einen Import ersetzt'); + error.code = 'CONFIG_WRITE_SUPERSEDED'; + return error; +} + +function createClosedConfigWriteError() { + const error = new Error('Die Anwendung wird gerade beendet'); + error.code = 'CONFIG_WRITE_CLOSED'; + return error; +} + +function beginConfigImport() { + if (configImportInProgress) throw new Error('Ein Einstellungsimport läuft bereits'); + configImportInProgress = true; + configWriteEpoch++; +} + +function endConfigImport() { + configImportInProgress = false; +} + +function isConfigWriteImportGateError(error) { + return String(error && error.message ? error.message : error).includes('Einstellungen werden gerade importiert'); +} + +function retainFailedConfigWrite(operation, epoch, preserveAcrossImport) { + if (!failedConfigWriteOperations.some(entry => entry.operation === operation)) { + failedConfigWriteOperations.push({ operation, epoch, preserveAcrossImport }); + } +} + +async function retryFailedConfigWrites() { + while (failedConfigWriteOperations.length > 0) { + const entry = failedConfigWriteOperations[0]; + if (entry.epoch !== configWriteEpoch && !entry.preserveAcrossImport) { + failedConfigWriteOperations.shift(); + continue; + } + try { + await entry.operation(); + failedConfigWriteOperations.shift(); + } catch (error) { + if (isConfigWriteImportGateError(error)) { + failedConfigWriteOperations.shift(); + continue; + } + throw error; + } + } +} + +function enqueueConfigWriteOperation(operation) { + const promise = configWriteQueue.then(operation, operation); + const tail = promise.then(() => undefined, () => undefined); + configWriteQueue = tail; + return { promise, tail }; +} + +function runConfigWrite(operation, options = {}) { + const epoch = configWriteEpoch; + const preserveAcrossImport = options.preserveAcrossImport === true; + const allowedDuringClose = options.allowDuringClose === true || closeWriteAccessDepth > 0; + const blockedByImport = configImportInProgress && !preserveAcrossImport; + const blockedByClose = closePreparationState !== 'open' && !allowedDuringClose; + return enqueueConfigWriteOperation(async () => { + if (blockedByClose || (closePreparationState === 'sealed' && !allowedDuringClose)) throw createClosedConfigWriteError(); + if (!preserveAcrossImport && (blockedByImport || configImportInProgress || epoch !== configWriteEpoch)) throw createSupersededConfigWriteError(); + try { + await retryFailedConfigWrites(); + } catch (error) { + retainFailedConfigWrite(operation, epoch, preserveAcrossImport); + throw error; + } + try { + return await operation(); + } catch (error) { + if (!isConfigWriteImportGateError(error)) retainFailedConfigWrite(operation, epoch, preserveAcrossImport); + throw error; + } + }).promise; +} + +async function withCloseWriteAccess(operation) { + closeWriteAccessDepth++; + try { + return await operation(); + } finally { + closeWriteAccessDepth--; + } +} + +function snapshotConfigWritePayload(payload) { + return structuredClone(payload); +} + +function saveConfigTracked(configPatch) { + const payload = snapshotConfigWritePayload(configPatch); + return runConfigWrite(() => window.api.saveConfig(payload)); +} + +function saveHosterSettingsTracked(settings) { + const payload = snapshotConfigWritePayload(settings); + return runConfigWrite(() => window.api.saveHosterSettings(payload)); +} + +function saveGlobalSettingsTracked(settings) { + const payload = snapshotConfigWritePayload(settings); + return runConfigWrite(() => window.api.saveGlobalSettings(payload)); +} + +function savePendingQueueTracked(pendingQueue) { + const payload = snapshotConfigWritePayload(pendingQueue); + return runConfigWrite(() => window.api.savePendingQueue(payload), { preserveAcrossImport: true }); +} + +function setAlwaysOnTopTracked(value) { + const enabled = !!value; + return runConfigWrite(() => window.api.setAlwaysOnTop(enabled)); +} + +function saveDiagnosticsSettingsTracked(settings) { + const payload = snapshotConfigWritePayload(settings); + return runConfigWrite(() => window.api.diagnosticsSaveSettings(payload)); +} + +function saveRemoteSettingsTracked(settings) { + const payload = snapshotConfigWritePayload(settings); + return runConfigWrite(() => window.api.remoteSaveSettings(payload)); +} + +function flushConfigWrites() { + if (configFlushPromise) return configFlushPromise; + configFlushPromise = (async () => { + while (true) { + const { promise, tail } = enqueueConfigWriteOperation(retryFailedConfigWrites); + await promise; + if (configWriteQueue === tail && failedConfigWriteOperations.length === 0) return; + } + })(); + configFlushPromise.then( + () => { configFlushPromise = null; }, + () => { configFlushPromise = null; } + ); + return configFlushPromise; +} + let _restoredSnapshotSavedAt = null; let settingsSaveTimer = null; const settingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performSaveSettings); @@ -142,11 +297,21 @@ const _doneRemovalCoalescer = window.CoalescedSet }) : null; const queueSortState = { key: 'filename', direction: 'asc' }; +let uploadSidebarFilter = 'all'; +let _queueFilterCache = { filter: '', source: null, result: [] }; // History state let historyRowsData = []; let historySortState = { key: 'date', direction: 'desc' }; let _historySortClicked = false; +let historySidebarCounts = { total: 0, success: 0, error: 0 }; +let accountSidebarFilter = 'all'; +let historySidebarFilter = 'all'; +let _knownUpdateInfo = null; +let _updateCheckBusy = false; +let _updateInstallBusy = false; +let _updateDialogReturnFocus = null; +let _updateDialogInertState = []; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; @@ -471,17 +636,76 @@ async function _handleMenuAction(action) { break; } case 'check-updates': { - showCopyToast('Suche nach Updates…'); - try { - const result = await window.api.checkForUpdate(); - if (result && result.available) { showUpdateBanner(result); showCopyToast('Update gefunden!'); } - else showCopyToast('Kein Update verfügbar'); - } catch { showCopyToast('Fehler beim Prüfen'); } + await requestUpdateCheck(); break; } } } +function _setHeaderUpdateLabel(text) { + const button = document.getElementById('headerUpdateBtn'); + if (!button) return; + const label = button.querySelector('[data-update-label], .header-update-label'); + if (label) label.textContent = text; + else button.textContent = text; +} + +function _syncHeaderUpdateState() { + const button = document.getElementById('headerUpdateBtn'); + const available = !!(_knownUpdateInfo && _knownUpdateInfo.available); + const version = available ? String(_knownUpdateInfo.remoteVersion || '').replace(/^v/i, '') : ''; + const hint = _updateCheckBusy + ? 'Suche nach Aktualisierungen…' + : available + ? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.` + : 'Nach Aktualisierungen suchen'; + if (button) { + button.classList.toggle('update-available', available); + button.classList.toggle('is-checking', _updateCheckBusy); + button.disabled = _updateCheckBusy; + button.setAttribute('aria-busy', _updateCheckBusy ? 'true' : 'false'); + button.setAttribute('aria-label', hint); + button.title = hint; + button.dataset.tooltip = hint; + _setHeaderUpdateLabel(_updateCheckBusy ? 'Prüfen…' : (available ? 'Update verfügbar' : 'Update')); + } + const manualButton = document.getElementById('manualUpdateCheckBtn'); + if (manualButton) { + manualButton.disabled = _updateCheckBusy; + manualButton.setAttribute('aria-busy', _updateCheckBusy ? 'true' : 'false'); + manualButton.textContent = _updateCheckBusy ? 'Prüfe…' : (available ? 'Update verfügbar' : 'Nach Updates suchen'); + } +} + +async function requestUpdateCheck() { + if (_knownUpdateInfo && _knownUpdateInfo.available) { + showUpdateBanner(_knownUpdateInfo); + return _knownUpdateInfo; + } + if (_updateCheckBusy) return null; + _updateCheckBusy = true; + _syncHeaderUpdateState(); + showCopyToast('Suche nach Updates…'); + try { + const result = await window.api.checkForUpdate(); + if (result && result.available) { + showUpdateBanner(result); + showCopyToast('Update gefunden!'); + } else if (result && result.error) { + showCopyToast('Updateprüfung fehlgeschlagen'); + } else { + showCopyToast('Kein Update verfügbar'); + } + return result; + } catch { + showCopyToast('Updateprüfung fehlgeschlagen'); + return null; + } finally { + _updateCheckBusy = false; + _syncHeaderUpdateState(); + } +} + function _syncMenuSettings() { const gs = config.globalSettings || {}; const pInput = document.getElementById('menuParallelInput'); @@ -500,7 +724,7 @@ function _menuSaveParallel(v) { const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0)); const gs = { ...(config.globalSettings || {}), parallelUploadCount: n }; config.globalSettings = gs; - window.api.saveGlobalSettings(gs).catch(() => {}); + saveGlobalSettingsTracked(gs).catch(() => {}); const mirror = document.getElementById('parallelUploadCountInput'); if (mirror) mirror.value = String(n); return n; @@ -510,7 +734,7 @@ function _menuSaveSpeedMbs(mbs, enabled) { const kbs = enabled ? Math.max(0, Math.round((parseFloat(mbs) || 0) * 1024)) : 0; const gs = { ...(config.globalSettings || {}), globalMaxSpeedKbs: kbs }; config.globalSettings = gs; - window.api.saveGlobalSettings(gs).catch(() => {}); + saveGlobalSettingsTracked(gs).catch(() => {}); const mirror = document.getElementById('globalMaxSpeedMbsInput'); if (mirror) mirror.value = kbs > 0 ? String(+(kbs / 1024).toFixed(2)) : '0'; } @@ -909,15 +1133,18 @@ function buildPersistedQueueState() { } async function persistQueueStateNow() { + const pendingQueue = buildPersistedQueueState(); const globalSettings = { ...(config.globalSettings || {}), - pendingQueue: buildPersistedQueueState() + pendingQueue }; config.globalSettings = globalSettings; - await window.api.saveGlobalSettings(globalSettings); + await savePendingQueueTracked(pendingQueue); + return pendingQueue; } function persistQueueStateSoon(immediate) { + if (closePreparationState !== 'open') return; if (immediate) { queuePersistThrottle.cancel(); persistQueueStateNow().catch(() => {}); @@ -930,13 +1157,10 @@ function persistQueueStateSoon(immediate) { } function clearPersistedQueueStateSoon() { + if (closePreparationState !== 'open') return; queuePersistThrottle.request(() => { - const globalSettings = { - ...(config.globalSettings || {}), - pendingQueue: null - }; - config.globalSettings = globalSettings; - window.api.saveGlobalSettings(globalSettings).catch(() => {}); + config.globalSettings = { ...(config.globalSettings || {}), pendingQueue: null }; + savePendingQueueTracked(null).catch(() => {}); }, 0); } @@ -1108,6 +1332,7 @@ const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server' function updateQueueActionButtons() { updateStartButton(); + _normalizeQueueSelectionToVisible(); const hasSelection = selectedJobIds.size > 0; // Single pass over the (usually small) selection set instead of three O(n) @@ -1435,11 +1660,58 @@ function _updateRowInPlace(tr, job) { return true; } +function _matchesUploadSidebarFilter(job, filter = uploadSidebarFilter) { + if (filter === 'active') return job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying'; + if (filter === 'waiting') return job.status === 'preview' || job.status === 'queued'; + if (filter === 'done') return job.status === 'done'; + if (filter === 'error') return job.status === 'error'; + return true; +} + +function _getVisibleQueueJobs() { + if (uploadSidebarFilter === 'all') return queueJobs; + const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job)); + if (_queueFilterCache.filter === uploadSidebarFilter && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) { + let unchanged = true; + for (let index = 0; index < filtered.length; index++) { + if (_queueFilterCache.result[index] !== filtered[index]) { + unchanged = false; + break; + } + } + if (unchanged) return _queueFilterCache.result; + } + _queueFilterCache = { filter: uploadSidebarFilter, source: queueJobs, result: filtered }; + return filtered; +} + +function _normalizeQueueSelectionToVisible(visibleJobs = _getVisibleQueueJobs()) { + if (selectedJobIds.size === 0) return false; + const visibleIds = new Set(visibleJobs.map(job => job.id)); + let changed = false; + for (const id of selectedJobIds) { + if (!visibleIds.has(id)) { + selectedJobIds.delete(id); + changed = true; + } + } + return changed; +} + +function _getVisibleSelectedQueueJobs(predicate) { + const visibleJobs = _getVisibleQueueJobs(); + _normalizeQueueSelectionToVisible(visibleJobs); + return visibleJobs.filter(job => selectedJobIds.has(job.id) && (!predicate || predicate(job))); +} + function renderQueueTable() { const tbody = document.getElementById('queueBody'); if (!tbody) return; - _sortedJobsCache = sortQueueJobs(queueJobs); + const visibleJobs = _getVisibleQueueJobs(); + const selectionChanged = _normalizeQueueSelectionToVisible(visibleJobs); + _sortedJobsCache = sortQueueJobs(visibleJobs); + if (selectionChanged) updateQueueActionButtons(); const totalRows = _sortedJobsCache.length; if (totalRows < 200) { @@ -1841,12 +2113,18 @@ async function doBackupExport() { } function applyImportedConfig(importedConfig, message) { - config = importedConfig; + const pendingQueue = buildPersistedQueueState(); + config = { + ...importedConfig, + globalSettings: { + ...(importedConfig.globalSettings || {}), + pendingQueue + } + }; hosterSettings = config.hosterSettings || {}; ensureAccountStatusEntries(); syncSelectedUploadHosters(); alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); - window.api.setAlwaysOnTop(alwaysOnTopState); renderSettings(); renderAccounts(); renderHosterSummary(); @@ -1855,6 +2133,25 @@ function applyImportedConfig(importedConfig, message) { showCopyToast(message); } +async function persistImportedQueueState() { + let persistenceError = null; + try { + await persistQueueStateNow(); + } catch (error) { + persistenceError = error; + } + try { + await flushConfigWrites(); + } catch (error) { + if (!persistenceError) persistenceError = error; + } + return persistenceError; +} + +function showImportQueuePersistenceError(error) { + showCopyToast(`Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden: ${error.message || error}`, 8000); +} + function setOnlineBackupStatus(message, state = '') { const status = document.getElementById('onlineBackupStatus'); if (!status) return; @@ -1928,12 +2225,21 @@ async function doOnlineBackupRestore() { try { await flushPendingSettingsSaves(); setOnlineBackupStatus('Lade und entschlüssele Einstellungen…', 'busy'); - const result = await window.api.restoreOnlineBackup(key); + beginConfigImport(); + let result; + let queuePersistenceError = null; + try { + result = await window.api.restoreOnlineBackup(key); + if (result && result.ok) applyImportedConfig(result.config, 'Online-Backup importiert'); + } finally { + queuePersistenceError = await persistImportedQueueState(); + endConfigImport(); + } if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht importiert werden'); - applyImportedConfig(result.config, 'Online-Backup importiert'); const warnings = Array.isArray(result.warnings) ? result.warnings : []; if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning'); else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success'); + if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); } catch (error) { setOnlineBackupStatus(error.message || String(error), 'error'); } finally { @@ -2012,7 +2318,16 @@ async function doBackupImport(legacyPassword) { const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined; try { await flushPendingSettingsSaves(); - const result = await window.api.importBackup(pw); + beginConfigImport(); + let result; + let queuePersistenceError = null; + try { + result = await window.api.importBackup(pw); + if (result && result.ok) applyImportedConfig(result.config, 'Backup importiert'); + } finally { + queuePersistenceError = await persistImportedQueueState(); + endConfigImport(); + } if (!result || result.canceled) return; if (result.needsPassword) { const entered = await askLegacyBackupPassword(result.hint); @@ -2020,10 +2335,10 @@ async function doBackupImport(legacyPassword) { return; } if (result.ok) { - applyImportedConfig(result.config, 'Backup importiert'); if (Array.isArray(result.warnings) && result.warnings.length) { alert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`); } + if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); } else if (result.error) { alert('Import fehlgeschlagen: ' + result.error); } @@ -2036,6 +2351,7 @@ document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { + if (_isUpdateDialogVisible()) return; const accountModal = document.getElementById('accountModal'); if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') { const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); @@ -2061,8 +2377,10 @@ document.addEventListener('keydown', (e) => { if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) { sessionFilesData.forEach(r => selectedRecentIds.add(r.order)); renderRecentUploadsPanel(); - } else if (queueJobs.length > 0) { - queueJobs.forEach(j => selectedJobIds.add(j.id)); + } else { + const visibleJobs = _getVisibleQueueJobs(); + selectedJobIds.clear(); + visibleJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); } } @@ -2071,6 +2389,7 @@ document.addEventListener('keydown', (e) => { if (e.key === 'Delete') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); + _normalizeQueueSelectionToVisible(); if (selectedRecentIds.size > 0) { deleteSelectedRecentFiles(); } else if (selectedJobIds.size > 0) { @@ -2112,6 +2431,7 @@ document.getElementById('contextMenu').addEventListener('click', (e) => { }); async function handleContextAction(action) { + _normalizeQueueSelectionToVisible(); if (action === 'start-selected') { startSelectedUpload(); } else if (action === 'copy-links') { @@ -2163,7 +2483,8 @@ async function handleContextAction(action) { persistQueueStateSoon(true); } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; - await window.api.setAlwaysOnTop(alwaysOnTopState); + await setAlwaysOnTopTracked(alwaysOnTopState); + config.globalSettings = { ...(config.globalSettings || {}), alwaysOnTop: alwaysOnTopState }; } else if (action.startsWith('delete-hoster:')) { const hoster = action.replace('delete-hoster:', ''); // Cancel active uploads for this hoster @@ -2190,8 +2511,7 @@ async function handleContextAction(action) { } function getSelectedJobLinks() { - return queueJobs - .filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result) + return _getVisibleSelectedQueueJobs(j => j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); } @@ -2275,10 +2595,11 @@ function _markSkippedJobs(result) { renderQueueTable(); } -async function startSelectedUpload() { +async function startSelectedUpload(explicitJobs) { + const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); if (uploading) { _hydrateMissingJobSizes(); - const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && isStartableQueueStatus(j.status)); + const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status)); if (addable.length === 0) { if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).'); return; @@ -2304,7 +2625,7 @@ async function startSelectedUpload() { uploading = false; updateQueueActionButtons(); updateStatusBar(); - await startSelectedUpload(); + await startSelectedUpload(addable); return; } _markSkippedJobs(result); @@ -2333,7 +2654,7 @@ async function startSelectedUpload() { updateQueueActionButtons(); const hosters = getSelectedHosters(); - const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status)); + const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { @@ -2745,11 +3066,12 @@ function _handleStatsImpl(data) { // --- Per-job log modal --- async function showJobLogModal() { - if (selectedJobIds.size === 0) return; + const selectedJobs = _getVisibleSelectedQueueJobs(); + if (selectedJobs.length === 0) return; // Use the first selected job — log view is per-file, multi-select doesn't // make sense here. - const jobId = [...selectedJobIds][0]; - const job = _jobIndexById.get(jobId); + const job = selectedJobs[0]; + const jobId = job.id; const modal = document.getElementById('jobLogModal'); const titleEl = document.getElementById('jobLogTitle'); const bodyEl = document.getElementById('jobLogBody'); @@ -2799,6 +3121,7 @@ async function copyJobLogToClipboard() { // --- Retry --- async function retrySelectedJobs() { + _normalizeQueueSelectionToVisible(); const retryJobs = []; // Build a Set for O(1) selectedFiles dedup below. const existingFilePaths = new Set(); @@ -2844,10 +3167,11 @@ async function retrySelectedJobs() { selectedJobIds.clear(); retryJobs.forEach(j => selectedJobIds.add(j.id)); persistQueueStateSoon(); - await startSelectedUpload(); + await startSelectedUpload(retryJobs); } async function abortSelectedJobs() { + _normalizeQueueSelectionToVisible(); const activeJobIds = []; queueJobs.forEach((job) => { @@ -2887,6 +3211,7 @@ async function abortAllUploads() { } function moveSelectedJobs(direction) { + _normalizeQueueSelectionToVisible(); if (uploading || selectedJobIds.size === 0) return; const jobs = queueJobs.slice(); @@ -3057,6 +3382,134 @@ function _computeQueueStats() { return _queueStatsCache; } +function _setSidebarCount(id, value) { + const element = document.getElementById(id); + if (element) element.textContent = Number(value || 0).toLocaleString('de-DE'); +} + +function _syncSidebarFilterButtons(selector, datasetKey, value) { + document.querySelectorAll(selector).forEach(button => { + const active = button.dataset[datasetKey] === value; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); +} + +function setUploadSidebarFilter(value) { + if (!['all', 'active', 'waiting', 'done', 'error'].includes(value)) return; + uploadSidebarFilter = value; + _syncSidebarFilterButtons('[data-upload-sidebar-target]', 'uploadSidebarTarget', value); + _queueFilterCache = { filter: '', source: null, result: [] }; + _normalizeQueueSelectionToVisible(); + _lastVisibleRange = { start: -1, end: -1 }; + const container = document.getElementById('queueContainer'); + if (container) container.scrollTop = 0; + renderQueueTable(); +} + +function updateUploadSidebarSummary(stats = _computeQueueStats()) { + let waiting = 0; + let active = 0; + for (const job of queueJobs) { + if (job.status === 'preview' || job.status === 'queued') waiting++; + else if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') active++; + } + const accountCount = getAccountsWithCredsFlat().filter(({ account }) => account.enabled !== false).length; + _setSidebarCount('uploadSidebarAllCount', stats.total); + _setSidebarCount('uploadSidebarActiveCount', active); + _setSidebarCount('uploadSidebarWaitingCount', waiting); + _setSidebarCount('uploadSidebarDoneCount', stats.done); + _setSidebarCount('uploadSidebarErrorCount', stats.errors); + _setSidebarCount('uploadSidebarAccountsCount', accountCount); +} + +function updateAccountSidebarSummary(allAccounts = getAllAccountsFlat()) { + let ready = 0; + let warning = 0; + let error = 0; + let availableAccounts = 0; + const hosterCounts = new Map(); + for (const { name, account } of allAccounts) { + hosterCounts.set(name, (hosterCounts.get(name) || 0) + 1); + if (account.enabled !== false && accountHasCreds(name, account)) availableAccounts++; + const category = _getAccountSidebarCategory(name, account); + if (category === 'ready') ready++; + else if (category === 'error') error++; + else warning++; + } + _setSidebarCount('accountsSidebarAllCount', allAccounts.length); + _setSidebarCount('accountsSidebarReadyCount', ready); + _setSidebarCount('accountsSidebarWarningCount', warning); + _setSidebarCount('accountsSidebarErrorCount', error); + _setSidebarCount('uploadSidebarAccountsCount', availableAccounts); + const container = document.getElementById('accountsSidebarHosters'); + if (container) { + container.replaceChildren(...HOSTERS.filter(name => hosterCounts.has(name)).map(name => { + const row = document.createElement('div'); + row.className = 'view-sidebar-hoster'; + const dot = document.createElement('span'); + dot.className = 'view-sidebar-hoster-dot'; + const label = document.createElement('span'); + label.className = 'view-sidebar-copy'; + label.textContent = getHosterLabel(name); + const count = document.createElement('span'); + count.className = 'view-sidebar-badge'; + count.textContent = hosterCounts.get(name).toLocaleString('de-DE'); + row.append(dot, label, count); + return row; + })); + } +} + +function _getAccountSidebarCategory(name, account) { + if (account.enabled === false || !accountHasCreds(name, account)) return 'warning'; + const status = (accountStatuses[account.id] && accountStatuses[account.id].status) || 'unchecked'; + if (status === 'ok') return 'ready'; + if (status === 'error') return 'error'; + return 'warning'; +} + +function _applyAccountSidebarFilter() { + const entries = new Map(getAllAccountsFlat().map(entry => [entry.account.id, entry])); + document.querySelectorAll('#accountsList .account-hoster-group').forEach(group => { + let matches = 0; + group.querySelectorAll('.account-card').forEach(card => { + const entry = entries.get(card.dataset.accountId); + const visible = !!entry && (accountSidebarFilter === 'all' || _getAccountSidebarCategory(entry.name, entry.account) === accountSidebarFilter); + card.hidden = !visible; + if (visible) matches++; + }); + group.hidden = matches === 0; + }); +} + +function setAccountSidebarFilter(value) { + if (!['all', 'ready', 'warning', 'error'].includes(value)) return; + accountSidebarFilter = value; + _syncSidebarFilterButtons('[data-accounts-sidebar-filter]', 'accountsSidebarFilter', value); + _applyAccountSidebarFilter(); +} + +function setHistorySidebarFilter(value) { + if (!['all', 'success', 'error'].includes(value)) return; + historySidebarFilter = value; + _syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', value); + const container = document.getElementById('historyContainer'); + if (container) { + container.scrollTop = 0; + renderHistoryTable(container); + } +} + +function updateHistorySidebarSummary() { + _setSidebarCount('historySidebarAllCount', historySidebarCounts.total); + _setSidebarCount('historySidebarSuccessCount', historySidebarCounts.success); + _setSidebarCount('historySidebarErrorCount', historySidebarCounts.error); + const retention = document.getElementById('historySidebarRetention'); + const select = document.getElementById('historyRetentionSelect'); + if (retention && select) retention.textContent = select.selectedOptions[0]?.textContent || 'Alles behalten'; +} + function updateStatusBar() { const stats = _computeQueueStats(); @@ -3084,6 +3537,7 @@ function updateStatusBar() { document.getElementById('sbInProgressCount').textContent = `Läuft ${stats.inProgress}`; document.getElementById('sbDoneCount').textContent = `Fertig ${_sessionDoneCount}`; document.getElementById('sbErrorCount').textContent = `Fehler ${_sessionErrorCount}`; + updateUploadSidebarSummary(stats); } // --- Health Check --- @@ -3219,6 +3673,10 @@ function renderSettings() { ${pageDefinitions.map((definition, index) => ``).join('')} +
+ Speicherstatus +
Automatisch gespeichert
+
`; container.appendChild(layout); @@ -3755,13 +4213,14 @@ function renderSettings() { if (bindHintEl) { bindHintEl.innerHTML = 'Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.'; } return; } - await window.api.diagnosticsSaveSettings({ + const diagnosticsSettings = { enabled: enabledEl.checked, port: parseInt(portEl.value, 10) || 9110, bindMode: modeEl.value, publicHost: publicHostEl.value.trim(), allowlist - }); + }; + await saveDiagnosticsSettingsTracked(diagnosticsSettings); applySettings(await window.api.diagnosticsGetSettings()); refreshStatus(); }; @@ -3782,7 +4241,7 @@ function renderSettings() { setTimeout(() => { b.textContent = 'Kopieren'; }, 1500); }); document.getElementById('diagRegenerateBtn').addEventListener('click', async () => { - const r = await window.api.diagnosticsRegenerate(); + const r = await runConfigWrite(() => window.api.diagnosticsRegenerate()); if (r && r.code) { codeEl.value = r.code; issuedEl.textContent = fmtIssued(r.codeIssuedAt); } refreshStatus(); }); @@ -3807,23 +4266,8 @@ function renderSettings() { document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); - document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => { - const btn = e.target; - btn.disabled = true; - btn.textContent = 'Prüfe...'; - try { - const result = await window.api.checkForUpdate(); - if (result && result.available) { - showUpdateBanner(result); - btn.textContent = 'Update gefunden!'; - } else { - btn.textContent = 'Kein Update verfügbar'; - } - } catch { - btn.textContent = 'Fehler beim Prüfen'; - } - setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000); - }); + document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); + _syncHeaderUpdateState(); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(eventName, scheduleSettingsSave); @@ -3839,6 +4283,7 @@ async function chooseLogFilePath() { } function scheduleSettingsSave() { + if (closePreparationState !== 'open') return; const feedback = document.getElementById('saveFeedback'); if (feedback) feedback.textContent = 'Speichert...'; clearTimeout(settingsSaveTimer); @@ -3922,7 +4367,7 @@ async function performSaveSettings(options = {}) { const newAot = !!aotCheckbox.checked; if (newAot !== alwaysOnTopState) { alwaysOnTopState = newAot; - await window.api.setAlwaysOnTop(alwaysOnTopState); + await setAlwaysOnTopTracked(newAot); } } @@ -3944,13 +4389,9 @@ async function performSaveSettings(options = {}) { newHosterSettings[name] = hs; } - // Fire both saves in parallel instead of serializing the two IPC round-trips. - // Skip the getConfig refetch — we just wrote it, we know the new state, and - // the round-trip added 100–200ms of UI stall per keystroke (autosave fires - // on every input change). await Promise.all([ - window.api.saveHosterSettings(newHosterSettings), - window.api.saveGlobalSettings(globalSettings) + saveHosterSettingsTracked(newHosterSettings), + saveGlobalSettingsTracked(globalSettings) ]); config.hosterSettings = newHosterSettings; config.globalSettings = globalSettings; @@ -3978,7 +4419,14 @@ async function performSaveSettings(options = {}) { const remoteBadge = document.getElementById('remoteStatusBadge'); if (remoteSettings) { try { - await window.api.remoteSaveSettings(remoteSettings); + const remoteSaveResult = await saveRemoteSettingsTracked(remoteSettings); + if (remoteSaveResult && remoteSaveResult.settings) { + globalSettings.remote = { ...remoteSaveResult.settings }; + config.globalSettings = { ...(config.globalSettings || {}), remote: { ...remoteSaveResult.settings } }; + const tokenInput = document.getElementById('remoteTokenInput'); + if (tokenInput) tokenInput.value = remoteSaveResult.settings.token || ''; + } + if (remoteSaveResult && remoteSaveResult.runtimeError) throw new Error(remoteSaveResult.runtimeError); if (remoteBadge) { remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'; remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`; @@ -3995,7 +4443,12 @@ async function performSaveSettings(options = {}) { statusEl.style.color = '#94a3b8'; } } - } catch {} + } catch { + if (remoteBadge) { + remoteBadge.textContent = 'Fehler'; + remoteBadge.className = 'panel-status'; + } + } } const feedback = document.getElementById('saveFeedback'); @@ -4082,6 +4535,8 @@ function updateAccountCard(accountId) { tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); card.replaceWith(tmp.firstElementChild); _refreshHosterGroupHeader(found.name); + updateAccountSidebarSummary(); + _applyAccountSidebarFilter(); } function _refreshHosterGroupHeader(name) { @@ -4122,6 +4577,7 @@ function renderAccounts() { ensureAccountStatusEntries(); const allAccounts = getAllAccountsFlat(); + updateAccountSidebarSummary(allAccounts); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; @@ -4133,10 +4589,10 @@ function renderAccounts() {

Noch keine Accounts

Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.

- `; if (footer) footer.style.display = 'none'; if (!_accountListenersBound) bindAccountListeners(container); + _applyAccountSidebarFilter(); return; } @@ -4158,6 +4614,7 @@ function renderAccounts() { _updateToggleAllAccountsBtn(); if (!_accountListenersBound) bindAccountListeners(container); + _applyAccountSidebarFilter(); } function _summarizeHosterGroup(accounts) { @@ -4191,6 +4648,7 @@ const _hosterGroupOpenMemory = new Map(); let _hosterSettingsSaveTimer = null; const hosterSettingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performHosterSettingsSave); function scheduleHosterSettingsSave() { + if (closePreparationState !== 'open') return; clearTimeout(_hosterSettingsSaveTimer); _hosterSettingsSaveTimer = setTimeout(() => { _hosterSettingsSaveTimer = null; @@ -4216,22 +4674,46 @@ async function performHosterSettingsSave() { }); newHosterSettings[name] = hs; } - await window.api.saveHosterSettings(newHosterSettings); + await saveHosterSettingsTracked(newHosterSettings); config.hosterSettings = newHosterSettings; hosterSettings = newHosterSettings; } +async function recoverSerializedSave(coordinator, retry) { + try { + await coordinator.flush(); + return false; + } catch { + await flushConfigWrites(); + await retry(); + return true; + } +} + async function flushPendingSettingsSaves() { - await Promise.all([settingsSaveCoordinator.flush(), hosterSettingsSaveCoordinator.flush()]); const flushSettings = settingsSaveTimer !== null; const flushHosters = _hosterSettingsSaveTimer !== null; clearTimeout(settingsSaveTimer); clearTimeout(_hosterSettingsSaveTimer); settingsSaveTimer = null; _hosterSettingsSaveTimer = null; - if (flushSettings) await saveSettings({ feedbackText: 'Automatisch gespeichert' }); - if (flushHosters) await saveHosterSettingsFromDom(); + queuePersistThrottle.flushSync(); + const settingsRecovered = await recoverSerializedSave( + settingsSaveCoordinator, + () => saveSettings({ feedbackText: 'Automatisch gespeichert' }) + ); + const hostersRecovered = await recoverSerializedSave( + hosterSettingsSaveCoordinator, + saveHosterSettingsFromDom + ); + if (flushSettings && !settingsRecovered) await saveSettings({ feedbackText: 'Automatisch gespeichert' }); + if (flushHosters && !hostersRecovered) await saveHosterSettingsFromDom(); await Promise.all([settingsSaveCoordinator.flush(), hosterSettingsSaveCoordinator.flush()]); + await flushConfigWrites(); + const persistedConfig = await window.api.getConfig(); + config = persistedConfig; + hosterSettings = config.hosterSettings || {}; + alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); } function _buildHosterSettingsHtml(name) { @@ -4476,7 +4958,7 @@ function bindAccountListeners(container) { // Persist in the background. saveConfig is idempotent; we don't need to // await here or re-fetch — our in-memory config is already the truth. - window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); + saveConfigTracked({ hosters: config.hosters }).catch(() => {}); }); } @@ -4490,7 +4972,7 @@ async function toggleAccount(accountId) { // accounts in the list. updateAccountCard(accountId); renderHosterSummary(); - window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); + saveConfigTracked({ hosters: config.hosters }).catch(() => {}); } async function checkSingleAccount(accountId) { @@ -4705,7 +5187,7 @@ async function deleteAccount(accountId) { // Fire-and-forget the persist. The earlier `await getConfig()` round-trip // was redundant (we already have the truth in memory) and was the main // source of perceived lag on add/delete. - window.api.saveConfig({ hosters: config.hosters }).catch((err) => { + saveConfigTracked({ hosters: config.hosters }).catch((err) => { if (window.api && window.api.debugLog) window.api.debugLog(`deleteAccount saveConfig failed: ${err && err.message ? err.message : err}`); showCopyToast('Account-Löschung konnte nicht persistiert werden — bitte erneut versuchen.'); }); @@ -4903,7 +5385,7 @@ async function _persistAccount(ctx, creds) { accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; candidateHosters[ctx.hosterName].push({ id: accountId, ...creds }); } - await window.api.saveConfig({ hosters: candidateHosters }); + await saveConfigTracked({ hosters: candidateHosters }); return { accountId, candidateHosters, isEdit: ctx.isEdit }; } @@ -4952,29 +5434,39 @@ async function loadHistory() { if (!history || history.length === 0) { historyRowsData = []; + historySidebarCounts = { total: 0, success: 0, error: 0 }; + updateHistorySidebarSummary(); container.innerHTML = '

Noch keine Uploads.

'; return; } historySortState = { key: 'date', direction: 'desc' }; historyRowsData = []; + historySidebarCounts = { total: 0, success: 0, error: 0 }; let order = 0; for (const batch of history) { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { - if (result.status === 'aborted' || result.status === 'error') continue; + historySidebarCounts.total++; + const isError = result.status === 'aborted' || result.status === 'error'; + if (isError) historySidebarCounts.error++; + else historySidebarCounts.success++; + const detail = isError + ? String(result.error || result.message || (result.status === 'aborted' ? 'Abgebrochen' : 'Fehlgeschlagen')) + : (result.download_url || result.embed_url || ''); historyRowsData.push({ date: dt.text, dateTs: dt.ts, filename: file.name || '', host: result.hoster || '', - link: result.download_url || result.embed_url || '', - isError: false, order: order++ + link: detail, + isError, order: order++ }); } } } + updateHistorySidebarSummary(); renderHistoryTable(container); } @@ -5201,8 +5693,15 @@ function _renderHistoryVirtualRows() { tbody.innerHTML = parts.join(''); } +function _getVisibleHistoryRows() { + if (historySidebarFilter === 'success') return historyRowsData.filter(row => !row.isError); + if (historySidebarFilter === 'error') return historyRowsData.filter(row => row.isError); + return historyRowsData; +} + function renderHistoryTable(container) { - if (!container || !historyRowsData.length) { + const visibleRows = _getVisibleHistoryRows(); + if (!container || !visibleRows.length) { if (container) container.innerHTML = '

Noch keine Uploads.

'; const emptyNotice = document.getElementById('historyCapNotice'); if (emptyNotice) emptyNotice.style.display = 'none'; @@ -5210,8 +5709,8 @@ function renderHistoryTable(container) { return; } - const total = historyRowsData.length; - const working = total > HISTORY_RENDER_CAP ? historyRowsData.slice(-HISTORY_RENDER_CAP) : historyRowsData; + const total = visibleRows.length; + const working = total > HISTORY_RENDER_CAP ? visibleRows.slice(-HISTORY_RENDER_CAP) : visibleRows; const notice = document.getElementById('historyCapNotice'); if (notice) { if (total > HISTORY_RENDER_CAP) { @@ -5274,30 +5773,127 @@ function sortHistoryRows(rows) { }); } -// Flush pending queue state on window close (sync IPC — blocks until save completes) -window.addEventListener('beforeunload', () => { - // Flush pending settings save if user changed settings right before closing - if (settingsSaveTimer) { - clearTimeout(settingsSaveTimer); - settingsSaveTimer = null; - try { saveSettings(); } catch {} +let closePreparationPromise = null; +let closePreparationInertState = []; +let closePreparationOverlayState = null; +let closePreparationGeneration = 0; +let activeClosePreparationAttempt = null; +const CLOSE_PREPARATION_TIMEOUT_MS = 1200; + +function waitForClosePreparationStep(promise) { + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => setTimeout(() => reject(new Error('Speichern vor dem Beenden hat zu lange gedauert')), CLOSE_PREPARATION_TIMEOUT_MS)) + ]); +} + +function setClosePreparationUi(active) { + const overlay = document.getElementById('shutdownOverlay'); + const message = document.getElementById('shutdownMessage'); + const cancelButton = document.getElementById('cancelShutdownBtn'); + if (!overlay || !message || !cancelButton) return; + if (active) { + if (closePreparationOverlayState) return; + closePreparationOverlayState = { + display: overlay.style.display, + message: message.innerHTML, + cancelDisplay: cancelButton.style.display + }; + closePreparationInertState = Array.from(document.body.children) + .filter(element => element !== overlay && 'inert' in element) + .map(element => ({ element, inert: element.inert })); + closePreparationInertState.forEach(({ element }) => { element.inert = true; }); + message.textContent = 'Einstellungen werden gespeichert…'; + cancelButton.style.display = 'none'; + overlay.style.display = 'flex'; + return; } - queuePersistThrottle.cancel(); - // Drain pending done-removals synchronously before persisting so jobs the - // user expected to disappear (removeFromQueueOnDone=true) don't reappear - // on next launch. Microtask wouldn't run before the sync IPC below. - if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); - const globalSettings = { - ...(config.globalSettings || {}), - pendingQueue: buildPersistedQueueState() - }; - config.globalSettings = globalSettings; - window.api.saveGlobalSettingsSync(globalSettings); -}); + closePreparationInertState.forEach(({ element, inert }) => { + if (element.isConnected) element.inert = inert; + }); + closePreparationInertState = []; + overlay.style.display = closePreparationOverlayState ? closePreparationOverlayState.display : 'none'; + message.innerHTML = closePreparationOverlayState ? closePreparationOverlayState.message : ''; + cancelButton.style.display = closePreparationOverlayState ? closePreparationOverlayState.cancelDisplay : ''; + closePreparationOverlayState = null; +} + +function isCurrentClosePreparation(generation, attempt) { + return generation === closePreparationGeneration && attempt === activeClosePreparationAttempt; +} + +async function recoverWindowClose(generation, attempt, originalError) { + if (!isCurrentClosePreparation(generation, attempt)) return; + closePreparationState = 'recovering'; + let restored = false; + try { + restored = await waitForClosePreparationStep(window.api.finishClosePreparation({ ready: false, attempt })); + } catch (error) { + if (isCurrentClosePreparation(generation, attempt)) showCopyToast(error.message || String(error), 8000); + return; + } + if (!isCurrentClosePreparation(generation, attempt)) return; + if (restored !== true) { + showCopyToast('Die Anwendung konnte nach dem fehlgeschlagenen Speichern nicht entsperrt werden', 8000); + return; + } + try { + await waitForClosePreparationStep(withCloseWriteAccess(async () => { + await flushConfigWrites(); + if (!isCurrentClosePreparation(generation, attempt)) return; + await persistQueueStateNow(); + await flushConfigWrites(); + if (failedConfigWriteOperations.length !== 0) throw new Error('Nicht alle Einstellungen konnten gespeichert werden'); + })); + } catch (error) { + if (isCurrentClosePreparation(generation, attempt)) showCopyToast(error.message || String(error), 8000); + return; + } + if (!isCurrentClosePreparation(generation, attempt)) return; + closePreparationPromise = null; + activeClosePreparationAttempt = null; + closePreparationState = 'open'; + setClosePreparationUi(false); + showCopyToast(originalError.message || String(originalError), 8000); +} + +function prepareForWindowClose(attempt) { + if (!Number.isInteger(attempt)) return Promise.resolve(false); + if (closePreparationPromise && activeClosePreparationAttempt === attempt) return closePreparationPromise; + const generation = ++closePreparationGeneration; + activeClosePreparationAttempt = attempt; + closePreparationState = 'preparing'; + setClosePreparationUi(true); + closePreparationPromise = (async () => { + if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); + await waitForClosePreparationStep(withCloseWriteAccess(flushPendingSettingsSaves)); + if (!isCurrentClosePreparation(generation, attempt)) return; + const pendingQueue = buildPersistedQueueState(); + closePreparationState = 'sealed'; + await waitForClosePreparationStep(flushConfigWrites()); + if (!isCurrentClosePreparation(generation, attempt)) return; + const accepted = await waitForClosePreparationStep(window.api.finishClosePreparation({ ready: true, attempt, pendingQueue })); + if (!accepted) throw new Error('Einstellungen konnten vor dem Beenden nicht gespeichert werden'); + })(); + closePreparationPromise.catch(error => recoverWindowClose(generation, attempt, error)); + return closePreparationPromise; +} // --- Setup Listeners --- function setupListeners() { try { initMenuBar(); } catch (err) { console.error('menu bar init failed', err); } + document.querySelectorAll('[data-upload-sidebar-target]').forEach(button => { + button.addEventListener('click', () => setUploadSidebarFilter(button.dataset.uploadSidebarTarget)); + }); + document.querySelectorAll('[data-accounts-sidebar-filter]').forEach(button => { + button.addEventListener('click', () => setAccountSidebarFilter(button.dataset.accountsSidebarFilter)); + }); + document.querySelectorAll('[data-history-filter]').forEach(button => { + button.addEventListener('click', () => setHistorySidebarFilter(button.dataset.historyFilter)); + }); + _syncSidebarFilterButtons('[data-upload-sidebar-target]', 'uploadSidebarTarget', uploadSidebarFilter); + _syncSidebarFilterButtons('[data-accounts-sidebar-filter]', 'accountsSidebarFilter', accountSidebarFilter); + _syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', historySidebarFilter); document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFolderBtn').addEventListener('click', pickFolder); document.getElementById('startUploadBtn').addEventListener('click', startUpload); @@ -5381,8 +5977,12 @@ function setupListeners() { document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; - await window.api.clearHistory(); - loadHistory(); + try { + await runConfigWrite(() => window.api.clearHistory()); + loadHistory(); + } catch (error) { + showCopyToast(error.message || String(error)); + } }); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); @@ -5398,14 +5998,15 @@ function setupListeners() { if (!ok) { historyRetentionSelect.value = prev; return; } } } - const globalSettings = { ...(config.globalSettings || {}), historyRetention: value }; - config.globalSettings = globalSettings; - await window.api.saveGlobalSettings(globalSettings).catch(() => {}); - if (value !== 'all') { - const res = await window.api.pruneHistory(value); + try { + const res = await runConfigWrite(() => window.api.pruneHistory(value)); + config.globalSettings = { ...(config.globalSettings || {}), historyRetention: value }; if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString('de-DE')} entfernt`); + loadHistory(); + } catch (error) { + historyRetentionSelect.value = prev; + showCopyToast(error.message || String(error)); } - loadHistory(); }); } @@ -5451,7 +6052,7 @@ function setupListeners() { // Click on empty area in queue → deselect all document.getElementById('upload-view').addEventListener('click', (e) => { - if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { + if (e.target.closest('.view-main') && !e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); @@ -5511,35 +6112,242 @@ function setupListeners() { document.getElementById('jobLogModal')?.addEventListener('click', (e) => { if (e.target.id === 'jobLogModal') hideJobLogModal(); }); + + document.getElementById('headerUpdateBtn')?.addEventListener('click', requestUpdateCheck); + document.getElementById('installUpdateBtn')?.addEventListener('click', installKnownUpdate); + document.getElementById('dismissUpdateBtn')?.addEventListener('click', closeUpdateDialog); + document.getElementById('updateCloseBtn')?.addEventListener('click', closeUpdateDialog); + document.getElementById('updateBanner')?.addEventListener('click', (event) => { + if (event.target.id === 'updateBanner') closeUpdateDialog(); + }); + document.addEventListener('keydown', _handleUpdateDialogKeydown, true); + _syncHeaderUpdateState(); } // --- Update UI --- function showUpdateBanner(info) { - const banner = document.getElementById('updateBanner'); - const msg = document.getElementById('updateMessage'); - if (!banner || !msg) return; - msg.textContent = `Update v${info.remoteVersion} verfügbar`; - banner.style.display = 'flex'; - document.getElementById('installUpdateBtn').onclick = async () => { - msg.textContent = 'Update wird heruntergeladen...'; - document.getElementById('installUpdateBtn').disabled = true; - await persistQueueStateNow().catch(() => {}); // Save queue before update restart - await window.api.installUpdate(); - }; - document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; }; + if (!info) return; + _knownUpdateInfo = { ...info, available: true }; + if (_updateInstallBusy) { + _syncHeaderUpdateState(); + _setUpdateDialogVisible(true); + return; + } + _updateInstallBusy = false; + const version = String(info.remoteVersion || '').replace(/^v/i, '') || 'unbekannt'; + const title = document.getElementById('updateDialogTitle'); + const message = document.getElementById('updateMessage'); + const notes = document.getElementById('updateReleaseNotes'); + const installButton = document.getElementById('installUpdateBtn'); + if (title) title.textContent = 'Eine neue Version ist verfügbar'; + if (message) message.textContent = `Update v${version} verfügbar`; + if (notes) { + const releaseNotes = String(info.releaseNotes || '').trim(); + notes.textContent = releaseNotes.length > 2400 ? `${releaseNotes.slice(0, 2399)}…` : releaseNotes; + notes.hidden = !releaseNotes; + } + if (installButton) { + installButton.disabled = false; + installButton.textContent = 'Jetzt updaten'; + } + _setUpdateProgress(0, 'Bereit zum Download'); + _setUpdateDialogBusy(false); + _syncHeaderUpdateState(); + _setUpdateDialogVisible(true); } function handleUpdateProgress(data) { - const msg = document.getElementById('updateMessage'); - if (!msg) return; - if (data.stage === 'downloading') msg.textContent = `Wird heruntergeladen… ${data.percent || 0}%`; - else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; - else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; - else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; - else if (data.stage === 'error') { - msg.textContent = `Update fehlgeschlagen: ${data.error}`; - const btn = document.getElementById('installUpdateBtn'); - if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } + const progress = data || {}; + const message = document.getElementById('updateMessage'); + const button = document.getElementById('installUpdateBtn'); + if (progress.stage === 'starting') { + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(0, 'Download 0%'); + if (message) message.textContent = 'Download wird vorbereitet…'; + if (button) button.textContent = 'Download 0%'; + } else if (progress.stage === 'downloading') { + const percent = Math.max(0, Math.min(100, Math.round(Number(progress.percent) || 0))); + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(percent, `Download ${percent}%`); + if (message) message.textContent = `Update wird heruntergeladen… ${percent}%`; + if (button) button.textContent = `Download ${percent}%`; + } else if (progress.stage === 'verifying') { + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(100, 'Prüfen…'); + if (message) message.textContent = 'Download wird geprüft…'; + if (button) button.textContent = 'Prüfen…'; + } else if (progress.stage === 'prepared') { + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(100, 'Neustart…'); + if (message) message.textContent = 'Update ist bereit. Einstellungen werden gespeichert…'; + if (button) button.textContent = 'Neustart…'; + } else if (progress.stage === 'launching' || progress.stage === 'done') { + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(100, 'Neustart…'); + if (message) message.textContent = progress.stage === 'done' ? 'Update installiert. Die Anwendung startet neu…' : 'Update wird installiert. Die Anwendung startet neu…'; + if (button) button.textContent = 'Neustart…'; + } else if (progress.stage === 'error') { + _updateInstallBusy = false; + _setUpdateDialogBusy(false); + _setUpdateProgress(0, 'Update fehlgeschlagen'); + if (message) message.textContent = `Update fehlgeschlagen: ${String(progress.error || 'Unbekannter Fehler').slice(0, 400)}`; + if (button) { + button.disabled = false; + button.textContent = 'Wiederholen'; + } + _setUpdateDialogVisible(true); + } +} + +function _isUpdateDialogVisible() { + const overlay = document.getElementById('updateBanner'); + return Boolean(overlay && overlay.style.display !== 'none' && overlay.getAttribute('aria-hidden') !== 'true'); +} + +function _setUpdateBackgroundInert(active) { + const overlay = document.getElementById('updateBanner'); + if (!overlay) return; + if (active) { + if (_updateDialogInertState.length > 0) return; + _updateDialogInertState = Array.from(document.body.children) + .filter(element => element !== overlay && 'inert' in element) + .map(element => ({ element, inert: element.inert })); + _updateDialogInertState.forEach(({ element }) => { element.inert = true; }); + return; + } + _updateDialogInertState.forEach(({ element, inert }) => { + if (element.isConnected) element.inert = inert; + }); + _updateDialogInertState = []; +} + +function _getUpdateDialogFocusable() { + const dialog = document.querySelector('#updateBanner .update-dialog'); + if (!dialog) return []; + return Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')) + .filter(element => !element.hidden && element.getClientRects().length > 0 && window.getComputedStyle(element).visibility !== 'hidden'); +} + +function _focusUpdateDialog() { + const dialog = document.querySelector('#updateBanner .update-dialog'); + if (!dialog) return; + const target = _getUpdateDialogFocusable()[0] || dialog; + target.focus(); +} + +function _handleUpdateDialogKeydown(event) { + if (!_isUpdateDialogVisible()) return; + const dialog = document.querySelector('#updateBanner .update-dialog'); + if (!dialog) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + if (!_updateInstallBusy) closeUpdateDialog(); + return; + } + if (event.key !== 'Tab') { + if (!dialog.contains(event.target)) { + event.preventDefault(); + event.stopImmediatePropagation(); + _focusUpdateDialog(); + } + return; + } + const focusable = _getUpdateDialogFocusable(); + if (focusable.length === 0) { + event.preventDefault(); + event.stopImmediatePropagation(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = document.activeElement; + if (!dialog.contains(activeElement) || (!event.shiftKey && activeElement === last) || (event.shiftKey && activeElement === first)) { + event.preventDefault(); + event.stopImmediatePropagation(); + (event.shiftKey ? last : first).focus(); + } +} + +function _setUpdateDialogVisible(visible) { + const overlay = document.getElementById('updateBanner'); + if (!overlay) return; + if (visible) { + const wasVisible = _isUpdateDialogVisible(); + if (!wasVisible) { + _updateDialogReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + _setUpdateBackgroundInert(true); + } + overlay.style.display = 'flex'; + overlay.setAttribute('aria-hidden', 'false'); + requestAnimationFrame(() => { + const dialog = overlay.querySelector('.update-dialog'); + if (_isUpdateDialogVisible() && dialog && !dialog.contains(document.activeElement)) _focusUpdateDialog(); + }); + } else { + overlay.style.display = 'none'; + overlay.setAttribute('aria-hidden', 'true'); + _setUpdateBackgroundInert(false); + const returnFocus = _updateDialogReturnFocus; + _updateDialogReturnFocus = null; + if (returnFocus && returnFocus.isConnected && typeof returnFocus.focus === 'function') returnFocus.focus(); + } +} + +function closeUpdateDialog() { + if (_updateInstallBusy) return false; + _setUpdateDialogVisible(false); + return true; +} + +function _setUpdateDialogBusy(busy) { + const dialog = document.querySelector('#updateBanner .update-dialog'); + if (dialog) dialog.setAttribute('aria-busy', busy ? 'true' : 'false'); + ['installUpdateBtn', 'dismissUpdateBtn', 'updateCloseBtn'].forEach(id => { + const button = document.getElementById(id); + if (button) button.disabled = busy; + }); + if (busy && dialog && (!dialog.contains(document.activeElement) || document.activeElement.matches?.(':disabled'))) dialog.focus(); +} + +function _setUpdateProgress(percent, text) { + const value = Math.max(0, Math.min(100, Math.round(Number(percent) || 0))); + const progressText = document.getElementById('updateProgressText'); + const progressBar = document.getElementById('updateProgressBar'); + if (progressText) progressText.textContent = text; + if (progressBar) { + progressBar.setAttribute('aria-valuenow', String(value)); + progressBar.setAttribute('aria-valuetext', String(text || `${value}%`)); + if ('value' in progressBar) progressBar.value = value; + progressBar.style.width = `${value}%`; + } +} + +async function installKnownUpdate() { + if (_updateInstallBusy) return; + if (!_knownUpdateInfo || !_knownUpdateInfo.available) { + await requestUpdateCheck(); + return; + } + _updateInstallBusy = true; + _setUpdateDialogBusy(true); + _setUpdateProgress(0, 'Download 0%'); + const message = document.getElementById('updateMessage'); + const button = document.getElementById('installUpdateBtn'); + if (message) message.textContent = 'Download wird vorbereitet…'; + if (button) button.textContent = 'Download 0%'; + try { + await persistQueueStateNow(); + const result = await window.api.installUpdate(); + if (result && result.started === false) throw new Error(result.error || 'Update konnte nicht gestartet werden'); + } catch (error) { + handleUpdateProgress({ stage: 'error', error: error && error.message ? error.message : String(error) }); } } @@ -5926,7 +6734,10 @@ function updateStatsPanel() { } // --- Start --- -init().catch((err) => { +window.api.onPrepareClose(prepareForWindowClose); +init().then(() => { + window.api.signalCloseHandshakeReady(); +}).catch((err) => { try { if (window.api && window.api.debugLog) window.api.debugLog(`init failed: ${err && err.stack ? err.stack : err}`); const root = document.getElementById('app') || document.body; diff --git a/renderer/drop-target.html b/renderer/drop-target.html index 5c8216e..399dfd1 100644 --- a/renderer/drop-target.html +++ b/renderer/drop-target.html @@ -18,27 +18,51 @@ display: flex; align-items: center; justify-content: center; - border: 2px dashed rgba(126, 220, 255, 0.5); - border-radius: 10px; - background: rgba(22, 24, 28, 0.85); - transition: border-color 0.15s, background 0.15s; + border: 1px dashed rgba(186, 208, 252, 0.62); + border-radius: 8px; + background: rgba(35, 35, 35, 0.94); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025); + transition: border-color 0.15s, background-color 0.15s, transform 0.15s; } .target.drag-over { - border-color: rgba(126, 220, 255, 0.9); - background: rgba(62, 167, 255, 0.15); + border-color: #bad0fc; + background: rgba(51, 52, 54, 0.98); + transform: scale(0.98); } .icon { - font-size: 64px; - font-weight: 200; - color: rgba(126, 220, 255, 0.7); - line-height: 1; + width: 54px; + height: 54px; + display: grid; + place-items: center; + border-radius: 8px; + background: #2b2b2b; + color: #bad0fc; -webkit-app-region: no-drag; } + .icon svg { + width: 30px; + height: 30px; + fill: none; + stroke: currentColor; + stroke-width: 1.65; + stroke-linecap: round; + stroke-linejoin: round; + } + @media (prefers-reduced-motion: reduce) { + .target { + transition: none; + } + .target.drag-over { + transform: none; + } + }
-
+
+