From 223e4b58b249244f3f1061799f2bc6bb9fc2cdb4 Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 11 Jul 2025 17:28:18 +0200 Subject: [PATCH] Add `electron updater` with custom updater function (#56493) * Add `electron-updater` * Implement custom updater * Provide custom updater implementation to `electron-updater` * Add a light check for checksum format * Improve comments * SHA-256 -> SHA-512 * Code style fix --- pnpm-lock.yaml | 46 ++++++ .../electron-publisher-custom.js | 7 + web/packages/teleterm/dev-app-update.yml | 4 + .../teleterm/electron-builder-config.js | 1 + web/packages/teleterm/package.json | 1 + web/packages/teleterm/src/main.ts | 2 + .../src/services/appUpdater/appUpdater.ts | 57 +++++++ .../appUpdater/clientToolsUpdateProvider.ts | 142 ++++++++++++++++++ .../teleterm/src/services/appUpdater/index.ts | 19 +++ 9 files changed, 279 insertions(+) create mode 100644 web/packages/teleterm/build_resources/electron-publisher-custom.js create mode 100644 web/packages/teleterm/dev-app-update.yml create mode 100644 web/packages/teleterm/src/services/appUpdater/appUpdater.ts create mode 100644 web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts create mode 100644 web/packages/teleterm/src/services/appUpdater/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393b7d7c633..af1fd29d451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: electron-builder: specifier: ^26.0.12 version: 26.0.12 + electron-updater: + specifier: ^6.6.5 + version: 6.6.5 electron-vite: specifier: ^3.1.0 version: 3.1.0(@swc/core@1.12.9)(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)) @@ -3564,6 +3567,10 @@ packages: resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.3.2: + resolution: {integrity: sha512-7QDXJ1FwT6d9ZhG4kuObUUPY8/ENBS/Ky26O4hR5vbeoRGavgekS2Jxv+8sCn/v23aPGU2DXRWEeJuijN2ooYA==} + engines: {node: '>=12.0.0'} + builder-util@26.0.11: resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==} @@ -4179,6 +4186,9 @@ packages: electron-to-chromium@1.5.150: resolution: {integrity: sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==} + electron-updater@6.6.5: + resolution: {integrity: sha512-jnk38WfByl2Pb0cje02xls/pJkvkq3AQZI7usDCLriU23adkerLTkRrugbCPuUxUOa79nY1g/rokHPWHZFBKyA==} + electron-vite@3.1.0: resolution: {integrity: sha512-M7aAzaRvSl5VO+6KN4neJCYLHLpF/iWo5ztchI/+wMxIieDZQqpbCYfaEHHHPH6eupEzfvZdLYdPdmvGqoVe0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -5560,9 +5570,16 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6831,6 +6848,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} @@ -11062,6 +11082,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.3.2: + dependencies: + debug: 4.4.1 + sax: 1.4.1 + transitivePeerDependencies: + - supports-color + builder-util@26.0.11: dependencies: 7zip-bin: 5.2.0 @@ -11811,6 +11838,19 @@ snapshots: electron-to-chromium@1.5.150: {} + electron-updater@6.6.5: + dependencies: + builder-util-runtime: 9.3.2 + fs-extra: 10.1.0 + js-yaml: 4.1.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.2 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@3.1.0(@swc/core@1.12.9)(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)): dependencies: '@babel/core': 7.27.7 @@ -13776,8 +13816,12 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} + lodash.flattendeep@4.4.0: {} + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -15234,6 +15278,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} + tiny-warning@1.0.3: {} tinyglobby@0.2.14: diff --git a/web/packages/teleterm/build_resources/electron-publisher-custom.js b/web/packages/teleterm/build_resources/electron-publisher-custom.js new file mode 100644 index 00000000000..ac3e3054718 --- /dev/null +++ b/web/packages/teleterm/build_resources/electron-publisher-custom.js @@ -0,0 +1,7 @@ +/** + * A publisher class is required by `electron-updater` even when using a custom provider. + * Although we don't use `electron-updater` to publish updates, + * this config allows generating `app-update.yml` file at build time (kept in + * app resources), which is required at runtime by `electron-updater` to work. + */ +export default class Noop {} diff --git a/web/packages/teleterm/dev-app-update.yml b/web/packages/teleterm/dev-app-update.yml new file mode 100644 index 00000000000..38ac0d43eeb --- /dev/null +++ b/web/packages/teleterm/dev-app-update.yml @@ -0,0 +1,4 @@ +provider: custom +# Downloads updates to /teleport-connect-dev-updater directory. +# E.g. ~/Library/Caches/teleport-connect-dev-updater on macOS. +updaterCacheDirName: teleport-connect-dev-updater diff --git a/web/packages/teleterm/electron-builder-config.js b/web/packages/teleterm/electron-builder-config.js index fb50d32b510..0fc5dd9773d 100644 --- a/web/packages/teleterm/electron-builder-config.js +++ b/web/packages/teleterm/electron-builder-config.js @@ -59,6 +59,7 @@ if (process.env.TEAMID) { module.exports = { appId, asar: true, + publish: [{ provider: 'custom' }], asarUnpack: '**\\*.{node,dll}', afterPack: packed => { // @electron-universal adds the `ElectronAsarIntegrity` key to every .plist diff --git a/web/packages/teleterm/package.json b/web/packages/teleterm/package.json index 48d751ebca9..876273c787d 100644 --- a/web/packages/teleterm/package.json +++ b/web/packages/teleterm/package.json @@ -47,6 +47,7 @@ "@xterm/xterm": "^5.5.0", "electron": "37.1.0", "electron-builder": "^26.0.12", + "electron-updater": "^6.6.5", "electron-vite": "^3.1.0", "events": "3.3.0", "jest-canvas-mock": "^2.5.2", diff --git a/web/packages/teleterm/src/main.ts b/web/packages/teleterm/src/main.ts index 1bee6575d32..8b35fa91e46 100644 --- a/web/packages/teleterm/src/main.ts +++ b/web/packages/teleterm/src/main.ts @@ -31,6 +31,7 @@ import { enableWebHandlersProtection } from 'teleterm/mainProcess/protocolHandle import { manageRootClusterProxyHostAllowList } from 'teleterm/mainProcess/rootClusterProxyHostAllowList'; import { getRuntimeSettings } from 'teleterm/mainProcess/runtimeSettings'; import { WindowsManager } from 'teleterm/mainProcess/windowsManager'; +import { AppUpdater } from 'teleterm/services/appUpdater'; import { createConfigService } from 'teleterm/services/config'; import { createFileStorage } from 'teleterm/services/fileStorage'; import { createFileLoggerService, LoggerColor } from 'teleterm/services/logger'; @@ -81,6 +82,7 @@ async function initializeApp(): Promise { nativeTheme.themeSource = configService.get('theme').value; const windowsManager = new WindowsManager(appStateFileStorage, settings); + new AppUpdater(); process.on('uncaughtException', (error, origin) => { logger.error(origin, error); diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts new file mode 100644 index 00000000000..4a1439541d3 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts @@ -0,0 +1,57 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + autoUpdater, + AppUpdater as ElectronAppUpdater, +} from 'electron-updater'; +import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; + +import Logger from 'teleterm/logger'; + +import { ClientToolsUpdateProvider } from './clientToolsUpdateProvider'; + +export class AppUpdater { + private readonly logger = new Logger('AppUpdater'); + + constructor() { + autoUpdater.setFeedURL({ + provider: 'custom', + // Wraps ClientToolsUpdateProvider to allow passing getClientToolsVersion. + updateProvider: class extends ClientToolsUpdateProvider { + constructor( + options: unknown, + updater: ElectronAppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super(() => undefined, updater, runtimeOptions); + } + }, + }); + + autoUpdater.logger = this.logger; + autoUpdater.autoDownload = true; + autoUpdater.allowDowngrade = true; + autoUpdater.autoInstallOnAppQuit = true; + // Enables checking for updates and downloading them in dev mode. + // It makes testing this feature easier. + // Only installing updates requires the packaged app. + // Downloads are saved to the path specified in dev-app-update.yml. + autoUpdater.forceDevUpdateConfig = true; + } +} diff --git a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts new file mode 100644 index 00000000000..286adbf8a80 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts @@ -0,0 +1,142 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { app } from 'electron'; +import { + AppUpdater, + DebUpdater, + MacUpdater, + NsisUpdater, + Provider, + ResolvedUpdateFileInfo, + RpmUpdater, + UpdateInfo, +} from 'electron-updater'; +import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; + +const CHECKSUM_FETCH_TIMEOUT = 5_000; +// Example: 99a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea1007099a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea10070 Teleport Connect-17.5.4-mac.zip +const CHECKSUM_FORMAT = /^.+\s+.+$/; + +/** + * Implements electron-updater's `Provider` with client tools updates. + * The official docs does not provide examples for creating custom providers. + * This implementation is inspired by existing built-in providers, such as `GenericProvider`. + * https://github.com/electron-userland/electron-builder/blob/065c6a456e34e7f8c13cba483d433502b9325168/packages/electron-updater/src/providers/GenericProvider.ts + * */ +export class ClientToolsUpdateProvider extends Provider { + constructor( + private getClientToolsVersion: ClientToolsVersionGetter, + private nativeUpdater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super(runtimeOptions); + } + + /** + * Fetches metadata about the latest available update. + * This method is called during the check for updates. + */ + override async getLatestVersion(): Promise { + const clientTools = await this.getClientToolsVersion(); + + // If no client tools version is specified, return the current version + // to simulate an up-to-date state. + if (!clientTools) { + return { + version: app.getVersion(), + releaseDate: '', + path: '', + sha512: '', + files: [], + }; + } + + const { baseUrl, version } = clientTools; + const fileUrl = `https://${baseUrl}/${makeDownloadFilename(this.nativeUpdater, version)}`; + const sha512 = await fetchChecksum(fileUrl); + + return { + version, + releaseDate: '', + path: '', + sha512: '', + files: [ + { + // Effective only on Windows. + isAdminRightsRequired: true, + url: fileUrl, + sha512, + }, + ], + }; + } + + /** + * Resolves file information before downloading. + * Since full URLs are already constructed in `getLatestVersion`, + * the files are returned without modification. + */ + override resolveFiles(updateInfo: UpdateInfo): ResolvedUpdateFileInfo[] { + return updateInfo.files.map(fileInfo => ({ + url: new URL(fileInfo.url), + info: fileInfo, + })); + } +} + +/** Should return undefined when client tools version is not available. */ +export type ClientToolsVersionGetter = () => Promise< + | { + /** Base URL for downloading Teleport packages. e.g. cdn.teleport.dev. */ + baseUrl: string; + /** Version to download. */ + version: string; + } + | undefined +>; + +function makeDownloadFilename(updater: AppUpdater, version: string): string { + if (updater instanceof MacUpdater) { + return `Teleport Connect-${version}-mac.zip`; + } + if (updater instanceof NsisUpdater) { + return `Teleport Connect Setup-${version}.exe`; + } + if (updater instanceof RpmUpdater) { + return `teleport-connect-${version}.x86_64.rpm`; + } + if (updater instanceof DebUpdater) { + return `teleport-connect_${version}_amd64.deb`; + } + + throw new Error(`Unsupported app updater: ${updater?.constructor?.name}`); +} + +async function fetchChecksum(fileUrl: string): Promise { + const checksumUrl = `${fileUrl}.sha512`; + const checksum = await fetch(checksumUrl, { + signal: AbortSignal.timeout(CHECKSUM_FETCH_TIMEOUT), + }); + const checksumText = await checksum.text(); + if (!CHECKSUM_FORMAT.test(checksumText)) { + throw new Error(`Invalid checksum format ${checksumText}`); + } + + return checksumText.split(' ').at(0); +} diff --git a/web/packages/teleterm/src/services/appUpdater/index.ts b/web/packages/teleterm/src/services/appUpdater/index.ts new file mode 100644 index 00000000000..cbefb025916 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export * from './appUpdater';