diff --git a/.eslint/rules/file-suffix.js b/.eslint/rules/file-suffix.js index 52d9da4f5b..954ba914ee 100644 --- a/.eslint/rules/file-suffix.js +++ b/.eslint/rules/file-suffix.js @@ -90,6 +90,7 @@ const NODE_PACKAGES = new Set([ '@signalapp/mock-server', '@tailwindcss/cli', '@tailwindcss/postcss', + 'better-blockmap', 'chokidar-cli', 'cross-env', 'electron-builder', diff --git a/app/main.main.ts b/app/main.main.ts index 9fd3394614..34eb460a29 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -135,6 +135,7 @@ import { getOwn } from '../ts/util/getOwn.std.js'; import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas.std.js'; import { getAppErrorIcon } from '../ts/util/getAppErrorIcon.node.js'; import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js'; +import { appRelaunch } from '../ts/util/relaunch.main.js'; import { sendDummyKeystroke } from './WindowsNotifications.main.js'; const { chmod, realpath, writeFile } = fsExtra; @@ -1935,7 +1936,7 @@ const onDatabaseInitializationError = async (error: Error) => { log.error( 'onDatabaseInitializationError: Requesting immediate restart after quit' ); - app.relaunch(); + appRelaunch(); } } else if (buttonIndex === goToSupportPageButtonIndex) { drop( @@ -2343,6 +2344,11 @@ app.on('ready', async () => { function setupMenu(options?: Partial) { const { platform } = process; + const platformForMenu = + platform === 'linux' && process.env.APPIMAGE != null + ? 'linux-appimage' + : platform; + const version = app.getVersion(); menuOptions = { // options @@ -2351,7 +2357,7 @@ function setupMenu(options?: Partial) { includeSetup: false, isNightly: isNightly(version), isProduction: isProduction(version), - platform, + platform: platformForMenu, // actions forceUpdate, @@ -2630,7 +2636,7 @@ ipc.on('draw-attention', () => { ipc.on('restart', () => { log.info('Relaunching application'); - app.relaunch(); + appRelaunch(); app.quit(); }); ipc.on('shutdown', () => { diff --git a/config/default.json b/config/default.json index cd64ea7fcc..3b7c3a117f 100644 --- a/config/default.json +++ b/config/default.json @@ -12,6 +12,7 @@ "updatesUrl": "https://updates2.signal.org/desktop", "resourcesUrl": "https://updates2.signal.org", "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", + "appImageUpdatesPublicKey": "05dc20ac83f663c517718b6057f2fe34e273b88455f2149513912f24a9a380cc63", "sfuUrl": "https://sfu.staging.voip.signal.org/", "challengeUrl": "https://signalcaptchas.org/staging/challenge/generate.html", "registrationChallengeUrl": "https://signalcaptchas.org/staging/registration/generate.html", diff --git a/package.json b/package.json index e34a6602bd..c980f1d657 100644 --- a/package.json +++ b/package.json @@ -301,6 +301,7 @@ "babel-core": "7.0.0-bridge.0", "babel-loader": "9.2.1", "babel-plugin-lodash": "3.3.4", + "better-blockmap": "1.0.2", "casual": "1.6.2", "chai": "4.4.1", "chai-as-promised": "7.1.1", @@ -526,11 +527,17 @@ "StartupWMClass": "signal" } }, + "artifactName": "${name}_${version}_${arch}.${ext}", "target": [ "deb" ], "icon": "build/icons/png", - "publish": [], + "publish": [ + { + "provider": "generic", + "url": "" + } + ], "extraResources": [ { "from": "build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6a5d08548..08a469624e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -622,6 +622,9 @@ importers: babel-plugin-lodash: specifier: 3.3.4 version: 3.3.4 + better-blockmap: + specifier: 1.0.2 + version: 1.0.2 casual: specifier: 1.6.2 version: 1.6.2(patch_hash=b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599) diff --git a/ts/scripts/artifact-build-completed.node.ts b/ts/scripts/artifact-build-completed.node.ts index 2d92f382f8..d014a65895 100644 --- a/ts/scripts/artifact-build-completed.node.ts +++ b/ts/scripts/artifact-build-completed.node.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only 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 { pipeline } from 'node:stream/promises'; import { createHash } from 'node:crypto'; import path from 'node:path'; import type { ArtifactCreated } from 'electron-builder'; +import { BlockMap } from 'better-blockmap'; export async function artifactBuildCompleted({ target, @@ -15,6 +17,14 @@ export async function artifactBuildCompleted({ packager, updateInfo, }: ArtifactCreated): Promise { + 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') { return; } diff --git a/ts/scripts/better-blockmap.d.ts b/ts/scripts/better-blockmap.d.ts new file mode 100644 index 0000000000..2d4ac52926 --- /dev/null +++ b/ts/scripts/better-blockmap.d.ts @@ -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; + } +} diff --git a/ts/types/Settings.std.ts b/ts/types/Settings.std.ts index b57a4a7aae..9a5e528969 100644 --- a/ts/types/Settings.std.ts +++ b/ts/types/Settings.std.ts @@ -69,7 +69,7 @@ export const isAutoDownloadUpdatesSupported = ( if (isNotUpdatable(appVersion)) { return false; } - return OS.isWindows() || OS.isMacOS(); + return OS.isWindows() || OS.isMacOS() || OS.isLinuxAppImage(); }; export const shouldHideExpiringMessageBody = ( diff --git a/ts/updater/common.main.ts b/ts/updater/common.main.ts index 0710e7e18b..b600022f64 100644 --- a/ts/updater/common.main.ts +++ b/ts/updater/common.main.ts @@ -282,6 +282,10 @@ export abstract class Updater { markShouldQuit(); } + protected getUpdatesPublicKey(): Buffer { + return hexToBinary(config.get('updatesPublicKey')); + } + // // Private methods // @@ -390,12 +394,11 @@ export abstract class Updater { const { updateFilePath, signature } = downloadResult; - const publicKey = hexToBinary(config.get('updatesPublicKey')); const verified = await verifySignature( updateFilePath, this.version, signature, - publicKey + this.getUpdatesPublicKey() ); if (!verified) { // 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`; } + if (process.platform === 'linux') { + return `${prefix}-linux.yml`; + } + return `${prefix}.yml`; } @@ -1020,7 +1027,7 @@ export function getVersion(info: JSONUpdateSchema): string | null { return info && info.version; } -const validFile = /^[A-Za-z0-9.-]+$/; +const validFile = /^[A-Za-z0-9._-]+$/; export function isUpdateFileNameValid(name: string): boolean { return validFile.test(name); } @@ -1041,6 +1048,8 @@ export function getUpdateFileName( fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.zip'); } else if (platform === 'win32') { fileFilter = ({ url }) => url.includes(arch) && url.endsWith('.exe'); + } else if (platform === 'linux' && process.env.APPIMAGE != null) { + fileFilter = ({ url }) => url.endsWith('.AppImage'); } if (fileFilter) { diff --git a/ts/updater/index.main.ts b/ts/updater/index.main.ts index c08a91271f..5434d7129c 100644 --- a/ts/updater/index.main.ts +++ b/ts/updater/index.main.ts @@ -6,6 +6,7 @@ import { app } from 'electron'; import type { Updater, UpdaterOptionsType } from './common.main.js'; import { MacOSUpdater } from './macos.main.js'; import { WindowsUpdater } from './windows.main.js'; +import { LinuxAppImageUpdater } from './linuxAppImage.main.js'; import { initLinux } from './linux.main.js'; let initialized = false; @@ -38,7 +39,11 @@ export async function start(options: UpdaterOptionsType): Promise { } else if (platform === 'darwin') { updater = new MacOSUpdater(options); } else if (platform === 'linux') { - initLinux(options); + if (process.env.APPIMAGE != null) { + updater = new LinuxAppImageUpdater(options); + } else { + initLinux(options); + } } else { throw new Error(`updater/start: Unsupported platform ${platform}`); } diff --git a/ts/updater/linux.main.ts b/ts/updater/linux.main.ts index d45643fa80..26fa6999db 100644 --- a/ts/updater/linux.main.ts +++ b/ts/updater/linux.main.ts @@ -13,6 +13,7 @@ import type { LoggerType } from '../types/Logging.std.js'; import { DialogType } from '../types/Dialogs.std.js'; import * as Errors from '../types/errors.std.js'; import type { UpdaterOptionsType } from './common.main.js'; +import { appRelaunch } from '../util/relaunch.main.js'; const MIN_UBUNTU_VERSION = '22.04'; @@ -60,7 +61,7 @@ export function initLinux({ logger, getMainWindow }: UpdaterOptionsType): void { ipcMain.handle('start-update', () => { logger?.info('updater/linux: restarting'); markShouldQuit(); - app.relaunch(); + appRelaunch(); app.quit(); }); diff --git a/ts/updater/linuxAppImage.main.ts b/ts/updater/linuxAppImage.main.ts new file mode 100644 index 0000000000..4ed8c8695e --- /dev/null +++ b/ts/updater/linuxAppImage.main.ts @@ -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 { + // No installers are cached beyond the most recent one + } + + protected async installUpdate( + updateFilePath: string + ): Promise<() => Promise> { + 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 { + 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); + } +} diff --git a/ts/util/os/osMain.node.ts b/ts/util/os/osMain.node.ts index b29b2f2a48..7b6988530c 100644 --- a/ts/util/os/osMain.node.ts +++ b/ts/util/os/osMain.node.ts @@ -18,7 +18,17 @@ function getLinuxName(): string | 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 { @@ -45,6 +55,7 @@ function isLinuxUsingKDE(): boolean { const OS = { ...getOSFunctions(os.release()), getLinuxName, + isAppImage, isFlatpak, isLinuxUsingKDE, isWaylandEnabled, diff --git a/ts/util/os/shared.std.ts b/ts/util/os/shared.std.ts index 7d2c06d684..09bc8f7c5f 100644 --- a/ts/util/os/shared.std.ts +++ b/ts/util/os/shared.std.ts @@ -23,6 +23,7 @@ export type OSType = { getClassName: () => string; getName: () => string; isLinux: (minVersion?: string) => boolean; + isLinuxAppImage: () => boolean; isMacOS: (minVersion?: string) => boolean; isWindows: (minVersion?: string) => boolean; }; @@ -32,6 +33,10 @@ export function getOSFunctions(osRelease: string): OSType { const isLinux = createIsPlatform('linux', osRelease); const isWindows = createIsPlatform('win32', osRelease); + const isLinuxAppImage = (): boolean => { + return process.platform === 'linux' && process.env.APPIMAGE != null; + }; + const getName = (): string => { if (isMacOS()) { return 'macOS'; @@ -56,6 +61,7 @@ export function getOSFunctions(osRelease: string): OSType { getClassName, getName, isLinux, + isLinuxAppImage, isMacOS, isWindows, }; diff --git a/ts/util/relaunch.main.ts b/ts/util/relaunch.main.ts new file mode 100644 index 0000000000..a422162c2e --- /dev/null +++ b/ts/util/relaunch.main.ts @@ -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); +}