feat(desktop): self-update appimages in place (#2843)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-19 19:06:53 +02:00
1 parent 98a42f612b
commit c4f0b2ece0
20 files changed
+2205 -52

No files matched your search

+12 -2
View File
@@ -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<void> {
private async downloadManualNativeUpdateOrOpen(
url: string,
suggestedName?: string,
sha256?: string | null,
): Promise<void> {
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 {
@@ -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';
}
@@ -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<InputMonitoringPermissionStatus>;
getScreenRecordingPermissionStatus?: () => Promise<InputMonitoringPermissionStatus>;
requestScreenRecordingPermission?: () => Promise<InputMonitoringPermissionStatus>;
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise<DownloadFileResult>;
toggleDevTools: () => void;
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
shouldPlayNotificationSound?: () => Promise<boolean>;
@@ -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<NativeDownloadOutcome> {
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);
+2 -1
View File
@@ -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<void>;
downloadFile(url: string, suggestedName: string): Promise<DownloadResult>;
downloadFile(url: string, suggestedName: string, sha256?: string | null): Promise<DownloadResult>;
onUpdaterEvent(callback: (event: UpdaterEvent) => void): () => void;
updaterCheck(context: 'user' | 'background'): Promise<void>;
updaterDownload(context: 'user' | 'background'): Promise<void>;
+2 -1
View File
@@ -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<InputMonitoringPermissionStatus>;
getScreenRecordingPermissionStatus: () => Promise<InputMonitoringPermissionStatus>;
requestScreenRecordingPermission: () => Promise<InputMonitoringPermissionStatus>;
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise<DownloadFileResult>;
toggleDevTools: () => void;
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
shouldPlayNotificationSound: () => Promise<boolean>;
@@ -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]);
});
});
+272
View File
@@ -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<typeof statSync>;
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<string> {
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<StagedAppImageUpdate> {
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<string> {
const prefix = stagingDirectoryPrefix(target.installedPath);
const reclaimed: Array<string> = [];
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;
}
+2 -2
View File
@@ -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');
}
@@ -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);
});
});
+42 -3
View File
@@ -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<void> {
@@ -26,13 +50,16 @@ async function writeCappedResponse(
response: DesktopOutboundHTTPMessage['message'],
destPath: string,
maxBytes: number,
hash: Hash | null,
): Promise<void> {
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<void> {
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);
}
+6 -2
View File
@@ -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<DownloadFileResult> => {
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'};
}
},
@@ -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);
});
});
+51 -10
View File
@@ -1,16 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function getStableLinuxLaunchPath(): string {
if (process.platform === 'linux') {
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;
if (appImage && appImage.length > 0) return appImage;
}
return process.execPath;
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});
}
}
+2 -2
View File
@@ -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);
}
+448
View File
@@ -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);
});
});
+218 -6
View File
@@ -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<void> | null = null;
let velopackDownloadPromise: Promise<void> | null = null;
let velopackInstallStarted = false;
let pendingAppImageUpdate: PendingAppImageUpdate | null = null;
let appImageUpdatePromise: Promise<void> | 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,12 +492,11 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
return info;
}
async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => BrowserWindow | null): Promise<void> {
send(getMainWindow(), {type: 'checking', context});
try {
const latest = await fetchManualLatest({forceRefresh: context === 'user'});
const current = app.getVersion();
if (compareVersions(latest.version, current) > 0) {
function sendManualUpdateAvailable(
getMainWindow: () => BrowserWindow | null,
context: UpdaterContext,
latest: ManualLatestInfo,
): void {
const downloadOptions = getManualDownloadOptions(latest);
send(getMainWindow(), {
type: 'available',
@@ -488,6 +507,15 @@ async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => B
downloadUrl: getManualDownloadUrl(latest),
...(downloadOptions.length > 0 ? {downloadOptions} : {}),
});
}
async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => BrowserWindow | null): Promise<void> {
send(getMainWindow(), {type: 'checking', context});
try {
const latest = await fetchManualLatest({forceRefresh: context === 'user'});
const current = app.getVersion();
if (compareVersions(latest.version, current) > 0) {
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<void> {
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<void> {
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');
}
@@ -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',
);
});
+3 -3
View File
@@ -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}`;
}
+2 -2
View File
@@ -444,8 +444,8 @@ const api: ElectronAPI = {
ipcRenderer.invoke('mac-tcc:status', 'screen-recording'),
requestScreenRecordingPermission: (): Promise<InputMonitoringPermissionStatus> =>
ipcRenderer.invoke('mac-tcc:request', 'screen-recording'),
downloadFile: (url: string, defaultPath: string): Promise<DownloadFileResult> =>
ipcRenderer.invoke('download-file', {url, defaultPath}),
downloadFile: (url: string, defaultPath: string, sha256?: string | null): Promise<DownloadFileResult> =>
ipcRenderer.invoke('download-file', {url, defaultPath, sha256}),
passkeyIsSupported: (): Promise<boolean> => ipcRenderer.invoke('passkey-is-supported'),
passkeyAuthenticate: (
options: PublicKeyCredentialRequestOptionsJSON,