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 }); }