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;