mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(desktop): self-update appimages in place (#2843)
This commit is contained in:
1 parent
98a42f612b
commit
c4f0b2ece0
20 files changed
+2210
-57
No files matched your search
@@ -696,10 +696,14 @@ class Updater {
|
|||||||
} finally {
|
} finally {
|
||||||
this.transition({type: 'manualDownload.finished'});
|
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) {
|
if (this.manualNativeDownloadInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -708,10 +712,16 @@ class Updater {
|
|||||||
const outcome = await downloadWithNative({
|
const outcome = await downloadWithNative({
|
||||||
url,
|
url,
|
||||||
suggestedName: suggestedName ?? this.getManualUpdateSuggestedName(url),
|
suggestedName: suggestedName ?? this.getManualUpdateSuggestedName(url),
|
||||||
|
sha256,
|
||||||
});
|
});
|
||||||
if (outcome === 'success' || outcome === 'canceled') {
|
if (outcome === 'success' || outcome === 'canceled') {
|
||||||
return;
|
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});
|
logger.warn('Native manual update download unavailable; opening update URL externally', {outcome});
|
||||||
await openExternalUrl(url);
|
await openExternalUrl(url);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export function shouldShowNativeDesktopUpdateInApp(platform: string | null | und
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean {
|
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 {
|
export interface DownloadFileResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
canceled?: boolean;
|
canceled?: boolean;
|
||||||
|
checksumMismatch?: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -357,7 +358,7 @@ export interface ElectronAPI {
|
|||||||
requestInputMonitoringPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
requestInputMonitoringPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
getScreenRecordingPermissionStatus?: () => Promise<InputMonitoringPermissionStatus>;
|
getScreenRecordingPermissionStatus?: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
requestScreenRecordingPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
requestScreenRecordingPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
|
downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise<DownloadFileResult>;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
|
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
|
||||||
shouldPlayNotificationSound?: () => Promise<boolean>;
|
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: {
|
export async function downloadWithNative(options: {
|
||||||
url: string;
|
url: string;
|
||||||
suggestedName?: string;
|
suggestedName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
sha256?: string | null;
|
||||||
}): Promise<NativeDownloadOutcome> {
|
}): Promise<NativeDownloadOutcome> {
|
||||||
const electronApi = getElectronAPI();
|
const electronApi = getElectronAPI();
|
||||||
if (!electronApi) {
|
if (!electronApi) {
|
||||||
return 'unavailable';
|
return 'unavailable';
|
||||||
}
|
}
|
||||||
try {
|
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.success) return 'success';
|
||||||
if (result.canceled) return 'canceled';
|
if (result.canceled) return 'canceled';
|
||||||
|
if (result.checksumMismatch) return 'checksum-mismatch';
|
||||||
return 'failed';
|
return 'failed';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(' Native download failed, falling back to browser', error);
|
logger.error(' Native download failed, falling back to browser', error);
|
||||||
|
|||||||
Vendored
+2
-1
@@ -283,6 +283,7 @@ export interface UpdaterDownloadOption {
|
|||||||
export interface DownloadResult {
|
export interface DownloadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
canceled?: boolean;
|
canceled?: boolean;
|
||||||
|
checksumMismatch?: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -349,7 +350,7 @@ export interface ElectronAPI {
|
|||||||
platform: 'darwin' | 'win32' | 'linux' | string;
|
platform: 'darwin' | 'win32' | 'linux' | string;
|
||||||
buildChannel: 'stable' | 'canary';
|
buildChannel: 'stable' | 'canary';
|
||||||
openExternal(url: string): Promise<void>;
|
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;
|
onUpdaterEvent(callback: (event: UpdaterEvent) => void): () => void;
|
||||||
updaterCheck(context: 'user' | 'background'): Promise<void>;
|
updaterCheck(context: 'user' | 'background'): Promise<void>;
|
||||||
updaterDownload(context: 'user' | 'background'): Promise<void>;
|
updaterDownload(context: 'user' | 'background'): Promise<void>;
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export type UpdaterEvent =
|
|||||||
export interface DownloadFileResult {
|
export interface DownloadFileResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
canceled?: boolean;
|
canceled?: boolean;
|
||||||
|
checksumMismatch?: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -697,7 +698,7 @@ export interface ElectronAPI {
|
|||||||
requestInputMonitoringPermission: () => Promise<InputMonitoringPermissionStatus>;
|
requestInputMonitoringPermission: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
getScreenRecordingPermissionStatus: () => Promise<InputMonitoringPermissionStatus>;
|
getScreenRecordingPermissionStatus: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
requestScreenRecordingPermission: () => Promise<InputMonitoringPermissionStatus>;
|
requestScreenRecordingPermission: () => Promise<InputMonitoringPermissionStatus>;
|
||||||
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
|
downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise<DownloadFileResult>;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
|
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
|
||||||
shouldPlayNotificationSound: () => Promise<boolean>;
|
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,
|
TrayActionPayload,
|
||||||
TrayRuntimeStatePayload,
|
TrayRuntimeStatePayload,
|
||||||
} from '@electron/common/Types';
|
} from '@electron/common/Types';
|
||||||
import {getStableRelaunchOptions} from '@electron/main/LinuxLaunchPath';
|
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
||||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||||
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
|
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ function restartFromTray(): void {
|
|||||||
trayRestartRequested = true;
|
trayRestartRequested = true;
|
||||||
controller?.setQuitting(true);
|
controller?.setQuitting(true);
|
||||||
destroyDesktopTray();
|
destroyDesktopTray();
|
||||||
app.relaunch(getStableRelaunchOptions());
|
relaunchStableLaunchPath();
|
||||||
app.quit();
|
app.quit();
|
||||||
armTrayForcedExit('restart');
|
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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {createHash, type Hash} from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import {pipeline} from 'node:stream/promises';
|
import {pipeline} from 'node:stream/promises';
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +14,32 @@ const MAX_DOWNLOAD_REDIRECTS = 5;
|
|||||||
const DOWNLOAD_DEADLINE_MS = 600_000;
|
const DOWNLOAD_DEADLINE_MS = 600_000;
|
||||||
const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
|
const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
|
||||||
const DOWNLOAD_CONTEXT = 'File download';
|
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 {
|
interface DownloadFileOptions {
|
||||||
maxBytes?: number;
|
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> {
|
async function removePartialDownload(destPath: string): Promise<void> {
|
||||||
@@ -26,13 +50,16 @@ async function writeCappedResponse(
|
|||||||
response: DesktopOutboundHTTPMessage['message'],
|
response: DesktopOutboundHTTPMessage['message'],
|
||||||
destPath: string,
|
destPath: string,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
|
hash: Hash | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let received = 0;
|
let received = 0;
|
||||||
response.on('data', (chunk: Buffer) => {
|
response.on('data', (chunk: Buffer) => {
|
||||||
received += chunk.length;
|
received += chunk.length;
|
||||||
if (received > maxBytes) {
|
if (received > maxBytes) {
|
||||||
response.destroy(new Error(`Download exceeds ${maxBytes} bytes`));
|
response.destroy(new Error(`Download exceeds ${maxBytes} bytes`));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
hash?.update(chunk);
|
||||||
});
|
});
|
||||||
await pipeline(response, fs.createWriteStream(destPath));
|
await pipeline(response, fs.createWriteStream(destPath));
|
||||||
}
|
}
|
||||||
@@ -42,6 +69,7 @@ async function downloadFileWithRedirects(
|
|||||||
destPath: string,
|
destPath: string,
|
||||||
redirects: number,
|
redirects: number,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
|
hash: Hash | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const message = await getDesktopOutboundHTTP().get({
|
const message = await getDesktopOutboundHTTP().get({
|
||||||
context: DOWNLOAD_CONTEXT,
|
context: DOWNLOAD_CONTEXT,
|
||||||
@@ -58,7 +86,7 @@ async function downloadFileWithRedirects(
|
|||||||
if (nextUrl == null) {
|
if (nextUrl == null) {
|
||||||
throw new Error(`HTTP ${statusCode} redirect target is unusable`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (statusCode === 204 || statusCode === 205) {
|
if (statusCode === 204 || statusCode === 205) {
|
||||||
@@ -71,7 +99,7 @@ async function downloadFileWithRedirects(
|
|||||||
throw new Error(`HTTP ${statusCode}`);
|
throw new Error(`HTTP ${statusCode}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await writeCappedResponse(message.message, destPath, maxBytes);
|
await writeCappedResponse(message.message, destPath, maxBytes, hash);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await removePartialDownload(destPath);
|
await removePartialDownload(destPath);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -83,7 +111,18 @@ export async function downloadFile(url: string, destPath: string, options: Downl
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
throw new Error('Download URL must use http or https');
|
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;
|
const maxBytes = options.maxBytes ?? DOWNLOAD_MAX_BYTES;
|
||||||
await removePartialDownload(destPath);
|
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,
|
hasActiveDesktopTray,
|
||||||
updateTrayRuntimeState,
|
updateTrayRuntimeState,
|
||||||
} from '@electron/main/DesktopTray';
|
} from '@electron/main/DesktopTray';
|
||||||
import {downloadFile} from '@electron/main/FileDownloads';
|
import {DownloadChecksumError, downloadFile} from '@electron/main/FileDownloads';
|
||||||
import {
|
import {
|
||||||
type LinuxAppearanceSnapshot,
|
type LinuxAppearanceSnapshot,
|
||||||
type LinuxAppearanceSubscription,
|
type LinuxAppearanceSubscription,
|
||||||
@@ -381,6 +381,7 @@ export function registerIpcHandlers(): void {
|
|||||||
options: {
|
options: {
|
||||||
url: string;
|
url: string;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
|
sha256?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<DownloadFileResult> => {
|
): Promise<DownloadFileResult> => {
|
||||||
requirePrivilegedRendererDocumentSender(event, 'download-file');
|
requirePrivilegedRendererDocumentSender(event, 'download-file');
|
||||||
@@ -395,9 +396,12 @@ export function registerIpcHandlers(): void {
|
|||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
return {success: false, canceled: true};
|
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};
|
return {success: true, path: result.filePath};
|
||||||
} catch (error) {
|
} 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'};
|
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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
export function getStableLinuxLaunchPath(): string {
|
import {spawn} from 'node:child_process';
|
||||||
if (process.platform === 'linux') {
|
import {existsSync} from 'node:fs';
|
||||||
const appImage = process.env.APPIMAGE;
|
import {homedir} from 'node:os';
|
||||||
if (appImage && appImage.length > 0) return appImage;
|
import {createChildLogger} from '@electron/common/Logger';
|
||||||
}
|
import {app} from 'electron';
|
||||||
return process.execPath;
|
|
||||||
|
const logger = createChildLogger('LinuxLaunchPath');
|
||||||
|
|
||||||
|
const PREFERRED_RELAUNCH_SHELL = '/bin/bash';
|
||||||
|
const FALLBACK_RELAUNCH_SHELL = '/bin/sh';
|
||||||
|
|
||||||
|
const RELAUNCH_AFTER_EXIT =
|
||||||
|
'pid="$1"; app="$2"; home="$3"; shift 3; cd /proc/self/fd && for n in *; do case "$n" in 0|1|2) ;; [0-9]) eval "exec $n>&-";; *) [ -n "$BASH_VERSION" ] && eval "exec $n>&-";; esac; done; cd "$home" || cd /; attempt=0; while [ "$attempt" -lt 300 ] && kill -0 "$pid"; do sleep 0.1; attempt=$((attempt + 1)); done; kill -0 "$pid" || exec "$app" "$@"';
|
||||||
|
|
||||||
|
function getAppImagePath(): string | null {
|
||||||
|
if (process.platform !== 'linux') return null;
|
||||||
|
const appImage = process.env.APPIMAGE;
|
||||||
|
return appImage && appImage.length > 0 ? appImage : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStableRelaunchOptions(): {execPath: string} | undefined {
|
export function getStableLinuxLaunchPath(): string {
|
||||||
if (process.platform !== 'linux') return undefined;
|
return getAppImagePath() ?? process.execPath;
|
||||||
const execPath = getStableLinuxLaunchPath();
|
}
|
||||||
if (execPath === process.execPath) return undefined;
|
|
||||||
return {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 {createChildLogger} from '@electron/common/Logger';
|
||||||
import {getLaunchDesktopTroubleshootingSettings} from '@electron/main/DesktopDebugInfo';
|
import {getLaunchDesktopTroubleshootingSettings} from '@electron/main/DesktopDebugInfo';
|
||||||
import {destroyDesktopTray} from '@electron/main/DesktopTray';
|
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 {t} from '@electron/main/MainI18n';
|
||||||
import {clearSavedWindowBounds, getMainWindow, setQuitting} from '@electron/main/Window';
|
import {clearSavedWindowBounds, getMainWindow, setQuitting} from '@electron/main/Window';
|
||||||
import {app, BrowserWindow, dialog, Menu, type MenuItemConstructorOptions, session} from 'electron';
|
import {app, BrowserWindow, dialog, Menu, type MenuItemConstructorOptions, session} from 'electron';
|
||||||
@@ -21,7 +21,7 @@ export function relaunchAndExit(): void {
|
|||||||
relaunchRequested = true;
|
relaunchRequested = true;
|
||||||
setQuitting(true);
|
setQuitting(true);
|
||||||
destroyDesktopTray();
|
destroyDesktopTray();
|
||||||
app.relaunch(getStableRelaunchOptions());
|
relaunchStableLaunchPath();
|
||||||
app.exit(0);
|
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 {createRequire} from 'node:module';
|
||||||
import {isPortableMode} from '@electron/common/UserDataPath';
|
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 {destroyDesktopTray} from '@electron/main/DesktopTray';
|
||||||
import {isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
import {isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
||||||
|
import {relaunchAndExit} from '@electron/main/Troubleshooting';
|
||||||
import {
|
import {
|
||||||
|
buildManualVersionDownloadUrl,
|
||||||
DOWNLOAD_PAGE_URL,
|
DOWNLOAD_PAGE_URL,
|
||||||
getManualDownloadOptions,
|
getManualDownloadOptions,
|
||||||
getManualDownloadUrl,
|
getManualDownloadUrl,
|
||||||
@@ -74,11 +88,17 @@ let pendingVelopackUpdate: VelopackUpdate | null = null;
|
|||||||
let velopackCheckPromise: Promise<void> | null = null;
|
let velopackCheckPromise: Promise<void> | null = null;
|
||||||
let velopackDownloadPromise: Promise<void> | null = null;
|
let velopackDownloadPromise: Promise<void> | null = null;
|
||||||
let velopackInstallStarted = false;
|
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_MAX_ATTEMPTS = 5;
|
||||||
const UPDATE_DOWNLOAD_RETRY_BASE_DELAY_MS = 3000;
|
const UPDATE_DOWNLOAD_RETRY_BASE_DELAY_MS = 3000;
|
||||||
const UPDATE_DOWNLOAD_RETRY_MAX_DELAY_MS = 60000;
|
const UPDATE_DOWNLOAD_RETRY_MAX_DELAY_MS = 60000;
|
||||||
const ELECTRON_DOWNLOAD_MAX_RETRIES = 4;
|
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) {
|
function send(win: BrowserWindow | null, event: UpdaterEvent) {
|
||||||
win?.webContents.send('updater-event', event);
|
win?.webContents.send('updater-event', event);
|
||||||
@@ -472,22 +492,30 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendManualUpdateAvailable(
|
||||||
|
getMainWindow: () => BrowserWindow | null,
|
||||||
|
context: UpdaterContext,
|
||||||
|
latest: ManualLatestInfo,
|
||||||
|
): void {
|
||||||
|
const downloadOptions = getManualDownloadOptions(latest);
|
||||||
|
send(getMainWindow(), {
|
||||||
|
type: 'available',
|
||||||
|
context,
|
||||||
|
version: latest.version,
|
||||||
|
downloadSize: null,
|
||||||
|
downloadStarted: false,
|
||||||
|
downloadUrl: getManualDownloadUrl(latest),
|
||||||
|
...(downloadOptions.length > 0 ? {downloadOptions} : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => BrowserWindow | null): Promise<void> {
|
async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => BrowserWindow | null): Promise<void> {
|
||||||
send(getMainWindow(), {type: 'checking', context});
|
send(getMainWindow(), {type: 'checking', context});
|
||||||
try {
|
try {
|
||||||
const latest = await fetchManualLatest({forceRefresh: context === 'user'});
|
const latest = await fetchManualLatest({forceRefresh: context === 'user'});
|
||||||
const current = app.getVersion();
|
const current = app.getVersion();
|
||||||
if (compareVersions(latest.version, current) > 0) {
|
if (compareVersions(latest.version, current) > 0) {
|
||||||
const downloadOptions = getManualDownloadOptions(latest);
|
sendManualUpdateAvailable(getMainWindow, context, latest);
|
||||||
send(getMainWindow(), {
|
|
||||||
type: 'available',
|
|
||||||
context,
|
|
||||||
version: latest.version,
|
|
||||||
downloadSize: null,
|
|
||||||
downloadStarted: false,
|
|
||||||
downloadUrl: getManualDownloadUrl(latest),
|
|
||||||
...(downloadOptions.length > 0 ? {downloadOptions} : {}),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
send(getMainWindow(), {type: 'not-available', context});
|
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(
|
function registerManualUpdater(
|
||||||
getMainWindow: () => BrowserWindow | null,
|
getMainWindow: () => BrowserWindow | null,
|
||||||
reason: 'platform' | 'unpackaged' | 'managed-package',
|
reason: 'platform' | 'unpackaged' | 'managed-package',
|
||||||
@@ -547,5 +752,12 @@ export function registerUpdater(getMainWindow: () => BrowserWindow | null) {
|
|||||||
registerElectronUpdater(getMainWindow);
|
registerElectronUpdater(getMainWindow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (process.platform === 'linux' && isRunningFromAppImage()) {
|
||||||
|
const appImage = resolveAppImageTarget();
|
||||||
|
if (appImage.ok) {
|
||||||
|
registerAppImageUpdater(getMainWindow, appImage.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
registerManualUpdater(getMainWindow, 'platform');
|
registerManualUpdater(getMainWindow, 'platform');
|
||||||
}
|
}
|
||||||
@@ -63,28 +63,28 @@ describe('UpdaterDownloads Linux manual update options', () => {
|
|||||||
{
|
{
|
||||||
format: 'appimage',
|
format: 'appimage',
|
||||||
label: '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',
|
suggestedName: 'Fluxer-2026.910.101500-linux-x86_64.AppImage',
|
||||||
sha256: APPIMAGE_SHA256,
|
sha256: APPIMAGE_SHA256,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
format: 'deb',
|
format: 'deb',
|
||||||
label: 'DEB package',
|
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',
|
suggestedName: 'Fluxer-2026.910.101500-linux-amd64.deb',
|
||||||
sha256: DEB_SHA256,
|
sha256: DEB_SHA256,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
format: 'rpm',
|
format: 'rpm',
|
||||||
label: 'RPM package',
|
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',
|
suggestedName: 'Fluxer-2026.910.101500-linux-x86_64.rpm',
|
||||||
sha256: null,
|
sha256: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
format: 'tar_gz',
|
format: 'tar_gz',
|
||||||
label: 'tar.gz archive',
|
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',
|
suggestedName: 'Fluxer-2026.910.101500-linux-x64.tar.gz',
|
||||||
sha256: TAR_GZ_SHA256,
|
sha256: TAR_GZ_SHA256,
|
||||||
},
|
},
|
||||||
@@ -99,26 +99,26 @@ describe('UpdaterDownloads Linux manual update options', () => {
|
|||||||
options.map((option) => [option.url, option.suggestedName]),
|
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',
|
'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',
|
'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',
|
'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',
|
'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 info = latestInfo('2026.910.101500');
|
||||||
const stableDeb = structuredClone(loadUpdaterDownloads({channel: 'stable'}).getManualDownloadOptions(info)).find(
|
const stableDeb = structuredClone(loadUpdaterDownloads({channel: 'stable'}).getManualDownloadOptions(info)).find(
|
||||||
(option) => option.format === 'deb',
|
(option) => option.format === 'deb',
|
||||||
@@ -127,9 +127,9 @@ describe('UpdaterDownloads Linux manual update options', () => {
|
|||||||
(option) => option.format === 'deb',
|
(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(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');
|
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');
|
const deb = options.find((option) => option.format === 'deb');
|
||||||
|
|
||||||
assert.equal(deb.suggestedName, 'Fluxer-Canary-2026.908.173325-linux-amd64.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(deb.sha256, DEB_SHA256);
|
||||||
assert.equal(options.length, 4);
|
assert.equal(options.length, 4);
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
@@ -156,7 +156,7 @@ describe('UpdaterDownloads Linux manual update options', () => {
|
|||||||
}
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getManualDownloadUrl(info),
|
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(
|
assert.equal(
|
||||||
getManualDownloadUrl(info),
|
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 DESKTOP_DOWNLOAD_ARCH = getDesktopDownloadArch(process.arch);
|
||||||
const UPDATE_API_ENDPOINT = BUILD_CHANNEL === 'canary' ? 'https://api.canary.fluxer.app' : 'https://api.fluxer.app';
|
const PKGS_BASE_URL = 'https://pkgs.fluxer.com';
|
||||||
export const UPDATE_BASE_URL = `${UPDATE_API_ENDPOINT}/dl/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}`;
|
export const UPDATE_BASE_URL = `${PKGS_BASE_URL}/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}`;
|
||||||
export const DOWNLOAD_PAGE_URL =
|
export const DOWNLOAD_PAGE_URL =
|
||||||
BUILD_CHANNEL === 'canary' ? 'https://canary.fluxer.app/download' : 'https://fluxer.app/download';
|
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';
|
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}`;
|
return `${UPDATE_BASE_URL}/${version}/${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -444,8 +444,8 @@ const api: ElectronAPI = {
|
|||||||
ipcRenderer.invoke('mac-tcc:status', 'screen-recording'),
|
ipcRenderer.invoke('mac-tcc:status', 'screen-recording'),
|
||||||
requestScreenRecordingPermission: (): Promise<InputMonitoringPermissionStatus> =>
|
requestScreenRecordingPermission: (): Promise<InputMonitoringPermissionStatus> =>
|
||||||
ipcRenderer.invoke('mac-tcc:request', 'screen-recording'),
|
ipcRenderer.invoke('mac-tcc:request', 'screen-recording'),
|
||||||
downloadFile: (url: string, defaultPath: string): Promise<DownloadFileResult> =>
|
downloadFile: (url: string, defaultPath: string, sha256?: string | null): Promise<DownloadFileResult> =>
|
||||||
ipcRenderer.invoke('download-file', {url, defaultPath}),
|
ipcRenderer.invoke('download-file', {url, defaultPath, sha256}),
|
||||||
passkeyIsSupported: (): Promise<boolean> => ipcRenderer.invoke('passkey-is-supported'),
|
passkeyIsSupported: (): Promise<boolean> => ipcRenderer.invoke('passkey-is-supported'),
|
||||||
passkeyAuthenticate: (
|
passkeyAuthenticate: (
|
||||||
options: PublicKeyCredentialRequestOptionsJSON,
|
options: PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
|||||||
Reference in new issue
Block a user