diff --git a/fluxer_app/src/features/app/state/Updater.test.ts b/fluxer_app/src/features/app/state/Updater.test.ts index d8787909a..215e2deec 100644 --- a/fluxer_app/src/features/app/state/Updater.test.ts +++ b/fluxer_app/src/features/app/state/Updater.test.ts @@ -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; + onDownload: (option: UpdaterDownloadOption) => void | Promise; +} + +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) | 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>(() => 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(() => {}); + 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(() => {}); + 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(() => {}); + 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((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`]]); + }); +}); diff --git a/fluxer_app/src/features/app/state/Updater.ts b/fluxer_app/src/features/app/state/Updater.ts index 74c1fd793..9aacd4358 100644 --- a/fluxer_app/src/features/app/state/Updater.ts +++ b/fluxer_app/src/features/app/state/Updater.ts @@ -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 { + if (this.checkInProgress) { + return option; + } + this.transition({type: 'check.started'}); + let timeoutId: number | undefined; + const timedOut = new Promise((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 { + 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 { if (this.manualNativeDownloadInFlight) { return; diff --git a/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.test.ts b/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.test.ts new file mode 100644 index 000000000..62ca1ece1 --- /dev/null +++ b/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.ts b/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.ts index a27c8a402..258d4ed97 100644 --- a/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.ts +++ b/fluxer_app/src/features/updater/utils/LinuxManualUpdateOptions.ts @@ -35,7 +35,7 @@ const DEFAULT_API_ENDPOINTS: Record = { 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 { - 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, }; });