From c4c7907686393d83c250dad7faf4814b1c69be66 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:40:58 +0200 Subject: [PATCH] feat: improve live update and upload feedback Refresh release metadata immediately before downloading an update and keep automatic checks current while the app remains open. Accept desktop file drops from the first renderer frame, preserve early and rapid drops until initialization completes, and show the live remaining upload size in the sidebar. Add regression coverage for update freshness, startup drag-and-drop ordering, localization, and remaining-byte retry behavior. Bump the application to v2.1.25. --- README.md | 3 +- lib/serialized-runner.js | 23 ++++++- lib/updater.js | 60 +++++++++++++---- main.js | 54 ++++++++++------ package-lock.json | 4 +- package.json | 2 +- preload.js | 2 +- renderer/app.js | 100 ++++++++++++++++------------ renderer/i18n.js | 1 + renderer/index.html | 1 + tests/i18n.test.js | 5 ++ tests/queue-stats.test.js | 15 +++++ tests/serialized-runner.test.js | 21 ++++++ tests/startup-renderer.test.js | 21 ++++++ tests/ui-smoke.js | 10 +-- tests/updater-version.test.js | 111 +++++++++++++++++++++++++++++++- 16 files changed, 351 insertions(+), 82 deletions(-) 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 @@