mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +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;
|
||||
|
||||
Reference in new issue
Block a user