fix(desktop): stop looping on an update that never installs (#2879)

This commit is contained in:
Hampus
2026-09-21 03:36:11 +02:00
committed by GitHub
parent c68d62b8a0
commit 78d105b46e
4 changed files with 372 additions and 4 deletions
+125 -4
View File
@@ -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, []);
});
});
+72
View File
@@ -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<typeof createVelopackUpdateManager>;
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<void> {
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();
@@ -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);
});
});
@@ -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);
}
}