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 handleClose()} />;
+ }
default: {
return assertUnreachable(dialog);
diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx
new file mode 100644
index 0000000000000..a2849fabfb5e2
--- /dev/null
+++ b/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx
@@ -0,0 +1,88 @@
+/**
+ * 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 { useEffect, useMemo } from 'react';
+
+import { ButtonIcon, H2 } from 'design';
+import DialogConfirmation, {
+ DialogContent,
+ DialogHeader,
+} from 'design/DialogConfirmation';
+import { Cross } from 'design/Icon';
+
+import { useAppContext } from 'teleterm/ui/appContextProvider';
+import { DetailsView, useAppUpdaterContext } from 'teleterm/ui/AppUpdater';
+
+export function AppUpdates(props: { hidden?: boolean; onClose(): void }) {
+ const appContext = useAppContext();
+ const { updateEvent } = useAppUpdaterContext();
+
+ const {
+ checkForAppUpdates,
+ downloadAppUpdate,
+ cancelAppUpdateDownload,
+ quitAndInstallAppUpdate,
+ changeAppUpdatesManagingCluster,
+ } = appContext.mainProcessClient;
+
+ useEffect(() => {
+ void checkForAppUpdates();
+ }, [checkForAppUpdates]);
+
+ const platform = useMemo(() => {
+ return appContext.mainProcessClient.getRuntimeSettings().platform;
+ }, [appContext.mainProcessClient]);
+
+ return (
+ ({
+ maxWidth: '420px',
+ width: '100%',
+ })}
+ >
+
+ App Updates
+
+
+
+
+
+
+ void cancelAppUpdateDownload()}
+ onDownload={() => void downloadAppUpdate()}
+ onCheckForUpdates={() => void checkForAppUpdates()}
+ onInstall={() => void quitAndInstallAppUpdate()}
+ changeManagingCluster={clusterUri =>
+ void changeAppUpdatesManagingCluster(clusterUri)
+ }
+ />
+
+
+ );
+}
diff --git a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx
index 62e72c1235347..f4b8f090739d0 100644
--- a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx
@@ -16,12 +16,13 @@
* along with this program. If not, see .
*/
-import { useRef, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import { Popover } from 'design';
import * as icons from 'design/Icon';
import { useAppContext } from 'teleterm/ui/appContextProvider';
+import { useAppUpdaterContext } from 'teleterm/ui/AppUpdater';
import { useWorkspaceServiceState } from 'teleterm/ui/services/workspacesService';
import { useNewTabOpener } from 'teleterm/ui/TabHost';
import { TopBarButton } from 'teleterm/ui/TopBar/TopBarButton';
@@ -30,6 +31,7 @@ import { Menu, MenuItem, MenuListItem } from '../components/Menu';
function useMenuItems(): MenuItem[] {
const ctx = useAppContext();
+ const appUpdaterContext = useAppUpdaterContext();
const { workspacesService, mainProcessClient, notificationsService } = ctx;
useWorkspaceServiceState();
const documentsService =
@@ -44,6 +46,10 @@ function useMenuItems(): MenuItem[] {
const { platform } = mainProcessClient.getRuntimeSettings();
const isDarwin = platform === 'darwin';
+ const supportsAppUpdates = useMemo(() => {
+ return mainProcessClient.supportsAppUpdates();
+ }, [mainProcessClient]);
+
const menuItems: (MenuItem & { isVisible: boolean })[] = [
{
title: 'Open new terminal',
@@ -80,6 +86,14 @@ function useMenuItems(): MenuItem[] {
ctx.commandLauncher.executeCommand('tsh-uninstall', undefined);
},
},
+ {
+ title: 'Check for updates…',
+ isVisible: supportsAppUpdates,
+ Icon: icons.Application,
+ onNavigate: () => {
+ appUpdaterContext.openDialog();
+ },
+ },
];
return menuItems.filter(i => i.isVisible);
diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
index c80f2cc1b3308..06b9c4a138bbd 100644
--- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts
+++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
@@ -310,6 +310,10 @@ export interface DialogConfigureSSHClients {
host?: string;
}
+export interface DialogAppUpdate {
+ kind: 'app-updates';
+}
+
export type Dialog =
| DialogClusterConnect
| DialogClusterLogout
@@ -324,4 +328,5 @@ export type Dialog =
| DialogHardwareKeyPin
| DialogHardwareKeyTouch
| DialogHardwareKeyPinChange
- | DialogHardwareKeySlotOverwrite;
+ | DialogHardwareKeySlotOverwrite
+ | DialogAppUpdate;