mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): self-update appimages in place (#2843)
This commit is contained in:
1 parent
98a42f612b
commit
c4f0b2ece0
20 files changed
+2205
-52
No files matched your search
@@ -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);
|
||||
|
||||
Vendored
+2
-1
@@ -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>;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user