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:
Sucukdeluxe
2026-08-20 23:57:54 +02:00
parent bbdfc37fdc
commit 577e1dd9c9
7 changed files with 136 additions and 26 deletions
+5
View File
@@ -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
View File
@@ -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');
+56 -1
View File
@@ -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;