Files
fluxer/fluxer_desktop/src/main/ShellUpdateCapability.test.mjs
T

503 lines
16 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
const events = [];
const ipcRegistrations = [];
let openExternalFailure = null;
class FakeWebContents {
constructor() {
this.sent = [];
this.destroyed = false;
}
on() {}
setWindowOpenHandler() {}
isDestroyed() {
return this.destroyed;
}
send(channel, payload) {
this.sent.push({channel, payload});
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = new FakeWebContents();
this.destroyed = false;
this.visible = false;
}
on() {}
once() {}
isDestroyed() {
return this.destroyed;
}
isVisible() {
return this.visible;
}
show() {
this.visible = true;
}
showInactive() {
this.visible = true;
}
hide() {
this.visible = false;
}
setSkipTaskbar() {}
setAlwaysOnTop() {}
close() {
this.destroyed = true;
}
destroy() {
this.destroyed = true;
}
loadURL() {
return Promise.resolve();
}
}
installElectronStub({
app: {
isPackaged: true,
isReady: () => true,
getAppPath: () => process.cwd(),
getPath: () => process.cwd(),
quit: () => {
events.push('quit');
},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
on: (channel, handler) => {
ipcRegistrations.push({channel, handler});
},
},
shell: {
openExternal: async (url) => {
if (openExternalFailure != null) {
const failure = openExternalFailure;
openExternalFailure = null;
throw failure;
}
events.push(`open:${url}`);
},
},
});
const {openExternalDeduped, shouldOpenExternalUrl} = await import('@electron/main/OpenExternal');
const {openSplashWindow} = await import('@electron/main/SplashWindow');
const {buildManualLatestDownloadUrl, DESKTOP_DOWNLOAD_ARCH, getDesktopDownloadArch, getUpdateBaseUrl} = await import(
'@electron/main/ShellDownloadFormats'
);
const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel');
const {decideShellUpdatePlan, resolveShellUpdatePlan, ShellUpdateCapability} = await import(
'@electron/main/ShellUpdateCapability'
);
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
const {armBlockedShellUpdate, getBlockedShellUpdate, SPLASH_MANUAL_UPDATE_MESSAGE} = await import(
'@electron/main/ShellUpdateSplash'
);
const SELF_UPDATE_PLAN = {capability: ShellUpdateCapability.SELF_UPDATE, updater: 'velopack'};
const MANUAL_PLATFORM_PLAN = {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'platform'};
const MANUAL_UNPACKAGED_PLAN = {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'unpackaged'};
const MANAGED_PLAN = {capability: ShellUpdateCapability.MANAGED_PACKAGE};
const APPIMAGE_TARGET = {
installedPath: '/home/user/Applications/Fluxer.AppImage',
directory: '/home/user/Applications',
};
const EXPECTED_PICKER_OPTIONS = [
{value: 'deb', label: 'Debian (deb)', buttonLabel: 'Download', kind: 'download'},
{value: 'rpm', label: 'Fedora (rpm)', buttonLabel: 'Download', kind: 'download'},
{value: 'appimage', label: 'Linux (AppImage)', buttonLabel: 'Download', kind: 'download'},
{value: 'tar_gz', label: 'Linux (tar.gz)', buttonLabel: 'Download', kind: 'download'},
{value: 'nope', label: "I'll figure it out", buttonLabel: 'Okay', kind: 'quit'},
];
function emitIpc(channel, event, payload) {
for (const registration of ipcRegistrations) {
if (registration.channel === channel) {
registration.handler(event, payload);
}
}
}
function settle() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
function withProperty(target, name, value, run) {
const original = Object.getOwnPropertyDescriptor(target, name);
Object.defineProperty(target, name, {value, configurable: true, writable: true});
try {
return run();
} finally {
if (original == null) {
delete target[name];
} else {
Object.defineProperty(target, name, original);
}
}
}
function withPlatform(platform, run) {
return withProperty(process, 'platform', platform, run);
}
function planFor(runtime) {
return decideShellUpdatePlan({
channel: 'canary',
packaged: true,
portable: false,
flatpak: false,
appImage: null,
platform: 'linux',
...runtime,
});
}
function hasAffordance(blocked) {
return blocked.action != null || blocked.options != null;
}
describe('shell update capability', () => {
test('every runtime signal picks one updater, first match wins', () => {
assert.deepEqual(planFor({packaged: false, platform: 'win32'}), {
capability: 'manual-download',
reason: 'unpackaged',
});
assert.deepEqual(planFor({portable: true, platform: 'win32'}), {capability: 'manual-download', reason: 'platform'});
assert.deepEqual(planFor({flatpak: true, platform: 'linux'}), {capability: 'managed-package'});
assert.deepEqual(planFor({platform: 'win32'}), {capability: 'self-update', updater: 'velopack'});
assert.deepEqual(planFor({platform: 'darwin'}), {capability: 'self-update', updater: 'electron'});
assert.deepEqual(planFor({platform: 'linux', appImage: APPIMAGE_TARGET}), {
capability: 'self-update',
updater: 'appimage',
target: APPIMAGE_TARGET,
});
assert.deepEqual(planFor({platform: 'linux'}), {capability: 'manual-download', reason: 'platform'});
assert.deepEqual(planFor({platform: 'freebsd'}), {capability: 'manual-download', reason: 'platform'});
});
test('a development shell never self-updates, because no feed publishes development builds', () => {
for (const platform of ['darwin', 'win32', 'linux']) {
assert.deepEqual(planFor({channel: 'development', platform, appImage: APPIMAGE_TARGET}), {
capability: 'manual-download',
reason: 'unpackaged',
});
}
assert.deepEqual(planFor({channel: 'stable', platform: 'darwin'}), {
capability: 'self-update',
updater: 'electron',
});
});
test('a store runtime outranks a portable marker, so a sandboxed install is never offered a deb or rpm it cannot apply', () => {
assert.deepEqual(planFor({portable: true, flatpak: true, platform: 'linux'}), {capability: 'managed-package'});
assert.deepEqual(planFor({portable: true, appImage: APPIMAGE_TARGET, platform: 'linux'}), {
capability: 'manual-download',
reason: 'platform',
});
assert.deepEqual(planFor({packaged: false, flatpak: true, platform: 'linux'}), {
capability: 'manual-download',
reason: 'unpackaged',
});
});
test('resolves the live runtime through the same decision', () => {
assert.deepEqual(
resolveShellUpdatePlan(),
decideShellUpdatePlan({
channel: BUILD_CHANNEL,
packaged: true,
portable: false,
flatpak: false,
appImage: null,
platform: process.platform,
}),
);
});
});
describe('blocked shell update states', () => {
test('every plan lands on an affordance that can resolve the block', () => {
for (const plan of [SELF_UPDATE_PLAN, MANUAL_PLATFORM_PLAN, MANUAL_UNPACKAGED_PLAN, MANAGED_PLAN]) {
for (const platform of ['linux', 'darwin', 'win32']) {
const blocked = withPlatform(platform, () => getBlockedShellUpdate(plan, '2026.823.1'));
assert.ok(hasAffordance(blocked), `${plan.capability} on ${platform} has no way out`);
assert.notEqual(
blocked.action?.kind,
'retry',
`${plan.capability} cannot escape a relaunch: the same binary refetches the same manifest and blocks again`,
);
for (const label of [blocked.action?.label, ...(blocked.options ?? []).map((option) => option.label)]) {
assert.ok(label == null || label.length > 0);
}
}
}
});
test('a shell that could have updated itself and did not falls back to the download page', () => {
const blocked = withPlatform('win32', () => getBlockedShellUpdate(SELF_UPDATE_PLAN, '2026.823.1'));
assert.deepEqual(blocked, {
layout: 'splash',
status: 'blocked-shell-update',
action: {kind: 'download', label: 'Download update'},
message: null,
versionLabel: null,
options: null,
downloadUrl: DOWNLOAD_PAGE_URL,
downloadUrls: new Map(),
});
});
test('a packaged linux install gets the picker, with the version main already knows', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'manual-update');
assert.equal(blocked.status, 'blocked-shell-update');
assert.equal(blocked.action, null);
assert.equal(blocked.message, SPLASH_MANUAL_UPDATE_MESSAGE);
assert.equal(blocked.versionLabel, 'Version 2026.823.1 available');
assert.deepEqual(blocked.options, EXPECTED_PICKER_OPTIONS);
assert.equal(blocked.downloadUrl, null);
});
test('an unknown latest version leaves the bottom line off rather than inventing one', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, null));
assert.equal(blocked.versionLabel, null);
assert.deepEqual(blocked.options, EXPECTED_PICKER_OPTIONS);
});
test('a dev run from source is never handed a distro artifact', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_UNPACKAGED_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'splash');
assert.equal(blocked.options, null);
assert.deepEqual(blocked.action, {kind: 'download', label: 'Download update'});
assert.equal(blocked.downloadUrl, DOWNLOAD_PAGE_URL);
});
test('a portable win32 or darwin build gets the page, never the linux picker', () => {
for (const platform of ['win32', 'darwin']) {
const blocked = withPlatform(platform, () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'splash', platform);
assert.equal(blocked.options, null, platform);
assert.equal(blocked.downloadUrl, DOWNLOAD_PAGE_URL, platform);
}
});
test('a package manager owned install is offered no download url at all', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANAGED_PLAN, '2026.823.1'));
assert.equal(blocked.downloadUrl, null);
assert.equal(blocked.downloadUrls.size, 0);
assert.equal(blocked.options, null);
assert.equal(blocked.status, 'blocked-shell-update-managed');
assert.deepEqual(blocked.action, {kind: 'quit', label: 'Quit'});
});
test('the download page is a channel aware page, never an artifact', () => {
assert.equal(shouldOpenExternalUrl(DOWNLOAD_PAGE_URL), true);
assert.equal(new URL(DOWNLOAD_PAGE_URL).pathname, '/download');
});
test('every picker entry resolves to the latest linux artifact route for the running arch', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.deepEqual(
[...blocked.downloadUrls.keys()],
EXPECTED_PICKER_OPTIONS.filter((option) => option.kind === 'download').map((option) => option.value),
);
for (const [format, url] of blocked.downloadUrls) {
assert.equal(url, `${getUpdateBaseUrl('linux')}/latest/${format}`);
assert.equal(new URL(url).pathname.endsWith(`/linux/${DESKTOP_DOWNLOAD_ARCH}/latest/${format}`), true, url);
assert.equal(shouldOpenExternalUrl(url), true);
}
assert.equal(blocked.downloadUrls.has('nope'), false);
});
test('the artifact route reads the platform at call time, so a darwin runner still proves the linux shape', () => {
const linux = withPlatform('linux', () => buildManualLatestDownloadUrl('deb'));
const win32 = withPlatform('win32', () => buildManualLatestDownloadUrl('setup'));
assert.equal(linux, `${getUpdateBaseUrl('linux')}/latest/deb`);
assert.ok(linux.includes('/linux/'), linux);
assert.ok(win32.includes('/win32/'), win32);
});
test('the download arch collapses to the two we publish', () => {
assert.equal(getDesktopDownloadArch('arm64'), 'arm64');
assert.equal(getDesktopDownloadArch('x64'), 'x64');
assert.equal(getDesktopDownloadArch('ia32'), 'x64');
assert.equal(getDesktopDownloadArch('ppc64'), 'x64');
});
});
describe('arming the blocked shell update', () => {
test('a package manager owned install arms nothing that could open a url', async () => {
const window = openSplashWindow();
events.length = 0;
armBlockedShellUpdate(MANAGED_PLAN, '2026.823.1');
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'blocked-shell-update-managed',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: {kind: 'quit', label: 'Quit'},
message: null,
versionLabel: null,
options: null,
},
});
emitIpc('desktop-splash:open-download', {sender: window.webContents});
await settle();
assert.deepEqual(events, []);
});
test('the download action opens the allowlisted page and then quits', async () => {
const window = openSplashWindow();
events.length = 0;
withPlatform('win32', () => armBlockedShellUpdate(SELF_UPDATE_PLAN, '2026.823.1'));
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'blocked-shell-update',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: {kind: 'download', label: 'Download update'},
message: null,
versionLabel: null,
options: null,
},
});
emitIpc('desktop-splash:open-download', {sender: window.webContents});
await settle();
assert.deepEqual(events, [`open:${DOWNLOAD_PAGE_URL}`, 'quit']);
});
test('the picker sends the whole manual layout and opens the artifact the user chose', async () => {
const window = openSplashWindow();
events.length = 0;
const blocked = withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'manual-update',
status: 'blocked-shell-update',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: SPLASH_MANUAL_UPDATE_MESSAGE,
versionLabel: 'Version 2026.823.1 available',
options: EXPECTED_PICKER_OPTIONS,
},
});
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'rpm');
await settle();
assert.deepEqual(events, [`open:${blocked.downloadUrls.get('rpm')}`, 'quit']);
});
test('a token that was never armed is dropped instead of being turned into a url', async () => {
const window = openSplashWindow();
withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
events.length = 0;
for (const value of ['setup', 'dmg', 'nope', 'unknown_format']) {
emitIpc('desktop-splash:open-download', {sender: window.webContents}, value);
}
await settle();
assert.deepEqual(events, []);
});
test('a download that cannot be opened still resolves the screen instead of stranding the user', async () => {
const window = openSplashWindow();
withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
events.length = 0;
openExternalFailure = new Error('no handler for https');
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'appimage');
await settle();
assert.deepEqual(events, ['quit']);
});
test('a protocol outside the allowlist never reaches the shell', async () => {
events.length = 0;
for (const url of [
'file:///etc/passwd',
'javascript:alert(1)',
'vbscript:msgbox(1)',
'data:text/html,hello',
'about:blank',
'chrome://settings',
'shell:Startup',
'ms-appinstaller:?source=https://example.com',
'a:payload',
]) {
await assert.rejects(openExternalDeduped(url), /External URL open request blocked/, url);
}
assert.deepEqual(events, []);
});
});