[eric] updater: a silent Squirrel check gets diagnosed by a main-side watchdog, DMG and translocated launches learn at boot they cannot update

This commit is contained in:
ciregenz
2026-08-06 14:55:18 -07:00
parent 0e43b7c0c9
commit 77ce26638d
3 changed files with 111 additions and 0 deletions
+61
View File
@@ -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();
+23
View File
@@ -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 };
+27
View File
@@ -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);
});