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