From 577e1dd9c93bac094f10c9bd3d124991510bdcb5 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:57:54 +0200 Subject: [PATCH] feat: allow active update downloads to be canceled Keep the dialog cancel action available during update downloads, abort the active network stream, and surface a clean canceled state with retry support. Normalize cancellation across updater, main process, renderer, and translations with regression coverage. --- lib/updater.js | 12 +++---- main.js | 13 +++---- renderer/app.js | 67 ++++++++++++++++++++++++++++------- renderer/i18n.js | 5 +++ tests/i18n.test.js | 5 +++ tests/ui-smoke.js | 3 +- tests/updater-version.test.js | 57 ++++++++++++++++++++++++++++- 7 files changed, 136 insertions(+), 26 deletions(-) diff --git a/lib/updater.js b/lib/updater.js index 365a8cc..d9618de 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -359,9 +359,10 @@ async function prepareUpdate(onProgress, options = {}) { return prepared; } catch (err) { + const failure = signal.aborted ? new Error('Update abgebrochen') : err; if (stagedInstallerPath) fs.rmSync(stagedInstallerPath, { force: true }); - if (onProgress) onProgress({ stage: 'error', error: err.message }); - throw err; + if (onProgress) onProgress({ stage: signal.aborted ? 'aborted' : 'error', error: failure.message }); + throw failure; } finally { activeAbort = null; } @@ -387,10 +388,9 @@ function launchPreparedUpdate(prepared, options = {}) { } function abortUpdate() { - if (activeAbort) { - activeAbort.abort(); - activeAbort = null; - } + if (!activeAbort) return false; + activeAbort.abort(); + return true; } module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState }; diff --git a/main.js b/main.js index b220a37..f168069 100644 --- a/main.js +++ b/main.js @@ -2897,18 +2897,19 @@ ipcMain.handle('app:install-update', async () => { try { return await updatePreparationPromise; } catch (error) { - rejectPendingUpdate(error); - safeSend('app:update-progress', { stage: 'error', error: error.message }); - return { started: false, error: error.message }; + const canceled = error && error.message === 'Update abgebrochen'; + if (!canceled) { + rejectPendingUpdate(error); + safeSend('app:update-progress', { stage: 'error', error: error.message }); + } + return { started: false, canceled, error: error.message }; } finally { updatePreparationPromise = null; } }); ipcMain.handle('app:abort-update', () => { - abortUpdate(); - rejectPendingUpdate(new Error('Update abgebrochen')); - return true; + return abortUpdate(); }); ipcMain.handle('app:get-version', () => { diff --git a/renderer/app.js b/renderer/app.js index 708ee6d..26bda6e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -364,6 +364,8 @@ let historySidebarFilter = 'all'; let _knownUpdateInfo = null; let _updateCheckBusy = false; let _updateInstallBusy = false; +let _updateDownloadCancelable = false; +let _updateCancelBusy = false; let _updateDialogReturnFocus = null; let _updateDialogInertState = []; let _startupAutoResumeController = null; @@ -7429,7 +7431,7 @@ function setupListeners() { document.getElementById('headerUpdateBtn')?.addEventListener('click', requestUpdateCheck); document.getElementById('installUpdateBtn')?.addEventListener('click', installKnownUpdate); - document.getElementById('dismissUpdateBtn')?.addEventListener('click', closeUpdateDialog); + document.getElementById('dismissUpdateBtn')?.addEventListener('click', handleUpdateDismiss); document.getElementById('updateCloseBtn')?.addEventListener('click', closeUpdateDialog); document.getElementById('updateBanner')?.addEventListener('click', (event) => { if (event.target.id === 'updateBanner') closeUpdateDialog(); @@ -7502,35 +7504,48 @@ function handleUpdateProgress(data) { const button = document.getElementById('installUpdateBtn'); if (progress.stage === 'starting') { _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, true); _setUpdateProgress(0, 'Download 0%'); if (message) message.hidden = true; if (button) button.textContent = 'Download 0%'; } else if (progress.stage === 'downloading') { const percent = Math.max(0, Math.min(100, Math.round(Number(progress.percent) || 0))); _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, true); _setUpdateProgress(percent, `Download ${percent}%`); if (message) message.hidden = true; if (button) button.textContent = `Download ${percent}%`; } else if (progress.stage === 'verifying') { _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, false); _setUpdateProgress(100, 'Prüfen…'); if (message) message.hidden = true; if (button) button.textContent = 'Prüfen…'; } else if (progress.stage === 'prepared') { _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, false); _setUpdateProgress(100, 'Neustart…'); if (message) message.hidden = true; if (button) button.textContent = 'Neustart…'; } else if (progress.stage === 'launching' || progress.stage === 'done') { _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, false); _setUpdateProgress(100, 'Neustart…'); if (message) message.hidden = true; if (button) button.textContent = 'Neustart…'; + } else if (progress.stage === 'aborted') { + _updateInstallBusy = false; + _setUpdateDialogBusy(false); + _setUpdateProgress(0, 'Download abgebrochen'); + if (message) { + message.hidden = false; + message.textContent = 'Download abgebrochen'; + } + if (button) { + button.disabled = false; + button.textContent = 'Wiederholen'; + } + _setUpdateDialogVisible(true); } else if (progress.stage === 'error') { _updateInstallBusy = false; _setUpdateDialogBusy(false); @@ -7649,13 +7664,37 @@ function closeUpdateDialog() { return true; } -function _setUpdateDialogBusy(busy) { +async function handleUpdateDismiss() { + if (!_updateInstallBusy) return closeUpdateDialog(); + if (!_updateDownloadCancelable || _updateCancelBusy) return false; + _updateCancelBusy = true; + _setUpdateDialogBusy(true, true); + _setUpdateProgress(Number(document.getElementById('updateProgressBar')?.getAttribute('aria-valuenow')) || 0, 'Abbrechen…'); + try { + const canceled = await window.api.abortUpdate(); + if (!canceled) handleUpdateProgress({ stage: 'error', error: 'Update konnte nicht abgebrochen werden' }); + } catch (error) { + handleUpdateProgress({ stage: 'error', error: error && error.message ? error.message : String(error) }); + } + return false; +} + +function _setUpdateDialogBusy(busy, cancelable = false) { + _updateDownloadCancelable = Boolean(busy && cancelable); + if (!busy) _updateCancelBusy = false; const dialog = document.querySelector('#updateBanner .update-dialog'); if (dialog) dialog.setAttribute('aria-busy', busy ? 'true' : 'false'); - ['installUpdateBtn', 'dismissUpdateBtn', 'updateCloseBtn'].forEach(id => { - const button = document.getElementById(id); - if (button) button.disabled = busy; - }); + const installButton = document.getElementById('installUpdateBtn'); + const dismissButton = document.getElementById('dismissUpdateBtn'); + const closeButton = document.getElementById('updateCloseBtn'); + if (installButton) installButton.disabled = busy; + if (closeButton) closeButton.disabled = busy; + if (dismissButton) { + dismissButton.disabled = Boolean(busy && (!cancelable || _updateCancelBusy)); + dismissButton.textContent = busy && cancelable + ? (_updateCancelBusy ? 'Abbrechen…' : 'Download abbrechen') + : 'Abbrechen'; + } if (busy && dialog && (!dialog.contains(document.activeElement) || document.activeElement.matches?.(':disabled'))) dialog.focus(); } @@ -7679,7 +7718,7 @@ async function installKnownUpdate() { return; } _updateInstallBusy = true; - _setUpdateDialogBusy(true); + _setUpdateDialogBusy(true, true); _setUpdateProgress(0, 'Download 0%'); const message = document.getElementById('updateMessage'); const button = document.getElementById('installUpdateBtn'); @@ -7688,6 +7727,10 @@ async function installKnownUpdate() { try { await persistQueueStateNow(); const result = await window.api.installUpdate(); + if (result && result.canceled) { + handleUpdateProgress({ stage: 'aborted', error: result.error || 'Update abgebrochen' }); + return; + } if (result && result.started === false) throw new Error(result.error || 'Update konnte nicht gestartet werden'); } catch (error) { handleUpdateProgress({ stage: 'error', error: error && error.message ? error.message : String(error) }); diff --git a/renderer/i18n.js b/renderer/i18n.js index 81254b8..dfa316b 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -526,6 +526,11 @@ ['Ein Update wird bereits vorbereitet', 'An update is already being prepared'], ['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'], ['Update abgebrochen', 'Update canceled'], + ['Download abbrechen', 'Cancel download'], + ['Abbrechen…', 'Cancelling…'], + ['Download abgebrochen', 'Download canceled'], + ['Wiederholen', 'Retry'], + ['Update konnte nicht abgebrochen werden', 'The update could not be canceled'], ['Update-Asset unvollständig (URL oder Name fehlt)', 'The update asset is incomplete (URL or name is missing)'], ['Heruntergeladene Datei ist keine gültige EXE', 'The downloaded file is not a valid EXE'], ['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'], diff --git a/tests/i18n.test.js b/tests/i18n.test.js index fe847b2..3c88322 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -116,6 +116,11 @@ test('rare account, backup, update, and confirmation states translate in both di ['Ein Update wird bereits vorbereitet', 'An update is already being prepared'], ['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'], ['Update abgebrochen', 'Update canceled'], + ['Download abbrechen', 'Cancel download'], + ['Abbrechen…', 'Cancelling…'], + ['Download abgebrochen', 'Download canceled'], + ['Wiederholen', 'Retry'], + ['Update konnte nicht abgebrochen werden', 'The update could not be canceled'], ['Ausgewählte Einträge entfernen?', 'Remove selected entries?'], ['Export fehlgeschlagen', 'Export failed'], ['Backup exportiert', 'Backup exported'], diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 2ac9830..d3313ed 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -2469,6 +2469,7 @@ setTimeout(async () => { hidden: overlay.getAttribute('aria-hidden'), closeDisabled: document.getElementById('updateCloseBtn').disabled, dismissDisabled: document.getElementById('dismissUpdateBtn').disabled, + dismissText: document.getElementById('dismissUpdateBtn').textContent, headerHidden: header.hidden, messageHidden: document.getElementById('updateMessage').hidden, messageText: document.getElementById('updateMessage').textContent, @@ -2476,7 +2477,7 @@ setTimeout(async () => { progressText: progress.getAttribute('aria-valuetext') }; })()\`); - check('Busy update keeps its progress dialog open', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === true && busyUpdateState.headerHidden === false); + check('Busy update keeps its progress dialog open with download cancellation available', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === false && busyUpdateState.dismissText === 'Download abbrechen' && busyUpdateState.headerHidden === false); check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%'); check('Busy update shows progress only below the bar', busyUpdateState.messageHidden === true && busyUpdateState.messageText === 'Update v9.9.9 verfügbar'); diff --git a/tests/updater-version.test.js b/tests/updater-version.test.js index eefba8b..63673a4 100644 --- a/tests/updater-version.test.js +++ b/tests/updater-version.test.js @@ -7,7 +7,7 @@ const crypto = require('node:crypto'); const Module = require('node:module'); const { pathToFileURL } = require('node:url'); -const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState } = require('../lib/updater'); +const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState, abortUpdate } = require('../lib/updater'); const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href; test('bridge title resolves product version instead of transport tag', () => { @@ -152,6 +152,61 @@ test('buffered installer downloads yield between progress updates so the rendere } }); +test('cancelling an active installer download reports an aborted state', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-abort-test-')); + const installer = Buffer.alloc(128 * 1024, 0); + installer[0] = 0x4d; + installer[1] = 0x5a; + const progress = []; + let reads = 0; + + try { + await assert.rejects( + prepareUpdate(value => { + progress.push(value); + if (value.stage === 'downloading') abortUpdate(); + }, { + checkResult: { + available: true, + assetUrl: 'https://update.invalid/setup.exe', + assetName: 'setup.exe', + assetSize: installer.length, + remoteVersion: '2.2.0', + latestYmlUrl: 'https://update.invalid/latest.yml' + }, + tempDir, + fetchImpl: async url => url.endsWith('latest.yml') + ? { + ok: true, + status: 200, + text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n` + } + : { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + reads++; + return reads === 1 + ? { done: false, value: installer.subarray(0, installer.length / 2) } + : { done: false, value: installer.subarray(installer.length / 2) }; + } + }) + } + } + }), + /Update abgebrochen/ + ); + + assert.equal(progress.at(-1).stage, 'aborted'); + assert.equal(progress.at(-1).error, 'Update abgebrochen'); + assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('update preparation refreshes a cached release before downloading the installer', async () => { const updaterPath = require.resolve('../lib/updater'); const originalLoad = Module._load;