diff --git a/.changeset/updater-retry-and-speed.md b/.changeset/updater-retry-and-speed.md new file mode 100644 index 00000000..22f8be38 --- /dev/null +++ b/.changeset/updater-retry-and-speed.md @@ -0,0 +1,7 @@ +### Bug Fixes +- The update dialog offers a Retry button when a check or a download fails, instead of leaving the error on screen with nothing to do about it. +- A failed check no longer says "Checking for updates…" in its header while showing the error underneath, and a long endpoint URL wraps inside the dialog rather than running past its edge. + +### Changes +- Update checks give up after 15 seconds. Without a bound they inherited the HTTP stack's default and could sit on "Checking for updates…" long after the request was dead. +- Downloading an update no longer redraws the dialog on every network chunk. Progress is published once a frame, which takes the redraw count for a 100MB build from thousands down to about one per 16ms. diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 5bce09f2..8616118f 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -31,11 +31,22 @@ let pendingUpdate = $state(null) let dismissed = $state(false) let checking = $state(false) + /** Which operation produced `errorMsg`, so Retry repeats that one. */ + let failedAction = $state(/** @type {'check'|'install'} */ ('check')) + + // A check that cannot reach GitHub should give up in seconds. Without an + // explicit bound it inherits the HTTP stack's default, which left the dialog + // spinning on "Checking for updates…" far longer than anyone waits. + const CHECK_TIMEOUT_MS = 15_000 /** Manually trigger an update check (e.g. from the command palette). */ export async function checkNow() { if (checking || status === 'downloading') return dismissed = false + // Drop the previous outcome: re-checking after a failure showed "Update + // failed" in the header while the new check was still running. + status = 'idle' + errorMsg = '' checking = true await checkForUpdate() if (status === 'idle') status = 'up-to-date' @@ -180,9 +191,30 @@ }) } + /** + * Record a failure and remember what caused it, so Retry repeats that step + * rather than always falling back to a fresh check. + * @param {'check'|'install'} action + * @param {unknown} e + */ + function fail(action, e) { + failedAction = action + errorMsg = String(e) + status = 'error' + } + + /** Re-run whatever failed: the download if we already have an update, else the check. */ + async function retry() { + if (failedAction === 'install' && pendingUpdate) { + await install() + return + } + await checkNow() + } + async function checkForUpdate() { try { - const update = await check() + const update = await check({ timeout: CHECK_TIMEOUT_MS }) if (!update) { console.info('[updater] no update available') return @@ -195,10 +227,7 @@ onupdatefound() } catch (e) { console.error('[updater] check failed:', e) - if (checking) { - errorMsg = String(e) - status = 'error' - } + if (checking) fail('check', e) } } @@ -209,21 +238,38 @@ progress = 0 downloadedBytes = 0 totalBytes = 0 + // downloadAndInstall reports one Progress event per HTTP chunk, which is + // thousands of them for a build this size. Writing $state on every event + // re-rendered the dialog at that same rate and was most of why downloading + // felt slow, so the running total is kept out of reactivity and published + // once a frame. + let received = 0 + let frame = 0 + const publish = () => { + frame = 0 + downloadedBytes = received + if (totalBytes > 0) progress = Math.round((received / totalBytes) * 100) + } + try { await pendingUpdate.downloadAndInstall((event) => { if (event.event === 'Started') { totalBytes = event.data.contentLength ?? 0 } else if (event.event === 'Progress') { - downloadedBytes += event.data.chunkLength - if (totalBytes > 0) progress = Math.round((downloadedBytes / totalBytes) * 100) + received += event.data.chunkLength + if (!frame) frame = requestAnimationFrame(publish) } else if (event.event === 'Finished') { + if (frame) cancelAnimationFrame(frame) + frame = 0 + downloadedBytes = received progress = 100 } }) status = 'done' } catch (e) { - errorMsg = String(e) - status = 'error' + if (frame) cancelAnimationFrame(frame) + console.error('[updater] download failed:', e) + fail('install', e) } } @@ -296,18 +342,18 @@ - {#if checking} - Checking for updates… + {#if status === 'error'} + {failedAction === 'install' ? 'Download failed' : "Couldn't check for updates"} {:else if status === 'available'} Stroke {updateVersion} available {:else if status === 'downloading'} Downloading update… {:else if status === 'done'} Ready to install - {:else if status === 'error'} - Update failed {:else if status === 'up-to-date'} Up to date + {:else if checking} + Checking for updates… {/if} @@ -390,7 +436,31 @@ {:else if status === 'error'} -

{errorMsg}

+

+ {failedAction === 'install' + ? 'The download did not finish.' + : 'Stroke could not reach the update server.'} +

+ +

{errorMsg}

+
+ + +
{:else if status === 'up-to-date' || checking}