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 {