Init AppImage support

This commit is contained in:
ayumi-signal
2025-11-19 12:00:37 -08:00
committed by GitHub
parent 7c12a1d3de
commit c6fa4c0c73
15 changed files with 182 additions and 12 deletions

View File

@@ -90,6 +90,7 @@ const NODE_PACKAGES = new Set([
'@signalapp/mock-server', '@signalapp/mock-server',
'@tailwindcss/cli', '@tailwindcss/cli',
'@tailwindcss/postcss', '@tailwindcss/postcss',
'better-blockmap',
'chokidar-cli', 'chokidar-cli',
'cross-env', 'cross-env',
'electron-builder', 'electron-builder',

View File

@@ -135,6 +135,7 @@ import { getOwn } from '../ts/util/getOwn.std.js';
import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas.std.js'; import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas.std.js';
import { getAppErrorIcon } from '../ts/util/getAppErrorIcon.node.js'; import { getAppErrorIcon } from '../ts/util/getAppErrorIcon.node.js';
import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js'; import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js';
import { appRelaunch } from '../ts/util/relaunch.main.js';
import { sendDummyKeystroke } from './WindowsNotifications.main.js'; import { sendDummyKeystroke } from './WindowsNotifications.main.js';
const { chmod, realpath, writeFile } = fsExtra; const { chmod, realpath, writeFile } = fsExtra;
@@ -1935,7 +1936,7 @@ const onDatabaseInitializationError = async (error: Error) => {
log.error( log.error(
'onDatabaseInitializationError: Requesting immediate restart after quit' 'onDatabaseInitializationError: Requesting immediate restart after quit'
); );
app.relaunch(); appRelaunch();
} }
} else if (buttonIndex === goToSupportPageButtonIndex) { } else if (buttonIndex === goToSupportPageButtonIndex) {
drop( drop(
@@ -2343,6 +2344,11 @@ app.on('ready', async () => {
function setupMenu(options?: Partial<CreateTemplateOptionsType>) { function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
const { platform } = process; const { platform } = process;
const platformForMenu =
platform === 'linux' && process.env.APPIMAGE != null
? 'linux-appimage'
: platform;
const version = app.getVersion(); const version = app.getVersion();
menuOptions = { menuOptions = {
// options // options
@@ -2351,7 +2357,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
includeSetup: false, includeSetup: false,
isNightly: isNightly(version), isNightly: isNightly(version),
isProduction: isProduction(version), isProduction: isProduction(version),
platform, platform: platformForMenu,
// actions // actions
forceUpdate, forceUpdate,
@@ -2630,7 +2636,7 @@ ipc.on('draw-attention', () => {
ipc.on('restart', () => { ipc.on('restart', () => {
log.info('Relaunching application'); log.info('Relaunching application');
app.relaunch(); appRelaunch();
app.quit(); app.quit();
}); });
ipc.on('shutdown', () => { ipc.on('shutdown', () => {

View File

@@ -12,6 +12,7 @@
"updatesUrl": "https://updates2.signal.org/desktop", "updatesUrl": "https://updates2.signal.org/desktop",
"resourcesUrl": "https://updates2.signal.org", "resourcesUrl": "https://updates2.signal.org",
"updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
"appImageUpdatesPublicKey": "05dc20ac83f663c517718b6057f2fe34e273b88455f2149513912f24a9a380cc63",
"sfuUrl": "https://sfu.staging.voip.signal.org/", "sfuUrl": "https://sfu.staging.voip.signal.org/",
"challengeUrl": "https://signalcaptchas.org/staging/challenge/generate.html", "challengeUrl": "https://signalcaptchas.org/staging/challenge/generate.html",
"registrationChallengeUrl": "https://signalcaptchas.org/staging/registration/generate.html", "registrationChallengeUrl": "https://signalcaptchas.org/staging/registration/generate.html",

View File

@@ -301,6 +301,7 @@
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"better-blockmap": "1.0.2",
"casual": "1.6.2", "casual": "1.6.2",
"chai": "4.4.1", "chai": "4.4.1",
"chai-as-promised": "7.1.1", "chai-as-promised": "7.1.1",
@@ -526,11 +527,17 @@
"StartupWMClass": "signal" "StartupWMClass": "signal"
} }
}, },
"artifactName": "${name}_${version}_${arch}.${ext}",
"target": [ "target": [
"deb" "deb"
], ],
"icon": "build/icons/png", "icon": "build/icons/png",
"publish": [], "publish": [
{
"provider": "generic",
"url": ""
}
],
"extraResources": [ "extraResources": [
{ {
"from": "build", "from": "build",

3
pnpm-lock.yaml generated
View File

@@ -622,6 +622,9 @@ importers:
babel-plugin-lodash: babel-plugin-lodash:
specifier: 3.3.4 specifier: 3.3.4
version: 3.3.4 version: 3.3.4
better-blockmap:
specifier: 1.0.2
version: 1.0.2
casual: casual:
specifier: 1.6.2 specifier: 1.6.2
version: 1.6.2(patch_hash=b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599) version: 1.6.2(patch_hash=b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599)

View File

@@ -2,12 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { mkdtemp, rm, rename, stat } from 'node:fs/promises';
import { mkdtemp, rm, rename, stat, writeFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import type { ArtifactCreated } from 'electron-builder'; import type { ArtifactCreated } from 'electron-builder';
import { BlockMap } from 'better-blockmap';
export async function artifactBuildCompleted({ export async function artifactBuildCompleted({
target, target,
@@ -15,6 +17,14 @@ export async function artifactBuildCompleted({
packager, packager,
updateInfo, updateInfo,
}: ArtifactCreated): Promise<void> { }: ArtifactCreated): Promise<void> {
if (packager.platform.name === 'linux' && file.endsWith('.AppImage')) {
const blockMapPath = `${file}.blockmap`;
console.log(`Generating blockmap ${blockMapPath}`);
const blockMapGenerator = new BlockMap({ detectZipBoundary: true });
await pipeline(createReadStream(file), blockMapGenerator);
await writeFile(blockMapPath, blockMapGenerator.compress());
}
if (packager.platform.name !== 'mac') { if (packager.platform.name !== 'mac') {
return; return;
} }

15
ts/scripts/better-blockmap.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
declare module 'better-blockmap' {
import { Writable } from 'node:stream';
type BlockMapOptions = {
detectZipBoundary?: boolean;
};
export class BlockMap extends Writable {
constructor(options?: BlockMapOptions);
compress(compression?: 'gzip' | 'deflate'): Buffer;
}
}

View File

@@ -69,7 +69,7 @@ export const isAutoDownloadUpdatesSupported = (
if (isNotUpdatable(appVersion)) { if (isNotUpdatable(appVersion)) {
return false; return false;
} }
return OS.isWindows() || OS.isMacOS(); return OS.isWindows() || OS.isMacOS() || OS.isLinuxAppImage();
}; };
export const shouldHideExpiringMessageBody = ( export const shouldHideExpiringMessageBody = (

View File

@@ -282,6 +282,10 @@ export abstract class Updater {
markShouldQuit(); markShouldQuit();
} }
protected getUpdatesPublicKey(): Buffer {
return hexToBinary(config.get('updatesPublicKey'));
}
// //
// Private methods // Private methods
// //
@@ -390,12 +394,11 @@ export abstract class Updater {
const { updateFilePath, signature } = downloadResult; const { updateFilePath, signature } = downloadResult;
const publicKey = hexToBinary(config.get('updatesPublicKey'));
const verified = await verifySignature( const verified = await verifySignature(
updateFilePath, updateFilePath,
this.version, this.version,
signature, signature,
publicKey this.getUpdatesPublicKey()
); );
if (!verified) { if (!verified) {
// Note: We don't delete the cache here, because we don't want to continually // Note: We don't delete the cache here, because we don't want to continually
@@ -989,6 +992,10 @@ export function getUpdatesFileName(): string {
return `${prefix}-mac.yml`; return `${prefix}-mac.yml`;
} }
if (process.platform === 'linux') {
return `${prefix}-linux.yml`;
}
return `${prefix}.yml`; return `${prefix}.yml`;
} }
@@ -1020,7 +1027,7 @@ export function getVersion(info: JSONUpdateSchema): string | null {
return info && info.version; return info && info.version;
} }
const validFile = /^[A-Za-z0-9.-]+$/; const validFile = /^[A-Za-z0-9._-]+$/;
export function isUpdateFileNameValid(name: string): boolean { export function isUpdateFileNameValid(name: string): boolean {
return validFile.test(name); return validFile.test(name);
} }
@@ -1041,6 +1048,8 @@ export function getUpdateFileName(
fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.zip'); fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.zip');
} else if (platform === 'win32') { } else if (platform === 'win32') {
fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.exe'); fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.exe');
} else if (platform === 'linux' && process.env.APPIMAGE != null) {
fileFilter = ({ url }) => url.endsWith('.AppImage');
} }
if (fileFilter) { if (fileFilter) {

View File

@@ -6,6 +6,7 @@ import { app } from 'electron';
import type { Updater, UpdaterOptionsType } from './common.main.js'; import type { Updater, UpdaterOptionsType } from './common.main.js';
import { MacOSUpdater } from './macos.main.js'; import { MacOSUpdater } from './macos.main.js';
import { WindowsUpdater } from './windows.main.js'; import { WindowsUpdater } from './windows.main.js';
import { LinuxAppImageUpdater } from './linuxAppImage.main.js';
import { initLinux } from './linux.main.js'; import { initLinux } from './linux.main.js';
let initialized = false; let initialized = false;
@@ -38,7 +39,11 @@ export async function start(options: UpdaterOptionsType): Promise<void> {
} else if (platform === 'darwin') { } else if (platform === 'darwin') {
updater = new MacOSUpdater(options); updater = new MacOSUpdater(options);
} else if (platform === 'linux') { } else if (platform === 'linux') {
initLinux(options); if (process.env.APPIMAGE != null) {
updater = new LinuxAppImageUpdater(options);
} else {
initLinux(options);
}
} else { } else {
throw new Error(`updater/start: Unsupported platform ${platform}`); throw new Error(`updater/start: Unsupported platform ${platform}`);
} }

View File

@@ -13,6 +13,7 @@ import type { LoggerType } from '../types/Logging.std.js';
import { DialogType } from '../types/Dialogs.std.js'; import { DialogType } from '../types/Dialogs.std.js';
import * as Errors from '../types/errors.std.js'; import * as Errors from '../types/errors.std.js';
import type { UpdaterOptionsType } from './common.main.js'; import type { UpdaterOptionsType } from './common.main.js';
import { appRelaunch } from '../util/relaunch.main.js';
const MIN_UBUNTU_VERSION = '22.04'; const MIN_UBUNTU_VERSION = '22.04';
@@ -60,7 +61,7 @@ export function initLinux({ logger, getMainWindow }: UpdaterOptionsType): void {
ipcMain.handle('start-update', () => { ipcMain.handle('start-update', () => {
logger?.info('updater/linux: restarting'); logger?.info('updater/linux: restarting');
markShouldQuit(); markShouldQuit();
app.relaunch(); appRelaunch();
app.quit(); app.quit();
}); });

View File

@@ -0,0 +1,74 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { copyFile, unlink } from 'node:fs/promises';
import { chmod } from 'fs-extra';
import config from 'config';
import { app } from 'electron';
import { Updater } from './common.main.js';
import { appRelaunch } from '../util/relaunch.main.js';
import { hexToBinary } from './signature.node.js';
export class LinuxAppImageUpdater extends Updater {
#installing = false;
protected async deletePreviousInstallers(): Promise<void> {
// No installers are cached beyond the most recent one
}
protected async installUpdate(
updateFilePath: string
): Promise<() => Promise<void>> {
const { logger } = this;
return async () => {
logger.info('downloadAndInstall: installing...');
try {
await this.#install(updateFilePath);
this.#installing = true;
} catch (error) {
this.markCannotUpdate(error);
throw error;
}
// If interrupted at this point, we only want to restart (not reattempt install)
this.setUpdateListener(this.restart);
this.restart();
};
}
protected restart(): void {
this.logger.info('downloadAndInstall: restarting...');
this.markRestarting();
appRelaunch();
app.quit();
}
override getUpdatesPublicKey(): Buffer {
return hexToBinary(config.get('appImageUpdatesPublicKey'));
}
async #install(updateFilePath: string): Promise<void> {
if (this.#installing) {
return;
}
const { logger } = this;
logger.info('linuxAppImage/install: installing package...');
const appImageFile = process.env.APPIMAGE;
if (appImageFile == null) {
throw new Error('APPIMAGE env is not defined!');
}
// https://stackoverflow.com/a/1712051/1910191
await unlink(appImageFile);
await copyFile(updateFilePath, appImageFile);
await chmod(appImageFile, 0o700);
}
}

View File

@@ -18,7 +18,17 @@ function getLinuxName(): string | undefined {
return undefined; return undefined;
} }
return match[1]; const name = match[1];
if (isAppImage()) {
return `${name} (AppImage)`;
}
// Flatpak is noted already in /etc/os-release
return name;
}
function isAppImage(): boolean {
return process.platform === 'linux' && process.env.APPIMAGE != null;
} }
function isFlatpak(): boolean { function isFlatpak(): boolean {
@@ -45,6 +55,7 @@ function isLinuxUsingKDE(): boolean {
const OS = { const OS = {
...getOSFunctions(os.release()), ...getOSFunctions(os.release()),
getLinuxName, getLinuxName,
isAppImage,
isFlatpak, isFlatpak,
isLinuxUsingKDE, isLinuxUsingKDE,
isWaylandEnabled, isWaylandEnabled,

View File

@@ -23,6 +23,7 @@ export type OSType = {
getClassName: () => string; getClassName: () => string;
getName: () => string; getName: () => string;
isLinux: (minVersion?: string) => boolean; isLinux: (minVersion?: string) => boolean;
isLinuxAppImage: () => boolean;
isMacOS: (minVersion?: string) => boolean; isMacOS: (minVersion?: string) => boolean;
isWindows: (minVersion?: string) => boolean; isWindows: (minVersion?: string) => boolean;
}; };
@@ -32,6 +33,10 @@ export function getOSFunctions(osRelease: string): OSType {
const isLinux = createIsPlatform('linux', osRelease); const isLinux = createIsPlatform('linux', osRelease);
const isWindows = createIsPlatform('win32', osRelease); const isWindows = createIsPlatform('win32', osRelease);
const isLinuxAppImage = (): boolean => {
return process.platform === 'linux' && process.env.APPIMAGE != null;
};
const getName = (): string => { const getName = (): string => {
if (isMacOS()) { if (isMacOS()) {
return 'macOS'; return 'macOS';
@@ -56,6 +61,7 @@ export function getOSFunctions(osRelease: string): OSType {
getClassName, getClassName,
getName, getName,
isLinux, isLinux,
isLinuxAppImage,
isMacOS, isMacOS,
isWindows, isWindows,
}; };

21
ts/util/relaunch.main.ts Normal file
View File

@@ -0,0 +1,21 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { app } from 'electron';
import type { RelaunchOptions } from 'electron';
import OS from './os/osMain.node.js';
// app.relaunch() doesn't work in AppImage, so this is a workaround
export function appRelaunch(): void {
if (!OS.isAppImage()) {
app.relaunch();
return;
}
const options: RelaunchOptions = {
args: ['--appimage-extract-and-run', ...process.argv],
execPath: process.env.APPIMAGE,
};
app.relaunch(options);
}