From 2935d6c48d5b712177ae8659edd40049d499864c Mon Sep 17 00:00:00 2001 From: mikelsr Date: Sun, 14 Jun 2026 19:46:54 +0200 Subject: [PATCH 1/3] Mute update error dialogs for transient errors --- src/auto-updater/index.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/auto-updater/index.js b/src/auto-updater/index.js index 702c6cea9..a946651a0 100644 --- a/src/auto-updater/index.js +++ b/src/auto-updater/index.js @@ -22,6 +22,7 @@ function isAutoUpdateSupported () { let updateNotification = null // must be a global to avoid gc let feedback = false +let updateStarted = false // set once the update has started function setup () { const ctx = getCtx() @@ -36,7 +37,15 @@ function setup () { logger.error(`[updater] stack: ${err.stack}`) } - // Show dialog for all errors (background and manual checks) + // Show dialog for all errors (background and manual checks), + // only if it's a manual check or the update has already started. + if (!feedback && !updateStarted) { + return + } + + updateStarted = false + feedback = false + const opt = showDialog({ title: i18n.t('autoUpdateError.title'), message: i18n.t('autoUpdateError.message'), @@ -50,16 +59,11 @@ function setup () { if (opt === 1) { shell.openExternal('https://github.com/ipfs/ipfs-desktop/releases/latest') } - - if (!feedback) { - return - } - - feedback = false }) autoUpdater.on('update-available', async ({ version, releaseNotes }) => { logger.info(`[updater] update to ${version} available, download will start`) + updateStarted = true try { await autoUpdater.downloadUpdate() From e218bdfd013fc77c5fc7062d8c41d1f26b43938c Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 15 Jun 2026 15:49:06 +0200 Subject: [PATCH 2/3] fix(updater): reset updateStarted after download The flag was only cleared on error, so the first successful background download left it stuck true and later transient check errors showed the dialog again. Clearing it when the download finishes scopes the flag to a single download, matching the reset on failure. --- src/auto-updater/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/auto-updater/index.js b/src/auto-updater/index.js index a946651a0..c6d9f5346 100644 --- a/src/auto-updater/index.js +++ b/src/auto-updater/index.js @@ -22,7 +22,7 @@ function isAutoUpdateSupported () { let updateNotification = null // must be a global to avoid gc let feedback = false -let updateStarted = false // set once the update has started +let updateStarted = false // true while a download is in progress function setup () { const ctx = getCtx() @@ -37,8 +37,8 @@ function setup () { logger.error(`[updater] stack: ${err.stack}`) } - // Show dialog for all errors (background and manual checks), - // only if it's a manual check or the update has already started. + // Surface the error only for manual checks or once a download has + // started. Stay silent on transient errors from background checks. if (!feedback && !updateStarted) { return } @@ -128,6 +128,7 @@ function setup () { autoUpdater.on('update-downloaded', ({ version }) => { logger.info(`[updater] update to ${version} downloaded`) + updateStarted = false // download finished const feedbackDialog = () => { const opt = showDialog({ From 4e1ba46259ec11e6c0c5b26a0c28dba6025f2d34 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 15 Jun 2026 18:27:40 +0200 Subject: [PATCH 3/3] test: cover auto-updater error dialog gating Drives the real entrypoint with a fake electron-updater and asserts the gating rule: errors during background checks stay silent, while manual checks and in-flight download failures surface a dialog. Guards the updateStarted reset so an error after a finished download stays silent. --- test/unit/auto-updater.spec.js | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/unit/auto-updater.spec.js diff --git a/test/unit/auto-updater.spec.js b/test/unit/auto-updater.spec.js new file mode 100644 index 000000000..0c057a2b3 --- /dev/null +++ b/test/unit/auto-updater.spec.js @@ -0,0 +1,151 @@ +const proxyquire = require('proxyquire').noCallThru() +const sinon = require('sinon') +const { test, expect } = require('@playwright/test') +const { EventEmitter } = require('events') + +// These tests cover the error-dialog gating in src/auto-updater. The module +// registers electron-updater event handlers and decides whether an error is +// worth a modal dialog. The rule: stay silent on transient errors during +// background checks, but surface errors from manual checks or once a download +// has started. +// +// We drive the real public entrypoint with a fake electron-updater that is an +// EventEmitter, then emit the events electron-updater would emit and assert on +// the dialog. `feedback` (manual-check mode) is reached through the same +// `manualCheckForUpdates` trigger the About menu uses. + +function loadUpdater () { + const fakeAutoUpdater = Object.assign(new EventEmitter(), { + checkForUpdates: sinon.stub().resolves(), + downloadUpdate: sinon.stub().resolves(), + quitAndInstall: sinon.stub() + }) + + const showDialog = sinon.stub().returns(0) // default: "Later" + const shellOpenExternal = sinon.spy() + const notifications = [] + class FakeNotification { + constructor (opts) { + this.opts = opts + this.on = sinon.spy() + this.show = sinon.spy() + notifications.push(this) + } + } + + const ctxProps = {} + const fakeCtx = { + getFn: () => sinon.stub().resolves(0), + setProp: (key, value) => { ctxProps[key] = value }, + getProp: (key) => ctxProps[key] + } + + const updater = proxyquire('../../src/auto-updater', { + electron: { + shell: { openExternal: shellOpenExternal }, + app: { removeAllListeners: sinon.spy() }, + BrowserWindow: { getAllWindows: () => [] }, + Notification: FakeNotification, + ipcMain: { emit: sinon.spy() }, + autoUpdater: { on: sinon.spy() } // electron's built-in updater (before-quit-for-update) + }, + 'electron-updater': { autoUpdater: fakeAutoUpdater }, + i18next: { t: (key) => key }, + '../common/logger': { info: () => {}, error: () => {} }, + '../dialogs': { showDialog }, + '../common/consts': { IS_MAC: true, IS_WIN: false, IS_APPIMAGE: false }, + '../common/ipc-main-events': { UPDATING: 'updating', UPDATING_ENDED: 'updating-ended' }, + '../context': () => fakeCtx, + '../common/store': { get: () => false }, + '../common/config-keys': { DISABLE_AUTO_UPDATE: 'disableAutoUpdate' } + }) + + return { updater, fakeAutoUpdater, showDialog, shellOpenExternal, notifications, ctxProps } +} + +// Run the real init in the supported-platform path so the event handlers and +// the manual-check trigger get wired. NODE_ENV is forced off 'test' (which +// otherwise short-circuits init), and the 12h setInterval is neutralised so it +// does not keep the worker alive. +async function initSupported (h) { + const setIntervalStub = sinon.stub(global, 'setInterval') + const prevEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + try { + await h.updater() + await new Promise(resolve => setImmediate(resolve)) // let the startup check settle + } finally { + process.env.NODE_ENV = prevEnv + setIntervalStub.restore() + } +} + +const tick = () => new Promise(resolve => setImmediate(resolve)) + +test.describe('auto-updater error dialog gating', () => { + test('background-check error stays silent', async () => { + const h = loadUpdater() + await initSupported(h) + + h.fakeAutoUpdater.emit('error', new Error('network not ready after wake')) + + expect(h.showDialog.called).toBe(false) + }) + + test('error while a download is in flight shows the dialog', async () => { + const h = loadUpdater() + await initSupported(h) + + h.fakeAutoUpdater.emit('update-available', { version: '1.2.3' }) // sets updateStarted = true + await tick() + h.fakeAutoUpdater.emit('error', new Error('download failed')) + + expect(h.showDialog.calledOnce).toBe(true) + }) + + test('error after a completed download stays silent', async () => { + // Regression guard: updateStarted must be cleared on update-downloaded, so a + // later transient background error after a finished download is not surfaced. + const h = loadUpdater() + await initSupported(h) + + h.fakeAutoUpdater.emit('update-available', { version: '1.2.3' }) // updateStarted = true + await tick() + h.fakeAutoUpdater.emit('update-downloaded', { version: '1.2.3' }) // updateStarted = false + h.fakeAutoUpdater.emit('error', new Error('later transient error')) + + expect(h.showDialog.called).toBe(false) + }) + + test('manual-check error shows the dialog', async () => { + const h = loadUpdater() + await initSupported(h) + + h.ctxProps.manualCheckForUpdates() // feedback = true + h.fakeAutoUpdater.emit('error', new Error('manual check failed')) + + expect(h.showDialog.calledOnce).toBe(true) + }) + + test('a shown error resets feedback so the next background error is silent', async () => { + const h = loadUpdater() + await initSupported(h) + + h.ctxProps.manualCheckForUpdates() // feedback = true + h.fakeAutoUpdater.emit('error', new Error('first, manual')) // shown, resets feedback + h.fakeAutoUpdater.emit('error', new Error('second, background')) // silent + + expect(h.showDialog.calledOnce).toBe(true) + }) + + test('choosing "Download Now" on an error opens the releases page', async () => { + const h = loadUpdater() + await initSupported(h) + h.showDialog.returns(1) // "Download Now" + + h.ctxProps.manualCheckForUpdates() + h.fakeAutoUpdater.emit('error', new Error('boom')) + + expect(h.shellOpenExternal.calledOnceWith('https://github.com/ipfs/ipfs-desktop/releases/latest')).toBe(true) + }) +})