diff --git a/Composer/packages/client/src/components/AppUpdater.tsx b/Composer/packages/client/src/components/AppUpdater/AppUpdater.tsx similarity index 73% rename from Composer/packages/client/src/components/AppUpdater.tsx rename to Composer/packages/client/src/components/AppUpdater/AppUpdater.tsx index 9c4c4aa2b9..329d14b641 100644 --- a/Composer/packages/client/src/components/AppUpdater.tsx +++ b/Composer/packages/client/src/components/AppUpdater/AppUpdater.tsx @@ -20,8 +20,10 @@ import { useRecoilValue } from 'recoil'; import { SharedColors, NeutralColors } from '@uifabric/fluent-theme'; import { IpcRendererEvent } from 'electron'; -import { AppUpdaterStatus } from '../constants'; -import { appUpdateState, dispatcherState } from '../recoilModel'; +import { AppUpdaterStatus } from '../../constants'; +import { appUpdateState, dispatcherState } from '../../recoilModel'; + +import { breakingUpdatesMap } from './breakingUpdates/breakingUpdatesMap'; const updateAvailableDismissBtn: Partial = { root: { @@ -85,6 +87,12 @@ const downloadOptions = { installAndUpdate: 'installAndUpdate', }; +// TODO: factor this out into shared or types +type BreakingUpdateMetaData = { + explicitCheck: boolean; + uxId: string; +}; + // -------------------- AppUpdater -------------------- // export const AppUpdater: React.FC<{}> = () => { @@ -93,9 +101,11 @@ export const AppUpdater: React.FC<{}> = () => { ); const { downloadSizeInBytes, error, progressPercent, showing, status, version } = useRecoilValue(appUpdateState); const [downloadOption, setDownloadOption] = useState(downloadOptions.installAndUpdate); + const [breakingMetaData, setBreakingMetaData] = useState(undefined); const handleDismiss = useCallback(() => { setAppUpdateShowing(false); + setBreakingMetaData(undefined); if (status === AppUpdaterStatus.UPDATE_UNAVAILABLE || status === AppUpdaterStatus.UPDATE_FAILED) { setAppUpdateStatus(AppUpdaterStatus.IDLE, undefined); } @@ -118,53 +128,69 @@ export const AppUpdater: React.FC<{}> = () => { setDownloadOption(option); }, []); + // necessary? + const handleContinueFromBreakingUx = useCallback(() => { + handlePreDownloadOkay(); + }, [handlePreDownloadOkay]); + // listen for app updater events from main process useEffect(() => { - ipcRenderer.on('app-update', (_event: IpcRendererEvent, name: string, payload) => { - switch (name) { - case 'update-available': - setAppUpdateStatus(AppUpdaterStatus.UPDATE_AVAILABLE, payload.version); - setAppUpdateShowing(true); - break; - - case 'progress': { - const progress = +(payload.percent as number).toFixed(2); - setAppUpdateProgress(progress, payload.total); - break; - } + ipcRenderer.on( + 'app-update', + (_event: IpcRendererEvent, name: string, payload, breakingMetaData?: BreakingUpdateMetaData) => { + switch (name) { + case 'update-available': + setAppUpdateStatus(AppUpdaterStatus.UPDATE_AVAILABLE, payload.version); + setAppUpdateShowing(true); + break; - case 'update-in-progress': { - setAppUpdateStatus(AppUpdaterStatus.UPDATE_AVAILABLE, payload.version); - setAppUpdateShowing(true); - break; - } + case 'progress': { + const progress = +(payload.percent as number).toFixed(2); + setAppUpdateProgress(progress, payload.total); + break; + } - case 'update-not-available': { - const explicit = payload; - if (explicit) { - // the user has explicitly checked for an update via the Help menu; - // we should display some UI feedback if there are no updates available - setAppUpdateStatus(AppUpdaterStatus.UPDATE_UNAVAILABLE, undefined); + case 'update-in-progress': { + setAppUpdateStatus(AppUpdaterStatus.UPDATE_AVAILABLE, payload.version); setAppUpdateShowing(true); + break; } - break; - } - case 'update-downloaded': - setAppUpdateStatus(AppUpdaterStatus.UPDATE_SUCCEEDED, undefined); - setAppUpdateShowing(true); - break; + case 'update-not-available': { + const explicit = payload; + if (explicit) { + // the user has explicitly checked for an update via the Help menu; + // we should display some UI feedback if there are no updates available + setAppUpdateStatus(AppUpdaterStatus.UPDATE_UNAVAILABLE, undefined); + setAppUpdateShowing(true); + } + break; + } - case 'error': - setAppUpdateStatus(AppUpdaterStatus.UPDATE_FAILED, undefined); - setAppUpdateError(payload); - setAppUpdateShowing(true); - break; + case 'update-downloaded': + setAppUpdateStatus(AppUpdaterStatus.UPDATE_SUCCEEDED, undefined); + setAppUpdateShowing(true); + break; - default: - break; + case 'error': + setAppUpdateStatus(AppUpdaterStatus.UPDATE_FAILED, undefined); + setAppUpdateError(payload); + setAppUpdateShowing(true); + break; + + case 'breaking-update-available': + if (breakingMetaData) { + setBreakingMetaData(breakingMetaData); + setAppUpdateStatus(AppUpdaterStatus.BREAKING_UPDATE_AVAILABLE, payload.version); + setAppUpdateShowing(true); + } + break; + + default: + break; + } } - }); + ); }, []); const title = useMemo(() => { @@ -292,6 +318,19 @@ export const AppUpdater: React.FC<{}> = () => { const subText = status === AppUpdaterStatus.UPDATE_AVAILABLE ? `${formatMessage('Bot Framework Composer')} v${version}` : ''; + if (status === AppUpdaterStatus.BREAKING_UPDATE_AVAILABLE && showing && breakingMetaData) { + const BreakingUpdateUx = breakingUpdatesMap[breakingMetaData.uxId]; + // TODO: check if breaking update ux component is defined and handle undefined case + return ( + + ); + } + return showing ? ( > = { + 'Version1.x.xTo2.x.x': Version1To2Content, +}; diff --git a/Composer/packages/client/src/components/AppUpdater/breakingUpdates/types.ts b/Composer/packages/client/src/components/AppUpdater/breakingUpdates/types.ts new file mode 100644 index 0000000000..9c5fa3374c --- /dev/null +++ b/Composer/packages/client/src/components/AppUpdater/breakingUpdates/types.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type BreakingUpdateProps = { + explicitCheck: boolean; + /** Called when the user dismisses the breaking changes UX; stops the update flow completely. */ + onCancel: () => void; + /** Called when the breaking changes UX is ready to continue into the normal update flow. */ + onContinue: () => void; + version?: string; +}; diff --git a/Composer/packages/client/src/components/AppUpdater/breakingUpdates/version1To2.tsx b/Composer/packages/client/src/components/AppUpdater/breakingUpdates/version1To2.tsx new file mode 100644 index 0000000000..f78c3e54ec --- /dev/null +++ b/Composer/packages/client/src/components/AppUpdater/breakingUpdates/version1To2.tsx @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React, { useCallback, useState } from 'react'; +import { DefaultButton, PrimaryButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { Dialog, DialogType, IDialogContentStyles } from 'office-ui-fabric-react/lib/Dialog'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { useRecoilValue } from 'recoil'; + +import { dispatcherState, userSettingsState } from '../../../recoilModel'; + +import { BreakingUpdateProps } from './types'; + +const dismissButton: Partial = { + root: { + marginRight: '6px;', + marginLeft: 'auto', + }, +}; + +const dialogContent: Partial = { + content: { color: NeutralColors.black }, +}; + +const dialogContentWithoutHeader: Partial = { + ...dialogContent, + header: { + display: 'none', + }, + inner: { + padding: '36px 24px 24px 24px', + }, +}; + +const buttonRow = css` + display: flex; + flex-flow: row nowrap; + justify-items: flex-end; +`; + +const gotItButton = css` + margin-left: auto; +`; + +const updateCancelledCopy = css` + margin-top: 0; + margin-bottom: 27px; +`; + +type ModalState = 'Default' | 'PressedNotNow'; + +export const Version1To2Content: React.FC = (props) => { + const { explicitCheck, onCancel, onContinue } = props; + const [currentState, setCurrentState] = useState('Default'); + const userSettings = useRecoilValue(userSettingsState); + const { updateUserSettings } = useRecoilValue(dispatcherState); + const onNotNow = useCallback(() => { + if (userSettings.appUpdater.autoDownload) { + // disable auto update and notify the user + updateUserSettings({ + appUpdater: { + autoDownload: false, + }, + }); + setCurrentState('PressedNotNow'); + } else { + onCancel(); + } + }, []); + + return currentState === 'Default' ? ( + + ) : ( + + ); +}; diff --git a/Composer/packages/client/src/components/AppUpdater/index.tsx b/Composer/packages/client/src/components/AppUpdater/index.tsx new file mode 100644 index 0000000000..57fef970fe --- /dev/null +++ b/Composer/packages/client/src/components/AppUpdater/index.tsx @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { AppUpdater } from './AppUpdater'; diff --git a/Composer/packages/client/src/constants.tsx b/Composer/packages/client/src/constants.tsx index 9aa333d208..ab54f7cc02 100644 --- a/Composer/packages/client/src/constants.tsx +++ b/Composer/packages/client/src/constants.tsx @@ -418,6 +418,7 @@ export const SupportedFileTypes = [ export const USER_TOKEN_STORAGE_KEY = 'composer.userToken'; export enum AppUpdaterStatus { + BREAKING_UPDATE_AVAILABLE, IDLE, UPDATE_AVAILABLE, UPDATE_UNAVAILABLE, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/application.ts b/Composer/packages/client/src/recoilModel/dispatchers/application.ts index 7decb8f7b8..bb89d91a83 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/application.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/application.ts @@ -35,7 +35,7 @@ export const applicationDispatcher = () => { const newAppUpdateState = { ...currentAppUpdate, }; - if (status === AppUpdaterStatus.UPDATE_AVAILABLE) { + if (status === AppUpdaterStatus.UPDATE_AVAILABLE || status === AppUpdaterStatus.BREAKING_UPDATE_AVAILABLE) { newAppUpdateState.version = version; } if (status === AppUpdaterStatus.IDLE) { diff --git a/Composer/packages/electron-server/__tests__/appUpdater.test.ts b/Composer/packages/electron-server/__tests__/appUpdater.test.ts index 51fb95c86e..f6d7f9872e 100644 --- a/Composer/packages/electron-server/__tests__/appUpdater.test.ts +++ b/Composer/packages/electron-server/__tests__/appUpdater.test.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { AppUpdater } from '../src/appUpdater'; + const mockAutoUpdater = { allowDowngrade: false, autoDownload: false, @@ -23,14 +25,13 @@ jest.mock('electron', () => ({ }, })); -import { AppUpdater } from '../src/appUpdater'; - describe('App updater', () => { let appUpdater: AppUpdater; beforeEach(() => { mockAutoUpdater.allowDowngrade = false; mockAutoUpdater.autoDownload = false; appUpdater = AppUpdater.getInstance(); + (appUpdater as any).currentAppVersion = mockGetVersion(); (appUpdater as any).checkingForUpdate = false; (appUpdater as any).downloadingUpdate = false; (appUpdater as any)._downloadedUpdate = false; @@ -45,12 +46,10 @@ describe('App updater', () => { }); it('should check for updates from the nightly repo', () => { - (appUpdater as any).settings.autoDownload = true; (appUpdater as any).settings.useNightly = true; appUpdater.checkForUpdates(true); expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); - expect(mockAutoUpdater.autoDownload).toBe(true); expect((appUpdater as any).explicitCheck).toBe(true); expect(mockAutoUpdater.setFeedURL).toHaveBeenCalledWith({ provider: 'github', @@ -89,10 +88,9 @@ describe('App updater', () => { }); it('should not allow a downgrade when checking for updates from nightly to (stable or nightly)', () => { - mockGetVersion.mockReturnValueOnce('0.0.1-nightly.12345.abcdef'); + (appUpdater as any).currentAppVersion = '0.0.1-nightly.12345.abcdef'; mockAutoUpdater.allowDowngrade = true; appUpdater.checkForUpdates(); - expect(mockAutoUpdater.allowDowngrade).toBe(false); }); diff --git a/Composer/packages/electron-server/src/appUpdater.ts b/Composer/packages/electron-server/src/appUpdater/appUpdater.ts similarity index 81% rename from Composer/packages/electron-server/src/appUpdater.ts rename to Composer/packages/electron-server/src/appUpdater/appUpdater.ts index 70d8460467..dac0e4d9a9 100644 --- a/Composer/packages/electron-server/src/appUpdater.ts +++ b/Composer/packages/electron-server/src/appUpdater/appUpdater.ts @@ -8,15 +8,26 @@ import { app } from 'electron'; import { prerelease as isNightly } from 'semver'; import { AppUpdaterSettings } from '@bfc/shared'; -import logger from './utility/logger'; +import logger from '../utility/logger'; + +import { breakingUpdates } from './breakingUpdates'; + const log = logger.extend('app-updater'); +export type BreakingUpdateMetaData = { + explicitCheck: boolean; + uxId: string; +}; + let appUpdater: AppUpdater | undefined; export class AppUpdater extends EventEmitter { + private currentAppVersion = app.getVersion(); + private checkingForUpdate = false; private downloadingUpdate = false; private _downloadedUpdate = false; private explicitCheck = false; + private isBreakingUpdate = false; private updateInfo: UpdateInfo | undefined = undefined; private settings: AppUpdaterSettings = { autoDownload: false, useNightly: false }; @@ -25,6 +36,7 @@ export class AppUpdater extends EventEmitter { autoUpdater.allowDowngrade = false; autoUpdater.allowPrerelease = true; + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; // we will explicitly call the install logic autoUpdater.on('error', this.onError.bind(this)); @@ -54,10 +66,8 @@ export class AppUpdater extends EventEmitter { this.emit('update-in-progress', this.updateInfo); return; } - this.setFeedURL(); this.determineUpdatePath(); - autoUpdater.autoDownload = this.settings.autoDownload; autoUpdater.checkForUpdates(); } @@ -98,8 +108,28 @@ export class AppUpdater extends EventEmitter { log('Update available: %O', updateInfo); this.checkingForUpdate = false; this.updateInfo = updateInfo; + + // check to see if the update will include breaking changes + const breakingUpdate = breakingUpdates + .map((predicate) => predicate(this.currentAppVersion, updateInfo.version)) + .find((result) => result.breaking); + if (breakingUpdate) { + this.isBreakingUpdate = true; + // show custom UX for the breaking changes + this.emit('breaking-update-available', updateInfo, { + uxId: breakingUpdate.uxId, + explicitCheck: this.explicitCheck, + }); + return; + } + + // show standard update UX if (this.explicitCheck || !this.settings.autoDownload) { + this.isBreakingUpdate = false; this.emit('update-available', updateInfo); + } else { + // silently download + autoUpdater.downloadUpdate(); } } @@ -115,7 +145,7 @@ export class AppUpdater extends EventEmitter { private onDownloadProgress(progress: any) { log('Got update progress: %O', progress); this.downloadingUpdate = true; - if (this.explicitCheck || !this.settings.autoDownload) { + if (this.explicitCheck || !this.settings.autoDownload || this.isBreakingUpdate) { this.emit('progress', progress); } } @@ -124,7 +154,7 @@ export class AppUpdater extends EventEmitter { log('Update downloaded: %O', updateInfo); this._downloadedUpdate = true; this.updateInfo = updateInfo; - if (this.explicitCheck || !this.settings.autoDownload) { + if (this.explicitCheck || !this.settings.autoDownload || this.isBreakingUpdate) { this.emit('update-downloaded', updateInfo); } this.resetToIdle(); @@ -158,12 +188,10 @@ export class AppUpdater extends EventEmitter { } private determineUpdatePath() { - const currentVersion = app.getVersion(); - // The following paths don't need to allow downgrade: // nightly -> stable (1.0.1-nightly.x.x -> 1.0.2) // nightly -> nightly (1.0.1-nightly.x.x -> 1.0.1-nightly.y.x) - if (isNightly(currentVersion)) { + if (isNightly(this.currentAppVersion)) { const targetChannel = this.settings.useNightly ? 'nightly' : 'stable'; log(`Updating from nightly to ${targetChannel}. Not allowing downgrade.`); autoUpdater.allowDowngrade = false; @@ -173,7 +201,7 @@ export class AppUpdater extends EventEmitter { // https://github.com/npm/node-semver/blob/v7.3.2/classes/semver.js#L127 // The following path needs to allow downgrade to work: // stable -> nightly (1.0.1 -> 1.0.1-nightly.x.x) - if (!isNightly(currentVersion) && this.settings.useNightly) { + if (!isNightly(this.currentAppVersion) && this.settings.useNightly) { log(`Updating from stable to nightly. Allowing downgrade.`); autoUpdater.allowDowngrade = true; return; diff --git a/Composer/packages/electron-server/src/appUpdater/breakingUpdates/index.ts b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/index.ts new file mode 100644 index 0000000000..2af7b54690 --- /dev/null +++ b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { BreakingUpdatePredicate } from './types'; +import { version1To2 } from './version1To2'; + +/** + * Array of functions that will check the current version and latest version during an update to determine + * if the user is about to install a breaking update. If any of these checks is satisfied, the client will + * display custom UX for the offending update before allowing the user to proceed with the standard + * update flow. + */ +export const breakingUpdates: BreakingUpdatePredicate[] = [version1To2]; diff --git a/Composer/packages/electron-server/src/appUpdater/breakingUpdates/types.ts b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/types.ts new file mode 100644 index 0000000000..f6574ef2ae --- /dev/null +++ b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/types.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { BreakingUpdateId } from '@botframework-composer/types'; + +type BreakingUpdateResult = { breaking: boolean; uxId: BreakingUpdateId }; +export type BreakingUpdatePredicate = (curVersion: string, newVersion: string) => BreakingUpdateResult; diff --git a/Composer/packages/electron-server/src/appUpdater/breakingUpdates/version1To2.ts b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/version1To2.ts new file mode 100644 index 0000000000..0c24ed06a1 --- /dev/null +++ b/Composer/packages/electron-server/src/appUpdater/breakingUpdates/version1To2.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { lt, satisfies } from 'semver'; + +import { BreakingUpdatePredicate } from './types'; + +export const version1To2: BreakingUpdatePredicate = (curVersion: string, newVersion: string) => { + const breaking = lt(curVersion, '2.0.0') && satisfies(newVersion, '>= 2.0.0 < 3.0.0'); + return { breaking, uxId: 'Version1.x.xTo2.x.x' }; +}; diff --git a/Composer/packages/electron-server/src/appUpdater/index.ts b/Composer/packages/electron-server/src/appUpdater/index.ts new file mode 100644 index 0000000000..a52cb14334 --- /dev/null +++ b/Composer/packages/electron-server/src/appUpdater/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './appUpdater'; diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts index dc39551c3d..f7208fd5da 100644 --- a/Composer/packages/electron-server/src/main.ts +++ b/Composer/packages/electron-server/src/main.ts @@ -12,7 +12,7 @@ import formatMessage from 'format-message'; import { mkdirp } from 'fs-extra'; import { initAppMenu } from './appMenu'; -import { AppUpdater } from './appUpdater'; +import { AppUpdater, BreakingUpdateMetaData } from './appUpdater'; import { OneAuthService } from './auth/oneAuthService'; import { composerProtocol } from './constants'; import ElectronWindow from './electronWindow'; @@ -102,6 +102,9 @@ function initializeAppUpdater(settings: AppUpdaterSettings) { appUpdater.on('update-available', (updateInfo: UpdateInfo) => { mainWindow.webContents.send('app-update', 'update-available', updateInfo); }); + appUpdater.on('breaking-update-available', (updateInfo: UpdateInfo, breakingMetaData: BreakingUpdateMetaData) => { + mainWindow.webContents.send('app-update', 'breaking-update-available', updateInfo, breakingMetaData); + }); appUpdater.on('progress', (progress) => { mainWindow.webContents.send('app-update', 'progress', progress); }); diff --git a/Composer/packages/types/src/appUpdates.ts b/Composer/packages/types/src/appUpdates.ts new file mode 100644 index 0000000000..50effc11d4 --- /dev/null +++ b/Composer/packages/types/src/appUpdates.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Used to look up the corresponding UX to display for each breaking update */ +export type BreakingUpdateId = 'Version1.x.xTo2.x.x'; diff --git a/Composer/packages/types/src/index.ts b/Composer/packages/types/src/index.ts index 095afc0004..300aabcf88 100644 --- a/Composer/packages/types/src/index.ts +++ b/Composer/packages/types/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export * from './appUpdates'; export * from './auth'; export * from './azure'; export * from './diagnostic'; diff --git a/releases/1.4.1.md b/releases/1.4.1.md new file mode 100644 index 0000000000..aeb84675b4 --- /dev/null +++ b/releases/1.4.1.md @@ -0,0 +1,18 @@ +# 1.4.1 + +## Changelog + +## Summary + +This release mostly contains small bug fixes and performance improvements in addition to providing a UX for a breaking change upgrade. + +#### Added + +- feat: Breaking change upgrade UX from 1.4 to 2.0 ([#7388](https://github.com/microsoft/BotFramework-Composer/pull/7388)) ([@srinaath](https://github.com/srinaath)) + +#### Fixed + +- fix: add retry logic for adding app password ([#6703](https://github.com/microsoft/BotFramework-Composer/pull/6703)) +- fix: ARM token for provisioning is now passed via body. ([#6684](https://github.com/microsoft/BotFramework-Composer/pull/6684)) +- fix: Publish page now populates publish types ([#6544](https://github.com/microsoft/BotFramework-Composer/pull/6544)) ([@GeoffCoxMSFT](https://github.com/GeoffCoxMSFT)) +- fix: unify runtime path from ejecting ([#6520](https://github.com/microsoft/BotFramework-Composer/pull/6520)) ([@lei9444](https://github.com/lei9444))