From 14881cb08d8dcc7352f8828778dc6be0693b8a1c Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 4 Aug 2025 08:02:48 +0200 Subject: [PATCH 01/20] Implement `checkForUpdates`, `download`, `cancelDownload`, `changeManagingCluster` and `quitAndInstall` --- .../src/services/appUpdater/appUpdater.ts | 250 ++++++++++++++++-- 1 file changed, 225 insertions(+), 25 deletions(-) diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts index 409995f6ff26e..cb73a9bacc97f 100644 --- a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts @@ -16,12 +16,18 @@ * along with this program. If not, see . */ +import { rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import process from 'process'; +import { app } from 'electron'; import { autoUpdater, AppUpdater as ElectronAppUpdater, + MacUpdater, ProgressInfo, + UpdateCheckResult, UpdateInfo, } from 'electron-updater'; import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; @@ -30,6 +36,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 +55,16 @@ 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; 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 ) { const getClientToolsVersion: ClientToolsVersionGetter = async () => { await this.refreshAutoUpdatesStatus(); @@ -81,6 +93,12 @@ export class AppUpdater { autoUpdater.logger = this.logger; autoUpdater.allowDowngrade = true; + autoUpdater.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). autoUpdater.autoInstallOnAppQuit = true; // Enables checking for updates and downloading them in dev mode. // It makes testing this feature easier. @@ -89,15 +107,202 @@ export class AppUpdater { autoUpdater.forceDevUpdateConfig = true; this.unregisterEventHandlers = registerEventHandlers( - () => {}, //TODO: send the events to the window. - () => this.autoUpdatesStatus + this.emit, + () => this.autoUpdatesStatus, + () => this.shouldAutoDownload() ); + + // 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. + app.on('will-quit', () => { + if (!this.isUpdateDownloaded) { + // Workaround for Windows and Linux. + autoUpdater.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 entire Squirrel + // update directory for Connect. + if (autoUpdater instanceof MacUpdater && app.isPackaged) { + const squirrelMacPath = path.join( + os.homedir(), + 'Library', + 'Caches', + 'gravitational.teleport.connect.ShipIt' + ); + try { + rmSync(squirrelMacPath, { + recursive: true, + force: true, + maxRetries: 2, + }); + } catch { + /* empty */ + } + } + } + }); } dispose(): void { this.unregisterEventHandlers(); } + /** + * 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 an update is checked after a download has completed, the updater + * automatically transitions to the `update-downloaded` state. + */ + async checkForUpdates(): Promise { + if (this.checkForUpdatesPromise) { + this.logger.info('Check for updates already in progress.'); + return this.checkForUpdatesPromise; + } + + this.checkForUpdatesPromise = this.doCheckForUpdates(); + try { + await this.checkForUpdatesPromise; + } catch (error) { + this.logger.error('Failed to check for updates.', error); + } finally { + this.checkForUpdatesPromise = undefined; + } + } + + /** Not safe for concurrent use. */ + private async doCheckForUpdates( + opts: { + /** + * Whether this is a retry attempt. + * Used as a guard to prevent infinite loops. + */ + hasRetried?: boolean; + } = {} + ): Promise { + const result = await autoUpdater.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 ( + 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({ hasRetried: true }); + } + } + + /** Starts download. */ + async download(): Promise { + if (this.downloadPromise) { + this.logger.info('Download already in progress.'); + return this.downloadPromise; + } + + this.downloadPromise = autoUpdater.downloadUpdate(); + try { + await this.downloadPromise; + this.isUpdateDownloaded = true; + } catch (error) { + 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. + try { + await autoUpdater.netSession.closeAllConnections(); + 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(); + } + + /** + * 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 { + autoUpdater.quitAndInstall(); + } catch (error) { + this.logger.error('Failed to quit and install update', error); + } + } + + private shouldAutoDownload(): boolean { + return ( + this.autoUpdatesStatus?.enabled && + shouldAutoDownload(this.autoUpdatesStatus) + ); + } + private async refreshAutoUpdatesStatus(): Promise { const versionEnvVar = process.env[TELEPORT_TOOLS_VERSION_ENV_VAR]; const { managingClusterUri } = this.storage.get(); @@ -107,9 +312,6 @@ 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); } } @@ -126,14 +328,14 @@ export interface AppUpdaterStorage< function registerEventHandlers( 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 +346,30 @@ 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'); + } + const serializedError = { + name: error.name, + message: error.message, + cause: error.cause, + stack: error.stack, + }; emit({ kind: 'error', - error: error, + error: serializedError, update: updateInfo, autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); @@ -189,7 +391,6 @@ function registerEventHandlers( 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); @@ -198,7 +399,6 @@ function registerEventHandlers( 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); From ff9d146500a4f297e6fbdd8fc0e3f2738e057542 Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 4 Aug 2025 08:03:06 +0200 Subject: [PATCH 02/20] Expose new APIs through Electron IPC --- .../src/mainProcess/fixtures/mocks.ts | 16 ++++++++ .../teleterm/src/mainProcess/mainProcess.ts | 33 +++++++++++++++- .../src/mainProcess/mainProcessClient.ts | 38 +++++++++++++++++++ .../teleterm/src/mainProcess/types.ts | 17 +++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index c4f5c0b07db7e..7d9889a0b0981 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -177,6 +177,22 @@ export class MockMainProcessClient implements MainProcessClient { async selectDirectoryForDesktopSession() { return ''; } + + async changeAppUpdatesManagingCluster() {} + 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/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 961c778f59fc4..86f0af5d5a6dd 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -161,7 +161,12 @@ export default class MainProcess { this.appUpdater = new AppUpdater( makeAppUpdaterStorage(this.appStateFileStorage), getClusterVersions, - getDownloadBaseUrl + getDownloadBaseUrl, + event => { + this.windowsManager + .getWindow() + .webContents.send(RendererIpc.AppUpdateEvent, event); + } ); } @@ -639,6 +644,32 @@ export default class MainProcess { } ); + ipcMain.handle(MainProcessIpc.CheckForAppUpdates, () => + this.appUpdater.checkForUpdates() + ); + + ipcMain.handle( + MainProcessIpc.ChangeAppUpdatesManagingCluster, + ( + event, + args: { + clusterUri: RootClusterUri | undefined; + } + ) => this.appUpdater.changeManagingCluster(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, diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 67b21fd49507e..c9997a8d77fdd 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -18,7 +18,10 @@ import { ipcRenderer } from 'electron'; +import { ensureError } from 'shared/utils/error'; + import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { createFileStorageClient } from 'teleterm/services/fileStorage'; import { RootClusterUri } from 'teleterm/ui/uri'; @@ -199,5 +202,40 @@ export default function createMainProcessClient(): MainProcessClient { args ); }, + 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, + } + ); + }, + subscribeToAppUpdateEvents: listener => { + const ipcListener = (_, updateEvent: AppUpdateEvent) => { + // Deserialize the error. + if (updateEvent.kind === 'error') { + updateEvent.error = ensureError(updateEvent.error); + } + listener(updateEvent); + }; + + ipcRenderer.addListener(RendererIpc.AppUpdateEvent, ipcListener); + return { + cleanup: () => + ipcRenderer.removeListener(RendererIpc.AppUpdateEvent, ipcListener), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 6ce4613069fad..2f5e1f0730716 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,16 @@ export type MainProcessClient = { desktopUri: string; login: string; }): Promise; + changeAppUpdatesManagingCluster( + clusterUri: RootClusterUri | undefined + ): Promise; + checkForAppUpdates(): Promise; + downloadAppUpdate(): Promise; + cancelAppUpdateDownload(): Promise; + quitAndInstallAppUpdate(): Promise; + subscribeToAppUpdateEvents(listener: (args: AppUpdateEvent) => void): { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { @@ -311,6 +322,7 @@ export enum RendererIpc { NativeThemeUpdate = 'renderer-native-theme-update', ConnectMyComputerAgentUpdate = 'renderer-connect-my-computer-agent-update', DeepLinkLaunch = 'renderer-deep-link-launch', + AppUpdateEvent = 'renderer-app-update-event', } export enum MainProcessIpc { @@ -322,6 +334,11 @@ 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', } export enum WindowsManagerIpc { From ea28b0ce67eae179a79b0f80cfe9883fe5b67efb Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 4 Aug 2025 08:04:22 +0200 Subject: [PATCH 03/20] Add app updater context to store updater state --- web/packages/teleterm/src/ui/App.tsx | 23 +++--- .../src/ui/AppUpdater/AppUpdaterContext.tsx | 74 +++++++++++++++++++ .../teleterm/src/ui/AppUpdater/index.ts | 1 + 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx 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..01083acc16ec0 --- /dev/null +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx @@ -0,0 +1,74 @@ +/** + * 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, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { type AppUpdateEvent } from 'teleterm/services/appUpdater'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +interface AppUpdaterContext { + updateEvent: AppUpdateEvent; +} + +const AppUpdaterContext = createContext(null); + +export function AppUpdaterContextProvider(props: PropsWithChildren) { + const appContext = useAppContext(); + const [updateEvent, setUpdateEvent] = useState({ + kind: 'checking-for-update', + }); + + useEffect(() => { + const { cleanup } = + appContext.mainProcessClient.subscribeToAppUpdateEvents(setUpdateEvent); + + return cleanup; + }, [appContext]); + + const value = useMemo( + () => ({ + updateEvent, + }), + [updateEvent] + ); + + 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/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'; From c003811d9579dd378eceb18a14325c5583842fc7 Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 4 Aug 2025 08:07:55 +0200 Subject: [PATCH 04/20] Add `AppUpdates` dialog --- .../teleterm/src/mainProcess/mainProcess.ts | 23 ++++- .../src/mainProcess/mainProcessClient.ts | 7 ++ .../teleterm/src/mainProcess/types.ts | 4 + .../src/ui/AppUpdater/AppUpdaterContext.tsx | 20 ++++- .../teleterm/src/ui/ModalsHost/ModalsHost.tsx | 4 + .../src/ui/ModalsHost/modals/AppUpdates.tsx | 88 +++++++++++++++++++ .../src/ui/TopBar/AdditionalActions.tsx | 10 +++ .../src/ui/services/modals/modalsService.ts | 7 +- 8 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 86f0af5d5a6dd..87df4f5553875 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -704,7 +704,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 c9997a8d77fdd..4a3a202458bea 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -237,5 +237,12 @@ export default function createMainProcessClient(): MainProcessClient { 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 2f5e1f0730716..135898ea1957d 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -217,6 +217,9 @@ export type MainProcessClient = { subscribeToAppUpdateEvents(listener: (args: AppUpdateEvent) => void): { cleanup: () => void; }; + subscribeToOpenAppUpdateDialog(listener: () => void): { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { @@ -322,6 +325,7 @@ 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', } diff --git a/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx index 01083acc16ec0..ebfd761addef4 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx @@ -19,6 +19,7 @@ import { createContext, PropsWithChildren, + useCallback, useContext, useEffect, useMemo, @@ -30,6 +31,7 @@ import { useAppContext } from 'teleterm/ui/appContextProvider'; interface AppUpdaterContext { updateEvent: AppUpdateEvent; + openDialog(): void; } const AppUpdaterContext = createContext(null); @@ -40,18 +42,28 @@ export function AppUpdaterContextProvider(props: PropsWithChildren) { kind: 'checking-for-update', }); + const openDialog = useCallback(() => { + appContext.modalsService.openRegularDialog({ kind: 'app-updates' }); + }, [appContext]); + useEffect(() => { - const { cleanup } = + const { cleanup: cleanUpEvents } = appContext.mainProcessClient.subscribeToAppUpdateEvents(setUpdateEvent); + const { cleanup: cleanUpDialog } = + appContext.mainProcessClient.subscribeToOpenAppUpdateDialog(openDialog); - return cleanup; - }, [appContext]); + return () => { + cleanUpEvents(); + cleanUpDialog(); + }; + }, [appContext, openDialog]); const value = useMemo( () => ({ updateEvent, + openDialog, }), - [updateEvent] + [updateEvent, openDialog] ); return ( 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