From c4f0b2ece027abbe7a099fd5cd8e0303d9ce12a2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 19 Sep 2026 19:06:53 +0200 Subject: [PATCH] feat(desktop): self-update appimages in place (#2843) --- fluxer_app/src/features/app/state/Updater.ts | 14 +- .../app/utils/UpdaterPlatformUtils.ts | 2 +- .../src/features/platform/types/Electron.ts | 3 +- .../src/features/ui/utils/NativeUtils.ts | 10 +- fluxer_app/src/types/electron.d.ts | 3 +- fluxer_desktop/src/common/Types.ts | 3 +- .../src/main/AppImageUpdate.test.mjs | 694 ++++++++++++++++++ fluxer_desktop/src/main/AppImageUpdate.ts | 272 +++++++ fluxer_desktop/src/main/DesktopTray.ts | 4 +- .../src/main/FileDownloads.test.mjs | 201 +++++ fluxer_desktop/src/main/FileDownloads.ts | 45 +- fluxer_desktop/src/main/IpcHandlers.ts | 8 +- .../src/main/LinuxLaunchPath.test.mjs | 223 ++++++ fluxer_desktop/src/main/LinuxLaunchPath.ts | 63 +- fluxer_desktop/src/main/Troubleshooting.ts | 4 +- fluxer_desktop/src/main/Updater.test.mjs | 448 +++++++++++ fluxer_desktop/src/main/Updater.ts | 232 +++++- .../src/main/UpdaterDownloads.test.mjs | 28 +- fluxer_desktop/src/main/UpdaterDownloads.ts | 6 +- fluxer_desktop/src/preload/index.ts | 4 +- 20 files changed, 2210 insertions(+), 57 deletions(-) create mode 100644 fluxer_desktop/src/main/AppImageUpdate.test.mjs create mode 100644 fluxer_desktop/src/main/AppImageUpdate.ts create mode 100644 fluxer_desktop/src/main/FileDownloads.test.mjs create mode 100644 fluxer_desktop/src/main/LinuxLaunchPath.test.mjs create mode 100644 fluxer_desktop/src/main/Updater.test.mjs diff --git a/fluxer_app/src/features/app/state/Updater.ts b/fluxer_app/src/features/app/state/Updater.ts index 9aacd4358..0db69c209 100644 --- a/fluxer_app/src/features/app/state/Updater.ts +++ b/fluxer_app/src/features/app/state/Updater.ts @@ -696,10 +696,14 @@ class Updater { } finally { this.transition({type: 'manualDownload.finished'}); } - await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName); + await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName, currentOption.sha256); } - private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise { + private async downloadManualNativeUpdateOrOpen( + url: string, + suggestedName?: string, + sha256?: string | null, + ): Promise { if (this.manualNativeDownloadInFlight) { return; } @@ -708,10 +712,16 @@ class Updater { const outcome = await downloadWithNative({ url, suggestedName: suggestedName ?? this.getManualUpdateSuggestedName(url), + sha256, }); if (outcome === 'success' || outcome === 'canceled') { return; } + if (outcome === 'checksum-mismatch') { + logger.error('Native manual update download did not match its published checksum', {url}); + pushDesktopUpdateDownloadFailedModal(); + return; + } logger.warn('Native manual update download unavailable; opening update URL externally', {outcome}); await openExternalUrl(url); } finally { diff --git a/fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts b/fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts index dc300dcfb..ab3c9e6ce 100644 --- a/fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts +++ b/fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts @@ -5,5 +5,5 @@ export function shouldShowNativeDesktopUpdateInApp(platform: string | null | und } export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean { - return platform === 'win32'; + return platform === 'win32' || platform === 'linux'; } diff --git a/fluxer_app/src/features/platform/types/Electron.ts b/fluxer_app/src/features/platform/types/Electron.ts index 376c78051..42bbee6b3 100644 --- a/fluxer_app/src/features/platform/types/Electron.ts +++ b/fluxer_app/src/features/platform/types/Electron.ts @@ -212,6 +212,7 @@ export interface DownloadFileOptions { export interface DownloadFileResult { success: boolean; canceled?: boolean; + checksumMismatch?: boolean; path?: string; error?: string; } @@ -357,7 +358,7 @@ export interface ElectronAPI { requestInputMonitoringPermission?: () => Promise; getScreenRecordingPermissionStatus?: () => Promise; requestScreenRecordingPermission?: () => Promise; - downloadFile: (url: string, defaultPath: string) => Promise; + downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise; toggleDevTools: () => void; showNotification: (options: NotificationOptions) => Promise; shouldPlayNotificationSound?: () => Promise; diff --git a/fluxer_app/src/features/ui/utils/NativeUtils.ts b/fluxer_app/src/features/ui/utils/NativeUtils.ts index c1be6bc6a..78eeb7011 100644 --- a/fluxer_app/src/features/ui/utils/NativeUtils.ts +++ b/fluxer_app/src/features/ui/utils/NativeUtils.ts @@ -273,21 +273,27 @@ export function attachExternalLinkInterceptor() { }; } -export type NativeDownloadOutcome = 'success' | 'canceled' | 'failed' | 'unavailable'; +export type NativeDownloadOutcome = 'success' | 'canceled' | 'checksum-mismatch' | 'failed' | 'unavailable'; export async function downloadWithNative(options: { url: string; suggestedName?: string; title?: string; + sha256?: string | null; }): Promise { const electronApi = getElectronAPI(); if (!electronApi) { return 'unavailable'; } try { - const result = await electronApi.downloadFile(options.url, options.suggestedName ?? 'download'); + const result = await electronApi.downloadFile( + options.url, + options.suggestedName ?? 'download', + options.sha256 ?? null, + ); if (result.success) return 'success'; if (result.canceled) return 'canceled'; + if (result.checksumMismatch) return 'checksum-mismatch'; return 'failed'; } catch (error) { logger.error(' Native download failed, falling back to browser', error); diff --git a/fluxer_app/src/types/electron.d.ts b/fluxer_app/src/types/electron.d.ts index bcea5d108..97409dcac 100644 --- a/fluxer_app/src/types/electron.d.ts +++ b/fluxer_app/src/types/electron.d.ts @@ -283,6 +283,7 @@ export interface UpdaterDownloadOption { export interface DownloadResult { success: boolean; canceled?: boolean; + checksumMismatch?: boolean; path?: string; error?: string; } @@ -349,7 +350,7 @@ export interface ElectronAPI { platform: 'darwin' | 'win32' | 'linux' | string; buildChannel: 'stable' | 'canary'; openExternal(url: string): Promise; - downloadFile(url: string, suggestedName: string): Promise; + downloadFile(url: string, suggestedName: string, sha256?: string | null): Promise; onUpdaterEvent(callback: (event: UpdaterEvent) => void): () => void; updaterCheck(context: 'user' | 'background'): Promise; updaterDownload(context: 'user' | 'background'): Promise; diff --git a/fluxer_desktop/src/common/Types.ts b/fluxer_desktop/src/common/Types.ts index a642e85a7..4f8a16e9b 100644 --- a/fluxer_desktop/src/common/Types.ts +++ b/fluxer_desktop/src/common/Types.ts @@ -246,6 +246,7 @@ export type UpdaterEvent = export interface DownloadFileResult { success: boolean; canceled?: boolean; + checksumMismatch?: boolean; path?: string; error?: string; } @@ -697,7 +698,7 @@ export interface ElectronAPI { requestInputMonitoringPermission: () => Promise; getScreenRecordingPermissionStatus: () => Promise; requestScreenRecordingPermission: () => Promise; - downloadFile: (url: string, defaultPath: string) => Promise; + downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise; toggleDevTools: () => void; showNotification: (options: NotificationOptions) => Promise; shouldPlayNotificationSound: () => Promise; diff --git a/fluxer_desktop/src/main/AppImageUpdate.test.mjs b/fluxer_desktop/src/main/AppImageUpdate.test.mjs new file mode 100644 index 000000000..f58d49a6b --- /dev/null +++ b/fluxer_desktop/src/main/AppImageUpdate.test.mjs @@ -0,0 +1,694 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {after, before, describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./AppImageUpdate.ts', import.meta.url)); +const source = readFileSync(sourcePath, 'utf8'); +const transformedSource = esbuild.transformSync(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +function loadAppImageUpdate(env = {}, overrides = {}, requireImpl = require) { + const module = {exports: {}}; + const context = vm.createContext({ + module, + exports: module.exports, + require: requireImpl, + console, + Buffer, + fetch, + Error, + Number, + process: {...process, ...overrides, env: {...env}}, + }); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return module.exports; +} + +const INSTALLED_NAME = 'My Fluxer.AppImage'; +const OLD_BYTES = Buffer.from('old-appimage-payload'); +const NEW_BYTES = Buffer.alloc(400_000, 7); +const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex'); + +let server; +let baseUrl; +let requestCount = 0; + +before(async () => { + server = createServer((request, response) => { + requestCount += 1; + if (request.url === '/appimage') { + response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length}); + response.end(NEW_BYTES); + return; + } + if (request.url === '/corrupt') { + const corrupt = Buffer.from(NEW_BYTES); + corrupt[0] = corrupt[0] ^ 0xff; + response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': corrupt.length}); + response.end(corrupt); + return; + } + response.writeHead(404); + response.end('missing'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +function createInstall() { + const root = mkdtempSync(join(tmpdir(), 'fluxer-appimage-test-')); + const applications = join(root, 'Applications'); + mkdirSync(applications); + const installedPath = join(applications, INSTALLED_NAME); + writeFileSync(installedPath, OLD_BYTES, {mode: 0o755}); + return {root, applications, installedPath}; +} + +function stagingLeftovers(directory) { + return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-')); +} + +function assertUnavailable(result, reason) { + assert.equal(result.ok, false); + assert.equal(result.reason, reason); +} + +function createAppDir(name) { + const root = mkdtempSync(join(tmpdir(), 'fluxer-appdir-test-')); + const dir = join(root, name); + mkdirSync(dir); + writeFileSync(join(dir, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); + const binary = join(dir, 'fluxer-canary'); + writeFileSync(binary, 'elf', {mode: 0o755}); + return {dir, binary}; +} + +function stagingPrefixFor(installedPath) { + return `.fluxer-update-${createHash('sha256').update(installedPath).digest('hex').slice(0, 8)}-`; +} + +describe('AppImageUpdate process provenance', () => { + test('accepts the process the AppImage runtime execed out of its own mount', () => { + const {isRunningFromAppImage} = loadAppImageUpdate({}); + const mounted = createAppDir('.mount_My FluAKcenK'); + + assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, mounted.binary), true); + assert.equal(isRunningFromAppImage({APPDIR: `${mounted.dir}/`}, mounted.binary), true); + assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, join(mounted.dir, 'usr', 'bin', 'fluxer-canary')), true); + }); + + test('refuses an extracted AppDir, whose APPIMAGE belongs to whatever launched it', () => { + const {isRunningFromAppImage} = loadAppImageUpdate({}); + const extracted = createAppDir('squashfs-root'); + const launcher = createAppDir('.mount_My FluOjOANH'); + + assert.equal(isRunningFromAppImage({}, extracted.binary), false); + assert.equal( + isRunningFromAppImage({APPDIR: launcher.dir}, extracted.binary), + false, + 'the generated AppRun assigns APPDIR without exporting it, so an APPDIR that reaches this process and does not contain it came from another AppImage', + ); + }); + + test('refuses an installed package that inherited APPIMAGE from the terminal it was started in', () => { + const {isRunningFromAppImage} = loadAppImageUpdate({}); + const terminal = createAppDir('.mount_Alacritty7fJk2L'); + + assert.equal(isRunningFromAppImage({}, '/opt/Fluxer/fluxer'), false); + assert.equal(isRunningFromAppImage({APPDIR: ''}, '/opt/Fluxer/fluxer'), false); + assert.equal(isRunningFromAppImage({APPDIR: terminal.dir}, '/opt/Fluxer/fluxer'), false); + }); + + test('refuses a mount path that only prefixes this executable', () => { + const {isRunningFromAppImage} = loadAppImageUpdate({}); + const mounted = createAppDir('.mount_My FluAKcenK'); + + assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, `${mounted.dir}-other/fluxer-canary`), false); + }); + + test('refuses an APPDIR the runtime cannot have mounted', () => { + const {isRunningFromAppImage} = loadAppImageUpdate({}); + const installDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-')); + const binary = join(installDir, 'fluxer-canary'); + writeFileSync(binary, 'elf', {mode: 0o755}); + + assert.equal(isRunningFromAppImage({APPDIR: installDir}, binary), false); + writeFileSync(join(installDir, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); + assert.equal(isRunningFromAppImage({APPDIR: installDir}, binary), true); + }); +}); + +describe('AppImageUpdate target resolution', () => { + test('falls back when APPIMAGE is unset', () => { + const {resolveAppImageTarget} = loadAppImageUpdate({}); + + assertUnavailable(resolveAppImageTarget(), 'appimage-path-missing'); + assertUnavailable(resolveAppImageTarget(''), 'appimage-path-missing'); + assertUnavailable(resolveAppImageTarget(' '), 'appimage-path-missing'); + }); + + test('falls back when the AppImage moved since launch', () => { + const install = createInstall(); + const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: join(install.applications, 'Gone.AppImage')}); + + assertUnavailable(resolveAppImageTarget(), 'appimage-path-gone'); + }); + + test('falls back when the path is not a regular file', () => { + const install = createInstall(); + const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.applications}); + + assertUnavailable(resolveAppImageTarget(), 'appimage-path-not-a-file'); + }); + + test('falls back when the containing directory is not writable', {skip: process.getuid?.() === 0}, () => { + const install = createInstall(); + chmodSync(install.applications, 0o500); + try { + const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.installedPath}); + + assertUnavailable(resolveAppImageTarget(), 'directory-not-writable'); + } finally { + chmodSync(install.applications, 0o755); + } + }); + + test('resolves a writable AppImage to its own path', () => { + const install = createInstall(); + const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.installedPath}); + + const resolved = resolveAppImageTarget(); + assert.equal(resolved.ok, true); + assert.equal(resolved.target.installedPath, install.installedPath); + assert.equal(resolved.target.directory, install.applications); + }); +}); + +describe('AppImageUpdate in-place replacement', () => { + test('replaces the running AppImage and keeps the user filename', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + assert.equal(resolved.ok, true); + + const progress = []; + const staged = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + onProgress: (sample) => progress.push(sample), + }); + + assert.equal(staged.stagedPath, join(staged.stagingDirectory, INSTALLED_NAME)); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.ok(progress.length > 0); + assert.equal(progress.at(-1).transferred, NEW_BYTES.length); + assert.equal(progress.at(-1).total, NEW_BYTES.length); + + appImageUpdate.applyStagedAppImageUpdate(resolved.target, staged); + + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true); + assert.equal(createHash('sha256').update(readFileSync(install.installedPath)).digest('hex'), NEW_SHA256); + assert.equal(statSync(install.installedPath).mode & 0o111, 0o111); + }); + + test('stages inside the install directory so the rename cannot cross filesystems', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + const staged = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + assert.equal(staged.stagingDirectory.startsWith(join(install.applications, '.fluxer-update-')), true); + appImageUpdate.discardStagedAppImageUpdate(staged); + assert.deepEqual(stagingLeftovers(install.applications), []); + }); + + test('refuses to swap when the download does not match the published checksum', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/corrupt`, + expectedSha256: NEW_SHA256, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageChecksumError, true); + assert.match(error.message, /checksum mismatch/); + return true; + }, + ); + + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('refuses a checksum the release feed never published', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const before = requestCount; + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: '', + }), + /checksum is missing or malformed/, + ); + + assert.equal(requestCount, before); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('cleans up and leaves the install untouched when the download fails', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/nothing-here`, + expectedSha256: NEW_SHA256, + }), + /Update download failed: 404/, + ); + + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('finishes a short write instead of installing a truncated AppImage', async () => { + const install = createInstall(); + let shortWrites = 0; + const shortWritingRequire = (specifier) => { + const real = require(specifier); + if (specifier !== 'node:fs/promises') { + return real; + } + return { + ...real, + open: async (...args) => { + const handle = await real.open(...args); + return { + close: () => handle.close(), + datasync: () => handle.datasync(), + write: (buffer, offset, length) => { + const capped = shortWrites === 0 ? Math.min(length, 4096) : length; + shortWrites += 1; + return handle.write(buffer, offset, capped); + }, + }; + }, + }; + }; + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, shortWritingRequire); + const resolved = appImageUpdate.resolveAppImageTarget(); + + const staged = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + assert.ok(shortWrites > 1, 'the write loop has to come back for the remainder of a short write'); + assert.equal(statSync(staged.stagedPath).size, NEW_BYTES.length); + assert.equal(createHash('sha256').update(readFileSync(staged.stagedPath)).digest('hex'), NEW_SHA256); + appImageUpdate.discardStagedAppImageUpdate(staged); + }); + + test('checksums the staged file from disk, not the stream it was read from', async () => { + const install = createInstall(); + const droppingRequire = (specifier) => { + const real = require(specifier); + if (specifier !== 'node:fs/promises') { + return real; + } + return { + ...real, + open: async (...args) => { + const handle = await real.open(...args); + return { + close: () => handle.close(), + datasync: () => handle.datasync(), + write: async (buffer, offset, length) => { + const kept = Math.max(0, length - 1024); + if (kept > 0) { + await handle.write(buffer, offset, kept); + } + return {bytesWritten: length}; + }, + }; + }, + }; + }; + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, droppingRequire); + const resolved = appImageUpdate.resolveAppImageTarget(); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageChecksumError, true); + return true; + }, + ); + + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('flushes the staged file to disk before it can be renamed over the install', async () => { + const install = createInstall(); + let flushes = 0; + const countingRequire = (specifier) => { + const real = require(specifier); + if (specifier !== 'node:fs/promises') { + return real; + } + return { + ...real, + open: async (...args) => { + const handle = await real.open(...args); + return { + close: () => handle.close(), + datasync: () => { + flushes += 1; + return handle.datasync(); + }, + write: (buffer, offset, length) => handle.write(buffer, offset, length), + }; + }, + }; + }; + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, countingRequire); + const resolved = appImageUpdate.resolveAppImageTarget(); + + const staged = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + assert.equal(flushes, 1); + appImageUpdate.discardStagedAppImageUpdate(staged); + }); + + test('gives up when the staged bytes cannot be flushed to disk', async () => { + const install = createInstall(); + const failingFlushRequire = (specifier) => { + const real = require(specifier); + if (specifier !== 'node:fs/promises') { + return real; + } + return { + ...real, + open: async (...args) => { + const handle = await real.open(...args); + return { + close: () => handle.close(), + datasync: async () => { + const error = new Error('ENOSPC: no space left on device, fsync'); + error.code = 'ENOSPC'; + throw error; + }, + write: (buffer, offset, length) => handle.write(buffer, offset, length), + }; + }, + }; + }; + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, failingFlushRequire); + const resolved = appImageUpdate.resolveAppImageTarget(); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); + assert.equal(error.code, 'ENOSPC'); + return true; + }, + ); + + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('retries a body that stops short of content-length instead of calling it a bad build', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const truncatingFetch = async () => { + let sent = false; + return { + ok: true, + status: 200, + headers: {get: (name) => (name === 'content-length' ? String(NEW_BYTES.length) : null)}, + body: { + cancel: async () => {}, + getReader: () => ({ + cancel: async () => {}, + read: async () => { + if (sent) { + return {done: true, value: undefined}; + } + sent = true; + return {done: false, value: NEW_BYTES.subarray(0, 1024)}; + }, + }), + }, + }; + }; + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + fetchImpl: truncatingFetch, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageChecksumError, false); + assert.equal(error instanceof appImageUpdate.AppImageStagingError, false); + assert.match(error.message, new RegExp(`ended after 1024 of ${NEW_BYTES.length} bytes`)); + return true; + }, + ); + + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('refuses to stage when the install filesystem cannot hold the download', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const hugeFetch = async () => ({ + ok: true, + status: 200, + headers: {get: (name) => (name === 'content-length' ? String(Number.MAX_SAFE_INTEGER) : null)}, + body: { + cancel: async () => {}, + getReader: () => ({cancel: async () => {}, read: async () => ({done: true, value: undefined})}), + }, + }); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + fetchImpl: hugeFetch, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); + assert.equal(error.code, 'ENOSPC'); + return true; + }, + ); + + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('marks a local write failure as non-retryable', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const failingFetch = async () => ({ + ok: true, + status: 200, + headers: {get: () => null}, + body: { + cancel: async () => {}, + getReader: () => ({ + cancel: async () => {}, + read: async () => { + const error = new Error('ENOSPC: no space left on device, write'); + error.code = 'ENOSPC'; + throw error; + }, + }), + }, + }); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + fetchImpl: failingFetch, + }), + (error) => { + assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); + assert.equal(error.code, 'ENOSPC'); + return true; + }, + ); + + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('refuses to install when the AppImage moved between download and install', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const staged = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + const relocated = {installedPath: join(install.applications, 'Moved.AppImage'), directory: install.applications}; + assert.throws( + () => appImageUpdate.applyStagedAppImageUpdate(relocated, staged), + /cannot be replaced in place: appimage-path-gone/, + ); + + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(stagingLeftovers(install.applications), []); + }); +}); + +describe('AppImageUpdate abandoned staging cleanup', () => { + test('reclaims staged updates left behind by a process that never quit cleanly', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const abandoned = await appImageUpdate.stageAppImageUpdate({ + target: resolved.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + assert.equal(stagingLeftovers(install.applications).length, 1); + + const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); + + assert.deepEqual([...reclaimed], [abandoned.stagingDirectory]); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + }); + + test('leaves a staged update belonging to another AppImage in the same directory alone', async () => { + const install = createInstall(); + const siblingPath = join(install.applications, 'Fluxer Canary.AppImage'); + writeFileSync(siblingPath, OLD_BYTES, {mode: 0o755}); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const sibling = appImageUpdate.resolveAppImageTarget(siblingPath); + const siblingStaged = await appImageUpdate.stageAppImageUpdate({ + target: sibling.target, + url: `${baseUrl}/appimage`, + expectedSha256: NEW_SHA256, + }); + + const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); + + assert.deepEqual([...reclaimed], []); + assert.equal(existsSync(siblingStaged.stagedPath), true); + appImageUpdate.discardStagedAppImageUpdate(siblingStaged); + assert.deepEqual(stagingLeftovers(install.applications), []); + }); + + test('unlinks a staging-named symlink instead of following it out of the install directory', () => { + const install = createInstall(); + const outside = mkdtempSync(join(tmpdir(), 'fluxer-outside-test-')); + writeFileSync(join(outside, 'keep-me'), 'not ours to delete'); + const planted = join(install.applications, `${stagingPrefixFor(install.installedPath)}link`); + symlinkSync(outside, planted); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); + + assert.deepEqual([...reclaimed], [planted]); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + assert.deepEqual(readdirSync(outside), ['keep-me']); + }); + + test('leaves everything that is not one of this AppImage staging directories alone', () => { + const install = createInstall(); + const neighbours = ['Fluxer Canary.AppImage', '.fluxer-update-notes.txt', '.fluxer-update-']; + for (const name of neighbours) { + writeFileSync(join(install.applications, name), 'keep'); + } + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + assert.deepEqual([...appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target)], []); + assert.deepEqual(readdirSync(install.applications).sort(), [...neighbours, INSTALLED_NAME].sort()); + }); + + test('reports nothing when the install directory holds no staged update', () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + + assert.deepEqual([...appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target)], []); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); +}); diff --git a/fluxer_desktop/src/main/AppImageUpdate.ts b/fluxer_desktop/src/main/AppImageUpdate.ts new file mode 100644 index 000000000..c181cf8f5 --- /dev/null +++ b/fluxer_desktop/src/main/AppImageUpdate.ts @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import { + accessSync, + chmodSync, + constants, + createReadStream, + existsSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, + statfsSync, + statSync, +} from 'node:fs'; +import {open} from 'node:fs/promises'; +import {basename, dirname, join, resolve, sep} from 'node:path'; + +type AppImageUnavailableReason = + | 'appimage-path-missing' + | 'appimage-path-gone' + | 'appimage-path-not-a-file' + | 'directory-not-writable'; + +export type AppImageTarget = { + installedPath: string; + directory: string; +}; + +type AppImageTargetResult = {ok: true; target: AppImageTarget} | {ok: false; reason: AppImageUnavailableReason}; + +export type StagedAppImageUpdate = { + stagedPath: string; + stagingDirectory: string; +}; + +type AppImageDownloadProgress = { + transferred: number; + total: number; +}; + +const STAGING_DIRECTORY_PREFIX = '.fluxer-update-'; +const APPDIR_ENTRYPOINT = 'AppRun'; +const APPIMAGE_MODE = 0o755; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const STAGING_FREE_SPACE_MARGIN_BYTES = 16 * 1024 * 1024; +const NON_RETRYABLE_STAGING_ERRNOS = new Set([ + 'ENOSPC', + 'EDQUOT', + 'EROFS', + 'EACCES', + 'EPERM', + 'EIO', + 'EMFILE', + 'EINVAL', + 'ENOSYS', +]); + +export class AppImageChecksumError extends Error { + constructor(expected: string, actual: string) { + super(`Update checksum mismatch: expected ${expected}, downloaded ${actual}`); + this.name = 'AppImageChecksumError'; + } +} + +export class AppImageStagingError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'AppImageStagingError'; + this.code = code; + } +} + +function errnoCode(error: unknown): string | null { + if (error !== null && typeof error === 'object' && 'code' in error) { + const code = (error as {code?: unknown}).code; + if (typeof code === 'string') { + return code; + } + } + return null; +} + +function asStagingError(error: unknown): unknown { + const code = errnoCode(error); + if (code === null || !NON_RETRYABLE_STAGING_ERRNOS.has(code)) { + return error; + } + return new AppImageStagingError(code, error instanceof Error ? error.message : String(error)); +} + +function stagingDirectoryPrefix(installedPath: string): string { + const scope = createHash('sha256').update(installedPath).digest('hex').slice(0, 8); + return `${STAGING_DIRECTORY_PREFIX}${scope}-`; +} + +export function isRunningFromAppImage(env: NodeJS.ProcessEnv = process.env, execPath = process.execPath): boolean { + const appDir = env.APPDIR; + if (typeof appDir !== 'string' || appDir.trim().length === 0) { + return false; + } + const mounted = resolve(appDir.trim()); + const running = resolve(execPath); + if (running !== mounted && !running.startsWith(`${mounted}${sep}`)) { + return false; + } + return existsSync(join(mounted, APPDIR_ENTRYPOINT)); +} + +export function resolveAppImageTarget(appImagePath = process.env.APPIMAGE): AppImageTargetResult { + if (typeof appImagePath !== 'string' || appImagePath.trim().length === 0) { + return {ok: false, reason: 'appimage-path-missing'}; + } + let stats: ReturnType; + try { + stats = statSync(appImagePath); + } catch { + return {ok: false, reason: 'appimage-path-gone'}; + } + if (!stats.isFile()) { + return {ok: false, reason: 'appimage-path-not-a-file'}; + } + const directory = dirname(appImagePath); + try { + accessSync(directory, constants.W_OK | constants.X_OK); + } catch { + return {ok: false, reason: 'directory-not-writable'}; + } + return {ok: true, target: {installedPath: appImagePath, directory}}; +} + +function assertRoomToStage(directory: string, total: number): void { + if (total <= 0) { + return; + } + let available: number; + try { + const stats = statfsSync(directory); + available = Number(stats.bavail) * Number(stats.bsize); + } catch { + return; + } + if (!Number.isFinite(available) || available >= total + STAGING_FREE_SPACE_MARGIN_BYTES) { + return; + } + throw new AppImageStagingError( + 'ENOSPC', + `Not enough free space in ${directory} to stage the update: ${available} bytes available, ${total} bytes needed.`, + ); +} + +async function digestFile(path: string): Promise { + const hash = createHash('sha256'); + const stream = createReadStream(path); + for await (const chunk of stream) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +export async function stageAppImageUpdate(options: { + target: AppImageTarget; + url: string; + expectedSha256: string; + onProgress?: (progress: AppImageDownloadProgress) => void; + fetchImpl?: typeof fetch; +}): Promise { + const expected = options.expectedSha256.trim().toLowerCase(); + if (!SHA256_PATTERN.test(expected)) { + throw new Error('Update checksum is missing or malformed.'); + } + const fetchImpl = options.fetchImpl ?? fetch; + const response = await fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'}); + if (!response.ok || response.body == null) { + throw new Error(`Update download failed: ${response.status}`); + } + const declaredTotal = Number.parseInt(response.headers.get('content-length') ?? '', 10); + const total = Number.isFinite(declaredTotal) && declaredTotal > 0 ? declaredTotal : 0; + try { + assertRoomToStage(options.target.directory, total); + } catch (error) { + await response.body.cancel().catch(() => {}); + throw error; + } + let stagingDirectory: string; + try { + stagingDirectory = mkdtempSync( + join(options.target.directory, stagingDirectoryPrefix(options.target.installedPath)), + ); + } catch (error) { + await response.body.cancel().catch(() => {}); + throw asStagingError(error); + } + const staged: StagedAppImageUpdate = { + stagedPath: join(stagingDirectory, basename(options.target.installedPath)), + stagingDirectory, + }; + try { + const handle = await open(staged.stagedPath, 'wx', APPIMAGE_MODE); + const reader = response.body.getReader(); + let transferred = 0; + try { + for (;;) { + const {done, value} = await reader.read(); + if (done) { + break; + } + let written = 0; + while (written < value.byteLength) { + const result = await handle.write(value, written, value.byteLength - written); + if (result.bytesWritten <= 0) { + throw new AppImageStagingError('ENOSPC', `Staging write stalled after ${transferred} bytes.`); + } + written += result.bytesWritten; + transferred += result.bytesWritten; + } + options.onProgress?.({transferred, total}); + } + if (total > 0 && transferred < total) { + throw new Error(`Update download ended after ${transferred} of ${total} bytes.`); + } + await handle.datasync(); + } catch (error) { + await reader.cancel().catch(() => {}); + throw error; + } finally { + await handle.close(); + } + const digest = await digestFile(staged.stagedPath); + if (digest !== expected) { + throw new AppImageChecksumError(expected, digest); + } + chmodSync(staged.stagedPath, APPIMAGE_MODE); + return staged; + } catch (error) { + discardStagedAppImageUpdate(staged); + throw asStagingError(error); + } +} + +export function applyStagedAppImageUpdate(target: AppImageTarget, staged: StagedAppImageUpdate): void { + const resolved = resolveAppImageTarget(target.installedPath); + if (!resolved.ok) { + discardStagedAppImageUpdate(staged); + throw new Error(`AppImage cannot be replaced in place: ${resolved.reason}`); + } + renameSync(staged.stagedPath, target.installedPath); + discardStagedAppImageUpdate(staged); +} + +export function discardStagedAppImageUpdate(staged: StagedAppImageUpdate): void { + try { + rmSync(staged.stagingDirectory, {recursive: true, force: true}); + } catch {} +} + +export function sweepAbandonedAppImageUpdates(target: AppImageTarget): Array { + const prefix = stagingDirectoryPrefix(target.installedPath); + const reclaimed: Array = []; + for (const entry of readdirSync(target.directory)) { + if (!entry.startsWith(prefix)) { + continue; + } + const abandoned = join(target.directory, entry); + rmSync(abandoned, {recursive: true, force: true}); + reclaimed.push(abandoned); + } + return reclaimed; +} diff --git a/fluxer_desktop/src/main/DesktopTray.ts b/fluxer_desktop/src/main/DesktopTray.ts index 57df8e379..17d5ef76f 100644 --- a/fluxer_desktop/src/main/DesktopTray.ts +++ b/fluxer_desktop/src/main/DesktopTray.ts @@ -11,7 +11,7 @@ import type { TrayActionPayload, TrayRuntimeStatePayload, } from '@electron/common/Types'; -import {getStableRelaunchOptions} from '@electron/main/LinuxLaunchPath'; +import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {onLocaleChange, t} from '@electron/main/MainI18n'; import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron'; @@ -277,7 +277,7 @@ function restartFromTray(): void { trayRestartRequested = true; controller?.setQuitting(true); destroyDesktopTray(); - app.relaunch(getStableRelaunchOptions()); + relaunchStableLaunchPath(); app.quit(); armTrayForcedExit('restart'); } diff --git a/fluxer_desktop/src/main/FileDownloads.test.mjs b/fluxer_desktop/src/main/FileDownloads.test.mjs new file mode 100644 index 000000000..a092929e2 --- /dev/null +++ b/fluxer_desktop/src/main/FileDownloads.test.mjs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {after, before, describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +function transform(name) { + const url = new URL(`./${name}`, import.meta.url); + const path = fileURLToPath(url); + return { + path, + code: esbuild.transformSync(readFileSync(path, 'utf8'), { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', + define: {'import.meta.url': JSON.stringify(url.href)}, + }).code, + }; +} + +const outboundSource = transform('DesktopOutboundHTTP.ts'); +const fileDownloadsSource = transform('FileDownloads.ts'); + +const PAYLOAD = Buffer.alloc(300_000, 9); +const PAYLOAD_SHA256 = createHash('sha256').update(PAYLOAD).digest('hex'); +const CORRUPT = Buffer.concat([Buffer.from([0]), PAYLOAD.subarray(1)]); +const CORRUPT_SHA256 = createHash('sha256').update(CORRUPT).digest('hex'); +const EMPTY_SHA256 = createHash('sha256').digest('hex'); + +let server; +let baseUrl; +let requests = 0; + +before(async () => { + server = createServer((request, response) => { + requests += 1; + if (request.url === '/payload' || request.url === '/redirected') { + response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': PAYLOAD.length}); + response.end(PAYLOAD); + return; + } + if (request.url === '/corrupt') { + response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': CORRUPT.length}); + response.end(CORRUPT); + return; + } + if (request.url === '/redirect') { + response.writeHead(302, {location: '/redirected'}); + response.end(); + return; + } + if (request.url === '/empty') { + response.writeHead(204); + response.end(); + return; + } + response.writeHead(404); + response.end('missing'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +function loadFileDownloads() { + const stubs = { + '@electron/common/DesktopConfig': {getAppUrl: () => baseUrl}, + '@electron/common/Logger': { + createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}}), + }, + }; + const sandbox = { + console, + Buffer, + URL, + process, + setTimeout, + clearTimeout, + require: (specifier) => stubs[specifier] ?? require(specifier), + }; + const context = vm.createContext(sandbox); + + const outboundModule = {exports: {}}; + sandbox.module = outboundModule; + sandbox.exports = outboundModule.exports; + vm.runInContext(outboundSource.code, context, {filename: outboundSource.path}); + stubs['@electron/main/DesktopOutboundHTTP'] = outboundModule.exports; + + const fileDownloadsModule = {exports: {}}; + sandbox.module = fileDownloadsModule; + sandbox.exports = fileDownloadsModule.exports; + vm.runInContext(fileDownloadsSource.code, context, {filename: fileDownloadsSource.path}); + return fileDownloadsModule.exports; +} + +function destination() { + return join(mkdtempSync(join(tmpdir(), 'fluxer-download-test-')), 'Fluxer-linux-amd64.deb'); +} + +describe('downloadFile checksum verification', () => { + test('keeps the file when the bytes match the published sha256', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await downloadFile(`${baseUrl}/payload`, destPath, {sha256: PAYLOAD_SHA256}); + + assert.equal(readFileSync(destPath).equals(PAYLOAD), true); + }); + + test('accepts an uppercase published sha256', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await downloadFile(`${baseUrl}/payload`, destPath, {sha256: PAYLOAD_SHA256.toUpperCase()}); + + assert.equal(readFileSync(destPath).equals(PAYLOAD), true); + }); + + test('verifies the bytes that survive a redirect', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await downloadFile(`${baseUrl}/redirect`, destPath, {sha256: PAYLOAD_SHA256}); + + assert.equal(readFileSync(destPath).equals(PAYLOAD), true); + }); + + test('deletes the download and reports both digests when the bytes do not match', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await assert.rejects(downloadFile(`${baseUrl}/corrupt`, destPath, {sha256: PAYLOAD_SHA256}), (error) => { + assert.equal(error.name, 'DownloadChecksumError'); + assert.match(error.message, new RegExp(PAYLOAD_SHA256)); + assert.match(error.message, new RegExp(CORRUPT_SHA256)); + return true; + }); + + assert.equal(existsSync(destPath), false); + }); + + test('leaves nothing behind when an earlier file sits at the destination', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + writeFileSync(destPath, Buffer.from('an older download'), {mode: 0o755}); + + await assert.rejects(downloadFile(`${baseUrl}/corrupt`, destPath, {sha256: PAYLOAD_SHA256})); + + assert.equal(existsSync(destPath), false); + }); + + test('refuses an empty body when a checksum was published', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await assert.rejects(downloadFile(`${baseUrl}/empty`, destPath, {sha256: PAYLOAD_SHA256}), (error) => { + assert.equal(error.name, 'DownloadChecksumError'); + assert.match(error.message, new RegExp(EMPTY_SHA256)); + return true; + }); + + assert.equal(existsSync(destPath), false); + }); + + test('refuses a malformed checksum before asking for any bytes', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + const requestsBefore = requests; + + await assert.rejects(downloadFile(`${baseUrl}/payload`, destPath, {sha256: 'deb-2026.909.202036'}), (error) => { + assert.equal(error.message, 'Download checksum is malformed'); + return true; + }); + + assert.equal(requests, requestsBefore); + assert.equal(existsSync(destPath), false); + }); + + test('downloads without verifying when the feed published no checksum', async () => { + const {downloadFile} = loadFileDownloads(); + const destPath = destination(); + + await downloadFile(`${baseUrl}/corrupt`, destPath, {sha256: null}); + + assert.equal(readFileSync(destPath).equals(CORRUPT), true); + }); +}); diff --git a/fluxer_desktop/src/main/FileDownloads.ts b/fluxer_desktop/src/main/FileDownloads.ts index 6fe265fbb..b33a907f7 100644 --- a/fluxer_desktop/src/main/FileDownloads.ts +++ b/fluxer_desktop/src/main/FileDownloads.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {createHash, type Hash} from 'node:crypto'; import fs from 'node:fs'; import {pipeline} from 'node:stream/promises'; import { @@ -13,9 +14,32 @@ const MAX_DOWNLOAD_REDIRECTS = 5; const DOWNLOAD_DEADLINE_MS = 600_000; const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024; const DOWNLOAD_CONTEXT = 'File download'; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +export class DownloadChecksumError extends Error { + constructor(expected: string, actual: string) { + super(`Download checksum mismatch: expected ${expected}, downloaded ${actual}`); + this.name = 'DownloadChecksumError'; + } +} interface DownloadFileOptions { maxBytes?: number; + sha256?: string | null; +} + +function parseExpectedSha256(value: string | null | undefined): string | null { + if (value == null) { + return null; + } + const expected = value.trim().toLowerCase(); + if (expected.length === 0) { + return null; + } + if (!SHA256_PATTERN.test(expected)) { + throw new Error('Download checksum is malformed'); + } + return expected; } async function removePartialDownload(destPath: string): Promise { @@ -26,13 +50,16 @@ async function writeCappedResponse( response: DesktopOutboundHTTPMessage['message'], destPath: string, maxBytes: number, + hash: Hash | null, ): Promise { let received = 0; response.on('data', (chunk: Buffer) => { received += chunk.length; if (received > maxBytes) { response.destroy(new Error(`Download exceeds ${maxBytes} bytes`)); + return; } + hash?.update(chunk); }); await pipeline(response, fs.createWriteStream(destPath)); } @@ -42,6 +69,7 @@ async function downloadFileWithRedirects( destPath: string, redirects: number, maxBytes: number, + hash: Hash | null, ): Promise { const message = await getDesktopOutboundHTTP().get({ context: DOWNLOAD_CONTEXT, @@ -58,7 +86,7 @@ async function downloadFileWithRedirects( if (nextUrl == null) { throw new Error(`HTTP ${statusCode} redirect target is unusable`); } - await downloadFileWithRedirects(nextUrl, destPath, redirects + 1, maxBytes); + await downloadFileWithRedirects(nextUrl, destPath, redirects + 1, maxBytes, hash); return; } if (statusCode === 204 || statusCode === 205) { @@ -71,7 +99,7 @@ async function downloadFileWithRedirects( throw new Error(`HTTP ${statusCode}`); } try { - await writeCappedResponse(message.message, destPath, maxBytes); + await writeCappedResponse(message.message, destPath, maxBytes, hash); } catch (error) { await removePartialDownload(destPath); throw error; @@ -83,7 +111,18 @@ export async function downloadFile(url: string, destPath: string, options: Downl if (target == null) { throw new Error('Download URL must use http or https'); } + const expectedSha256 = parseExpectedSha256(options.sha256); + const hash = expectedSha256 === null ? null : createHash('sha256'); const maxBytes = options.maxBytes ?? DOWNLOAD_MAX_BYTES; await removePartialDownload(destPath); - await downloadFileWithRedirects(target, destPath, 0, maxBytes); + await downloadFileWithRedirects(target, destPath, 0, maxBytes, hash); + if (expectedSha256 === null || hash === null) { + return; + } + const digest = hash.digest('hex'); + if (digest === expectedSha256) { + return; + } + await removePartialDownload(destPath); + throw new DownloadChecksumError(expectedSha256, digest); } diff --git a/fluxer_desktop/src/main/IpcHandlers.ts b/fluxer_desktop/src/main/IpcHandlers.ts index de345a84e..8d47a83ff 100644 --- a/fluxer_desktop/src/main/IpcHandlers.ts +++ b/fluxer_desktop/src/main/IpcHandlers.ts @@ -20,7 +20,7 @@ import { hasActiveDesktopTray, updateTrayRuntimeState, } from '@electron/main/DesktopTray'; -import {downloadFile} from '@electron/main/FileDownloads'; +import {DownloadChecksumError, downloadFile} from '@electron/main/FileDownloads'; import { type LinuxAppearanceSnapshot, type LinuxAppearanceSubscription, @@ -381,6 +381,7 @@ export function registerIpcHandlers(): void { options: { url: string; defaultPath: string; + sha256?: string | null; }, ): Promise => { requirePrivilegedRendererDocumentSender(event, 'download-file'); @@ -395,9 +396,12 @@ export function registerIpcHandlers(): void { if (result.canceled || !result.filePath) { return {success: false, canceled: true}; } - await downloadFile(options.url, result.filePath); + await downloadFile(options.url, result.filePath, {sha256: options.sha256}); return {success: true, path: result.filePath}; } catch (error) { + if (error instanceof DownloadChecksumError) { + return {success: false, checksumMismatch: true, error: error.message}; + } return {success: false, error: error instanceof Error ? error.message : 'Unknown error'}; } }, diff --git a/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs b/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs new file mode 100644 index 000000000..3cff45a76 --- /dev/null +++ b/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./LinuxLaunchPath.ts', import.meta.url)); +const transformedSource = esbuild.transformSync(readFileSync(sourcePath, 'utf8'), { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +const APPIMAGE_PATH = '/home/hampus/Applications/My Fluxer.AppImage'; +const EXEC_PATH = '/tmp/.mount_My FluhKjNGD/fluxer-canary'; + +function loadLaunchPath({ + platform = 'linux', + appImagePath = APPIMAGE_PATH, + argv = [], + spawnThrows = false, + shells = ['/bin/bash'], +} = {}) { + const spawned = []; + const relaunched = []; + const module = {exports: {}}; + const stubs = { + '@electron/common/Logger': {createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}})}, + electron: { + app: { + relaunch(options) { + relaunched.push(options); + }, + }, + }, + 'node:child_process': { + spawn(command, args, options) { + if (spawnThrows) { + throw new Error('spawn EPERM'); + } + let unrefCalls = 0; + const child = { + pid: 4242, + unref() { + unrefCalls += 1; + }, + get unrefCalls() { + return unrefCalls; + }, + }; + spawned.push({command, args, options, child}); + return child; + }, + }, + 'node:fs': {existsSync: (path) => shells.includes(path)}, + 'node:os': {homedir: () => '/home/hampus'}, + }; + const context = vm.createContext({ + module, + exports: module.exports, + console, + process: { + platform, + pid: 13274, + execPath: EXEC_PATH, + argv: [EXEC_PATH, ...argv], + env: appImagePath ? {APPIMAGE: appImagePath} : {}, + }, + require: (specifier) => stubs[specifier] ?? require(specifier), + }); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return {...module.exports, spawned, relaunched}; +} + +describe('Linux launch path', () => { + test('reports the AppImage as the stable launch path', () => { + assert.equal(loadLaunchPath({}).getStableLinuxLaunchPath(), APPIMAGE_PATH); + assert.equal(loadLaunchPath({appImagePath: null}).getStableLinuxLaunchPath(), EXEC_PATH); + assert.equal(loadLaunchPath({platform: 'darwin'}).getStableLinuxLaunchPath(), EXEC_PATH); + }); +}); + +function relaunchScript() { + const probe = loadLaunchPath({}); + probe.relaunchStableLaunchPath(); + return probe.spawned[0].args[1]; +} + +describe('AppImage relaunch script', () => { + for (const shell of ['/bin/bash', '/bin/sh']) { + test(`replaces the process once the parent exits under ${shell}`, {skip: !existsSync(shell)}, async () => { + const root = mkdtempSync(join(tmpdir(), 'fluxer-relaunch-test-')); + const marker = join(root, 'replacement-argv'); + const replacement = join(root, 'My Fluxer.AppImage'); + writeFileSync(replacement, `#!${shell}\nprintf '%s\\n' "$@" > '${marker}'\n`, {mode: 0o755}); + const parent = spawn(shell, ['-c', 'sleep 30'], {stdio: 'ignore', detached: true}); + parent.unref(); + + const relaunch = spawn( + shell, + [ + '-c', + relaunchScript(), + 'fluxer-relaunch', + String(parent.pid), + replacement, + root, + '--fluxer-portable', + 'fluxer://invite/a b', + ], + {cwd: root, detached: true, stdio: 'ignore'}, + ); + relaunch.unref(); + + spawnSync('kill', [String(parent.pid)]); + for (let attempt = 0; attempt < 100 && !existsSync(marker); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + assert.equal(existsSync(marker), true, 'the replacement never started'); + assert.deepEqual(readFileSync(marker, 'utf8').split('\n').slice(0, 2), [ + '--fluxer-portable', + 'fluxer://invite/a b', + ]); + chmodSync(replacement, 0o755); + }); + } +}); + +describe('AppImage relaunch', () => { + test('never asks Electron to relaunch an AppImage', () => { + const updater = loadLaunchPath({argv: ['--fluxer-portable']}); + + updater.relaunchStableLaunchPath(); + + assert.equal( + updater.relaunched.length, + 0, + "Electron's relauncher runs under PR_SET_NO_NEW_PRIVS, which makes the setuid fusermount the AppImage runtime needs fail with EPERM, so the app quits and never comes back", + ); + assert.equal(updater.spawned.length, 1); + const [relaunch] = updater.spawned; + assert.equal(relaunch.command, '/bin/bash'); + assert.equal(relaunch.options.detached, true); + assert.equal(relaunch.options.stdio, 'ignore'); + assert.equal(relaunch.options.cwd, '/home/hampus'); + assert.equal(relaunch.child.unrefCalls, 1); + }); + + test('waits for this process to exit before starting the replacement, and keeps its arguments', () => { + const updater = loadLaunchPath({argv: ['--fluxer-portable', 'fluxer://invite/a b']}); + + updater.relaunchStableLaunchPath(); + + const [flag, script, name, pid, target, home, ...forwarded] = updater.spawned[0].args; + assert.equal(flag, '-c'); + assert.match(script, /kill -0 "\$pid"/); + assert.match(script, /exec "\$app" "\$@"/); + assert.equal(name, 'fluxer-relaunch'); + assert.equal(pid, '13274'); + assert.equal(target, APPIMAGE_PATH); + assert.equal(home, '/home/hampus'); + assert.deepEqual(forwarded, ['--fluxer-portable', 'fluxer://invite/a b']); + }); + + test('hands the replacement a clean descriptor table', () => { + const updater = loadLaunchPath({}); + + updater.relaunchStableLaunchPath(); + + const script = updater.spawned[0].args[1]; + assert.match( + script, + /cd \/proc\/self\/fd/, + "Chromium leaves its icudtl.dat, v8 snapshot, .pak and app.asar descriptors open across a spawn, so the replacement would pin the outgoing build's fuse mount and its unlinked AppImage for as long as it runs", + ); + assert.match( + script, + /\[ -n "\$BASH_VERSION" \]/, + 'dash only parses single digit descriptor numbers, and a failed exec kills a non-interactive shell, so the two-digit closes have to be guarded', + ); + }); + + test('falls back to the system shell when bash is missing', () => { + const updater = loadLaunchPath({shells: ['/bin/sh']}); + + updater.relaunchStableLaunchPath(); + + assert.equal(updater.spawned[0].command, '/bin/sh'); + assert.equal(updater.relaunched.length, 0); + }); + + test('leaves every other install to Electron', () => { + const deb = loadLaunchPath({appImagePath: null}); + deb.relaunchStableLaunchPath(); + assert.equal(deb.spawned.length, 0); + assert.deepEqual(deb.relaunched, [undefined]); + + const mac = loadLaunchPath({platform: 'darwin'}); + mac.relaunchStableLaunchPath(); + assert.equal(mac.spawned.length, 0); + assert.deepEqual(mac.relaunched, [undefined]); + }); + + test('falls back to Electron when the replacement process cannot be started', () => { + const updater = loadLaunchPath({spawnThrows: true}); + + updater.relaunchStableLaunchPath(); + + assert.equal(updater.relaunched.length, 1); + assert.equal(updater.relaunched[0]?.execPath, APPIMAGE_PATH); + }); +}); diff --git a/fluxer_desktop/src/main/LinuxLaunchPath.ts b/fluxer_desktop/src/main/LinuxLaunchPath.ts index 2fb8886f6..6c30a9e60 100644 --- a/fluxer_desktop/src/main/LinuxLaunchPath.ts +++ b/fluxer_desktop/src/main/LinuxLaunchPath.ts @@ -1,16 +1,57 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -export function getStableLinuxLaunchPath(): string { - if (process.platform === 'linux') { - const appImage = process.env.APPIMAGE; - if (appImage && appImage.length > 0) return appImage; - } - return process.execPath; +import {spawn} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {createChildLogger} from '@electron/common/Logger'; +import {app} from 'electron'; + +const logger = createChildLogger('LinuxLaunchPath'); + +const PREFERRED_RELAUNCH_SHELL = '/bin/bash'; +const FALLBACK_RELAUNCH_SHELL = '/bin/sh'; + +const RELAUNCH_AFTER_EXIT = + 'pid="$1"; app="$2"; home="$3"; shift 3; cd /proc/self/fd && for n in *; do case "$n" in 0|1|2) ;; [0-9]) eval "exec $n>&-";; *) [ -n "$BASH_VERSION" ] && eval "exec $n>&-";; esac; done; cd "$home" || cd /; attempt=0; while [ "$attempt" -lt 300 ] && kill -0 "$pid"; do sleep 0.1; attempt=$((attempt + 1)); done; kill -0 "$pid" || exec "$app" "$@"'; + +function getAppImagePath(): string | null { + if (process.platform !== 'linux') return null; + const appImage = process.env.APPIMAGE; + return appImage && appImage.length > 0 ? appImage : null; } -export function getStableRelaunchOptions(): {execPath: string} | undefined { - if (process.platform !== 'linux') return undefined; - const execPath = getStableLinuxLaunchPath(); - if (execPath === process.execPath) return undefined; - return {execPath}; +export function getStableLinuxLaunchPath(): string { + return getAppImagePath() ?? process.execPath; +} + +export function relaunchStableLaunchPath(): void { + const appImagePath = getAppImagePath(); + if (appImagePath == null) { + app.relaunch(); + return; + } + try { + const child = spawn( + existsSync(PREFERRED_RELAUNCH_SHELL) ? PREFERRED_RELAUNCH_SHELL : FALLBACK_RELAUNCH_SHELL, + [ + '-c', + RELAUNCH_AFTER_EXIT, + 'fluxer-relaunch', + String(process.pid), + appImagePath, + homedir(), + ...process.argv.slice(1), + ], + { + cwd: homedir(), + detached: true, + stdio: 'ignore', + }, + ); + child.unref(); + logger.info('Relaunching the AppImage once this process exits', {path: appImagePath, pid: child.pid}); + } catch (error) { + logger.error('Failed to start the AppImage replacement process', {error}); + app.relaunch({execPath: appImagePath}); + } } diff --git a/fluxer_desktop/src/main/Troubleshooting.ts b/fluxer_desktop/src/main/Troubleshooting.ts index 41a9f57e2..3ba47b264 100644 --- a/fluxer_desktop/src/main/Troubleshooting.ts +++ b/fluxer_desktop/src/main/Troubleshooting.ts @@ -4,7 +4,7 @@ import {setDesktopTroubleshootingSettings} from '@electron/common/DesktopConfig' import {createChildLogger} from '@electron/common/Logger'; import {getLaunchDesktopTroubleshootingSettings} from '@electron/main/DesktopDebugInfo'; import {destroyDesktopTray} from '@electron/main/DesktopTray'; -import {getStableRelaunchOptions} from '@electron/main/LinuxLaunchPath'; +import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {t} from '@electron/main/MainI18n'; import {clearSavedWindowBounds, getMainWindow, setQuitting} from '@electron/main/Window'; import {app, BrowserWindow, dialog, Menu, type MenuItemConstructorOptions, session} from 'electron'; @@ -21,7 +21,7 @@ export function relaunchAndExit(): void { relaunchRequested = true; setQuitting(true); destroyDesktopTray(); - app.relaunch(getStableRelaunchOptions()); + relaunchStableLaunchPath(); app.exit(0); } diff --git a/fluxer_desktop/src/main/Updater.test.mjs b/fluxer_desktop/src/main/Updater.test.mjs new file mode 100644 index 000000000..dbb9097f5 --- /dev/null +++ b/fluxer_desktop/src/main/Updater.test.mjs @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {after, before, describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +function transform(name) { + const url = new URL(`./${name}`, import.meta.url); + const path = fileURLToPath(url); + return { + path, + code: esbuild.transformSync(readFileSync(path, 'utf8'), { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', + define: {'import.meta.url': JSON.stringify(url.href)}, + }).code, + }; +} + +const appImageUpdateSource = transform('AppImageUpdate.ts'); +const updaterDownloadsSource = transform('UpdaterDownloads.ts'); +const updaterSource = transform('Updater.ts'); +const updaterPlatformUtilsSource = transform('../../../fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts'); + +function loadUpdaterPlatformUtils() { + const module = {exports: {}}; + const context = vm.createContext({module, exports: module.exports, require}); + vm.runInContext(updaterPlatformUtilsSource.code, context, {filename: updaterPlatformUtilsSource.path}); + return module.exports; +} + +const INSTALLED_NAME = 'My Fluxer.AppImage'; +const CURRENT_VERSION = '2026.903.231208'; +const PUBLISHED_VERSION = '2026.904.135113'; +const OLD_BYTES = Buffer.from('installed-appimage'); +const NEW_BYTES = Buffer.alloc(300_000, 3); +const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex'); + +let server; +let baseUrl; +let serveCorruptBytes = false; +let appImageRequests = 0; +let failAppImageRequests = 0; + +before(async () => { + server = createServer((request, response) => { + if (request.url === '/latest') { + response.writeHead(200, {'content-type': 'application/json'}); + response.end( + JSON.stringify({ + version: PUBLISHED_VERSION, + pub_date: '2026-09-04T13:51:13Z', + files: { + appimage: {url: `${baseUrl}/appimage`, sha256: NEW_SHA256}, + deb: {url: `${baseUrl}/deb`, sha256: 'deadbeef'}, + }, + }), + ); + return; + } + if (request.url === `/${PUBLISHED_VERSION}/appimage`) { + appImageRequests += 1; + if (failAppImageRequests > 0) { + failAppImageRequests -= 1; + response.writeHead(503); + response.end('try again'); + return; + } + const body = serveCorruptBytes ? Buffer.concat([Buffer.from([0]), NEW_BYTES.subarray(1)]) : NEW_BYTES; + response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': body.length}); + response.end(body); + return; + } + response.writeHead(404); + response.end('missing'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +function createInstall() { + const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-')); + const applications = join(root, 'Applications'); + mkdirSync(applications); + const mount = join(root, '.mount_Fluxer'); + mkdirSync(mount); + writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); + const installedPath = join(applications, INSTALLED_NAME); + writeFileSync(installedPath, OLD_BYTES, {mode: 0o755}); + return {applications, installedPath, mount}; +} + +function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, stagingErrno}) { + const events = []; + const handlers = new Map(); + const appEvents = new Map(); + const state = {relaunched: false, electronRelaunched: false}; + const module = {exports: {}}; + const stubs = { + '@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'}, + '@electron/common/UserDataPath': {isPortableMode: () => false}, + '@electron/main/DesktopTray': {destroyDesktopTray() {}}, + '@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false}, + '@electron/main/Troubleshooting': { + relaunchAndExit() { + state.relaunched = true; + }, + }, + '@electron/main/Window': {setQuitting() {}}, + 'electron-log': {info() {}, warn() {}, error() {}, debug() {}}, + electron: { + app: { + isPackaged: true, + getVersion: () => version, + on(event, handler) { + appEvents.set(event, handler); + }, + relaunch() { + state.electronRelaunched = true; + }, + exit() {}, + }, + autoUpdater: {on() {}}, + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + }, + }; + const sandbox = { + console, + Buffer, + process: { + ...process, + platform: 'linux', + arch: 'arm64', + execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath), + env: { + ...(appImagePath ? {APPIMAGE: appImagePath} : {}), + ...(appDir ? {APPDIR: appDir} : {}), + }, + }, + setTimeout, + clearTimeout, + fetch: (input, init) => { + const url = String(input).replace('https://pkgs.fluxer.com/desktop/canary/linux/arm64', baseUrl); + return fetch(url, init); + }, + require: (specifier) => { + if (specifier === 'node:fs/promises' && stagingErrno) { + return { + ...require(specifier), + open: async () => { + const error = new Error(`${stagingErrno}: staging refused`); + error.code = stagingErrno; + throw error; + }, + }; + } + return stubs[specifier] ?? require(specifier); + }, + }; + const context = vm.createContext(sandbox); + + const appImageModule = {exports: {}}; + sandbox.module = appImageModule; + sandbox.exports = appImageModule.exports; + sandbox.__filename = appImageUpdateSource.path; + vm.runInContext(appImageUpdateSource.code, context, {filename: appImageUpdateSource.path}); + stubs['@electron/main/AppImageUpdate'] = appImageModule.exports; + + const updaterDownloadsModule = {exports: {}}; + sandbox.module = updaterDownloadsModule; + sandbox.exports = updaterDownloadsModule.exports; + sandbox.__filename = updaterDownloadsSource.path; + vm.runInContext(updaterDownloadsSource.code, context, {filename: updaterDownloadsSource.path}); + stubs['@electron/main/UpdaterDownloads'] = updaterDownloadsModule.exports; + + sandbox.module = module; + sandbox.exports = module.exports; + sandbox.__filename = updaterSource.path; + vm.runInContext(updaterSource.code, context, {filename: updaterSource.path}); + + module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}})); + return { + events, + state, + check: () => handlers.get('updater-check')({}, 'user'), + install: () => handlers.get('updater-install')({}), + quit: () => appEvents.get('will-quit')?.(), + }; +} + +function types(events) { + return events.map((event) => event.type); +} + +function stagingLeftovers(directory) { + return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-')); +} + +describe('Updater AppImage lifecycle', () => { + test('downloads on check, reports the macOS lifecycle, and swaps in place', async () => { + const install = createInstall(); + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + + await updater.check(); + + const available = updater.events.find((event) => event.type === 'available'); + assert.equal(available.downloadStarted, true); + assert.equal(available.version, PUBLISHED_VERSION); + assert.equal(available.downloadUrl, undefined); + assert.equal(available.downloadOptions, undefined); + const progress = updater.events.filter((event) => event.type === 'progress'); + assert.ok(progress.length > 0); + assert.equal(progress.at(-1).total, NEW_BYTES.length); + assert.equal(progress.at(-1).transferred, NEW_BYTES.length); + assert.equal(progress.at(-1).percent, 100); + const downloaded = updater.events.find((event) => event.type === 'downloaded'); + assert.equal(downloaded.version, PUBLISHED_VERSION); + assert.deepEqual( + types(updater.events).filter((type) => type !== 'progress'), + ['checking', 'available', 'downloaded'], + ); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + + await updater.install(); + + assert.equal(updater.state.relaunched, true); + assert.equal( + updater.state.electronRelaunched, + false, + "app.relaunch cannot start an AppImage: Electron's relauncher runs under PR_SET_NO_NEW_PRIVS and the setuid fusermount the AppImage runtime needs fails with EPERM", + ); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true); + }); + + test('refuses to swap when the download does not match the published sha256', async () => { + const install = createInstall(); + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + serveCorruptBytes = true; + try { + await updater.check(); + } finally { + serveCorruptBytes = false; + } + + assert.deepEqual( + types(updater.events).filter((type) => type !== 'progress'), + ['checking', 'available', 'error', 'available'], + ); + const failure = updater.events.find((event) => event.type === 'error'); + assert.equal(failure.phase, 'download'); + assert.match(failure.message, /checksum mismatch/); + const fallback = updater.events.at(-1); + assert.equal(fallback.downloadStarted, false); + assert.ok(fallback.downloadOptions.length > 0); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(stagingLeftovers(install.applications), []); + await assert.rejects(updater.install(), /No AppImage update is ready to install/); + }); + + test('offers the manual download when the install directory is not writable', { + skip: process.getuid?.() === 0, + }, async () => { + const install = createInstall(); + chmodSync(install.applications, 0o500); + try { + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'available']); + const available = updater.events.at(-1); + assert.equal(available.downloadStarted, false); + assert.equal(available.version, PUBLISHED_VERSION); + assert.ok(available.downloadOptions.length > 0); + assert.equal(available.downloadOptions[0].format, 'appimage'); + assert.equal( + available.downloadOptions[0].suggestedName, + `Fluxer-Canary-${PUBLISHED_VERSION}-linux-arm64.AppImage`, + ); + } finally { + chmodSync(install.applications, 0o755); + } + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('falls back to the manual updater when APPIMAGE is unset', async () => { + const updater = loadUpdater({appImagePath: null}); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'available']); + assert.equal(updater.events.at(-1).downloadStarted, false); + await assert.rejects(updater.install(), /In-app updates are not supported on this platform/); + }); + + test('never rewrites an inherited APPIMAGE that this process did not come from', async () => { + const install = createInstall(); + const victim = join(install.applications, 'Kdenlive-24.12.AppImage'); + writeFileSync(victim, OLD_BYTES, {mode: 0o755}); + const optDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-')); + const updater = loadUpdater({appImagePath: victim, execPath: join(optDir, 'fluxer')}); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'available']); + assert.equal(updater.events.at(-1).downloadStarted, false); + assert.equal(readFileSync(victim).equals(OLD_BYTES), true); + assert.deepEqual(stagingLeftovers(install.applications), []); + await assert.rejects(updater.install(), /In-app updates are not supported on this platform/); + }); + + test('never rewrites the AppImage that only the shell around this process came from', async () => { + const install = createInstall(); + const victim = join(install.applications, 'Kdenlive-24.12.AppImage'); + writeFileSync(victim, OLD_BYTES, {mode: 0o755}); + const extracted = join(mkdtempSync(join(tmpdir(), 'fluxer-extracted-test-')), 'squashfs-root'); + mkdirSync(extracted); + writeFileSync(join(extracted, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); + const updater = loadUpdater({ + appImagePath: victim, + appDir: install.mount, + execPath: join(extracted, 'fluxer-canary'), + }); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'available']); + assert.equal(updater.events.at(-1).downloadStarted, false); + assert.equal(readFileSync(victim).equals(OLD_BYTES), true); + assert.deepEqual(stagingLeftovers(install.applications), []); + await assert.rejects(updater.install(), /In-app updates are not supported on this platform/); + }); + + test('gives up on a local write failure instead of downloading the AppImage again', async () => { + const install = createInstall(); + const updater = loadUpdater({ + appImagePath: install.installedPath, + appDir: install.mount, + stagingErrno: 'ENOSPC', + }); + const before = appImageRequests; + + await updater.check(); + + assert.equal(appImageRequests - before, 1, 'a full disk must not pull the whole AppImage down five more times'); + const failure = updater.events.find((event) => event.type === 'error'); + assert.equal(failure.phase, 'download'); + assert.match(failure.message, /ENOSPC/); + assert.equal(updater.events.at(-1).type, 'available'); + assert.equal(updater.events.at(-1).downloadStarted, false); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('downloads the AppImage again after a transient server failure', async () => { + const install = createInstall(); + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + const before = appImageRequests; + failAppImageRequests = 1; + + try { + await updater.check(); + } finally { + failAppImageRequests = 0; + } + + assert.equal(appImageRequests - before, 2); + assert.equal(updater.events.at(-1).type, 'downloaded'); + + await updater.install(); + + assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true); + }); + + test('reports no update when the published version is not newer', async () => { + const install = createInstall(); + const updater = loadUpdater({ + appImagePath: install.installedPath, + appDir: install.mount, + version: PUBLISHED_VERSION, + }); + + await updater.check(); + + assert.deepEqual(types(updater.events), ['checking', 'not-available']); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + }); + + test('reclaims a staged update abandoned by a process that never reached will-quit', async () => { + const install = createInstall(); + const abandoned = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + + await abandoned.check(); + assert.equal(stagingLeftovers(install.applications).length, 1); + + loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + + assert.deepEqual(stagingLeftovers(install.applications), []); + assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + }); + + test('shows the renderer the download progress the main process reports', async () => { + const install = createInstall(); + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + const {shouldShowNativeDesktopUpdateDownloadProgress} = loadUpdaterPlatformUtils(); + + await updater.check(); + + const reportsProgress = updater.events.some((event) => event.type === 'progress'); + assert.equal(reportsProgress, true); + assert.equal(shouldShowNativeDesktopUpdateDownloadProgress('linux'), reportsProgress); + }); + + test('discards a staged update that the user never installed', async () => { + const install = createInstall(); + const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount}); + + await updater.check(); + assert.equal(stagingLeftovers(install.applications).length, 1); + + updater.quit(); + + assert.deepEqual(stagingLeftovers(install.applications), []); + assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); + }); +}); diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index a9ec7f8d5..d52c209d5 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -2,9 +2,23 @@ import {createRequire} from 'node:module'; import {isPortableMode} from '@electron/common/UserDataPath'; +import { + AppImageChecksumError, + AppImageStagingError, + type AppImageTarget, + applyStagedAppImageUpdate, + discardStagedAppImageUpdate, + isRunningFromAppImage, + resolveAppImageTarget, + type StagedAppImageUpdate, + stageAppImageUpdate, + sweepAbandonedAppImageUpdates, +} from '@electron/main/AppImageUpdate'; import {destroyDesktopTray} from '@electron/main/DesktopTray'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; +import {relaunchAndExit} from '@electron/main/Troubleshooting'; import { + buildManualVersionDownloadUrl, DOWNLOAD_PAGE_URL, getManualDownloadOptions, getManualDownloadUrl, @@ -74,11 +88,17 @@ let pendingVelopackUpdate: VelopackUpdate | null = null; let velopackCheckPromise: Promise | null = null; let velopackDownloadPromise: Promise | null = null; let velopackInstallStarted = false; +let pendingAppImageUpdate: PendingAppImageUpdate | null = null; +let appImageUpdatePromise: Promise | null = null; +let appImageInstallStarted = false; const UPDATE_DOWNLOAD_MAX_ATTEMPTS = 5; const UPDATE_DOWNLOAD_RETRY_BASE_DELAY_MS = 3000; const UPDATE_DOWNLOAD_RETRY_MAX_DELAY_MS = 60000; const ELECTRON_DOWNLOAD_MAX_RETRIES = 4; +const UPDATE_PROGRESS_SAMPLE_INTERVAL_MS = 500; + +type PendingAppImageUpdate = {version: string; target: AppImageTarget; staged: StagedAppImageUpdate}; function send(win: BrowserWindow | null, event: UpdaterEvent) { win?.webContents.send('updater-event', event); @@ -472,22 +492,30 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis return info; } +function sendManualUpdateAvailable( + getMainWindow: () => BrowserWindow | null, + context: UpdaterContext, + latest: ManualLatestInfo, +): void { + const downloadOptions = getManualDownloadOptions(latest); + send(getMainWindow(), { + type: 'available', + context, + version: latest.version, + downloadSize: null, + downloadStarted: false, + downloadUrl: getManualDownloadUrl(latest), + ...(downloadOptions.length > 0 ? {downloadOptions} : {}), + }); +} + async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => BrowserWindow | null): Promise { send(getMainWindow(), {type: 'checking', context}); try { const latest = await fetchManualLatest({forceRefresh: context === 'user'}); const current = app.getVersion(); if (compareVersions(latest.version, current) > 0) { - const downloadOptions = getManualDownloadOptions(latest); - send(getMainWindow(), { - type: 'available', - context, - version: latest.version, - downloadSize: null, - downloadStarted: false, - downloadUrl: getManualDownloadUrl(latest), - ...(downloadOptions.length > 0 ? {downloadOptions} : {}), - }); + sendManualUpdateAvailable(getMainWindow, context, latest); } else { send(getMainWindow(), {type: 'not-available', context}); } @@ -497,6 +525,183 @@ async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => B } } +async function downloadAppImageUpdate( + context: UpdaterContext, + getMainWindow: () => BrowserWindow | null, + latest: ManualLatestInfo, + target: AppImageTarget, + expectedSha256: string, +): Promise { + const version = latest.version; + const url = buildManualVersionDownloadUrl(version, 'appimage'); + let lastError: unknown; + for (let attempt = 1; attempt <= UPDATE_DOWNLOAD_MAX_ATTEMPTS; attempt += 1) { + let lastSampleAt = Date.now(); + let lastSampleTransferred = 0; + let smoothedBytesPerSecond = 0; + try { + const staged = await stageAppImageUpdate({ + target, + url, + expectedSha256, + onProgress: ({transferred, total}) => { + const now = Date.now(); + const dtMs = now - lastSampleAt; + const complete = total > 0 && transferred >= total; + if (dtMs < UPDATE_PROGRESS_SAMPLE_INTERVAL_MS && !complete) { + return; + } + if (dtMs > 0 && transferred >= lastSampleTransferred) { + const instant = ((transferred - lastSampleTransferred) * 1000) / dtMs; + smoothedBytesPerSecond = + smoothedBytesPerSecond === 0 ? instant : smoothedBytesPerSecond * 0.7 + instant * 0.3; + } + lastSampleAt = now; + lastSampleTransferred = transferred; + send(getMainWindow(), { + type: 'progress', + context, + percent: total > 0 ? Math.min(100, (transferred / total) * 100) : 0, + transferred, + total, + bytesPerSecond: Math.round(smoothedBytesPerSecond), + }); + }, + }); + if (pendingAppImageUpdate) { + discardStagedAppImageUpdate(pendingAppImageUpdate.staged); + } + pendingAppImageUpdate = {version, target, staged}; + send(getMainWindow(), {type: 'downloaded', context, version}); + return; + } catch (error) { + lastError = error; + if ( + error instanceof AppImageChecksumError || + error instanceof AppImageStagingError || + attempt >= UPDATE_DOWNLOAD_MAX_ATTEMPTS + ) { + break; + } + const delay = backoffDelay(attempt); + const reason = getErrorMessage(error); + const waitSeconds = Math.round(delay / 1000); + log.warn( + `AppImage update download attempt ${attempt}/${UPDATE_DOWNLOAD_MAX_ATTEMPTS} failed (${reason}), retrying in ${waitSeconds}s`, + ); + await sleep(delay); + } + } + log.error('AppImage update download failed', lastError); + send(getMainWindow(), {type: 'error', context, phase: 'download', message: getErrorMessage(lastError)}); + sendManualUpdateAvailable(getMainWindow, context, latest); +} + +async function checkAppImageUpdate( + context: UpdaterContext, + getMainWindow: () => BrowserWindow | null, + target: AppImageTarget, +): Promise { + if (appImageUpdatePromise) { + return appImageUpdatePromise; + } + appImageUpdatePromise = (async () => { + send(getMainWindow(), {type: 'checking', context}); + let latest: ManualLatestInfo; + try { + latest = await fetchManualLatest({forceRefresh: context === 'user'}); + } catch (error) { + log.warn('AppImage update check failed', error); + send(getMainWindow(), {type: 'error', context, phase: 'check', message: getErrorMessage(error)}); + return; + } + if (pendingAppImageUpdate && compareVersions(latest.version, pendingAppImageUpdate.version) <= 0) { + send(getMainWindow(), {type: 'downloaded', context, version: pendingAppImageUpdate.version}); + return; + } + if (compareVersions(latest.version, app.getVersion()) <= 0) { + send(getMainWindow(), {type: 'not-available', context}); + return; + } + const published = latest.files.appimage; + const resolved = resolveAppImageTarget(target.installedPath); + if (!resolved.ok || !published?.sha256) { + log.info('AppImage cannot be replaced in place, so the manual download is offered instead.', { + reason: resolved.ok ? 'published-checksum-missing' : resolved.reason, + }); + sendManualUpdateAvailable(getMainWindow, context, latest); + return; + } + send(getMainWindow(), { + type: 'available', + context, + version: latest.version, + downloadSize: null, + downloadStarted: true, + }); + await downloadAppImageUpdate(context, getMainWindow, latest, resolved.target, published.sha256); + })().finally(() => { + appImageUpdatePromise = null; + }); + return appImageUpdatePromise; +} + +function installAppImageUpdate(getMainWindow: () => BrowserWindow | null): void { + if (appImageInstallStarted) { + log.warn('AppImage install already in progress, ignoring the duplicate request.'); + return; + } + const pending = pendingAppImageUpdate; + if (!pending) { + throw new Error('No AppImage update is ready to install.'); + } + try { + applyStagedAppImageUpdate(pending.target, pending.staged); + } catch (error) { + log.error('AppImage update install failed', error); + pendingAppImageUpdate = null; + send(getMainWindow(), {type: 'error', context: lastContext, phase: 'install', message: getErrorMessage(error)}); + return; + } + appImageInstallStarted = true; + pendingAppImageUpdate = null; + log.info(`Replaced ${pending.target.installedPath} with ${pending.version}, relaunching now.`); + relaunchAndExit(); +} + +function reclaimAbandonedAppImageUpdates(target: AppImageTarget): void { + try { + const reclaimed = sweepAbandonedAppImageUpdates(target); + if (reclaimed.length > 0) { + log.info(`Reclaimed ${reclaimed.length} abandoned AppImage staging directories in ${target.directory}.`); + } + } catch (error) { + log.warn('Failed to reclaim abandoned AppImage staging directories', error); + } +} + +function registerAppImageUpdater(getMainWindow: () => BrowserWindow | null, target: AppImageTarget): void { + reclaimAbandonedAppImageUpdates(target); + app.on('will-quit', () => { + if (!pendingAppImageUpdate || appImageInstallStarted) { + return; + } + discardStagedAppImageUpdate(pendingAppImageUpdate.staged); + pendingAppImageUpdate = null; + }); + ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => { + lastContext = context; + await checkAppImageUpdate(context, getMainWindow, target); + }); + ipcMain.handle('updater-download', async (_e, context: UpdaterContext) => { + lastContext = context; + await checkAppImageUpdate(context, getMainWindow, target); + }); + ipcMain.handle('updater-install', async () => { + installAppImageUpdate(getMainWindow); + }); +} + function registerManualUpdater( getMainWindow: () => BrowserWindow | null, reason: 'platform' | 'unpackaged' | 'managed-package', @@ -547,5 +752,12 @@ export function registerUpdater(getMainWindow: () => BrowserWindow | null) { registerElectronUpdater(getMainWindow); return; } + if (process.platform === 'linux' && isRunningFromAppImage()) { + const appImage = resolveAppImageTarget(); + if (appImage.ok) { + registerAppImageUpdater(getMainWindow, appImage.target); + return; + } + } registerManualUpdater(getMainWindow, 'platform'); } diff --git a/fluxer_desktop/src/main/UpdaterDownloads.test.mjs b/fluxer_desktop/src/main/UpdaterDownloads.test.mjs index 20e41a3f4..523812823 100644 --- a/fluxer_desktop/src/main/UpdaterDownloads.test.mjs +++ b/fluxer_desktop/src/main/UpdaterDownloads.test.mjs @@ -63,28 +63,28 @@ describe('UpdaterDownloads Linux manual update options', () => { { format: 'appimage', label: 'AppImage', - url: 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/appimage', + url: 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/appimage', suggestedName: 'Fluxer-2026.910.101500-linux-x86_64.AppImage', sha256: APPIMAGE_SHA256, }, { format: 'deb', label: 'DEB package', - url: 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/deb', + url: 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/deb', suggestedName: 'Fluxer-2026.910.101500-linux-amd64.deb', sha256: DEB_SHA256, }, { format: 'rpm', label: 'RPM package', - url: 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/rpm', + url: 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/rpm', suggestedName: 'Fluxer-2026.910.101500-linux-x86_64.rpm', sha256: null, }, { format: 'tar_gz', label: 'tar.gz archive', - url: 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/tar_gz', + url: 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/tar_gz', suggestedName: 'Fluxer-2026.910.101500-linux-x64.tar.gz', sha256: TAR_GZ_SHA256, }, @@ -99,26 +99,26 @@ describe('UpdaterDownloads Linux manual update options', () => { options.map((option) => [option.url, option.suggestedName]), [ [ - 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/2026.910.101500/appimage', + 'https://pkgs.fluxer.com/desktop/stable/linux/arm64/2026.910.101500/appimage', 'Fluxer-2026.910.101500-linux-arm64.AppImage', ], [ - 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/2026.910.101500/deb', + 'https://pkgs.fluxer.com/desktop/stable/linux/arm64/2026.910.101500/deb', 'Fluxer-2026.910.101500-linux-arm64.deb', ], [ - 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/2026.910.101500/rpm', + 'https://pkgs.fluxer.com/desktop/stable/linux/arm64/2026.910.101500/rpm', 'Fluxer-2026.910.101500-linux-aarch64.rpm', ], [ - 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/2026.910.101500/tar_gz', + 'https://pkgs.fluxer.com/desktop/stable/linux/arm64/2026.910.101500/tar_gz', 'Fluxer-2026.910.101500-linux-arm64.tar.gz', ], ], ); }); - test('names canary and stable artefacts after their own product and api', () => { + test('names canary and stable artefacts after their own product and origin', () => { const info = latestInfo('2026.910.101500'); const stableDeb = structuredClone(loadUpdaterDownloads({channel: 'stable'}).getManualDownloadOptions(info)).find( (option) => option.format === 'deb', @@ -127,9 +127,9 @@ describe('UpdaterDownloads Linux manual update options', () => { (option) => option.format === 'deb', ); - assert.equal(stableDeb.url, 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/deb'); + assert.equal(stableDeb.url, 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/deb'); assert.equal(stableDeb.suggestedName, 'Fluxer-2026.910.101500-linux-amd64.deb'); - assert.equal(canaryDeb.url, 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64/2026.910.101500/deb'); + assert.equal(canaryDeb.url, 'https://pkgs.fluxer.com/desktop/canary/linux/x64/2026.910.101500/deb'); assert.equal(canaryDeb.suggestedName, 'Fluxer-Canary-2026.910.101500-linux-amd64.deb'); }); @@ -145,7 +145,7 @@ describe('UpdaterDownloads Linux manual update options', () => { const deb = options.find((option) => option.format === 'deb'); assert.equal(deb.suggestedName, 'Fluxer-Canary-2026.908.173325-linux-amd64.deb'); - assert.equal(deb.url, 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64/2026.908.173325/deb'); + assert.equal(deb.url, 'https://pkgs.fluxer.com/desktop/canary/linux/x64/2026.908.173325/deb'); assert.equal(deb.sha256, DEB_SHA256); assert.equal(options.length, 4); for (const option of options) { @@ -156,7 +156,7 @@ describe('UpdaterDownloads Linux manual update options', () => { } assert.equal( getManualDownloadUrl(info), - 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64/2026.908.173325/appimage', + 'https://pkgs.fluxer.com/desktop/canary/linux/x64/2026.908.173325/appimage', ); }); }); @@ -173,7 +173,7 @@ describe('UpdaterDownloads manual download url', () => { assert.equal( getManualDownloadUrl(info), - 'https://api.fluxer.app/dl/desktop/stable/linux/x64/2026.910.101500/appimage', + 'https://pkgs.fluxer.com/desktop/stable/linux/x64/2026.910.101500/appimage', ); }); diff --git a/fluxer_desktop/src/main/UpdaterDownloads.ts b/fluxer_desktop/src/main/UpdaterDownloads.ts index 542528314..b72a39bf8 100644 --- a/fluxer_desktop/src/main/UpdaterDownloads.ts +++ b/fluxer_desktop/src/main/UpdaterDownloads.ts @@ -17,8 +17,8 @@ function getDesktopDownloadArch(arch: NodeJS.Architecture): DesktopDownloadArch } const DESKTOP_DOWNLOAD_ARCH = getDesktopDownloadArch(process.arch); -const UPDATE_API_ENDPOINT = BUILD_CHANNEL === 'canary' ? 'https://api.canary.fluxer.app' : 'https://api.fluxer.app'; -export const UPDATE_BASE_URL = `${UPDATE_API_ENDPOINT}/dl/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}`; +const PKGS_BASE_URL = 'https://pkgs.fluxer.com'; +export const UPDATE_BASE_URL = `${PKGS_BASE_URL}/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}`; export const DOWNLOAD_PAGE_URL = BUILD_CHANNEL === 'canary' ? 'https://canary.fluxer.app/download' : 'https://fluxer.app/download'; @@ -72,7 +72,7 @@ function isLinuxManualDesktopFormat(format: ManualDesktopFormat): format is Linu return format === 'appimage' || format === 'deb' || format === 'rpm' || format === 'tar_gz'; } -function buildManualVersionDownloadUrl(version: string, format: ManualDesktopFormat): string { +export function buildManualVersionDownloadUrl(version: string, format: ManualDesktopFormat): string { return `${UPDATE_BASE_URL}/${version}/${format}`; } diff --git a/fluxer_desktop/src/preload/index.ts b/fluxer_desktop/src/preload/index.ts index da8ad2130..c67954eff 100644 --- a/fluxer_desktop/src/preload/index.ts +++ b/fluxer_desktop/src/preload/index.ts @@ -444,8 +444,8 @@ const api: ElectronAPI = { ipcRenderer.invoke('mac-tcc:status', 'screen-recording'), requestScreenRecordingPermission: (): Promise => ipcRenderer.invoke('mac-tcc:request', 'screen-recording'), - downloadFile: (url: string, defaultPath: string): Promise => - ipcRenderer.invoke('download-file', {url, defaultPath}), + downloadFile: (url: string, defaultPath: string, sha256?: string | null): Promise => + ipcRenderer.invoke('download-file', {url, defaultPath, sha256}), passkeyIsSupported: (): Promise => ipcRenderer.invoke('passkey-is-supported'), passkeyAuthenticate: ( options: PublicKeyCredentialRequestOptionsJSON,