mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
519 lines
17 KiB
JavaScript
519 lines
17 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {readFileSync} from 'node:fs';
|
|
import {describe, test} from 'node:test';
|
|
import {fileURLToPath} from 'node:url';
|
|
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 UPDATER_SOURCE = readFileSync(fileURLToPath(new URL('./Updater.ts', import.meta.url)), 'utf8');
|
|
|
|
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,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('the updater keeps no platform checks and no download page of its own', () => {
|
|
assert.doesNotMatch(UPDATER_SOURCE, /isPortableMode|isFlatpakRuntime|isRunningFromAppImage/);
|
|
assert.doesNotMatch(UPDATER_SOURCE, /const DOWNLOAD_PAGE_URL =/);
|
|
assert.match(UPDATER_SOURCE, /const plan = resolveShellUpdatePlan\(\);/);
|
|
});
|
|
|
|
test('the updater reads the arch and the format urls from the shared leaf instead of keeping its own', () => {
|
|
assert.doesNotMatch(UPDATER_SOURCE, /const UPDATE_BASE_URL =|function getUpdateBaseUrl\(/);
|
|
assert.doesNotMatch(UPDATER_SOURCE, /function buildManualLatestDownloadUrl/);
|
|
assert.match(UPDATER_SOURCE, /from '@electron\/main\/ShellDownloadFormats'/);
|
|
});
|
|
});
|
|
|
|
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, []);
|
|
});
|
|
});
|