From 15cca23773352899bc57a74c037225f14ef73e91 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 9 Jul 2026 16:10:27 +0900 Subject: [PATCH] fix(updater): never throw from an auto update check (#5028) checkForAppUpdates runs fire-and-forget on reader/library mount, but it threw on any network failure: the desktop check() propagated "error sending request for url (...latest.json)" (READEST-J) and the Android branch re-threw "Failed to fetch Android update info" (READEST-22). Both surfaced as unhandled rejections. Wrap the check so a failure is swallowed (returns false) for an auto-check; manual checks (About window) still surface the error. Adds regression tests. Fixes READEST-J Fixes READEST-22 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/helpers/updater.test.ts | 18 ++++ apps/readest-app/src/helpers/updater.ts | 92 +++++++++++-------- 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/apps/readest-app/src/__tests__/helpers/updater.test.ts b/apps/readest-app/src/__tests__/helpers/updater.test.ts index a1308e02c..e887fe3a0 100644 --- a/apps/readest-app/src/__tests__/helpers/updater.test.ts +++ b/apps/readest-app/src/__tests__/helpers/updater.test.ts @@ -256,6 +256,24 @@ describe('updater', () => { ); }); + test('auto-check swallows Android update failure (READEST-22)', async () => { + mockOsType.mockReturnValue('android'); + mockTauriFetch.mockRejectedValue(new Error('Network error')); + + // Auto-check runs fire-and-forget on mount; a network failure must resolve + // false, not reject (which would become an unhandled rejection). + await expect(checkForAppUpdates(dummyTranslate, true)).resolves.toBe(false); + }); + + test('auto-check swallows desktop update failure (READEST-J)', async () => { + mockOsType.mockReturnValue('macos'); + mockCheck.mockRejectedValue( + new Error('error sending request for url (releases/latest/download/latest.json)'), + ); + + await expect(checkForAppUpdates(dummyTranslate, true)).resolves.toBe(false); + }); + test('returns false for unsupported OS types', async () => { mockOsType.mockReturnValue('ios'); diff --git a/apps/readest-app/src/helpers/updater.ts b/apps/readest-app/src/helpers/updater.ts index 7ec235d02..ebcd73bcd 100644 --- a/apps/readest-app/src/helpers/updater.ts +++ b/apps/readest-app/src/helpers/updater.ts @@ -149,48 +149,62 @@ export const checkForAppUpdates = async ( console.log('Checking for updates', { updateChannel }); const OS_TYPE = osType(); - if (updateChannel === 'nightly') { - const platformKey = getNightlyPlatformKey( - OS_TYPE, - osArch(), - Boolean(process.env['NEXT_PUBLIC_PORTABLE_APP']), - Boolean((window as { __READEST_IS_APPIMAGE?: boolean }).__READEST_IS_APPIMAGE), - ); - if (!platformKey) return false; - const resolved = await resolveNightlyUpdate(getAppVersion(), platformKey, fetch); - if (resolved) { - setUpdaterWindowVisible(true, resolved.version, getAppVersion(), true, resolved); - return true; + try { + if (updateChannel === 'nightly') { + const platformKey = getNightlyPlatformKey( + OS_TYPE, + osArch(), + Boolean(process.env['NEXT_PUBLIC_PORTABLE_APP']), + Boolean((window as { __READEST_IS_APPIMAGE?: boolean }).__READEST_IS_APPIMAGE), + ); + if (!platformKey) return false; + const resolved = await resolveNightlyUpdate(getAppVersion(), platformKey, fetch); + if (resolved) { + setUpdaterWindowVisible(true, resolved.version, getAppVersion(), true, resolved); + return true; + } + return false; } + + if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { + const update = await check(); + if (update) { + // Enum ScrollBarStyle is exported as type by tauri, so it cannot be used directly. + const scrollBarStyle = (OS_TYPE === 'windows' + ? 'fluentOverlay' + : 'default') as unknown as ScrollBarStyle; + showUpdateWindow(update.version, scrollBarStyle); + } + return !!update; + } else if (OS_TYPE === 'android') { + try { + const response = await fetch(READEST_UPDATER_FILE, { connectTimeout: 5000 }); + const data = await response.json(); + const isNewer = semver.gt(data.version, getAppVersion()); + if ( + isNewer && + ('android-arm64' in data.platforms || 'android-universal' in data.platforms) + ) { + setUpdaterWindowVisible(true, data.version!, getAppVersion()); + } + return isNewer; + } catch (err) { + console.warn('Failed to fetch Android update info', err); + throw new Error('Failed to fetch Android update info'); + } + } + + return false; + } catch (err) { + // Update checks are best-effort: they fail routinely when offline or when + // the release host is unreachable. An auto-check runs fire-and-forget on + // mount, so throwing here becomes an unhandled rejection (READEST-J desktop + // latest.json, READEST-22 Android update info). Only surface the failure for + // a manual check (About window). + console.warn('Update check failed', err); + if (!isAutoCheck) throw err; return false; } - - if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { - const update = await check(); - if (update) { - // Enum ScrollBarStyle is exported as type by tauri, so it cannot be used directly. - const scrollBarStyle = (OS_TYPE === 'windows' - ? 'fluentOverlay' - : 'default') as unknown as ScrollBarStyle; - showUpdateWindow(update.version, scrollBarStyle); - } - return !!update; - } else if (OS_TYPE === 'android') { - try { - const response = await fetch(READEST_UPDATER_FILE, { connectTimeout: 5000 }); - const data = await response.json(); - const isNewer = semver.gt(data.version, getAppVersion()); - if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) { - setUpdaterWindowVisible(true, data.version!, getAppVersion()); - } - return isNewer; - } catch (err) { - console.warn('Failed to fetch Android update info', err); - throw new Error('Failed to fetch Android update info'); - } - } - - return false; }; const LAST_SHOWN_RELEASE_NOTES_KEY = 'lastShownReleaseNotesVersion';