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.
This commit is contained in:
+6
-6
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+55
-12
@@ -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) });
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+2
-1
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user