From 77ce26638dbe48d701b487441d1eee552ca0df75 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 14:55:18 -0700 Subject: [PATCH] [eric] updater: a silent Squirrel check gets diagnosed by a main-side watchdog, DMG and translocated launches learn at boot they cannot update --- electron/main.js | 61 +++++++++++++++++++++++++++ electron/updateCheckDiagnosis.js | 23 ++++++++++ electron/updateCheckDiagnosis.test.js | 27 ++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 electron/updateCheckDiagnosis.js create mode 100644 electron/updateCheckDiagnosis.test.js diff --git a/electron/main.js b/electron/main.js index fc69b505..82d2ca9a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1624,6 +1624,50 @@ function sendToRenderer(channel, ...args) { // em/en dashes per repo style. // Extracted to electron/updateErrorMessage.js so the mapping is unit-testable; see node --test there. const { friendlyUpdateError } = require('./updateErrorMessage'); +const { diagnoseSilentUpdateCheck } = require('./updateCheckDiagnosis'); + +// Squirrel's built-in updater reports only via events; when AV or a proxy kills its request +// internally, no event EVER arrives and the renderer's spinner spins forever. This watchdog turns +// that silence into a diagnosed update-error. Settled by every real updater event. +let p_squirrelCheckWatchdog = null; +function settleUpdateCheckWatchdog() { + if (p_squirrelCheckWatchdog) { + clearTimeout(p_squirrelCheckWatchdog); + p_squirrelCheckWatchdog = null; + } +} + +// Reachability probe through Electron's net stack, so a system proxy that blocks Squirrel blocks this the same way. Any HTTP response (even a redirect) proves the feed is reachable. +function probeUpdateFeed(timeoutMs = 8000) { + return new Promise((resolve) => { + try { + const { net } = require('electron'); + const req = net.request({ method: 'HEAD', url: 'https://github.com/openswarm-ai/openswarm/releases/latest/download/RELEASES' }); + const timer = setTimeout(() => { try { req.abort(); } catch (_) {} resolve(false); }, timeoutMs); + req.on('response', () => { clearTimeout(timer); resolve(true); }); + req.on('error', () => { clearTimeout(timer); resolve(false); }); + req.end(); + } catch (_) { + resolve(false); + } + }); +} + +function armSquirrelCheckWatchdog() { + settleUpdateCheckWatchdog(); + p_squirrelCheckWatchdog = setTimeout(async () => { + p_squirrelCheckWatchdog = null; + let updateExeExists = false; + try { + updateExeExists = fs.existsSync(path.resolve(path.dirname(process.execPath), '..', 'Update.exe')); + } catch (_) {} + const feedReachable = await probeUpdateFeed(); + const msg = diagnoseSilentUpdateCheck({ updateExeExists, feedReachable }); + console.warn('[updater] Squirrel check went silent; diagnosis:', msg); + cachedUpdateStatus = { status: 'error', info: null, error: msg }; + sendToRenderer('update-error', msg); + }, 15000); +} // Phase 2 provenance: which exact commit produced this build. The build // scripts write electron/build-info.json (gitignored, regenerated each build) @@ -1669,6 +1713,17 @@ async function clearStaleFrontendCache() { function setupAutoUpdater() { if (!autoUpdater) return; + // Proactive, not post-mortem: an app running off the DMG or a Gatekeeper-translocated copy can NEVER self-update (Squirrel.Mac refuses read-only volumes, proven in the packaged smoke). Tell that cohort what to do at boot instead of after a failed check they may never click. + if (process.platform === 'darwin' && isPackaged) { + const exe = process.execPath || ''; + if (exe.includes('/AppTranslocation/') || exe.startsWith('/Volumes/')) { + const msg = 'OpenSwarm is running from the disk image, so macOS blocks self-update. Drag OpenSwarm to Applications, then relaunch it from there.'; + console.warn('[updater] read-only launch detected at boot:', exe); + cachedUpdateStatus = { status: 'error', info: null, error: msg }; + sendToRenderer('update-error', msg); + return; + } + } if (isSquirrelUpdater) { // Squirrel.Windows fetches its RELEASES feed from GH /latest/download/. The // built-in autoUpdater has no autoDownload/allowPrerelease/allowDowngrade knobs. @@ -1695,6 +1750,7 @@ function setupAutoUpdater() { // args and update-downloaded with positional (event, releaseNotes, releaseName, // releaseDate, updateURL). Normalize so these handlers work for both. autoUpdater.on('update-available', (info) => { + settleUpdateCheckWatchdog(); const norm = info && info.version ? info : { version: '' }; console.log(`Update available: ${norm.version || '(version not reported by Squirrel)'}`); cachedUpdateStatus = { status: 'available', info: norm, error: null }; @@ -1702,6 +1758,7 @@ function setupAutoUpdater() { }); autoUpdater.on('update-not-available', (info) => { + settleUpdateCheckWatchdog(); console.log('App is up to date'); cachedUpdateStatus = { status: 'not-available', info: info || {}, error: null }; sendToRenderer('update-not-available', info || {}); @@ -1713,6 +1770,7 @@ function setupAutoUpdater() { }); autoUpdater.on('update-downloaded', (info, releaseNotes, releaseName) => { + settleUpdateCheckWatchdog(); const version = (info && info.version) || releaseName || ''; console.log(`Update downloaded: ${version || '(ready to install)'}`); const norm = info && info.version ? info : { version }; @@ -1721,6 +1779,7 @@ function setupAutoUpdater() { }); autoUpdater.on('error', (err) => { + settleUpdateCheckWatchdog(); // Squirrel throws "AutoUpdater process ... is already running" when a check or // download is already in flight (e.g. the user clicked Check twice). Benign. if (/already running/i.test((err && err.message) || '')) { @@ -3447,6 +3506,8 @@ ipcMain.handle('check-for-updates', async () => { // update-available / update-not-available events, so don't expect a result. if (isSquirrelUpdater) { autoUpdater.checkForUpdates(); + // Silence past this point would leave the spinner forever; the watchdog diagnoses it instead. + armSquirrelCheckWatchdog(); return { success: true }; } const result = await autoUpdater.checkForUpdates(); diff --git a/electron/updateCheckDiagnosis.js b/electron/updateCheckDiagnosis.js new file mode 100644 index 00000000..601b3d80 --- /dev/null +++ b/electron/updateCheckDiagnosis.js @@ -0,0 +1,23 @@ +// Why a Windows Squirrel update check goes SILENT, told as something the user can act on. +// +// The built-in Squirrel autoUpdater reports only via events. When a corporate proxy or antivirus +// kills its request internally, or Update.exe dies without an error event, NO event ever arrives: +// the renderer's spinner just spins. The main process arms a watchdog around the check; when it +// fires, it probes the two things that actually distinguish the causes (does the update helper +// still exist on disk, can this machine reach the release feed) and maps them here. +// +// Pure mapping so it is unit-testable: cd electron && node --test updateCheckDiagnosis.test.js + +'use strict'; + +function diagnoseSilentUpdateCheck({ updateExeExists, feedReachable }) { + if (!updateExeExists) { + return 'The Windows update helper is missing, which usually means antivirus quarantined it. Reinstall OpenSwarm from openswarm.com to restore updates.'; + } + if (!feedReachable) { + return 'Could not reach the update server. A firewall or proxy may be blocking github.com; OpenSwarm will keep retrying in the background.'; + } + return 'The update check stalled without a response. Security software may be blocking the updater; reinstalling OpenSwarm usually clears it.'; +} + +module.exports = { diagnoseSilentUpdateCheck }; diff --git a/electron/updateCheckDiagnosis.test.js b/electron/updateCheckDiagnosis.test.js new file mode 100644 index 00000000..b6f178c6 --- /dev/null +++ b/electron/updateCheckDiagnosis.test.js @@ -0,0 +1,27 @@ +// Run: cd electron && node --test updateCheckDiagnosis.test.js + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { diagnoseSilentUpdateCheck } = require('./updateCheckDiagnosis'); + +test('a quarantined Update.exe names the reinstall, whatever the network says', () => { + for (const feedReachable of [true, false]) { + const msg = diagnoseSilentUpdateCheck({ updateExeExists: false, feedReachable }); + assert.match(msg, /update helper is missing/i); + assert.match(msg, /Reinstall/); + } +}); + +test('an unreachable feed names the firewall, not the user', () => { + const msg = diagnoseSilentUpdateCheck({ updateExeExists: true, feedReachable: false }); + assert.match(msg, /firewall or proxy/i); + assert.doesNotMatch(msg, /try again/i); +}); + +test('helper present and feed reachable still gets an actionable message, never a shrug', () => { + const msg = diagnoseSilentUpdateCheck({ updateExeExists: true, feedReachable: true }); + assert.match(msg, /Security software|reinstalling/i); + assert.doesNotMatch(msg, /timed out/i); +});