fix(desktop): update from the splash only after the user clicks (#3280)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:25:34 +02:00
1 parent f1373a63c3
commit 7937d5f802
90 files changed
+6888 -11255

No files matched your search

+108 -443
View File
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {createServer} from 'node:http';
import {createRequire} from 'node:module';
import {tmpdir} from 'node:os';
@@ -34,14 +34,6 @@ const shellDownloadFormatsSource = transform('ShellDownloadFormats.ts');
const shellUpdateCapabilitySource = transform('ShellUpdateCapability.ts');
const updaterDownloadsSource = transform('UpdaterDownloads.ts');
const updaterSource = transform('Updater.ts');
const updaterPlatformUtilsSource = transform('../../../fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts');
function loadUpdaterPlatformUtils() {
const module = {exports: {}};
const context = vm.createContext({module, exports: module.exports, require});
vm.runInContext(updaterPlatformUtilsSource.code, context, {filename: updaterPlatformUtilsSource.path});
return module.exports;
}
const INSTALLED_NAME = 'My Fluxer.AppImage';
const CURRENT_VERSION = '2026.903.231208';
@@ -52,9 +44,7 @@ const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex');
let server;
let baseUrl;
let serveCorruptBytes = false;
let appImageRequests = 0;
let failAppImageRequests = 0;
before(async () => {
server = createServer((request, response) => {
@@ -75,15 +65,8 @@ before(async () => {
}
if (request.url === `/${PUBLISHED_VERSION}/appimage`) {
appImageRequests += 1;
if (failAppImageRequests > 0) {
failAppImageRequests -= 1;
response.writeHead(503);
response.end('try again');
return;
}
const body = serveCorruptBytes ? Buffer.concat([Buffer.from([0]), NEW_BYTES.subarray(1)]) : NEW_BYTES;
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': body.length});
response.end(body);
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length});
response.end(NEW_BYTES);
return;
}
response.writeHead(404);
@@ -97,74 +80,38 @@ after(async () => {
await new Promise((resolve) => server.close(resolve));
});
function createInstall() {
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
const applications = join(root, 'Applications');
mkdirSync(applications);
const mount = join(root, '.mount_Fluxer');
mkdirSync(mount);
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const installedPath = join(applications, INSTALLED_NAME);
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
return {applications, installedPath, mount};
}
function loadUpdater({
appImagePath,
version = CURRENT_VERSION,
appDir,
execPath,
stagingErrno,
platform = 'linux',
arch = 'arm64',
velopack,
applyAttempt = null,
packaged = true,
flatpak = false,
gateCheck = async () => ({available: true}),
}) {
const events = [];
const handlers = new Map();
const appEvents = new Map();
const state = {relaunched: false, electronRelaunched: false};
const applyState = {attempt: applyAttempt, recorded: [], cleared: 0};
const gate = {checks: 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/Constants': {DOWNLOAD_PAGE_URLS: {canary: 'https://canary.fluxer.app/download'}},
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
'@electron/common/UserDataPath': {isPortableMode: () => false},
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
'@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
'@electron/main/Troubleshooting': {
relaunchAndExit() {
state.relaunched = true;
'@electron/main/DesktopUpdateGate': {
checkDesktopUpdateNow: async () => {
gate.checks += 1;
return await gateCheck();
},
},
'@electron/main/Window': {setQuitting() {}},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => flatpak},
'electron-log': {info() {}, warn() {}, error() {}, debug() {}},
electron: {
app: {
isPackaged: true,
isPackaged: packaged,
getVersion: () => version,
on(event, handler) {
appEvents.set(event, handler);
},
relaunch() {
state.electronRelaunched = true;
},
exit() {},
},
autoUpdater: {on() {}},
net: {fetch: (input, init) => sandbox.fetch(input, init)},
ipcMain: {
handle(channel, handler) {
@@ -176,11 +123,12 @@ function loadUpdater({
const sandbox = {
console,
Buffer,
URL,
process: {
...process,
platform,
arch,
execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath),
execPath: appDir ? join(appDir, 'fluxer-canary') : process.execPath,
env: {
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
...(appDir ? {APPDIR: appDir} : {}),
@@ -193,56 +141,21 @@ function loadUpdater({
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),
open: async () => {
const error = new Error(`${stagingErrno}: staging refused`);
error.code = stagingErrno;
throw error;
},
};
}
return stubs[specifier] ?? require(specifier);
},
require: (specifier) => stubs[specifier] ?? require(specifier),
};
const context = vm.createContext(sandbox);
const appImageModule = {exports: {}};
sandbox.module = appImageModule;
sandbox.exports = appImageModule.exports;
sandbox.__filename = appImageUpdateSource.path;
vm.runInContext(appImageUpdateSource.code, context, {filename: appImageUpdateSource.path});
stubs['@electron/main/AppImageUpdate'] = appImageModule.exports;
const shellUpdateCapabilityModule = {exports: {}};
sandbox.module = shellUpdateCapabilityModule;
sandbox.exports = shellUpdateCapabilityModule.exports;
sandbox.__filename = shellUpdateCapabilitySource.path;
vm.runInContext(shellUpdateCapabilitySource.code, context, {filename: shellUpdateCapabilitySource.path});
stubs['@electron/main/ShellUpdateCapability'] = shellUpdateCapabilityModule.exports;
const shellDownloadFormatsModule = {exports: {}};
sandbox.module = shellDownloadFormatsModule;
sandbox.exports = shellDownloadFormatsModule.exports;
sandbox.__filename = shellDownloadFormatsSource.path;
vm.runInContext(shellDownloadFormatsSource.code, context, {filename: shellDownloadFormatsSource.path});
stubs['@electron/main/ShellDownloadFormats'] = shellDownloadFormatsModule.exports;
const updaterDownloadsModule = {exports: {}};
sandbox.module = updaterDownloadsModule;
sandbox.exports = updaterDownloadsModule.exports;
sandbox.__filename = updaterDownloadsSource.path;
vm.runInContext(updaterDownloadsSource.code, context, {filename: updaterDownloadsSource.path});
stubs['@electron/main/UpdaterDownloads'] = updaterDownloadsModule.exports;
const load = (source, specifier) => {
const loaded = {exports: {}};
sandbox.module = loaded;
sandbox.exports = loaded.exports;
sandbox.__filename = source.path;
vm.runInContext(source.code, context, {filename: source.path});
stubs[specifier] = loaded.exports;
};
load(appImageUpdateSource, '@electron/main/AppImageUpdate');
load(shellUpdateCapabilitySource, '@electron/main/ShellUpdateCapability');
load(shellDownloadFormatsSource, '@electron/main/ShellDownloadFormats');
load(updaterDownloadsSource, '@electron/main/UpdaterDownloads');
sandbox.module = module;
sandbox.exports = module.exports;
sandbox.__filename = updaterSource.path;
@@ -250,12 +163,10 @@ function loadUpdater({
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
return {
applyState,
events,
state,
check: () => handlers.get('updater-check')({}, 'user'),
install: () => handlers.get('updater-install')({}),
quit: () => appEvents.get('will-quit')?.(),
gate,
channels: () => [...handlers.keys()],
check: (context = 'user') => handlers.get('updater-check')({}, context),
};
}
@@ -263,359 +174,113 @@ function types(events) {
return events.map((event) => event.type);
}
function stagingLeftovers(directory) {
return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-'));
function createInstall() {
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
const applications = join(root, 'Applications');
mkdirSync(applications);
const mount = join(root, '.mount_Fluxer');
mkdirSync(mount);
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const installedPath = join(applications, INSTALLED_NAME);
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
return {applications, installedPath, mount};
}
describe('Updater AppImage lifecycle', () => {
test('downloads on check, reports the macOS lifecycle, and swaps in place', async () => {
describe('Updater on a shell that updates itself', () => {
test('registers only the check, never a download or an install channel', () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await updater.check();
const available = updater.events.find((event) => event.type === 'available');
assert.equal(available.downloadStarted, true);
assert.equal(available.version, PUBLISHED_VERSION);
assert.equal(available.downloadUrl, undefined);
assert.equal(available.downloadOptions, undefined);
const progress = updater.events.filter((event) => event.type === 'progress');
assert.ok(progress.length > 0);
assert.equal(progress.at(-1).total, NEW_BYTES.length);
assert.equal(progress.at(-1).transferred, NEW_BYTES.length);
assert.equal(progress.at(-1).percent, 100);
const downloaded = updater.events.find((event) => event.type === 'downloaded');
assert.equal(downloaded.version, PUBLISHED_VERSION);
assert.deepEqual(
types(updater.events).filter((type) => type !== 'progress'),
['checking', 'available', 'downloaded'],
);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
await updater.install();
assert.equal(updater.state.relaunched, true);
assert.equal(
updater.state.electronRelaunched,
false,
"app.relaunch cannot start an AppImage: Electron's relauncher runs under PR_SET_NO_NEW_PRIVS and the setuid fusermount the AppImage runtime needs fails with EPERM",
);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
assert.deepEqual(updater.channels(), ['updater-check']);
});
test('refuses to swap when the download does not match the published sha256', async () => {
test('a background check downloads nothing and leaves the poll to the bootstrap', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
serveCorruptBytes = true;
try {
await updater.check();
} finally {
serveCorruptBytes = false;
}
appImageRequests = 0;
assert.deepEqual(
types(updater.events).filter((type) => type !== 'progress'),
['checking', 'available', 'error', 'available'],
);
const failure = updater.events.find((event) => event.type === 'error');
assert.equal(failure.phase, 'download');
assert.match(failure.message, /checksum mismatch/);
const fallback = updater.events.at(-1);
assert.equal(fallback.downloadStarted, false);
assert.ok(fallback.downloadOptions.length > 0);
await updater.check('background');
await updater.check('focus');
assert.equal(updater.gate.checks, 0);
assert.deepEqual(updater.events, []);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /No AppImage update is ready to install/);
});
test('offers the manual download when the install directory is not writable', {
skip: process.getuid?.() === 0,
}, async () => {
test('a user check asks the manifest and leaves the update itself to the click', async () => {
const install = createInstall();
chmodSync(install.applications, 0o500);
try {
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
appImageRequests = 0;
await updater.check();
await updater.check('user');
assert.deepEqual(types(updater.events), ['checking', 'available']);
const available = updater.events.at(-1);
assert.equal(available.downloadStarted, false);
assert.equal(available.version, PUBLISHED_VERSION);
assert.ok(available.downloadOptions.length > 0);
assert.equal(available.downloadOptions[0].format, 'appimage');
assert.equal(
available.downloadOptions[0].suggestedName,
`Fluxer-Canary-${PUBLISHED_VERSION}-linux-arm64.AppImage`,
);
} finally {
chmodSync(install.applications, 0o755);
}
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
assert.equal(updater.gate.checks, 1);
assert.deepEqual(types(updater.events), ['not-available']);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('falls back to the manual updater when APPIMAGE is unset', async () => {
test('a user check that cannot reach the manifest says so', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
gateCheck: async () => {
throw new Error('manifest unreachable');
},
});
await updater.check('user');
assert.deepEqual(types(updater.events), ['error']);
assert.match(updater.events[0].message, /manifest unreachable/);
});
test('macOS and Windows go through the same check', async () => {
for (const platform of ['darwin', 'win32']) {
const updater = loadUpdater({platform, arch: 'x64'});
await updater.check('user');
assert.equal(updater.gate.checks, 1, platform);
assert.deepEqual(updater.channels(), ['updater-check'], platform);
}
});
});
describe('Updater on a shell that cannot update itself', () => {
test('a Linux package install offers the newer package for manual download', async () => {
const updater = loadUpdater({appImagePath: null});
await updater.check();
await updater.check('user');
assert.equal(updater.gate.checks, 1, 'the renderer module can update even when the shell cannot');
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
const available = updater.events.at(-1);
assert.equal(available.version, PUBLISHED_VERSION);
assert.equal(Object.hasOwn(available, 'downloadStarted'), false);
assert.ok(available.downloadOptions.some((option) => option.format === 'deb'));
});
test('never rewrites an inherited APPIMAGE that this process did not come from', async () => {
const install = createInstall();
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
const optDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-'));
const updater = loadUpdater({appImagePath: victim, execPath: join(optDir, 'fluxer')});
test('a Linux package install that is current reports no update', async () => {
const updater = loadUpdater({appImagePath: null, version: PUBLISHED_VERSION});
await updater.check();
await updater.check('user');
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
});
test('never rewrites the AppImage that only the shell around this process came from', async () => {
const install = createInstall();
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
const extracted = join(mkdtempSync(join(tmpdir(), 'fluxer-extracted-test-')), 'squashfs-root');
mkdirSync(extracted);
writeFileSync(join(extracted, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const updater = loadUpdater({
appImagePath: victim,
appDir: install.mount,
execPath: join(extracted, 'fluxer-canary'),
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
});
test('gives up on a local write failure instead of downloading the AppImage again', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
stagingErrno: 'ENOSPC',
});
const before = appImageRequests;
await updater.check();
assert.equal(appImageRequests - before, 1, 'a full disk must not pull the whole AppImage down five more times');
const failure = updater.events.find((event) => event.type === 'error');
assert.equal(failure.phase, 'download');
assert.match(failure.message, /ENOSPC/);
assert.equal(updater.events.at(-1).type, 'available');
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('downloads the AppImage again after a transient server failure', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const before = appImageRequests;
failAppImageRequests = 1;
try {
await updater.check();
} finally {
failAppImageRequests = 0;
}
assert.equal(appImageRequests - before, 2);
assert.equal(updater.events.at(-1).type, 'downloaded');
await updater.install();
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
});
test('reports no update when the published version is not newer', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
version: PUBLISHED_VERSION,
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('reclaims a staged update abandoned by a process that never reached will-quit', async () => {
const install = createInstall();
const abandoned = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await abandoned.check();
assert.equal(stagingLeftovers(install.applications).length, 1);
loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
assert.deepEqual(stagingLeftovers(install.applications), []);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
});
test('shows the renderer the download progress the main process reports', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const {shouldShowNativeDesktopUpdateDownloadProgress} = loadUpdaterPlatformUtils();
await updater.check();
const reportsProgress = updater.events.some((event) => event.type === 'progress');
assert.equal(reportsProgress, true);
assert.equal(shouldShowNativeDesktopUpdateDownloadProgress('linux'), reportsProgress);
});
test('discards a staged update that the user never installed', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await updater.check();
assert.equal(stagingLeftovers(install.applications).length, 1);
updater.quit();
assert.deepEqual(stagingLeftovers(install.applications), []);
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('keeps offering the installer when no newer release than the failed one is out', async () => {
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
remoteUpdate: {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[2].downloadStarted, false);
assert.equal(updater.applyState.cleared, 0);
});
test('retries the in-app update when a newer release than the failed one is out', async () => {
const newerVersion = '2026.905.101010';
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
remoteUpdate: {Version: newerVersion, Size: 200},
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', 'available']);
assert.equal(updater.events[1].version, newerVersion);
assert.equal(updater.events[1].downloadUrl, undefined);
});
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},
});
test('Flatpak and development builds point at their own update path', async () => {
const flatpak = loadUpdater({appImagePath: null, flatpak: true});
await flatpak.check('user');
assert.deepEqual(JSON.parse(JSON.stringify(flatpak.events)), [
{type: 'unsupported', context: 'user', reason: 'managed-package'},
]);
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, []);
const unpackaged = loadUpdater({appImagePath: null, packaged: false});
await unpackaged.check('user');
assert.equal(unpackaged.events[0].reason, 'unpackaged');
});
});