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 @@
Gesamt0
Verbindungen0
Verbleibend0
+
Verbleibende Größe0 B
Läuft0
Fertig0
Fehler0
diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 9722eb9..fe847b2 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -11,6 +11,11 @@ test('translates permanent source deletion controls to English', () => { assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion'); }); +test('translates the remaining upload size label', () => { + assert.equal(translateText('Verbleibende Größe', 'en'), 'Remaining size'); + assert.equal(translateText('Remaining size', 'de'), 'Verbleibende Größe'); +}); + test('English is the fallback language and German remains selectable', () => { assert.equal(normalizeLanguage(), 'en'); assert.equal(normalizeLanguage('fr'), 'en'); diff --git a/tests/queue-stats.test.js b/tests/queue-stats.test.js index 407bdfd..551ba49 100644 --- a/tests/queue-stats.test.js +++ b/tests/queue-stats.test.js @@ -21,3 +21,18 @@ test('queue totals keep skipped and aborted jobs out of remaining work', () => { }); assert.equal(stats.remainingSize, 160); }); + +test('remaining bytes decrease with progress and return when a failed job is queued again', () => { + const jobs = [ + { status: 'queued', bytesTotal: 1024, bytesUploaded: 0 }, + { status: 'uploading', bytesTotal: 2048, bytesUploaded: 512 }, + { status: 'error', bytesTotal: 4096, bytesUploaded: 1024 } + ]; + + assert.equal(calculateQueueStats(jobs).bytesRemaining, 2560); + jobs[1].bytesUploaded = 1536; + assert.equal(calculateQueueStats(jobs).bytesRemaining, 1536); + jobs[2].status = 'queued'; + jobs[2].bytesUploaded = 0; + assert.equal(calculateQueueStats(jobs).bytesRemaining, 5632); +}); diff --git a/tests/serialized-runner.test.js b/tests/serialized-runner.test.js index 9324bac..cee7b35 100644 --- a/tests/serialized-runner.test.js +++ b/tests/serialized-runner.test.js @@ -29,4 +29,25 @@ describe('serialized runner', () => { assert.equal(flushed, true); assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']); }); + + it('queues work before readiness and drains every task in arrival order', async () => { + const { createReadySerializedRunner } = require('../lib/serialized-runner'); + const calls = []; + const runner = createReadySerializedRunner(async (value) => { + calls.push(value); + return value; + }); + + const first = runner.run('first'); + const second = runner.run('second'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(runner.isReady, false); + assert.deepEqual(calls, []); + assert.equal(runner.ready(), true); + assert.equal(runner.ready(), false); + assert.deepEqual(await Promise.all([first, second]), ['first', 'second']); + assert.deepEqual(calls, ['first', 'second']); + assert.equal(runner.isReady, true); + }); }); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 06d7dbe..637889e 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -103,3 +103,24 @@ test('startup load forwards navigation options before the renderer becomes visib assert.deepEqual(startup.window.loadOptions, options); }); + +test('desktop drag and drop is accepted before asynchronous renderer initialization', () => { + const projectRoot = path.join(__dirname, '..'); + const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8'); + const earlyBinding = appSource.lastIndexOf('\nsetupDragDrop();'); + const initialization = appSource.lastIndexOf('\ninit().then('); + + assert.notEqual(earlyBinding, -1); + assert.notEqual(initialization, -1); + assert.ok(earlyBinding < initialization); + assert.match(appSource, /dataTransfer\.dropEffect\s*=\s*['"]copy['"]/u); +}); + +test('upload sidebar renders and updates the remaining upload size', () => { + const projectRoot = path.join(__dirname, '..'); + const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8'); + const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8'); + + assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B { const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))'); check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History'); const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")'); - check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA'); + check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Remaining size|Running|Completed|Failed|Speed|ETA'); const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()'); check('English labels fit every main view without horizontal overflow', englishLayoutFits === true); const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()'); @@ -447,10 +447,10 @@ setTimeout(async () => { check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik'); const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")'); - check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA'); + check('Upload telemetry exposes all nine German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Verbleibende Größe|Läuft|Fertig|Fehler|Geschwindigkeit|ETA'); const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")'); - check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--'); + check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0 B|0|0|0|0 B/s|--:--'); const previewQueueCounts = await wc.executeJavaScript(\`(() => { queueJobs = [{ id: 'existing-done', file: 'C:/ui/existing-done.bin', fileName: 'existing-done.bin', hoster: 'doodstream.com', status: 'done', bytesUploaded: 1024, bytesTotal: 1024, speedKbs: 0, elapsed: 1, remaining: 0, progress: 1 }]; @@ -517,7 +517,7 @@ setTimeout(async () => { const total = document.getElementById('uploadTelemetryTotal'); const rolling = total?.querySelectorAll(':scope > span').length; return { - values: ['Total', 'Connections', 'Remaining', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => { + values: ['Total', 'Connections', 'Remaining', 'RemainingSize', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => { const element = document.getElementById('uploadTelemetry' + key); return element?.getAttribute('aria-label') || element?.textContent.trim(); }).join('|'), @@ -526,7 +526,7 @@ setTimeout(async () => { speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|') }; })()\`); - check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03'); + check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|5.00 KB|1|7|2|2 kB/s|00:03'); check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up'); check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s'); const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")'); diff --git a/tests/updater-version.test.js b/tests/updater-version.test.js index 8a153cc..91e0eba 100644 --- a/tests/updater-version.test.js +++ b/tests/updater-version.test.js @@ -4,9 +4,10 @@ const path = require('node:path'); const fs = require('node:fs'); const os = require('node:os'); const crypto = require('node:crypto'); +const Module = require('node:module'); const { pathToFileURL } = require('node:url'); -const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater'); +const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState } = require('../lib/updater'); const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href; test('bridge title resolves product version instead of transport tag', () => { @@ -86,6 +87,114 @@ test('update preparation writes a verified installer without launching it', asyn } }); +test('update preparation refreshes a cached release before downloading the installer', async () => { + const updaterPath = require.resolve('../lib/updater'); + const originalLoad = Module._load; + const originalFetch = global.fetch; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-')); + const installer = Buffer.alloc(128 * 1024, 0); + installer[0] = 0x4d; + installer[1] = 0x5a; + const sha512 = crypto.createHash('sha512').update(installer).digest('base64'); + const oldVersion = '2.1.23'; + const latestVersion = '2.1.24'; + const createRelease = version => ({ + name: `Multi-Hoster-Upload v${version}`, + tag_name: `v${version}`, + html_url: `https://update.invalid/releases/${version}`, + body: `Release ${version}`, + assets: [ + { + name: `Multi-Hoster-Upload Setup ${version}.exe`, + size: installer.length, + browser_download_url: `https://update.invalid/${version}/setup.exe` + }, + { + name: 'latest.yml', + browser_download_url: `https://update.invalid/${version}/latest.yml` + } + ] + }); + const createResponse = payload => ({ + ok: true, + status: 200, + text: async () => JSON.stringify(payload), + json: async () => payload + }); + const createInstallerResponse = () => ({ + ok: true, + status: 200, + body: { + getReader: () => { + let served = false; + return { + read: async () => { + if (served) return { done: true }; + served = true; + return { done: false, value: installer }; + } + }; + } + } + }); + const createManifestResponse = version => ({ + ok: true, + status: 200, + text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n` + }); + + Module._load = function load(request, parent, isMain) { + if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } }; + return originalLoad.call(this, request, parent, isMain); + }; + delete require.cache[updaterPath]; + const isolatedUpdater = require(updaterPath); + + try { + global.fetch = async url => { + const value = String(url); + if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]); + if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` }); + throw new Error(`Unexpected initial request: ${value}`); + }; + const oldCheck = await isolatedUpdater.checkForUpdate(); + assert.equal(oldCheck.remoteVersion, oldVersion); + + global.fetch = async url => { + const value = String(url); + if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]); + if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` }); + if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion); + if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion); + if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse(); + throw new Error(`Unexpected refreshed request: ${value}`); + }; + + const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir }); + assert.equal(prepared.remoteVersion, latestVersion); + assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`); + } finally { + global.fetch = originalFetch; + Module._load = originalLoad; + delete require.cache[updaterPath]; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('an update found before renderer readiness remains announceable afterwards', () => { + const state = createUpdateAnnouncementState(); + const update = { available: true, remoteVersion: '2.1.24' }; + + assert.equal(state.canAnnounce(update, false), false); + assert.equal(state.canAnnounce(update, true), true); + assert.equal(state.canAnnounce(update, true), true); + state.markAnnounced(update); + assert.equal(state.canAnnounce(update, true), false); + + state.reset(); + assert.equal(state.canAnnounce(update, true), true); +}); + test('update preparation fails closed when checksum metadata is unavailable', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-')); try {