mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): download the version a linux update prompt names (#2669)
This commit is contained in:
1 parent
fc0e2628a4
commit
61a13e1c1a
4 files changed
+608
-29
No files matched your search
@@ -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`]]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user