fix(app): download the version a linux update prompt names (#2669)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 13:16:27 +02:00
1 parent fc0e2628a4
commit 61a13e1c1a
4 files changed
+608 -29

No files matched your search

+216 -15
View File
@@ -1,21 +1,39 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {UpdaterEvent} from '@app/types/electron.d';
import {afterEach, describe, expect, test, vi} from 'vitest';
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
interface ManualUpdatePrompt {
options: ReadonlyArray<UpdaterDownloadOption>;
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
}
const {
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} = vi.hoisted(() => ({
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
pushUnsupportedUpdateModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateReadyModal: vi.fn(),
pushUpToDateModal: vi.fn(),
}));
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
pushDesktopUpdateDownloadFailedModal: vi.fn(),
pushDesktopUpdateInstallFailedModal: vi.fn(),
pushManualUpdateAvailableModal: vi.fn(),
pushUnsupportedUpdateModal: vi.fn(),
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal: vi.fn(),
pushUpToDateModal,
}));
vi.mock('@lingui/core/macro', () => ({
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
installVoiceMenuTestBootstrap();
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const REPORTED_DEB_DOWNLOAD = [
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
];
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
let onUpdaterCheck: (() => void) | null = null;
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
let loadedUpdater: {dispose: () => void} | null = null;
function installElectronApi(): void {
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
Promise.resolve({success: true}),
);
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
function installElectronApi(platform: string): void {
nativeEventListener = null;
onUpdaterCheck = null;
(window as unknown as {electron: unknown}).electron = {
platform: 'win32',
platform,
buildChannel: 'canary',
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
nativeEventListener = listener;
@@ -45,12 +78,11 @@ function installElectronApi(): void {
nativeEventListener = null;
};
},
updaterCheck: () => {
onUpdaterCheck?.();
return Promise.resolve();
},
updaterCheck,
updaterDownload: () => Promise.resolve(),
updaterInstall: () => Promise.resolve(),
downloadFile,
openExternal,
};
}
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
nativeEventListener(event);
}
async function loadUpdater() {
async function loadUpdater(platform = 'win32') {
vi.resetModules();
installElectronApi();
installElectronApi(platform);
const {default: Updater} = await import('@app/features/app/state/Updater');
loadedUpdater = Updater;
await vi.waitFor(() => {
expect(nativeEventListener).not.toBeNull();
expect(Updater.lastCheckedAt).not.toBeNull();
});
pushUpdateReadyModal.mockClear();
vi.clearAllMocks();
return Updater;
}
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
emit({type: 'downloaded', context: 'user', version});
}
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
emit({
type: 'available',
context,
version,
downloadStarted: false,
downloadUrl: `${CANARY_X64}/latest/appimage`,
downloadOptions: [
{
format: 'deb',
label: 'DEB package',
url: `${CANARY_X64}/latest/deb`,
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
],
});
}
async function openLinuxManualUpdatePrompt() {
const Updater = await loadUpdater('linux');
emitLinuxManualUpdate('background', REPORTED_VERSION);
await Updater.applyUpdate();
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
const debOption = prompt?.options.find((option) => option.format === 'deb');
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
}
afterEach(() => {
vi.useRealTimers();
loadedUpdater?.dispose();
loadedUpdater = null;
});
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
});
});
describe('updater manual Linux package download', () => {
test('refreshes the check as the user and saves a newer build under its own name', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => {
emit({type: 'checking', context: 'user'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(Updater.displayVersion).toBe(NEWER_VERSION);
expect(Updater.isChecking).toBe(false);
});
test('saves the build the prompt named when the refresh fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh finds no update', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpToDateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh does not answer in time', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(Updater.isChecking).toBe(false);
});
test.each([
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
['no update', () => emit({type: 'not-available', context: 'user'})],
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
answerLate();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpToDateModal).not.toHaveBeenCalled();
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const firstDownload = download();
await vi.advanceTimersByTimeAsync(5_000);
await firstDownload;
onUpdaterCheck = () => {
emit({type: 'error', context: 'user', message: 'fetch failed'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('still shows the failure of a user check made while a package is being saved', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
void download();
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await Updater.checkForUpdates(true, true);
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
});
test('does not refresh when a check is already running at the click', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
void Updater.checkForUpdates(true);
await download();
expect(updaterCheck).not.toHaveBeenCalled();
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
});
test('saves one package when the button is pressed twice during the refresh', async () => {
const {download} = await openLinuxManualUpdatePrompt();
let answerRefresh = () => {};
onUpdaterCheck = () =>
new Promise<void>((resolve) => {
answerRefresh = () => {
emitLinuxManualUpdate('user', NEWER_VERSION);
resolve();
};
});
const firstClick = download();
const secondClick = download();
answerRefresh();
await Promise.all([firstClick, secondClick]);
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
});
test('opens the pinned link and never the download page when saving fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockResolvedValueOnce({success: false});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
});
});
+50 -2
View File
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
const VERSION_ENDPOINT = '/version.json';
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
@@ -132,6 +133,7 @@ class Updater {
private backgroundCheckCleanups: Array<() => void> = [];
private unsubscribeNativeEvents: (() => void) | null = null;
private updateReadyNagbarDismissedVersion: string | null = null;
private pendingManualDownloadRefreshes = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -288,7 +290,17 @@ class Updater {
}
private handleNativeEvent(event: UpdaterEvent): void {
const isUserCheck = event.context === 'user';
const isManualDownloadRefreshResult =
event.context === 'user' &&
this.pendingManualDownloadRefreshes > 0 &&
(event.type === 'available' ||
event.type === 'not-available' ||
event.type === 'error' ||
event.type === 'unsupported');
if (isManualDownloadRefreshResult) {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
@@ -643,7 +655,7 @@ class Updater {
currentVersion: this.currentVersion,
version: this.updateInfo.native.version,
options: this.nativeManualDownloadOptions,
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
});
return;
}
@@ -651,6 +663,42 @@ class Updater {
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
}
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
if (this.checkInProgress) {
return option;
}
this.transition({type: 'check.started'});
let timeoutId: number | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
});
try {
this.pendingManualDownloadRefreshes += 1;
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
if (!checked) {
return option;
}
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
} finally {
window.clearTimeout(timeoutId);
this.transition({type: 'check.finished', now: Date.now()});
}
}
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
this.transition({type: 'manualDownload.started'});
let currentOption = option;
try {
currentOption = await this.refreshManualNativeDownloadOption(option);
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
}
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {describe, expect, it, vi} from 'vitest';
import {buildLinuxManualUpdateOptions} from './LinuxManualUpdateOptions';
vi.mock('@app/features/app/config/Config', () => ({
default: {
PUBLIC_BUILD_VERSION: 'test',
PUBLIC_RELEASE_CHANNEL: 'canary',
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
},
}));
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const STABLE_X64 = 'https://api.fluxer.app/dl/desktop/stable/linux/x64';
function desktopMainOptions(params: {
linkBase: string;
productName: string;
version: string;
linkVersion: string;
}): Array<UpdaterDownloadOption> {
const {linkBase, productName, version, linkVersion} = params;
return [
{
format: 'appimage',
label: 'AppImage',
url: `${linkBase}/${linkVersion}/appimage`,
suggestedName: `${productName}-${version}-linux-x86_64.AppImage`,
sha256: `appimage-${version}`,
},
{
format: 'deb',
label: 'DEB package',
url: `${linkBase}/${linkVersion}/deb`,
suggestedName: `${productName}-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
{
format: 'rpm',
label: 'RPM package',
url: `${linkBase}/${linkVersion}/rpm`,
suggestedName: `${productName}-${version}-linux-x86_64.rpm`,
sha256: `rpm-${version}`,
},
{
format: 'tar_gz',
label: 'tar.gz archive',
url: `${linkBase}/${linkVersion}/tar_gz`,
suggestedName: `${productName}-${version}-linux-x64.tar.gz`,
sha256: `tar_gz-${version}`,
},
];
}
describe('buildLinuxManualUpdateOptions', () => {
it('pins the link, file name and checksum of every format to the same version', () => {
const options = buildLinuxManualUpdateOptions({channel: 'stable', arch: 'x64', version: REPORTED_VERSION});
expect(options).toEqual([
{
format: 'appimage',
label: 'AppImage',
url: `${STABLE_X64}/${REPORTED_VERSION}/appimage`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x86_64.AppImage`,
sha256: null,
},
{
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: null,
},
{
format: 'rpm',
label: 'RPM package',
url: `${STABLE_X64}/${REPORTED_VERSION}/rpm`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x86_64.rpm`,
sha256: null,
},
{
format: 'tar_gz',
label: 'tar.gz archive',
url: `${STABLE_X64}/${REPORTED_VERSION}/tar_gz`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x64.tar.gz`,
sha256: null,
},
]);
});
it('pins the latest links sent by current desktop builds to the announced version', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage`,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options).toEqual(
desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: REPORTED_VERSION,
}),
);
});
it('keeps the pinned links sent by newer desktop builds', () => {
const pinnedOptions = desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: REPORTED_VERSION,
});
const options = buildLinuxManualUpdateOptions({
downloadUrl: pinnedOptions[0].url,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: pinnedOptions,
});
expect(options).toEqual(pinnedOptions);
});
it('only uses latest links and latest file names when no version is known', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${STABLE_X64}/latest/appimage`,
channel: 'stable',
arch: 'x64',
version: null,
knownOptions: desktopMainOptions({
linkBase: STABLE_X64,
productName: 'Fluxer',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options.map(({url, suggestedName, sha256}) => ({url, suggestedName, sha256}))).toEqual([
{url: `${STABLE_X64}/latest/appimage`, suggestedName: 'Fluxer-latest-linux-x86_64.AppImage', sha256: null},
{url: `${STABLE_X64}/latest/deb`, suggestedName: 'Fluxer-latest-linux-amd64.deb', sha256: null},
{url: `${STABLE_X64}/latest/rpm`, suggestedName: 'Fluxer-latest-linux-x86_64.rpm', sha256: null},
{url: `${STABLE_X64}/latest/tar_gz`, suggestedName: 'Fluxer-latest-linux-x64.tar.gz', sha256: null},
]);
});
it('uses arm64 links and file name tokens on arm64 systems', () => {
const expected = [
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/appimage`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.AppImage`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.deb`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/rpm`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-aarch64.rpm`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/tar_gz`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.tar.gz`,
},
];
const fromDesktopInfo = buildLinuxManualUpdateOptions({
channel: 'stable',
arch: 'aarch64',
version: REPORTED_VERSION,
});
const fromLatestLink = buildLinuxManualUpdateOptions({
downloadUrl: 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/latest/appimage',
version: REPORTED_VERSION,
});
const fromPinnedLink = buildLinuxManualUpdateOptions({
downloadUrl: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
version: REPORTED_VERSION,
});
for (const options of [fromDesktopInfo, fromLatestLink, fromPinnedLink]) {
expect(options.map(({url, suggestedName}) => ({url, suggestedName}))).toEqual(expected);
}
});
it('preserves the query string of latest and pinned links', () => {
const fromLatestLink = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage?test=1`,
version: REPORTED_VERSION,
});
const fromPinnedLink = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/${REPORTED_VERSION}/appimage?test=1`,
version: REPORTED_VERSION,
});
const expectedUrls = [
`${CANARY_X64}/${REPORTED_VERSION}/appimage?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/deb?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/rpm?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/tar_gz?test=1`,
];
expect(fromLatestLink.map((option) => option.url)).toEqual(expectedUrls);
expect(fromPinnedLink.map((option) => option.url)).toEqual(expectedUrls);
});
it('builds links on the stable and canary endpoints', () => {
const stable = buildLinuxManualUpdateOptions({channel: 'stable', arch: 'x64', version: REPORTED_VERSION});
const canary = buildLinuxManualUpdateOptions({channel: 'canary', arch: 'x64', version: REPORTED_VERSION});
const configuredCanary = buildLinuxManualUpdateOptions({
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
apiEndpoint: 'https://web.canary.fluxer.app/api/',
});
const proxiedCanaryLink = buildLinuxManualUpdateOptions({
downloadUrl: 'https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/latest/deb',
version: REPORTED_VERSION,
apiEndpoint: 'https://api.fluxer.app',
});
expect(stable[1].url).toBe(`${STABLE_X64}/${REPORTED_VERSION}/deb`);
expect(canary[1].url).toBe(`${CANARY_X64}/${REPORTED_VERSION}/deb`);
expect(configuredCanary[1].url).toBe(
`https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/${REPORTED_VERSION}/deb`,
);
expect(proxiedCanaryLink[1].url).toBe(
`https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/${REPORTED_VERSION}/deb`,
);
});
it('never points a prompt for 2026.908.173325 at a link that could serve another build', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage`,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
apiEndpoint: 'https://api.canary.fluxer.app',
knownOptions: desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options.find((option) => option.format === 'deb')).toMatchObject({
url: `${CANARY_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: `deb-${REPORTED_VERSION}`,
});
for (const option of options) {
expect(option.url).toContain(`/${REPORTED_VERSION}/`);
expect(option.url).not.toContain('/latest/');
expect(option.url).not.toContain(NEWER_VERSION);
expect(option.suggestedName).toContain(REPORTED_VERSION);
}
});
it.each([
'v2026.908.173325',
'2026.908',
'2026.908.173325-beta',
])('treats the off-shape version %s as unknown', (version) => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${STABLE_X64}/latest/appimage`,
version,
knownOptions: desktopMainOptions({
linkBase: STABLE_X64,
productName: 'Fluxer',
version,
linkVersion: 'latest',
}),
});
expect(options[1]).toEqual({
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/latest/deb`,
suggestedName: 'Fluxer-latest-linux-amd64.deb',
sha256: null,
});
});
it('ignores a download link that is not for Linux', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: 'https://api.fluxer.app/dl/desktop/stable/darwin/arm64/latest/dmg?test=1',
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
});
expect(options.map((option) => option.url)).toEqual([
`${CANARY_X64}/${REPORTED_VERSION}/appimage`,
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`${CANARY_X64}/${REPORTED_VERSION}/rpm`,
`${CANARY_X64}/${REPORTED_VERSION}/tar_gz`,
]);
});
it('reads the architecture of a known package from its pinned link before its file name', () => {
const options = buildLinuxManualUpdateOptions({
channel: 'stable',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: [
{
format: 'deb',
label: 'DEB package',
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: 'arm64-deb',
},
],
});
expect(options[1]).toEqual({
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: null,
});
});
});
@@ -35,7 +35,7 @@ const DEFAULT_API_ENDPOINTS: Record<DesktopDownloadChannel, string> = {
canary: 'https://api.canary.fluxer.app',
};
interface ParsedLinuxLatestDownloadUrl {
interface ParsedLinuxDownloadUrl {
apiEndpoint: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
@@ -79,7 +79,7 @@ function normalizeApiEndpoint(value: string | null | undefined, channel: Desktop
return DEFAULT_API_ENDPOINTS[channel];
}
function parseLinuxLatestDownloadUrl(value: string | null | undefined): ParsedLinuxLatestDownloadUrl | null {
function parseLinuxDownloadUrl(value: string | null | undefined): ParsedLinuxDownloadUrl | null {
if (!value) {
return null;
}
@@ -94,7 +94,8 @@ function parseLinuxLatestDownloadUrl(value: string | null | undefined): ParsedLi
const platform = segments[dlIndex + 3];
const arch = normalizeLinuxDownloadArch(segments[dlIndex + 4]);
const version = segments[dlIndex + 5];
if (platform !== 'linux' || version !== 'latest') {
const format = segments[dlIndex + 6];
if (platform !== 'linux' || !version || !format) {
return null;
}
const endpointSegments = segments.slice(0, dlIndex);
@@ -126,7 +127,7 @@ function getLinuxDownloadArchFromText(value: string | null | undefined): LinuxDo
function getLinuxDownloadOptionArch(option: UpdaterDownloadOption): LinuxDownloadArch | null {
return (
parseLinuxLatestDownloadUrl(option.url)?.arch ??
parseLinuxDownloadUrl(option.url)?.arch ??
getLinuxDownloadArchFromText(option.suggestedName) ??
getLinuxDownloadArchFromText(option.label)
);
@@ -144,14 +145,15 @@ function getKnownLinuxManualOption(
);
}
function buildLinuxLatestDownloadUrl(params: {
function buildLinuxDownloadUrl(params: {
apiEndpoint: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
versionToken: string;
format: LinuxManualDownloadFormat;
search: string;
}): string {
return `${params.apiEndpoint}/dl/desktop/${params.channel}/linux/${params.arch}/latest/${params.format}${params.search}`;
return `${params.apiEndpoint}/dl/desktop/${params.channel}/linux/${params.arch}/${params.versionToken}/${params.format}${params.search}`;
}
function getModernProductName(channel: DesktopDownloadChannel): string {
@@ -162,27 +164,30 @@ function getSuggestedName(
format: LinuxManualDownloadFormat,
channel: DesktopDownloadChannel,
arch: LinuxDownloadArch,
version: string | null | undefined,
versionToken: string,
): string {
const versionToken = version?.trim() || 'latest';
const archToken = LINUX_MANUAL_ARCH_TOKENS[format][arch];
const extension = LINUX_MANUAL_FORMAT_EXTENSIONS[format];
return `${getModernProductName(channel)}-${versionToken}-linux-${archToken}${extension}`;
}
export function buildLinuxManualUpdateOptions(input: LinuxManualUpdateOptionsInput): Array<UpdaterDownloadOption> {
const parsedUrl = parseLinuxLatestDownloadUrl(input.downloadUrl);
const parsedUrl = parseLinuxDownloadUrl(input.downloadUrl);
const channel = parsedUrl?.channel ?? normalizeDesktopDownloadChannel(input.channel);
const arch = normalizeLinuxDownloadArchOrNull(input.arch) ?? parsedUrl?.arch ?? 'x64';
const apiEndpoint = parsedUrl?.apiEndpoint ?? normalizeApiEndpoint(input.apiEndpoint, channel);
const search = parsedUrl?.search ?? '';
const version = input.version?.trim() ?? '';
const hasVersion = /^\d+\.\d+\.\d+$/u.test(version);
const versionToken = hasVersion ? version : 'latest';
const knownOptions = hasVersion ? (input.knownOptions ?? []) : [];
return LINUX_MANUAL_DOWNLOAD_FORMATS.map((format) => {
const knownOption = getKnownLinuxManualOption(input.knownOptions ?? [], format, arch);
const knownOption = getKnownLinuxManualOption(knownOptions, format, arch);
return {
format,
label: LINUX_MANUAL_FORMAT_LABELS[format],
url: buildLinuxLatestDownloadUrl({apiEndpoint, channel, arch, format, search}),
suggestedName: knownOption?.suggestedName ?? getSuggestedName(format, channel, arch, input.version),
url: buildLinuxDownloadUrl({apiEndpoint, channel, arch, versionToken, format, search}),
suggestedName: knownOption?.suggestedName ?? getSuggestedName(format, channel, arch, versionToken),
sha256: knownOption?.sha256 ?? null,
};
});