From 78d105b46ef45ecfe19f3036d440be1e48ff6831 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 21 Sep 2026 03:36:11 +0200 Subject: [PATCH] fix(desktop): stop looping on an update that never installs (#2879) --- fluxer_desktop/src/main/Updater.test.mjs | 129 +++++++++++++++++- fluxer_desktop/src/main/Updater.ts | 72 ++++++++++ .../src/main/UpdaterApplyState.test.mjs | 119 ++++++++++++++++ fluxer_desktop/src/main/UpdaterApplyState.ts | 56 ++++++++ 4 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 fluxer_desktop/src/main/UpdaterApplyState.test.mjs create mode 100644 fluxer_desktop/src/main/UpdaterApplyState.ts diff --git a/fluxer_desktop/src/main/Updater.test.mjs b/fluxer_desktop/src/main/Updater.test.mjs index dbb9097f5..1cc924fc6 100644 --- a/fluxer_desktop/src/main/Updater.test.mjs +++ b/fluxer_desktop/src/main/Updater.test.mjs @@ -65,6 +65,7 @@ before(async () => { files: { appimage: {url: `${baseUrl}/appimage`, sha256: NEW_SHA256}, deb: {url: `${baseUrl}/deb`, sha256: 'deadbeef'}, + setup: {url: `${baseUrl}/setup`, sha256: 'cafebabe'}, }, }), ); @@ -106,13 +107,35 @@ function createInstall() { return {applications, installedPath, mount}; } -function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, stagingErrno}) { +function loadUpdater({ + appImagePath, + version = CURRENT_VERSION, + appDir, + execPath, + stagingErrno, + platform = 'linux', + arch = 'arm64', + velopack, + applyAttempt = null, +}) { const events = []; const handlers = new Map(); const appEvents = new Map(); const state = {relaunched: false, electronRelaunched: false}; + const applyState = {attempt: applyAttempt, recorded: [], cleared: 0}; const module = {exports: {}}; const stubs = { + '@electron/main/UpdaterApplyState': { + readVelopackApplyAttempt: () => applyState.attempt, + recordVelopackApplyAttempt: (recordedVersion) => { + applyState.recorded.push(recordedVersion); + applyState.attempt = {version: recordedVersion, attemptedAt: 0}; + }, + clearVelopackApplyAttempt: () => { + applyState.cleared += 1; + applyState.attempt = null; + }, + }, '@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'}, '@electron/common/UserDataPath': {isPortableMode: () => false}, '@electron/main/DesktopTray': {destroyDesktopTray() {}}, @@ -149,8 +172,8 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, Buffer, process: { ...process, - platform: 'linux', - arch: 'arm64', + platform, + arch, execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath), env: { ...(appImagePath ? {APPIMAGE: appImagePath} : {}), @@ -159,11 +182,18 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, }, setTimeout, clearTimeout, + setImmediate, fetch: (input, init) => { - const url = String(input).replace('https://pkgs.fluxer.com/desktop/canary/linux/arm64', baseUrl); + const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl); return fetch(url, init); }, require: (specifier) => { + if (specifier === 'node:module' && velopack) { + return { + ...require('node:module'), + createRequire: () => (name) => (name === 'velopack' ? velopack : require(name)), + }; + } if (specifier === 'node:fs/promises' && stagingErrno) { return { ...require(specifier), @@ -200,6 +230,7 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}})); return { + applyState, events, state, check: () => handlers.get('updater-check')({}, 'user'), @@ -446,3 +477,93 @@ describe('Updater AppImage lifecycle', () => { assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); }); }); + +function createVelopackStub({installedVersion, pendingRestart = null, remoteUpdate = null}) { + const applied = []; + class UpdateManager { + getCurrentVersion() { + return installedVersion; + } + getUpdatePendingRestart() { + return pendingRestart; + } + checkForUpdatesAsync() { + return Promise.resolve(remoteUpdate); + } + downloadUpdateAsync() { + return Promise.resolve(); + } + waitExitThenApplyUpdate(update) { + applied.push(update); + } + } + return {applied, module: {UpdateManager}}; +} + +function loadWindowsUpdater({installedVersion, pendingRestart, remoteUpdate, applyAttempt = null}) { + const velopack = createVelopackStub({installedVersion, pendingRestart, remoteUpdate}); + const updater = loadUpdater({ + platform: 'win32', + arch: 'x64', + velopack: velopack.module, + applyAttempt, + }); + return {...updater, applied: velopack.applied}; +} + +describe('Updater Windows apply failures', () => { + test('offers the installer when a downloaded update never applied', async () => { + const updater = loadWindowsUpdater({ + installedVersion: CURRENT_VERSION, + pendingRestart: {Version: PUBLISHED_VERSION, Size: 100}, + applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0}, + }); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'error', 'available']); + assert.equal(updater.events[1].phase, 'install'); + assert.ok(updater.events[1].message.includes(PUBLISHED_VERSION)); + assert.equal(updater.events[2].downloadStarted, false); + assert.equal(updater.events[2].downloadUrl, `${baseUrl}/setup`); + assert.equal(updater.applyState.attempt.version, PUBLISHED_VERSION); + }); + + test('resumes normal updates once the installed version catches up', async () => { + const updater = loadWindowsUpdater({ + installedVersion: PUBLISHED_VERSION, + applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0}, + }); + + await updater.check(); + + assert.equal(updater.applyState.cleared, 1); + assert.equal(updater.applyState.attempt, null); + assert.deepEqual(types(updater.events), ['checking', 'not-available']); + }); + + test('records the version it hands to the updater before quitting', async () => { + const updater = loadWindowsUpdater({ + installedVersion: CURRENT_VERSION, + pendingRestart: {Version: PUBLISHED_VERSION, Size: 100}, + }); + + await updater.install(); + + assert.deepEqual(updater.applyState.recorded, [PUBLISHED_VERSION]); + assert.deepEqual(updater.applied, [{Version: PUBLISHED_VERSION, Size: 100}]); + }); + + test('refuses to re-apply a version that already failed to install', async () => { + const updater = loadWindowsUpdater({ + installedVersion: CURRENT_VERSION, + pendingRestart: {Version: PUBLISHED_VERSION, Size: 100}, + applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0}, + }); + + await assert.rejects(() => updater.install(), /Download the installer/); + + assert.deepEqual(updater.applied, []); + assert.deepEqual(updater.applyState.recorded, []); + }); +}); diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index d52c209d5..fa5d1c3ef 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -17,6 +17,12 @@ import { import {destroyDesktopTray} from '@electron/main/DesktopTray'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; import {relaunchAndExit} from '@electron/main/Troubleshooting'; +import { + clearVelopackApplyAttempt, + readVelopackApplyAttempt, + recordVelopackApplyAttempt, + type VelopackApplyAttempt, +} from '@electron/main/UpdaterApplyState'; import { buildManualVersionDownloadUrl, DOWNLOAD_PAGE_URL, @@ -149,6 +155,60 @@ function createVelopackUpdateManager() { return new UpdateManager(UPDATE_BASE_URL); } +type VelopackUpdateManager = ReturnType; + +function getInstalledVelopackVersion(updateManager: VelopackUpdateManager): string | null { + try { + const version = updateManager.getCurrentVersion(); + return typeof version === 'string' && version.length > 0 ? version : null; + } catch (error) { + log.warn('Failed to read the installed Velopack version', error); + return null; + } +} + +function resolveFailedVelopackApply(updateManager: VelopackUpdateManager): VelopackApplyAttempt | null { + const attempt = readVelopackApplyAttempt(); + if (!attempt) { + return null; + } + const installedVersion = getInstalledVelopackVersion(updateManager) ?? app.getVersion(); + if (compareVersions(installedVersion, attempt.version) >= 0) { + clearVelopackApplyAttempt(); + return null; + } + return attempt; +} + +async function sendVelopackApplyFailure( + context: UpdaterContext, + getMainWindow: () => BrowserWindow | null, + attempt: VelopackApplyAttempt, +): Promise { + log.error('A downloaded update was never applied, so the installer is offered instead.', attempt); + send(getMainWindow(), { + type: 'error', + context, + phase: 'install', + message: `Fluxer could not finish installing version ${attempt.version}.`, + }); + try { + const latest = await fetchManualLatest({forceRefresh: true}); + sendManualUpdateAvailable(getMainWindow, context, latest); + return; + } catch (error) { + log.warn('Failed to resolve the installer download after a failed update apply', error); + } + send(getMainWindow(), { + type: 'available', + context, + version: attempt.version, + downloadSize: null, + downloadStarted: false, + downloadUrl: buildManualVersionDownloadUrl(attempt.version, 'setup'), + }); +} + async function checkVelopackForUpdates( context: UpdaterContext, getMainWindow: () => BrowserWindow | null, @@ -160,6 +220,11 @@ async function checkVelopackForUpdates( try { send(getMainWindow(), {type: 'checking', context}); const updateManager = createVelopackUpdateManager(); + const failedApply = resolveFailedVelopackApply(updateManager); + if (failedApply) { + await sendVelopackApplyFailure(context, getMainWindow, failedApply); + return; + } const pendingUpdate = updateManager.getUpdatePendingRestart(); const update = await updateManager.checkForUpdatesAsync(); if (!update) { @@ -300,6 +365,13 @@ function installVelopackUpdate(): void { if (!update) { throw new Error('No Velopack update is ready to install.'); } + if (resolveFailedVelopackApply(updateManager)) { + throw new Error('The last update could not be installed. Download the installer to update.'); + } + const updateVersion = getVelopackUpdateVersion(update); + if (updateVersion) { + recordVelopackApplyAttempt(updateVersion); + } velopackInstallStarted = true; setQuitting(true); destroyDesktopTray(); diff --git a/fluxer_desktop/src/main/UpdaterApplyState.test.mjs b/fluxer_desktop/src/main/UpdaterApplyState.test.mjs new file mode 100644 index 000000000..e852899bd --- /dev/null +++ b/fluxer_desktop/src/main/UpdaterApplyState.test.mjs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./UpdaterApplyState.ts', import.meta.url)); +const source = readFileSync(sourcePath, 'utf8'); +const transformedSource = esbuild.transformSync(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +const USER_DATA = 'C:\\Users\\csh\\AppData\\Roaming\\fluxercanary'; +const STATE_PATH = path.win32.join(USER_DATA, 'update-apply-state.json'); + +function loadApplyState(initialFiles = []) { + const files = new Map(initialFiles); + const warnings = []; + const fakeFs = { + readFileSync: (target) => { + if (!files.has(target)) throw new Error(`ENOENT ${target}`); + return files.get(target); + }, + writeFileSync: (target, contents) => { + files.set(target, contents); + }, + rmSync: (target) => { + files.delete(target); + }, + }; + + const module = {exports: {}}; + const context = vm.createContext({ + module, + exports: module.exports, + console, + Date, + JSON, + require: (specifier) => { + if (specifier === 'node:fs') { + return {default: fakeFs, ...fakeFs}; + } + if (specifier === 'node:path') { + return {default: path.win32, ...path.win32}; + } + if (specifier === 'electron') { + return {app: {getPath: () => USER_DATA}}; + } + if (specifier === 'electron-log') { + const log = {warn: (...args) => warnings.push(args)}; + return {default: log, ...log}; + } + return require(specifier); + }, + }); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return {...module.exports, files, warnings}; +} + +describe('update apply attempt state', () => { + test('records an attempt that reads back with its version', () => { + const harness = loadApplyState(); + + harness.recordVelopackApplyAttempt('2026.920.144552'); + + const attempt = harness.readVelopackApplyAttempt(); + assert.equal(attempt.version, '2026.920.144552'); + assert.equal(typeof attempt.attemptedAt, 'number'); + assert.equal(harness.files.has(STATE_PATH), true); + }); + + test('reads nothing when no attempt was ever recorded', () => { + const harness = loadApplyState(); + + assert.equal(harness.readVelopackApplyAttempt(), null); + assert.deepEqual(harness.warnings, []); + }); + + test('clearing an attempt removes the state file', () => { + const harness = loadApplyState(); + harness.recordVelopackApplyAttempt('2026.920.144552'); + + harness.clearVelopackApplyAttempt(); + + assert.equal(harness.files.has(STATE_PATH), false); + assert.equal(harness.readVelopackApplyAttempt(), null); + }); + + test('treats a corrupt state file as no attempt', () => { + const harness = loadApplyState([[STATE_PATH, '{not json']]); + + assert.equal(harness.readVelopackApplyAttempt(), null); + assert.equal(harness.warnings.length, 1); + }); + + test('treats a state file without a version as no attempt', () => { + const harness = loadApplyState([[STATE_PATH, JSON.stringify({attemptedAt: 1})]]); + + assert.equal(harness.readVelopackApplyAttempt(), null); + }); + + test('defaults a missing timestamp so an old state file still blocks the loop', () => { + const harness = loadApplyState([[STATE_PATH, JSON.stringify({version: '2026.920.144552'})]]); + + const attempt = harness.readVelopackApplyAttempt(); + assert.equal(attempt.version, '2026.920.144552'); + assert.equal(attempt.attemptedAt, 0); + }); +}); diff --git a/fluxer_desktop/src/main/UpdaterApplyState.ts b/fluxer_desktop/src/main/UpdaterApplyState.ts new file mode 100644 index 000000000..a9280a455 --- /dev/null +++ b/fluxer_desktop/src/main/UpdaterApplyState.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import path from 'node:path'; +import {app} from 'electron'; +import log from 'electron-log'; + +const APPLY_STATE_FILE_NAME = 'update-apply-state.json'; + +export type VelopackApplyAttempt = { + version: string; + attemptedAt: number; +}; + +function getApplyStatePath(): string { + return path.join(app.getPath('userData'), APPLY_STATE_FILE_NAME); +} + +export function readVelopackApplyAttempt(): VelopackApplyAttempt | null { + let raw: string; + try { + raw = fs.readFileSync(getApplyStatePath(), 'utf8'); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw) as {version?: unknown; attemptedAt?: unknown}; + if (typeof parsed.version !== 'string' || parsed.version.length === 0) { + return null; + } + return { + version: parsed.version, + attemptedAt: typeof parsed.attemptedAt === 'number' ? parsed.attemptedAt : 0, + }; + } catch (error) { + log.warn('Failed to parse the recorded update apply attempt', error); + return null; + } +} + +export function recordVelopackApplyAttempt(version: string): void { + const payload: VelopackApplyAttempt = {version, attemptedAt: Date.now()}; + try { + fs.writeFileSync(getApplyStatePath(), JSON.stringify(payload), 'utf8'); + } catch (error) { + log.warn('Failed to record the update apply attempt', error); + } +} + +export function clearVelopackApplyAttempt(): void { + try { + fs.rmSync(getApplyStatePath(), {force: true}); + } catch (error) { + log.warn('Failed to clear the recorded update apply attempt', error); + } +}