From 4d3a4f1a8ab6ab9c81e721f0c299f173b0630322 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:28:48 +0200 Subject: [PATCH] feat: show update download telemetry Calculate download throughput and remaining time from verified installer bytes and display downloaded size, total size, speed, and ETA beneath the progress track while retaining the last values after cancellation. --- lib/updater.js | 9 ++++++++- renderer/app.js | 19 +++++++++++++++++++ renderer/index.html | 5 ++++- renderer/styles.css | 21 ++++++++++++++++++++- tests/startup-renderer.test.js | 1 + tests/ui-smoke.js | 4 +++- tests/updater-version.test.js | 15 ++++++++++++--- 7 files changed, 67 insertions(+), 7 deletions(-) diff --git a/lib/updater.js b/lib/updater.js index d9618de..3aebc59 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -241,6 +241,7 @@ async function prepareUpdate(onProgress, options = {}) { activeAbort = new AbortController(); const signal = activeAbort.signal; const fetchImpl = options.fetchImpl || fetch; + const now = typeof options.now === 'function' ? options.now : Date.now; let stagedInstallerPath = ''; try { @@ -284,6 +285,7 @@ async function prepareUpdate(onProgress, options = {}) { const totalBytes = manifest.size; let downloadedBytes = 0; let lastReportedPercent = -1; + const downloadStartedAt = now(); const chunks = []; const DOWNLOAD_STALL_MS = 45000; @@ -312,12 +314,17 @@ async function prepareUpdate(onProgress, options = {}) { downloadedBytes += value.length; const percent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))); if (onProgress && percent !== lastReportedPercent) { + const elapsedMs = Math.max(1, now() - downloadStartedAt); + const bytesPerSecond = Math.round((downloadedBytes * 1000) / elapsedMs); + const etaSeconds = bytesPerSecond > 0 ? Math.ceil(Math.max(0, totalBytes - downloadedBytes) / bytesPerSecond) : null; lastReportedPercent = percent; onProgress({ stage: 'downloading', percent, bytesDownloaded: downloadedBytes, - bytesTotal: totalBytes + bytesTotal: totalBytes, + bytesPerSecond, + etaSeconds }); await new Promise(resolve => setImmediate(resolve)); } diff --git a/renderer/app.js b/renderer/app.js index 8132990..ae24e39 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -7497,6 +7497,7 @@ function showUpdateBanner(info) { installButton.textContent = 'Jetzt installieren'; } _setUpdateProgress(0, 'Bereit zum Download', 'ready'); + _setUpdateProgressDetails(null); _setUpdateDialogBusy(false); _syncHeaderUpdateState(); _setUpdateDialogVisible(true); @@ -7517,6 +7518,7 @@ function handleUpdateProgress(data) { _updateInstallBusy = true; _setUpdateDialogBusy(true, true); _setUpdateProgress(percent, `Download ${percent}%`, 'downloading'); + _setUpdateProgressDetails(progress); if (button) button.textContent = `Download ${percent}%`; } else if (progress.stage === 'verifying') { _updateInstallBusy = true; @@ -7708,6 +7710,22 @@ function _setUpdateProgress(percent, text, state = 'ready') { } } +function _setUpdateProgressDetails(progress) { + const details = document.getElementById('updateProgressDetails'); + if (!details) return; + const downloaded = Math.max(0, Number(progress?.bytesDownloaded) || 0); + const total = Math.max(0, Number(progress?.bytesTotal) || 0); + if (total <= 0) { + details.textContent = ''; + details.hidden = true; + return; + } + const bytesPerSecond = Math.max(0, Number(progress?.bytesPerSecond) || 0); + const etaSeconds = Number.isFinite(Number(progress?.etaSeconds)) ? Math.max(0, Number(progress.etaSeconds)) : 0; + details.textContent = `${formatSize(downloaded)} / ${formatSize(total)} · ${formatSize(bytesPerSecond)}/s · ETA ${formatTime(etaSeconds)}`; + details.hidden = false; +} + async function installKnownUpdate() { if (_updateInstallBusy) return; if (!_knownUpdateInfo || !_knownUpdateInfo.available) { @@ -7717,6 +7735,7 @@ async function installKnownUpdate() { _updateInstallBusy = true; _setUpdateDialogBusy(true, true); _setUpdateProgress(0, 'Download 0%', 'downloading'); + _setUpdateProgressDetails(null); const message = document.getElementById('updateMessage'); const button = document.getElementById('installUpdateBtn'); if (message) message.hidden = true; diff --git a/renderer/index.html b/renderer/index.html index 1e0708f..209581a 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -161,7 +161,10 @@
- +
diff --git a/renderer/styles.css b/renderer/styles.css index 2fb3af7..984ca45 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -2515,10 +2515,29 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } background: var(--danger); } +.update-progress-footer { + min-width: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +#updateProgressDetails { + min-width: 0; + overflow: hidden; + color: var(--text-dim); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + #updateProgressText { min-width: 34px; max-width: 100%; - justify-self: end; + margin-left: auto; + flex: 0 0 auto; color: var(--text-dim); font-size: 11px; line-height: 1.35; diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index e3165bd..bd05f09 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -176,4 +176,5 @@ test('header occupies its final geometry before asynchronous initialization', () assert.match(css, /\.header-update-button\.update-available:hover\s*\{[^}]*background:\s*var\(--success-end\);[^}]*color:\s*#000;/su); assert.match(css, /\.update-dialog\s*\{[^}]*width:\s*min\(576px,\s*100%\);/su); assert.match(css, /\.update-release-notes\s*\{[^}]*height:\s*min\(220px,\s*42vh\);/su); + assert.match(html, /class="update-progress-footer"[\s\S]*id="updateProgressDetails"[\s\S]*id="updateProgressText"/u); }); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 270c9f0..de1ca5d 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -2460,7 +2460,7 @@ setTimeout(async () => { const busyUpdateState = await wc.executeJavaScript(\`(() => { showUpdateBanner({ remoteVersion: '9.9.9' }); - handleUpdateProgress({ stage: 'downloading', percent: 50 }); + handleUpdateProgress({ stage: 'downloading', percent: 50, bytesDownloaded: 40 * 1024 * 1024, bytesTotal: 100 * 1024 * 1024, bytesPerSecond: 8 * 1024 * 1024, etaSeconds: 8 }); const overlay = document.getElementById('updateBanner'); document.getElementById('updateCloseBtn').click(); document.getElementById('dismissUpdateBtn').click(); @@ -2480,6 +2480,7 @@ setTimeout(async () => { messageText: document.getElementById('updateMessage').textContent, progressLabel: progress.getAttribute('aria-label'), progressText: progress.getAttribute('aria-valuetext'), + progressDetails: document.getElementById('updateProgressDetails').textContent, progressState: progress.dataset.state, progressColor: getComputedStyle(progress).backgroundColor, progressTrackWidth: progress.parentElement.getBoundingClientRect().width, @@ -2489,6 +2490,7 @@ setTimeout(async () => { })()\`); check('Busy update keeps its progress dialog open with a dangerous download cancellation action', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === false && busyUpdateState.dismissText === 'Download abbrechen' && busyUpdateState.dismissDanger === true && busyUpdateState.headerHidden === false); check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%'); + check('Update progress shows downloaded size, total size, speed, and ETA', busyUpdateState.progressDetails === '40.0 MB / 100.0 MB · 8.0 MB/s · ETA 00:08'); check('Update download progress is green, wide, and places its status below the line', busyUpdateState.progressState === 'downloading' && busyUpdateState.progressColor === 'rgb(117, 211, 155)' && busyUpdateState.progressTrackWidth >= 390 && busyUpdateState.progressTextTop >= busyUpdateState.progressTrackBottom); check('Busy update preserves the available version subtitle while progress stays below the bar', busyUpdateState.messageHidden === false && busyUpdateState.messageText === 'Update v9.9.9 verfügbar'); diff --git a/tests/updater-version.test.js b/tests/updater-version.test.js index 63673a4..09e3f4f 100644 --- a/tests/updater-version.test.js +++ b/tests/updater-version.test.js @@ -103,6 +103,7 @@ test('buffered installer downloads yield between progress updates so the rendere let preparationFinished = false; let rendererObservedProgressBeforeFinish = false; let rendererObservationScheduled = false; + let nowMs = 0; try { await prepareUpdate(value => { @@ -121,6 +122,7 @@ test('buffered installer downloads yield between progress updates so the rendere latestYmlUrl: 'https://update.invalid/latest.yml' }, tempDir, + now: () => nowMs, fetchImpl: async url => url.endsWith('latest.yml') ? { ok: true, @@ -132,9 +134,12 @@ test('buffered installer downloads yield between progress updates so the rendere status: 200, body: { getReader: () => ({ - read: async () => readerIndex < chunks.length - ? { done: false, value: chunks[readerIndex++] } - : { done: true } + read: async () => { + nowMs += 1000; + return readerIndex < chunks.length + ? { done: false, value: chunks[readerIndex++] } + : { done: true }; + } }) } } @@ -147,6 +152,10 @@ test('buffered installer downloads yield between progress updates so the rendere progress.filter(value => value.stage === 'downloading').map(value => value.percent), [25, 50, 75, 100] ); + assert.deepEqual( + progress.filter(value => value.stage === 'downloading').map(value => [value.bytesPerSecond, value.etaSeconds]), + [[32768, 3], [32768, 2], [32768, 1], [32768, 0]] + ); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }