From b61a653180d9709d1d4ea016e4a9f122c4dee180 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 15:59:34 -0700 Subject: [PATCH] [eric] updater: name the failures a user can fix instead of telling them to try again later --- electron/main.js | 17 ++----- electron/package.json | 2 +- electron/updateErrorMessage.js | 46 +++++++++++++++++++ electron/updateErrorMessage.test.js | 70 +++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 electron/updateErrorMessage.js create mode 100644 electron/updateErrorMessage.test.js diff --git a/electron/main.js b/electron/main.js index 56b22d0f..7a7f642f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1523,19 +1523,8 @@ function sendToRenderer(channel, ...args) { // Maps a raw electron-updater error to a short, human message. The raw error // is always logged separately for debugging; users only ever see this. No // em/en dashes per repo style. -function friendlyUpdateError(err) { - const raw = ((err && err.message) || String(err) || '').toLowerCase(); - // Experimental on, but there is no pre-release to fetch: the provider 404s - // looking for the pre-release channel feed. This is the screenshot case. - if (autoUpdater && autoUpdater.allowPrerelease && - /404|not found|cannot find|no published|latest.*\.yml/.test(raw)) { - return 'No experimental builds available right now. You are on the latest version.'; - } - if (/net::|enotfound|etimedout|econnrefused|getaddrinfo|network/.test(raw)) { - return 'Could not reach the update server. Check your connection and try again.'; - } - return 'Update check failed. Please try again later.'; -} +// Extracted to electron/updateErrorMessage.js so the mapping is unit-testable; see node --test there. +const { friendlyUpdateError } = require('./updateErrorMessage'); // Phase 2 provenance: which exact commit produced this build. The build // scripts write electron/build-info.json (gitignored, regenerated each build) @@ -1645,7 +1634,7 @@ function setupAutoUpdater() { // but no pre-release exists": the GitHub provider 404s hunting a pre-release // feed, which is not a real failure, just "nothing newer to install". console.error('Auto-update error:', err); - const friendly = friendlyUpdateError(err); + const friendly = friendlyUpdateError(err, !!(autoUpdater && autoUpdater.allowPrerelease)); cachedUpdateStatus = { status: 'error', info: null, error: friendly }; sendToRenderer('update-error', friendly); }); diff --git a/electron/package.json b/electron/package.json index 34ddef3f..45872096 100644 --- a/electron/package.json +++ b/electron/package.json @@ -15,7 +15,7 @@ "dist:win": "electron-builder --win --x64 --publish never", "dist:win:publish": "electron-builder --win --x64 --publish always", "dist:all": "electron-builder --mac --win --linux", - "test": "node --test affiliateTracking.test.js", + "test": "node --test affiliateTracking.test.js updateErrorMessage.test.js", "test:mouseclamp": "bash native/mouseclamp/run-tests.sh" }, "dependencies": { diff --git a/electron/updateErrorMessage.js b/electron/updateErrorMessage.js new file mode 100644 index 00000000..05221939 --- /dev/null +++ b/electron/updateErrorMessage.js @@ -0,0 +1,46 @@ +// Turning an updater failure into something the user can act on. +// +// electron-updater reports check, download and install failures through one 'error' event, so the +// message a user sees is whatever this mapping decides. The default used to be "Update check +// failed. Please try again later." for everything that was not a Chromium net:: error, which is a +// dead end: the two most common real causes (running from the DMG on macOS, a quarantined +// Update.exe on Windows) never resolve themselves no matter how long you wait, and the user is told +// to wait. Each branch below names a failure the user can actually fix. + +'use strict'; + +/** + * @param {unknown} err the error electron-updater emitted + * @param {boolean} allowPrerelease whether the experimental channel is on + * @returns {string} a message safe to show in Settings + */ +function friendlyUpdateError(err, allowPrerelease) { + const raw = ((err && err.message) || String(err) || '').toLowerCase(); + // Experimental on, but there is no pre-release to fetch: the provider 404s looking for the + // pre-release channel feed. + if (allowPrerelease && /404|not found|cannot find|no published|latest.*\.yml/.test(raw)) { + return 'No experimental builds available right now. You are on the latest version.'; + } + if (/net::|enotfound|etimedout|econnrefused|getaddrinfo|network/.test(raw)) { + return 'Could not reach the update server. Check your connection and try again.'; + } + // Running from the DMG or a Gatekeeper-translocated copy in ~/Downloads. Squirrel.Mac refuses + // deterministically, so this user can never update until the app moves. + if (/read-only volume|read only volume|translocat/.test(raw)) { + return 'Move OpenSwarm to your Applications folder, then check again. Updates cannot install while it runs from the disk image.'; + } + // Windows Squirrel missing or quarantined by antivirus; retrying never fixes it either. + if (/can not find squirrel|cannot find squirrel|update\.exe/.test(raw)) { + return 'The updater is missing, which usually means antivirus quarantined it. Reinstall from openswarm.com to fix it.'; + } + // Reachability failures whose text carries no net:: token, so they would otherwise look unexplained. + if (/http error|econnreset|certificate|self.signed|\b403\b|\b429\b|econnaborted/.test(raw)) { + return 'The update server refused the request. Check your VPN or network, then try again.'; + } + if (/enospc|no space/.test(raw)) { + return 'Not enough disk space to download the update. Free up some space and try again.'; + } + return 'Update check failed. Please try again later.'; +} + +module.exports = { friendlyUpdateError }; diff --git a/electron/updateErrorMessage.test.js b/electron/updateErrorMessage.test.js new file mode 100644 index 00000000..5fa94da2 --- /dev/null +++ b/electron/updateErrorMessage.test.js @@ -0,0 +1,70 @@ +// A user who cannot update needs to be told what to DO. +// +// Every failure that was not a Chromium net:: error used to collapse into "Update check failed. +// Please try again later." A real 1.5.9 user sat on that message with a perfectly healthy release +// feed: the release, its ymls, checksums, signature and notarization all verified good. The cause +// was local and permanent, and the app told them to wait. +// +// Run: cd electron && node --test updateErrorMessage.test.js + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { friendlyUpdateError } = require('./updateErrorMessage'); + +const GENERIC = 'Update check failed. Please try again later.'; + +test('running from the disk image tells the user to move the app', () => { + const msg = friendlyUpdateError(new Error('Cannot update while running on a read-only volume'), false); + assert.match(msg, /Applications folder/); + assert.notEqual(msg, GENERIC); +}); + +test('a Gatekeeper-translocated copy gets the same advice', () => { + const msg = friendlyUpdateError(new Error('app is translocated'), false); + assert.match(msg, /Applications folder/); +}); + +test('a quarantined Windows updater says so instead of "try again"', () => { + const msg = friendlyUpdateError(new Error('Can not find Squirrel'), false); + assert.match(msg, /antivirus/); + assert.notEqual(msg, GENERIC); +}); + +test('an HTTP refusal reads as reachability, not as an unexplained failure', () => { + for (const raw of ['HTTP error: Forbidden', 'ECONNRESET', 'unable to verify the first certificate']) { + const msg = friendlyUpdateError(new Error(raw), false); + assert.match(msg, /VPN or network/, `"${raw}" should read as reachability`); + } +}); + +test('a full disk names the disk', () => { + assert.match(friendlyUpdateError(new Error('ENOSPC: no space left on device'), false), /disk space/); +}); + +test('network errors keep their existing message', () => { + const msg = friendlyUpdateError(new Error('net::ERR_INTERNET_DISCONNECTED'), false); + assert.match(msg, /Check your connection/); +}); + +test('the experimental-channel 404 still wins when prerelease is on', () => { + const msg = friendlyUpdateError(new Error('404 Not Found: latest-mac.yml'), true); + assert.match(msg, /No experimental builds/); +}); + +test('the same 404 is NOT the experimental message when prerelease is off', () => { + const msg = friendlyUpdateError(new Error('404 Not Found: latest-mac.yml'), false); + assert.doesNotMatch(msg, /No experimental builds/); +}); + +test('a genuinely unknown failure still falls through to the generic message', () => { + // The discriminating half. If everything matched something, the buckets would be meaningless. + assert.equal(friendlyUpdateError(new Error('something nobody predicted'), false), GENERIC); +}); + +test('a null error does not throw', () => { + assert.equal(typeof friendlyUpdateError(null, false), 'string'); + assert.equal(typeof friendlyUpdateError(undefined, true), 'string'); +});