diff --git a/lib/teleterm/autoupdate/service.go b/lib/teleterm/autoupdate/service.go index a548eb5dbc585..f124d8904618d 100644 --- a/lib/teleterm/autoupdate/service.go +++ b/lib/teleterm/autoupdate/service.go @@ -96,7 +96,7 @@ func (s *Service) GetClusterVersions(ctx context.Context, _ *api.GetClusterVersi mu.Lock() unreachableClusters = append(unreachableClusters, &api.UnreachableCluster{ ClusterUri: cluster.URI.String(), - ErrorMessage: err.Error(), + ErrorMessage: pingErr.Error(), }) mu.Unlock() return nil @@ -147,6 +147,7 @@ func (s *Service) GetDownloadBaseUrl(_ context.Context, _ *api.GetDownloadBaseUr func resolveBaseURL() (string, error) { envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar) if envBaseURL != "" { + // TODO(gzdunek): Validate if it's correct URL. return envBaseURL, nil } diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index c4f5c0b07db7e..e0eec52f45a88 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -177,6 +177,26 @@ export class MockMainProcessClient implements MainProcessClient { async selectDirectoryForDesktopSession() { return ''; } + + supportsAppUpdates() { + return true; + } + async changeAppUpdatesManagingCluster() {} + async maybeRemoveAppUpdatesManagingCluster() {} + async checkForAppUpdates() {} + async downloadAppUpdate() {} + async cancelAppUpdateDownload() {} + async quitAndInstallAppUpdate() {} + subscribeToAppUpdateEvents(): { + cleanup: () => void; + } { + return { cleanup: () => undefined }; + } + subscribeToOpenAppUpdateDialog(): { + cleanup: () => void; + } { + return { cleanup: () => undefined }; + } } export const makeRuntimeSettings = ( diff --git a/web/packages/teleterm/src/mainProcess/ipcSerializer.ts b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts new file mode 100644 index 0000000000000..42555704147c5 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts @@ -0,0 +1,43 @@ +/** + * 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 type SerializedError = { + name: string; + message: string; + stack?: string; + cause?: unknown; +}; + +/** Serializes an Error into a plain object for transport through Electron IPC. */ +export function serializeError(error: Error): SerializedError { + return { + name: error.name, + message: error.message, + cause: error.cause, + stack: error.stack, + }; +} + +/** Deserializes a plain object back into an Error instance. */ +export function deserializeError(serialized: SerializedError): Error { + const error = new Error(serialized.message); + error.name = serialized.name; + error.cause = serialized.cause; + error.stack = serialized.stack; + return error; +} diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 961c778f59fc4..f4dfe39151f58 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -83,6 +83,7 @@ import { removeAgentDirectory, type CreateAgentConfigFileArgs, } from './createAgentConfigFile'; +import { serializeError } from './ipcSerializer'; import { ResolveError, resolveNetworkAddress } from './resolveNetworkAddress'; import { terminateWithTimeout } from './terminateWithTimeout'; import { WindowsManager } from './windowsManager'; @@ -161,7 +162,15 @@ export default class MainProcess { this.appUpdater = new AppUpdater( makeAppUpdaterStorage(this.appStateFileStorage), getClusterVersions, - getDownloadBaseUrl + getDownloadBaseUrl, + event => { + if (event.kind === 'error') { + event.error = serializeError(event.error); + } + this.windowsManager + .getWindow() + .webContents.send(RendererIpc.AppUpdateEvent, event); + } ); } @@ -180,8 +189,8 @@ export default class MainProcess { async dispose(): Promise { this.windowsManager.dispose(); - this.appUpdater.dispose(); await Promise.all([ + this.appUpdater.dispose(), // sending usage events on tshd shutdown has 10-seconds timeout terminateWithTimeout(this.tshdProcess, 10_000, () => { this.gracefullyKillTshdProcess(); @@ -639,6 +648,46 @@ export default class MainProcess { } ); + ipcMain.on(MainProcessIpc.SupportsAppUpdates, event => { + event.returnValue = this.appUpdater.supportsUpdates(); + }); + + ipcMain.handle(MainProcessIpc.CheckForAppUpdates, () => + this.appUpdater.checkForUpdates() + ); + + ipcMain.handle( + MainProcessIpc.ChangeAppUpdatesManagingCluster, + ( + event, + args: { + clusterUri: RootClusterUri | undefined; + } + ) => this.appUpdater.changeManagingCluster(args.clusterUri) + ); + + ipcMain.handle( + MainProcessIpc.MaybeRemoveAppUpdatesManagingCluster, + ( + event, + args: { + clusterUri: RootClusterUri; + } + ) => this.appUpdater.maybeRemoveManagingCluster(args.clusterUri) + ); + + ipcMain.handle(MainProcessIpc.DownloadAppUpdate, () => + this.appUpdater.download() + ); + + ipcMain.handle(MainProcessIpc.CancelAppUpdateDownload, () => + this.appUpdater.cancelDownload() + ); + + ipcMain.handle(MainProcessIpc.QuiteAndInstallAppUpdate, () => + this.appUpdater.quitAndInstall() + ); + subscribeToTerminalContextMenuEvent(this.configService); subscribeToTabContextMenuEvent( this.settings.availableShells, @@ -673,7 +722,28 @@ export default class MainProcess { }; const macTemplate: MenuItemConstructorOptions[] = [ - { role: 'appMenu' }, + { + role: 'appMenu', + submenu: [ + { role: 'about' }, + { + label: 'Check for Updates…', + click: () => { + this.windowsManager + .getWindow() + .webContents.send(RendererIpc.OpenAppUpdateDialog); + }, + }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, { role: 'editMenu' }, viewMenuTemplate, { diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 67b21fd49507e..3e1c36cf3eed8 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -19,12 +19,14 @@ import { ipcRenderer } from 'electron'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { createFileStorageClient } from 'teleterm/services/fileStorage'; import { RootClusterUri } from 'teleterm/ui/uri'; import { createConfigServiceClient } from '../services/config'; import { openTabContextMenu } from './contextMenus/tabContextMenu'; import { openTerminalContextMenu } from './contextMenus/terminalContextMenu'; +import { deserializeError } from './ipcSerializer'; import { AgentProcessState, ChildProcessAddresses, @@ -199,5 +201,57 @@ export default function createMainProcessClient(): MainProcessClient { args ); }, + supportsAppUpdates() { + return ipcRenderer.sendSync(MainProcessIpc.SupportsAppUpdates); + }, + checkForAppUpdates() { + return ipcRenderer.invoke(MainProcessIpc.CheckForAppUpdates); + }, + downloadAppUpdate() { + return ipcRenderer.invoke(MainProcessIpc.DownloadAppUpdate); + }, + cancelAppUpdateDownload() { + return ipcRenderer.invoke(MainProcessIpc.CancelAppUpdateDownload); + }, + quitAndInstallAppUpdate() { + return ipcRenderer.invoke(MainProcessIpc.QuiteAndInstallAppUpdate); + }, + changeAppUpdatesManagingCluster(clusterUri) { + return ipcRenderer.invoke( + MainProcessIpc.ChangeAppUpdatesManagingCluster, + { + clusterUri, + } + ); + }, + maybeRemoveAppUpdatesManagingCluster(clusterUri) { + return ipcRenderer.invoke( + MainProcessIpc.MaybeRemoveAppUpdatesManagingCluster, + { + clusterUri, + } + ); + }, + subscribeToAppUpdateEvents: listener => { + const ipcListener = (_, updateEvent: AppUpdateEvent) => { + if (updateEvent.kind === 'error') { + updateEvent.error = deserializeError(updateEvent.error); + } + listener(updateEvent); + }; + + ipcRenderer.addListener(RendererIpc.AppUpdateEvent, ipcListener); + return { + cleanup: () => + ipcRenderer.removeListener(RendererIpc.AppUpdateEvent, ipcListener), + }; + }, + subscribeToOpenAppUpdateDialog: listener => { + ipcRenderer.addListener(RendererIpc.OpenAppUpdateDialog, listener); + return { + cleanup: () => + ipcRenderer.removeListener(RendererIpc.OpenAppUpdateDialog, listener), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 6ce4613069fad..c39d6e9071d4b 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -18,6 +18,7 @@ import { DeepLinkParseResult } from 'teleterm/deepLinks'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { FileStorage } from 'teleterm/services/fileStorage'; import { Document } from 'teleterm/ui/services/workspacesService'; import { RootClusterUri } from 'teleterm/ui/uri'; @@ -206,6 +207,23 @@ export type MainProcessClient = { desktopUri: string; login: string; }): Promise; + changeAppUpdatesManagingCluster( + clusterUri: RootClusterUri | undefined + ): Promise; + maybeRemoveAppUpdatesManagingCluster( + clusterUri: RootClusterUri + ): Promise; + supportsAppUpdates(): boolean; + checkForAppUpdates(): Promise; + downloadAppUpdate(): Promise; + cancelAppUpdateDownload(): Promise; + quitAndInstallAppUpdate(): Promise; + subscribeToAppUpdateEvents(listener: (args: AppUpdateEvent) => void): { + cleanup: () => void; + }; + subscribeToOpenAppUpdateDialog(listener: () => void): { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { @@ -311,6 +329,8 @@ export enum RendererIpc { NativeThemeUpdate = 'renderer-native-theme-update', ConnectMyComputerAgentUpdate = 'renderer-connect-my-computer-agent-update', DeepLinkLaunch = 'renderer-deep-link-launch', + OpenAppUpdateDialog = 'renderer-open-app-update-dialog', + AppUpdateEvent = 'renderer-app-update-event', } export enum MainProcessIpc { @@ -322,6 +342,13 @@ export enum MainProcessIpc { SaveTextToFile = 'main-process-save-text-to-file', ForceFocusWindow = 'main-process-force-focus-window', SelectDirectoryForDesktopSession = 'main-process-select-directory-for-desktop-session', + CheckForAppUpdates = 'main-process-check-for-app-updates', + DownloadAppUpdate = 'main-process-download-app-update', + CancelAppUpdateDownload = 'main-process-cancel-app-update-download', + QuiteAndInstallAppUpdate = 'main-process-quit-and-install-app-update', + ChangeAppUpdatesManagingCluster = 'main-process-change-app-updates-managing-cluster', + MaybeRemoveAppUpdatesManagingCluster = 'main-process-maybe-remove-app-updates-managing-cluster', + SupportsAppUpdates = 'main-process-supports-app-updates', } export enum WindowsManagerIpc { diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts new file mode 100644 index 0000000000000..c57787f127993 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts @@ -0,0 +1,320 @@ +/** + * 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 { createHash } from 'node:crypto'; + +import { MacUpdater } from 'electron-updater'; + +import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; +import { wait } from 'shared/utils/wait'; + +import Logger, { NullService } from 'teleterm/logger'; + +import { AppUpdateEvent, AppUpdater, AppUpdaterStorage } from './appUpdater'; + +const mockedAppVersion = '15.0.0'; + +jest.mock('electron', () => ({ + app: { + // Should be false to avoid removing real Squirrel directory in `dispose`. + isPackaged: false, + getVersion: () => mockedAppVersion, + }, + autoUpdater: { on: () => {} }, +})); + +beforeAll(() => { + Logger.init(new NullService()); + + // Mock fetching checksum. + // Creates hash from the passed URL. + global.fetch = jest.fn(async url => ({ + ok: true, + text: async () => + `${createHash('sha-512').update(url).digest('hex')} Teleport Connect-17.5.4-mac.zip`, + })) as jest.Mock; +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +function makeUpdaterStorage( + initialValue: { managingClusterUri?: string } = {} +): AppUpdaterStorage { + return { + get: () => initialValue, + put: newValue => (initialValue = newValue), + }; +} + +// MacUpdater with mocked download and install features. +class MockedMacUpdater extends MacUpdater { + constructor() { + super(undefined, { + appUpdateConfigPath: __dirname, + baseCachePath: __dirname, + isPackaged: false, + userDataPath: '', + onQuit(): void {}, + quit(): void {}, + relaunch(): void {}, + version: mockedAppVersion, + whenReady: () => Promise.resolve(), + name: 'Teleport Connect', + }); + // Prevents electron-updater from creating .updaterId file. + this.stagingUserIdPromise.value = Promise.resolve( + '153432a8-93de-577c-a76a-3a042f1d7580' + ); + } + + protected override async doDownloadUpdate(): Promise { + // Simulate download. + await wait(10); + this.dispatchUpdateDownloaded({ + ...this.updateInfoAndProvider.info, + downloadedFile: 'some-update', + }); + return ['some-update']; + } + + override quitAndInstall() {} +} + +function setUpAppUpdater(options: { + clusters: GetClusterVersionsResponse; + storage?: AppUpdaterStorage; +}) { + const clusterGetter = async () => { + return options.clusters; + }; + + const nativeUpdater = new MockedMacUpdater(); + + const checkForUpdatesSpy = jest.spyOn(nativeUpdater, 'checkForUpdates'); + const downloadUpdateSpy = jest.spyOn(nativeUpdater, 'downloadUpdate'); + let lastEvent: { value?: AppUpdateEvent } = {}; + const appUpdater = new AppUpdater( + options.storage || makeUpdaterStorage(), + clusterGetter, + async () => 'https://cdn.teleport.dev', + event => { + lastEvent.value = event; + }, + nativeUpdater + ); + + return { + appUpdater, + nativeUpdater, + checkForUpdatesSpy, + downloadUpdateSpy, + lastEvent, + }; +} + +test('auto-downloads update when all clusters are reachable', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + ], + unreachableClusters: [], + }, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: true, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + }) + ); +}); + +test('does not auto-download update when there are unreachable clusters', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + ], + unreachableClusters: [ + { + clusterUri: '/clusters/bar', + errorMessage: 'Network issue', + }, + ], + }, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: false, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(0); +}); + +test('does not auto-download update when all clusters are reachable and noAutoDownload is set', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + ], + unreachableClusters: [], + }, + }); + + await setup.appUpdater.checkForUpdates({ noAutoDownload: true }); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: false, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(0); +}); + +test('discards previous update if a new one is found that should not auto-download', async () => { + const clusters = { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + ], + unreachableClusters: [], + }; + + const setup = setUpAppUpdater({ + clusters, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + update: expect.objectContaining({ + version: '18.0.0', + }), + }) + ); + + clusters.reachableClusters = [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + + // This cluster is on newer version, so it will be providing updates. + { + clusterUri: '/clusters/bar', + toolsAutoUpdate: true, + toolsVersion: '18.0.1', + minToolsVersion: '17.0.0-aa', + }, + ]; + await setup.appUpdater.checkForUpdates({ noAutoDownload: true }); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + autoDownload: false, + kind: 'update-available', + update: expect.objectContaining({ + version: '18.0.1', + }), + }) + ); + await setup.appUpdater.dispose(); + // Check if the app is set to discard the first downloaded update on close. + expect(setup.nativeUpdater.autoInstallOnAppQuit).toBeFalsy(); +}); + +test('discards previous update if the latest check returns no update', async () => { + const clusters = { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '18.0.0', + minToolsVersion: '17.0.0-aa', + }, + ], + unreachableClusters: [], + }; + + const setup = setUpAppUpdater({ + clusters, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + update: expect.objectContaining({ + version: '18.0.0', + }), + }) + ); + + clusters.reachableClusters = []; + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-not-available', + }) + ); + await setup.appUpdater.dispose(); + // Check if the app is set to discard the first downloaded update on close. + expect(setup.nativeUpdater.autoInstallOnAppQuit).toBeFalsy(); +}); diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts index 409995f6ff26e..28e07d2cf214f 100644 --- a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts @@ -16,12 +16,21 @@ * along with this program. If not, see . */ +import { rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import process from 'process'; +import { app } from 'electron'; import { autoUpdater, - AppUpdater as ElectronAppUpdater, + DebUpdater, + MacUpdater, + AppUpdater as NativeUpdater, + NsisUpdater, ProgressInfo, + RpmUpdater, + UpdateCheckResult, UpdateInfo, } from 'electron-updater'; import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; @@ -30,6 +39,7 @@ import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/telet import { AbortError } from 'shared/utils/error'; import Logger from 'teleterm/logger'; +import { RootClusterUri } from 'teleterm/ui/uri'; import { AutoUpdatesEnabled, @@ -48,11 +58,19 @@ export class AppUpdater { private readonly logger = new Logger('AppUpdater'); private readonly unregisterEventHandlers: () => void; private autoUpdatesStatus: AutoUpdatesStatus | undefined; + private updateCheckResult: UpdateCheckResult | undefined; + private checkForUpdatesPromise: Promise | undefined; + private downloadPromise: Promise | undefined; + private isUpdateDownloaded = false; + private forceNoAutoDownload = false; constructor( - private storage: AppUpdaterStorage, - private getClusterVersions: () => Promise, - getDownloadBaseUrl: () => Promise + private readonly storage: AppUpdaterStorage, + private readonly getClusterVersions: () => Promise, + readonly getDownloadBaseUrl: () => Promise, + private readonly emit: (event: AppUpdateEvent) => void, + /** Allows overring autoUpdater in tests. */ + private nativeUpdater: NativeUpdater = autoUpdater ) { const getClientToolsVersion: ClientToolsVersionGetter = async () => { await this.refreshAutoUpdatesStatus(); @@ -65,13 +83,13 @@ export class AppUpdater { } }; - autoUpdater.setFeedURL({ + this.nativeUpdater.setFeedURL({ provider: 'custom', // Wraps ClientToolsUpdateProvider to allow passing getClientToolsVersion. updateProvider: class extends ClientToolsUpdateProvider { constructor( options: unknown, - updater: ElectronAppUpdater, + updater: NativeUpdater, runtimeOptions: ProviderRuntimeOptions ) { super(getClientToolsVersion, updater, runtimeOptions); @@ -79,23 +97,230 @@ export class AppUpdater { }, }); - autoUpdater.logger = this.logger; - autoUpdater.allowDowngrade = true; - autoUpdater.autoInstallOnAppQuit = true; + this.nativeUpdater.logger = this.logger; + this.nativeUpdater.allowDowngrade = true; + this.nativeUpdater.autoDownload = false; + // Must be set to true before any download starts. + // electron-updater registers a listener to install the update when + // the app quits, after the download has completed. + // It can be then set to false, it the update shouldn't be installed + // (except macOS). + this.nativeUpdater.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; + this.nativeUpdater.forceDevUpdateConfig = true; this.unregisterEventHandlers = registerEventHandlers( - () => {}, //TODO: send the events to the window. - () => this.autoUpdatesStatus + this.nativeUpdater, + this.emit, + () => this.autoUpdatesStatus, + () => this.shouldAutoDownload() ); } - dispose(): void { + /** Must be called before `quit` event is emitted. */ + async dispose(): Promise { this.unregisterEventHandlers(); + await this.preventInstallingOutdatedUpdates(); + } + + /** + * Determines whether updates are supported for the current distribution. + * Note: Updating `.tar.gz` archives is not supported, but `electron-updater` + * incorrectly treats them as AppImage packages. + */ + supportsUpdates(): boolean { + return ( + this.nativeUpdater instanceof MacUpdater || + this.nativeUpdater instanceof NsisUpdater || + this.nativeUpdater instanceof DebUpdater || + this.nativeUpdater instanceof RpmUpdater + ); + } + + /** + * Checks for app updates. + * + * This method enhances the standard autoUpdater.checkForUpdates() by adding + * the following behaviors: + * - It allows update checks during an ongoing download process. + * If a new update is found (or no update is available), the current download + * is canceled. + * - If downloading the update requires user confirmation, but the update has + * already been downloaded, checking for updates will transition the updater + * to the `update-downloaded` state (instead of staying in `update-available` + * state). + */ + async checkForUpdates( + options: { noAutoDownload?: boolean } = {} + ): Promise { + if (this.checkForUpdatesPromise) { + this.logger.info('Check for updates already in progress.'); + return this.checkForUpdatesPromise; + } + + this.checkForUpdatesPromise = this.doCheckForUpdates(options); + try { + await this.checkForUpdatesPromise; + } catch (error) { + // The error from autoUpdater.checkForUpdates is surfaced to the UI through error event. + this.logger.error('Failed to check for updates.', error); + } finally { + this.checkForUpdatesPromise = undefined; + } + } + + /** Not safe for concurrent use. */ + private async doCheckForUpdates( + opts: { + noAutoDownload?: boolean; + /** + * Whether this is a retry attempt. + * Used as a guard to prevent infinite loops. + */ + hasRetried?: boolean; + } = {} + ): Promise { + if (!this.supportsUpdates()) { + return; + } + + this.forceNoAutoDownload = opts.noAutoDownload; + + const result = await this.nativeUpdater.checkForUpdates(); + + const newSha = result.updateInfo?.files[0]?.sha512; + const oldSha = this.updateCheckResult?.updateInfo.files[0]?.sha512; + const isSameUpdate = newSha && oldSha && newSha === oldSha; + + this.updateCheckResult = result; + + const updateUnavailable = !result.isUpdateAvailable; + const updateChanged = !isSameUpdate; + + let downloadCanceled = false; + if (updateUnavailable || updateChanged) { + downloadCanceled = await this.cancelDownload(); + } + + if ( + result.isUpdateAvailable && + (this.shouldAutoDownload() || + // This can occur if the user manually downloads an update + // and then triggers another check for updates. + // Since the update is already downloaded, the updater should transition + // to the `update-downloaded` state automatically. + // The update file will be read from the local cache. + this.isUpdateDownloaded) + ) { + void this.download(); + return; + } + + // Retry to refresh the state so that the UI won't be showing + // a cancellation error. + if (downloadCanceled && !opts.hasRetried) { + await this.doCheckForUpdates({ ...opts, hasRetried: true }); + } + } + + /** Starts download. */ + async download(): Promise { + if (this.downloadPromise) { + this.logger.info('Download already in progress.'); + return this.downloadPromise; + } + + this.downloadPromise = this.nativeUpdater.downloadUpdate(); + try { + await this.downloadPromise; + this.isUpdateDownloaded = true; + } catch (error) { + // The error from autoUpdater.download is surfaced to the UI through error event. + this.logger.error('Failed to download update.', error); + } finally { + this.downloadPromise = undefined; + } + } + + /** Cancels download. Returns true if aborted the network request. */ + async cancelDownload(): Promise { + if (!this.downloadPromise) { + this.isUpdateDownloaded = false; + return false; + } + + // Due to a bug in electron-updater, we can't cancel downloads using cancellation + // token passed to autoUpdater.download(). + // Repeatedly starting and canceling downloads causes the updater to go + // into a broken state where it becomes unresponsive. + // To avoid this, we instead close the network connections to abort + // the current download. + await this.nativeUpdater.netSession.closeAllConnections(); + try { + await this.downloadPromise; + return false; + } catch { + return true; + } finally { + this.isUpdateDownloaded = false; + } + } + + /** + * Sets given cluster as managing app version. + * When `undefined` is passed, the managing cluster is cleared. + * + * Immediately cancels an in-progress download and then checks for updates. + */ + async changeManagingCluster( + clusterUri: RootClusterUri | undefined + ): Promise { + this.storage.put({ managingClusterUri: clusterUri }); + await this.cancelDownload(); + await this.checkForUpdates(); + } + + /** + * Removes the managing cluster if it matches the given cluster URI. + * Cancels any in-progress update that was triggered by this cluster. + */ + async maybeRemoveManagingCluster(clusterUri: RootClusterUri): Promise { + const { managingClusterUri } = this.storage.get(); + if (managingClusterUri === clusterUri) { + this.storage.put({ managingClusterUri: undefined }); + } + + // checkForUpdates will discard any update triggered by the removed managing + // cluster. If a different update is found, do not download it automatically. + // Currently, updates aren't checked in the background, and on Windows and Linux, + // users may be surprised by an admin prompt when there is an update to install + // after quitting the app. + // We may revisit this behavior in the future. For example, if we introduce + // a UI notification indicating that there's an update to be installed. + await this.checkForUpdates({ noAutoDownload: true }); + } + + /** + * Restarts the app and installs the update after it has been downloaded. + * It should only be called after update-downloaded has been emitted. + */ + quitAndInstall(): void { + try { + this.nativeUpdater.quitAndInstall(); + } catch (error) { + this.logger.error('Failed to quit and install update', error); + } + } + + private shouldAutoDownload(): boolean { + return ( + !this.forceNoAutoDownload && + this.autoUpdatesStatus?.enabled && + shouldAutoDownload(this.autoUpdatesStatus) + ); } private async refreshAutoUpdatesStatus(): Promise { @@ -107,11 +332,50 @@ export class AppUpdater { managingClusterUri, getClusterVersions: this.getClusterVersions, }); - if (this.autoUpdatesStatus.enabled) { - autoUpdater.autoDownload = shouldAutoDownload(this.autoUpdatesStatus); - } this.logger.info('Resolved auto updates status', this.autoUpdatesStatus); } + + /** + * Workaround to prevent installing outdated updates. + * electron-updater lacks support for this: once an update is downloaded, + * it will be installed on quit—even if subsequent update checks report + * no new updates. + */ + private async preventInstallingOutdatedUpdates(): Promise { + if (this.isUpdateDownloaded) { + return; + } + + // Workaround for Windows and Linux. + this.nativeUpdater.autoInstallOnAppQuit = false; + + // macOS-specific workaround: + // On macOS, electron-updater downloads the update file and, if + // `autoUpdater.autoInstallOnAppQuit` is true, passes it to the native Electron + // autoUpdater via a local server. The update is then handed off to the Squirrel + // framework for installation (either on demand or after quitting the app). + // Unfortunately, once Squirrel gets the update, it is always installed + // on quit, regardless of the `autoInstallOnAppQuit` value. + // The only workaround I've found is to manually delete the ShipItState.plist + // file so Squirrel cannot apply the update. + // The downloaded update will be overwritten with the next update. + if (this.nativeUpdater instanceof MacUpdater && app.isPackaged) { + const squirrelPlistFilePath = path.join( + os.homedir(), + 'Library', + 'Caches', + 'gravitational.teleport.connect.ShipIt', + 'ShipItState.plist' + ); + try { + await rm(squirrelPlistFilePath, { + force: true, + }); + } catch (error) { + this.logger.error(error); + } + } + } } export interface AppUpdaterStorage< @@ -125,15 +389,16 @@ export interface AppUpdaterStorage< } function registerEventHandlers( + nativeUpdater: NativeUpdater, emit: (event: AppUpdateEvent) => void, - getAutoUpdatesStatus: () => AutoUpdatesStatus + getAutoUpdatesStatus: () => AutoUpdatesStatus, + getAutoDownload: () => boolean ): () => void { // updateInfo becomes defined when an update is available (see onUpdateAvailable). // It is later attached to other events, like 'download-progress' or 'error'. let updateInfo: UpdateInfo | undefined; const onCheckingForUpdate = () => { - updateInfo = undefined; emit({ kind: 'checking-for-update', autoUpdatesStatus: getAutoUpdatesStatus(), @@ -144,30 +409,24 @@ function registerEventHandlers( emit({ kind: 'update-available', update, - autoDownload: autoUpdater.autoDownload, + autoDownload: getAutoDownload(), autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); }; - const onUpdateNotAvailable = () => + const onUpdateNotAvailable = () => { + updateInfo = undefined; emit({ kind: 'update-not-available', autoUpdatesStatus: getAutoUpdatesStatus(), }); - const onUpdateCancelled = (update: UpdateInfo) => { - emit({ - kind: 'error', - error: new AbortError('Update download was canceled'), - update, - autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, - }); }; const onError = (error: Error) => { - // Functions can't be sent through IPC. - delete error.toString; - + if (error.message.includes('net::ERR_ABORTED')) { + error = new AbortError('Update download was canceled'); + } emit({ kind: 'error', - error: error, + error, update: updateInfo, autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); @@ -186,22 +445,20 @@ function registerEventHandlers( autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); - autoUpdater.on('checking-for-update', onCheckingForUpdate); - autoUpdater.on('update-available', onUpdateAvailable); - autoUpdater.on('update-not-available', onUpdateNotAvailable); - autoUpdater.on('update-cancelled', onUpdateCancelled); - autoUpdater.on('error', onError); - autoUpdater.on('download-progress', onDownloadProgress); - autoUpdater.on('update-downloaded', onUpdateDownloaded); + nativeUpdater.on('checking-for-update', onCheckingForUpdate); + nativeUpdater.on('update-available', onUpdateAvailable); + nativeUpdater.on('update-not-available', onUpdateNotAvailable); + nativeUpdater.on('error', onError); + nativeUpdater.on('download-progress', onDownloadProgress); + nativeUpdater.on('update-downloaded', onUpdateDownloaded); return () => { - autoUpdater.off('checking-for-update', onCheckingForUpdate); - autoUpdater.off('update-available', onUpdateAvailable); - autoUpdater.off('update-not-available', onUpdateNotAvailable); - autoUpdater.off('update-cancelled', onUpdateCancelled); - autoUpdater.off('error', onError); - autoUpdater.off('download-progress', onDownloadProgress); - autoUpdater.off('update-downloaded', onUpdateDownloaded); + nativeUpdater.off('checking-for-update', onCheckingForUpdate); + nativeUpdater.off('update-available', onUpdateAvailable); + nativeUpdater.off('update-not-available', onUpdateNotAvailable); + nativeUpdater.off('error', onError); + nativeUpdater.off('download-progress', onDownloadProgress); + nativeUpdater.off('update-downloaded', onUpdateDownloaded); }; } diff --git a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts index afc5053d8b1e3..7c421c82067f7 100644 --- a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts +++ b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts @@ -68,7 +68,7 @@ export class ClientToolsUpdateProvider extends Provider { } const { baseUrl, version } = clientTools; - const fileUrl = `https://${baseUrl}/${makeDownloadFilename(this.nativeUpdater, version)}`; + const fileUrl = `${baseUrl}/${makeDownloadFilename(this.nativeUpdater, version)}`; const sha512 = await fetchChecksum(fileUrl); return { @@ -138,7 +138,8 @@ async function fetchChecksum(fileUrl: string): Promise { `Could not retrieve checksum from "${response.url}" (${response.status} ${response.statusText}).` ); } - const checksumText = await response.text(); + // Trim the response which ends with a new line. + const checksumText = (await response.text()).trim(); if (!CHECKSUM_FORMAT.test(checksumText)) { throw new Error(`Invalid checksum format ${checksumText}`); } diff --git a/web/packages/teleterm/src/ui/App.tsx b/web/packages/teleterm/src/ui/App.tsx index ba05e9cf37950..5356514b244e7 100644 --- a/web/packages/teleterm/src/ui/App.tsx +++ b/web/packages/teleterm/src/ui/App.tsx @@ -21,6 +21,7 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { AppInitializer } from 'teleterm/ui/AppInitializer'; +import { AppUpdaterContextProvider } from 'teleterm/ui/AppUpdater'; import AppContext from './appContext'; import AppContextProvider from './appContextProvider'; @@ -41,17 +42,19 @@ export const App: React.FC<{ - - - - - + + + + + + - - - - - + + + + + + diff --git a/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx new file mode 100644 index 0000000000000..ebfd761addef4 --- /dev/null +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx @@ -0,0 +1,86 @@ +/** + * 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 { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { type AppUpdateEvent } from 'teleterm/services/appUpdater'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +interface AppUpdaterContext { + updateEvent: AppUpdateEvent; + openDialog(): void; +} + +const AppUpdaterContext = createContext(null); + +export function AppUpdaterContextProvider(props: PropsWithChildren) { + const appContext = useAppContext(); + const [updateEvent, setUpdateEvent] = useState({ + kind: 'checking-for-update', + }); + + const openDialog = useCallback(() => { + appContext.modalsService.openRegularDialog({ kind: 'app-updates' }); + }, [appContext]); + + useEffect(() => { + const { cleanup: cleanUpEvents } = + appContext.mainProcessClient.subscribeToAppUpdateEvents(setUpdateEvent); + const { cleanup: cleanUpDialog } = + appContext.mainProcessClient.subscribeToOpenAppUpdateDialog(openDialog); + + return () => { + cleanUpEvents(); + cleanUpDialog(); + }; + }, [appContext, openDialog]); + + const value = useMemo( + () => ({ + updateEvent, + openDialog, + }), + [updateEvent, openDialog] + ); + + return ( + + {props.children} + + ); +} + +export const useAppUpdaterContext = () => { + const context = useContext(AppUpdaterContext); + + if (!context) { + throw new Error( + 'useAppUpdaterContext must be used within an AppUpdaterContext' + ); + } + + return context; +}; diff --git a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx index 8c49998bc96c4..5f98b8018b2a7 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useId } from 'react'; +import { useId, useState } from 'react'; import { Alert, @@ -89,6 +89,7 @@ export function DetailsView({ onDownload={onDownload} onCancelDownload={onCancelDownload} onInstall={onInstall} + key={JSON.stringify(updateEvent)} /> ); @@ -109,6 +110,7 @@ function UpdaterState({ onCancelDownload(): void; onInstall(): void; }) { + const [downloadStarted, setDownloadStarted] = useState(false); switch (event.kind) { case 'checking-for-update': return ( @@ -126,12 +128,18 @@ function UpdaterState({ return ( - {event.autoDownload ? ( + {event.autoDownload || downloadStarted ? ( Starting Download… ) : ( - + { + setDownloadStarted(true); + onDownload(); + }} + > Download )} diff --git a/web/packages/teleterm/src/ui/AppUpdater/index.ts b/web/packages/teleterm/src/ui/AppUpdater/index.ts index 81de53b7c4c90..25b472fb51672 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/index.ts +++ b/web/packages/teleterm/src/ui/AppUpdater/index.ts @@ -18,3 +18,4 @@ export * from './DetailsView'; export * from './WidgetView'; +export * from './AppUpdaterContext'; diff --git a/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts b/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts index 560cd93dc563b..90683da79f324 100644 --- a/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts +++ b/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts @@ -18,6 +18,7 @@ import { useAsync } from 'shared/hooks/useAsync'; +import { useLogger } from 'teleterm/ui/hooks/useLogger'; import { RootClusterUri } from 'teleterm/ui/uri'; import { useAppContext } from '../appContextProvider'; @@ -28,8 +29,15 @@ export function useClusterLogout({ clusterUri: RootClusterUri; }) { const ctx = useAppContext(); + const logger = useLogger('useClusterLogout'); const [{ status, statusText }, removeCluster] = useAsync(async () => { await ctx.clustersService.logout(clusterUri); + // This function checks for updates, do not wait for it. + ctx.mainProcessClient + .maybeRemoveAppUpdatesManagingCluster(clusterUri) + .catch(err => { + logger.error('Failed to remove managing cluster', err); + }); if (ctx.workspacesService.getRootClusterUri() === clusterUri) { const [firstConnectedWorkspace] = diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index 77cc1037883a1..4e4f8fc50c4aa 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -28,6 +28,7 @@ import { ClusterLogout } from '../ClusterLogout'; import { ResourceSearchErrors } from '../Search/ResourceSearchErrors'; import { assertUnreachable } from '../utils'; import { ConfigureSSHClients } from '../Vnet/ConfigureSSHClients'; +import { AppUpdates } from './modals/AppUpdates'; import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind'; import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys'; import { ReAuthenticate } from './modals/ReAuthenticate'; @@ -293,6 +294,9 @@ function renderDialog({ /> ); } + case 'app-updates': { + return