mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(desktop): stop looping on an update that never installs (#2879)
This commit is contained in:
@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user