diff --git a/README.md b/README.md index 85b3dc7..7a33bb2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.24. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.25. Use the release page for the executables and the full English changelog. ## Features @@ -22,6 +22,7 @@ The latest public release is version 2.1.24. Use the release page for the execut - Search and filter queue entries by file name, host, and status. - Open per-upload diagnostics with the selected account, retry count, and safe error details. - Track status, smoothly interpolated progress, transferred size, speed, and the selected host account. +- Track the remaining upload size as queued and active files progress or return for a retry. - Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry. - Follow current upload speed in the sidebar and the synchronized header graph. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work. diff --git a/lib/serialized-runner.js b/lib/serialized-runner.js index 4bcfed6..60c3f28 100644 --- a/lib/serialized-runner.js +++ b/lib/serialized-runner.js @@ -16,7 +16,28 @@ }; } - const api = { createSerializedRunner }; + function createReadySerializedRunner(task) { + let ready = false; + let releaseReady; + const readyPromise = new Promise((resolve) => { releaseReady = resolve; }); + const runner = createSerializedRunner(async (...args) => { + await readyPromise; + return task(...args); + }); + return { + run: (...args) => runner.run(...args), + flush: () => runner.flush(), + ready() { + if (ready) return false; + ready = true; + releaseReady(); + return true; + }, + get isReady() { return ready; } + }; + } + + const api = { createSerializedRunner, createReadySerializedRunner }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else if (root) root.SerializedRunner = api; })(typeof window !== 'undefined' ? window : this); diff --git a/lib/updater.js b/lib/updater.js index f540b4a..0135a20 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -17,6 +17,27 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 min let activeAbort = null; const launchedInstallerPaths = new Set(); +function createUpdateAnnouncementState() { + let announcedVersion = ''; + + function versionOf(update) { + return String(update?.remoteVersion || '').replace(/^v/i, '').trim(); + } + + return Object.freeze({ + canAnnounce(update, rendererReady) { + const version = versionOf(update); + return Boolean(rendererReady && update?.available && version && version !== announcedVersion); + }, + markAnnounced(update) { + announcedVersion = versionOf(update); + }, + reset() { + announcedVersion = ''; + } + }); +} + function getCurrentVersion() { return app.getVersion(); } @@ -61,17 +82,32 @@ function findLatestYml(assets) { return assets.find(a => /^latest\.yml$/i.test(a.name)) || null; } -async function fetchJson(url, signal) { +function cacheBustedUrl(url) { + const parsed = new URL(url); + parsed.searchParams.set('_mhu_update', crypto.randomUUID()); + return parsed.toString(); +} + +async function fetchJson(url, signal, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT); const onAbort = () => controller.abort(); + const forceRefresh = options.forceRefresh === true; + const fetchImpl = options.fetchImpl || fetch; if (signal) signal.addEventListener('abort', onAbort); try { - const res = await fetch(url, { + const res = await fetchImpl(forceRefresh ? cacheBustedUrl(url) : url, { method: 'GET', signal: controller.signal, - redirect: 'follow' + redirect: 'follow', + ...(forceRefresh ? { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache, no-store', + Pragma: 'no-cache' + } + } : {}) }); const text = await res.text(); try { @@ -112,13 +148,14 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl = } } -async function checkForUpdate() { - // Return cached result if fresh - if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) { +async function checkForUpdate(options = {}) { + const forceRefresh = options.forceRefresh === true; + const fetchImpl = options.fetchImpl || fetch; + if (!forceRefresh && cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) { return cachedCheck; } - const releases = await fetchJson(API_URL); + const releases = await fetchJson(API_URL, undefined, { forceRefresh, fetchImpl }); if (!Array.isArray(releases) || releases.length === 0) { return { available: false }; @@ -142,7 +179,7 @@ async function checkForUpdate() { return { available: false, reason: 'Kein Setup-Asset im Release gefunden' }; } - const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || ''); + const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '', fetchImpl); cachedCheck = { available: true, currentVersion, @@ -210,10 +247,9 @@ async function prepareUpdate(onProgress, options = {}) { // Stage: starting if (onProgress) onProgress({ stage: 'starting', percent: 0 }); - // Check or use cached - let check = options.checkResult || cachedCheck; + let check = options.checkResult || null; if (!check || !check.available) { - check = await checkForUpdate(); + check = await checkForUpdate({ forceRefresh: true, fetchImpl }); } if (!check || !check.available) { throw new Error('Kein Update verfügbar'); @@ -353,4 +389,4 @@ function abortUpdate() { } } -module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml }; +module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState }; diff --git a/main.js b/main.js index a1812b9..5c9848a 100644 --- a/main.js +++ b/main.js @@ -17,7 +17,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, prepareUpdate, launchPreparedUpdate, abortUpdate } = require('./lib/updater'); +const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup'); const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup'); @@ -113,6 +113,8 @@ let preparedUpdate = null; let updatePreparationPromise = null; let updateQuitPending = false; let preparedUpdateLaunchStarted = false; +let updateCheckInterval = null; +const updateAnnouncementState = createUpdateAnnouncementState(); let _lastImportPath = null; let dropTargetWindow = null; let tray = null; @@ -1458,6 +1460,7 @@ function createWindow() { mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { if (isInPlace || !isMainFrame) return; closeHandshakeReady = false; + updateAnnouncementState.reset(); restoreClosePreparation(closePreparationAttempt); }); @@ -1543,6 +1546,25 @@ function updateTrayTooltip(text) { if (tray && !tray.isDestroyed()) tray.setToolTip(text); } +function announceAvailableUpdate(result) { + if (!updateAnnouncementState.canAnnounce(result, closeHandshakeReady)) return false; + if (!safeSend('app:update-available', result)) return false; + updateAnnouncementState.markAnnounced(result); + return true; +} + +async function runAutomaticUpdateCheck(forceRefresh) { + try { + logInfo('update-check: starting'); + const result = await checkForUpdate({ forceRefresh }); + logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); + logDebug(`update-check result: ${JSON.stringify(result)}`); + announceAvailableUpdate(result); + } catch (err) { + logError('update-check failed', err); + } +} + app.whenReady().then(async () => { if (!_hasSingleInstanceLock) return; try { @@ -1630,20 +1652,9 @@ app.whenReady().then(async () => { } } catch {} - // Auto-check for updates after 3 seconds - setTimeout(async () => { - try { - logInfo('update-check: starting'); - const result = await checkForUpdate(); - logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); - logDebug(`update-check result: ${JSON.stringify(result)}`); - if (result && result.available && mainWindow && !mainWindow.isDestroyed()) { - safeSend('app:update-available', result); - } - } catch (err) { - logError('update-check failed', err); - } - }, 3000); + setTimeout(() => { void runAutomaticUpdateCheck(true); }, 3000); + updateCheckInterval = setInterval(() => { void runAutomaticUpdateCheck(true); }, 5 * 60 * 1000); + updateCheckInterval.unref?.(); }); app.on('window-all-closed', () => { @@ -1663,6 +1674,8 @@ app.on('before-quit', (event) => { app.on('will-quit', () => { if (quitTeardownStarted) return; quitTeardownStarted = true; + if (updateCheckInterval) clearInterval(updateCheckInterval); + updateCheckInterval = null; if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) { preparedUpdateLaunchStarted = true; try { @@ -2856,9 +2869,9 @@ ipcMain.handle('copy-to-clipboard', (_event, text) => { return true; }); -ipcMain.handle('app:check-updates', async () => { +ipcMain.handle('app:check-updates', async (_event, options) => { try { - return await checkForUpdate(); + return await checkForUpdate({ forceRefresh: options && options.forceRefresh === true }); } catch (err) { return { available: false, error: err.message }; } @@ -2912,7 +2925,12 @@ ipcMain.handle('app:quit', () => { }); ipcMain.on('app:close-handshake-ready', (event) => { - if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true; + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { + closeHandshakeReady = true; + void checkForUpdate().then(announceAvailableUpdate).catch((error) => { + logError('update-check failed', error); + }); + } }); ipcMain.on('app:close-preparation-started', (event, attempt) => { diff --git a/package-lock.json b/package-lock.json index 559c70a..eac8f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.24", + "version": "2.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.24", + "version": "2.1.25", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 9958b9a..8cb9692 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.24", + "version": "2.1.25", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index a05989b..e2eaf64 100644 --- a/preload.js +++ b/preload.js @@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('api', { copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text), // Updates - checkForUpdate: () => ipcRenderer.invoke('app:check-updates'), + checkForUpdate: (options = {}) => ipcRenderer.invoke('app:check-updates', { forceRefresh: options && options.forceRefresh === true }), installUpdate: () => ipcRenderer.invoke('app:install-update'), abortUpdate: () => ipcRenderer.invoke('app:abort-update'), getVersion: () => ipcRenderer.invoke('app:get-version'), diff --git a/renderer/app.js b/renderer/app.js index 1476091..f6b347e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -416,7 +416,7 @@ async function init() { renderSettings(); renderAccounts(); setupListeners(); - setupDragDrop(); + importEntryCoordinator.ready(); initUploadSpeedSparkline(); restoreQueueColumnWidths(); loadHistory(); @@ -826,8 +826,8 @@ function _syncHeaderUpdateState() { } } -async function requestUpdateCheck() { - if (_knownUpdateInfo && _knownUpdateInfo.available) { +async function requestUpdateCheck({ forceRefresh = true } = {}) { + if (!forceRefresh && _knownUpdateInfo && _knownUpdateInfo.available) { showUpdateBanner(_knownUpdateInfo); return _knownUpdateInfo; } @@ -836,7 +836,7 @@ async function requestUpdateCheck() { _syncHeaderUpdateState(); showCopyToast('Suche nach Updates…'); try { - const result = await window.api.checkForUpdate(); + const result = await window.api.checkForUpdate({ forceRefresh }); if (result && result.available) { showUpdateBanner(result); showCopyToast('Update gefunden!'); @@ -1368,17 +1368,24 @@ function clearPersistedQueueStateSoon() { } // --- File selection --- +let dragDropBound = false; function setupDragDrop() { + if (dragDropBound) return; + dragDropBound = true; const dropZone = document.getElementById('dropZone'); - // Allow drop on the entire upload view const uploadView = document.getElementById('upload-view'); let _dragCounter = 0; - dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); - dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); }); - dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } }); + const acceptCopy = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }; + dropZone.addEventListener('dragover', acceptCopy); + dropZone.addEventListener('dragenter', (e) => { acceptCopy(e); _dragCounter++; dropZone.classList.add('drag-over'); }); + dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over'); - addDroppedFiles(e.dataTransfer.files).catch(console.error); + enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error); }); dropZone.addEventListener('click', () => pickFiles()); dropZone.addEventListener('keydown', (event) => { @@ -1387,12 +1394,12 @@ function setupDragDrop() { pickFiles(); }); - // Also handle drops on queue container - uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); + uploadView.addEventListener('dragover', acceptCopy); + uploadView.addEventListener('dragenter', acceptCopy); uploadView.addEventListener('drop', (e) => { - e.preventDefault(); - if (e.target.closest('.drop-zone')) return; // handled above - addDroppedFiles(e.dataTransfer.files).catch(console.error); + e.preventDefault(); e.stopPropagation(); + if (e.target.closest('.drop-zone')) return; + enqueueDroppedFiles(e.dataTransfer?.files).catch(console.error); }); } @@ -1401,7 +1408,24 @@ let _pendingImportInspection = null; let _importCoordination = Promise.resolve(); let _importGeneration = 0; let _pendingImportInspections = 0; -let _addingDropped = false; +const importEntryCoordinator = window.SerializedRunner.createReadySerializedRunner(async (kind, payload) => { + if (kind === 'drop') return processDroppedFiles(payload); + return addPathsToQueue(payload); +}); + +function enqueueDroppedFiles(fileList) { + const files = Array.from(fileList || []); + if (files.length === 0) return Promise.resolve(null); + return importEntryCoordinator.run('drop', files); +} + +function enqueueImportEntries(entries) { + const snapshot = Array.from(entries || [], entry => ( + entry && typeof entry === 'object' ? { ...entry } : entry + )); + if (snapshot.length === 0) return Promise.resolve(null); + return importEntryCoordinator.run('entries', snapshot); +} function existingImportPaths() { return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)]; @@ -1522,44 +1546,38 @@ function coordinateImportEntries(entries) { return pending; } -async function addDroppedFiles(fileList) { - if (_addingDropped) return; - _addingDropped = true; - try { - const entries = []; - for (const file of Array.from(fileList)) { - let filePath = ''; - try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } - if (!filePath) continue; - if (file.type === '' && file.size === 0) { - try { - const folderFiles = await window.api.resolveFolderFiles(filePath); - if (folderFiles && folderFiles.length > 0) { - entries.push(...folderFiles); - continue; - } - } catch {} - } - entries.push({ path: filePath, name: file.name || '', size: file.size }); +async function processDroppedFiles(files) { + const entries = []; + for (const file of files) { + let filePath = ''; + try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } + if (!filePath) continue; + if (file.type === '' && file.size === 0) { + try { + const folderFiles = await window.api.resolveFolderFiles(filePath); + if (folderFiles && folderFiles.length > 0) { + entries.push(...folderFiles); + continue; + } + } catch {} } - await addPathsToQueue(entries); - } finally { - _addingDropped = false; + entries.push({ path: filePath, name: file.name || '', size: file.size }); } + return addPathsToQueue(entries); } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; - await addPathsToQueue(paths); + await enqueueImportEntries(paths); } async function pickFolder() { const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null; - if (richFiles && Array.isArray(richFiles)) return addPathsToQueue(richFiles); + if (richFiles && Array.isArray(richFiles)) return enqueueImportEntries(richFiles); const paths = await window.api.selectFolder(); if (!paths) return; - return addPathsToQueue(paths); + return enqueueImportEntries(paths); } function addPathsToQueue(paths) { @@ -4187,6 +4205,7 @@ function updateStatusBar() { _setRollingUploadMetric('uploadTelemetryTotal', stats.total); _setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0); _setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining); + _setUploadTelemetryText('uploadTelemetryRemainingSize', formatBytes(stats.bytesRemaining)); _setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress); _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount); _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors)); @@ -8097,6 +8116,7 @@ function updateStatsPanel() { window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); window.api.onPrepareClose(prepareForWindowClose); +setupDragDrop(); init().then(() => { window.api.signalCloseHandshakeReady(); }).catch((err) => { diff --git a/renderer/i18n.js b/renderer/i18n.js index 55c8c3a..81254b8 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -82,6 +82,7 @@ ['Aktuelle Upload-Geschwindigkeit', 'Current upload speed'], ['Gesamt', 'Total'], ['Verbindungen', 'Connections'], + ['Verbleibende Größe', 'Remaining size'], ['Verbleibend', 'Remaining'], ['Läuft', 'Running'], ['Suche Aktualisierungen', 'Check for updates'], diff --git a/renderer/index.html b/renderer/index.html index 3c448d9..ce1c85d 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -212,6 +212,7 @@