From c41d6ff4de65cfb2752f9cfa588ef7e5900da286 Mon Sep 17 00:00:00 2001 From: Tony Anziano Date: Tue, 21 Apr 2020 12:15:20 -0700 Subject: [PATCH] feat: Implemented Auto-Update Electron UX (#2721) * Started stubbing out auto updater. * Added logging. * Fixed build scripts. * Working rough draft of app update UX * More auto update UX polish. * More styling & visual polish. * Added client tests. * Added tests and more polish. * Fixed tests. * Linting fixes. * More linting fixes. * Addressing PR comments * Small settings tweak. Co-authored-by: Chris Whitten --- .../__tests__/components/appUpdater.test.jsx | 120 +++++++++ Composer/packages/client/setupTests.ts | 3 + Composer/packages/client/src/App.tsx | 2 + .../src/components/AppUpdater/index.tsx | 246 ++++++++++++++++++ .../src/components/AppUpdater/styles.ts | 41 +++ .../client/src/components/Header/index.jsx | 27 +- .../client/src/components/Header/styles.js | 20 ++ .../packages/client/src/constants/index.ts | 14 +- .../client/src/store/action/appUpdate.ts | 39 +++ .../packages/client/src/store/action/index.ts | 1 + Composer/packages/client/src/store/index.tsx | 7 +- .../client/src/store/reducer/index.ts | 43 ++- Composer/packages/client/src/store/types.ts | 12 +- .../__tests__/appUpdater.test.ts | 122 +++++++++ .../packages/electron-server/jest.config.js | 2 - .../packages/electron-server/package.json | 18 +- .../electron-server/scripts/common.js | 3 +- .../electron-server/scripts/windows.js | 2 +- .../electron-server/src/appUpdater.ts | 95 +++++++ .../packages/electron-server/src/constants.ts | 4 + .../electron-server/src/electronWindow.ts | 10 +- Composer/packages/electron-server/src/main.ts | 51 +++- .../packages/electron-server/src/preload.js | 9 + .../packages/electron-server/tsconfig.json | 2 +- Composer/yarn.lock | 36 +++ 25 files changed, 901 insertions(+), 28 deletions(-) create mode 100644 Composer/packages/client/__tests__/components/appUpdater.test.jsx create mode 100644 Composer/packages/client/src/components/AppUpdater/index.tsx create mode 100644 Composer/packages/client/src/components/AppUpdater/styles.ts create mode 100644 Composer/packages/client/src/store/action/appUpdate.ts create mode 100644 Composer/packages/electron-server/__tests__/appUpdater.test.ts create mode 100644 Composer/packages/electron-server/src/appUpdater.ts create mode 100644 Composer/packages/electron-server/src/constants.ts create mode 100644 Composer/packages/electron-server/src/preload.js diff --git a/Composer/packages/client/__tests__/components/appUpdater.test.jsx b/Composer/packages/client/__tests__/components/appUpdater.test.jsx new file mode 100644 index 0000000000..3801734dae --- /dev/null +++ b/Composer/packages/client/__tests__/components/appUpdater.test.jsx @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { render } from '@bfc/test-utils'; + +import { StoreContext } from '../../src/store'; +import { AppUpdater } from '../../src/components/AppUpdater'; +import { AppUpdaterStatus } from '../../src/constants'; + +describe('', () => { + let storeContext; + beforeEach(() => { + storeContext = { + actions: {}, + state: { + appUpdate: { + progressPercent: 0, + showing: true, + status: AppUpdaterStatus.IDLE, + }, + }, + }; + }); + + it('should not render anything when the modal is set to hidden', () => { + storeContext.state.appUpdate.showing = false; + const { container } = render( + + + + ); + expect(container.firstChild).toBeFalsy(); + }); + + it('should not render anything when the modal is set to hidden (even when not idle)', () => { + storeContext.state.appUpdate.showing = false; + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_UNAVAILABLE; + const { container } = render( + + + + ); + expect(container.firstChild).toBeFalsy(); + }); + + it('should render the update available dialog', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_AVAILABLE; + storeContext.state.appUpdate.version = '1.0.0'; + const { getByText } = render( + + + + ); + getByText('New update available'); + getByText('Bot Framework Composer v1.0.0'); + getByText('Install the update and restart Composer.'); + getByText('Download the new version manually.'); + }); + + it('should render the update unavailable dialog', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_UNAVAILABLE; + const { getByText } = render( + + + + ); + getByText('No updates available'); + getByText('Composer is up to date.'); + }); + + it('should render the update completed dialog', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_SUCCEEDED; + const { getByText } = render( + + + + ); + getByText('Update complete'); + getByText('Composer will restart.'); + }); + + it('should render the update in progress dialog (before total size in known)', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_IN_PROGRESS; + const { getByText } = render( + + + + ); + getByText('Update in progress'); + getByText('Downloading...'); + getByText('0% of Calculating...'); + }); + + it('should render the update in progress dialog', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_IN_PROGRESS; + storeContext.state.appUpdate.progressPercent = 23; + storeContext.state.appUpdate.downloadSizeInBytes = 14760000; + const { getByText } = render( + + + + ); + getByText('Update in progress'); + getByText('Downloading...'); + getByText('23% of 14.76MB'); + }); + + it('should render the error dialog', () => { + storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_FAILED; + storeContext.state.appUpdate.error = '408 Request timed out.'; + const { getByText } = render( + + + + ); + getByText('Update failed'); + getByText(`Couldn't complete the update: 408 Request timed out.`); + }); +}); diff --git a/Composer/packages/client/setupTests.ts b/Composer/packages/client/setupTests.ts index f0d8b681aa..7169a6cd50 100644 --- a/Composer/packages/client/setupTests.ts +++ b/Composer/packages/client/setupTests.ts @@ -23,3 +23,6 @@ expect.extend({ }; }, }); + +// for tests using Electron IPC to talk to main process +(window as any).ipcRenderer = { on: jest.fn() }; diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 12803c771e..372589dca1 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -20,6 +20,7 @@ import { CreationFlow } from './CreationFlow'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RequireAuth } from './components/RequireAuth'; import { CreationFlowStatus } from './constants'; +import { AppUpdater } from './components/AppUpdater'; initializeIcons(undefined, { disableWarnings: true }); @@ -209,6 +210,7 @@ export const App: React.FC = () => { }>{!state.onboarding.complete && } + {(window as any).__IS_ELECTRON__ && } ); 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..c5afee8528 --- /dev/null +++ b/Composer/packages/client/src/components/AppUpdater/index.tsx @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { Dialog, DialogFooter, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator'; +import { ChoiceGroup } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import formatMessage from 'format-message'; + +import { StoreContext } from '../../store'; +import { AppUpdaterStatus } from '../../constants'; + +import { dialogContent, dialogCopy, dialogFooter, optionRoot, optionIcon, updateAvailableDismissBtn } from './styles'; + +const { ipcRenderer } = window as any; + +function SelectOption(props) { + const { checked, text, key } = props; + return ( +
+ + {text} +
+ ); +} + +const downloadOptions = { + downloadOnly: 'downloadOnly', + installAndUpdate: 'installAndUpdate', +}; + +export const AppUpdater: React.FC<{}> = _props => { + const { + actions: { setAppUpdateError, setAppUpdateProgress, setAppUpdateShowing, setAppUpdateStatus }, + state: { appUpdate }, + } = useContext(StoreContext); + const { downloadSizeInBytes, error, progressPercent, showing, status, version } = appUpdate; + const [downloadOption, setDownloadOption] = useState(downloadOptions.installAndUpdate); + + const handleDismiss = useCallback(() => { + setAppUpdateShowing(false); + if (status === AppUpdaterStatus.UPDATE_UNAVAILABLE || status === AppUpdaterStatus.UPDATE_FAILED) { + setAppUpdateStatus({ status: AppUpdaterStatus.IDLE, version: undefined }); + } + }, [showing, status]); + + const handlePreDownloadOkay = useCallback(() => { + // notify main to download the update + setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_IN_PROGRESS }); + ipcRenderer.send('app-update', 'start-download'); + }, []); + + const handlePostDownloadOkay = useCallback(() => { + setAppUpdateShowing(false); + if (downloadOption === downloadOptions.installAndUpdate) { + ipcRenderer.send('app-update', 'install-update'); + } + }, [downloadOption]); + + const handleDownloadOptionChange = useCallback((_ev, option) => { + setDownloadOption(option); + }, []); + + // listen for app updater events from main process + useEffect(() => { + ipcRenderer.on('app-update', (_event, name, payload) => { + switch (name) { + case 'update-available': + setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_AVAILABLE, version: payload.version }); + setAppUpdateShowing(true); + break; + + case 'progress': { + const progress = (payload.percent as number).toFixed(2); + setAppUpdateProgress({ progressPercent: progress, downloadSizeInBytes: payload.total }); + break; + } + + case 'update-not-available': + // TODO: re-enable once we have implemented explicit "check for updates" + // setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_UNAVAILABLE }); + // setAppUpdateShowing(true); + break; + + case 'update-downloaded': + setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_SUCCEEDED }); + setAppUpdateShowing(true); + break; + + case 'error': + setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_FAILED }); + setAppUpdateError(payload); + setAppUpdateShowing(true); + break; + + default: + break; + } + }); + }, []); + + const title = useMemo(() => { + switch (status) { + case AppUpdaterStatus.UPDATE_AVAILABLE: + return formatMessage('New update available'); + + case AppUpdaterStatus.UPDATE_FAILED: + return formatMessage('Update failed'); + + case AppUpdaterStatus.UPDATE_IN_PROGRESS: + return formatMessage('Update in progress'); + + case AppUpdaterStatus.UPDATE_SUCCEEDED: + return formatMessage('Update complete'); + + case AppUpdaterStatus.UPDATE_UNAVAILABLE: + return formatMessage('No updates available'); + + case AppUpdaterStatus.IDLE: + return ''; + + default: + return ''; + } + }, [status]); + + const content = useMemo(() => { + switch (status) { + case AppUpdaterStatus.UPDATE_AVAILABLE: + return ( + + ); + + case AppUpdaterStatus.UPDATE_FAILED: + return

{`${formatMessage(`Couldn't complete the update:`)} ${error}`}

; + + case AppUpdaterStatus.UPDATE_IN_PROGRESS: { + let trimmedTotalInMB; + if (downloadSizeInBytes === undefined) { + trimmedTotalInMB = 'Calculating...'; + } else { + trimmedTotalInMB = `${((downloadSizeInBytes || 0) / 1000000).toFixed(2)}MB`; + } + const progressInHundredths = (progressPercent || 0) / 100; + return ( + + ); + } + + case AppUpdaterStatus.UPDATE_SUCCEEDED: { + const text = + downloadOption === downloadOptions.installAndUpdate + ? formatMessage('Composer will restart.') + : formatMessage('Composer will update the next time you start the app.'); + return

{text}

; + } + + case AppUpdaterStatus.UPDATE_UNAVAILABLE: + return

{formatMessage('Composer is up to date.')}

; + + case AppUpdaterStatus.IDLE: + return undefined; + + default: + return undefined; + } + }, [status, progressPercent]); + + const footer = useMemo(() => { + switch (status) { + case AppUpdaterStatus.UPDATE_AVAILABLE: + return ( +
+ + +
+ ); + + case AppUpdaterStatus.UPDATE_SUCCEEDED: + return ; + + case AppUpdaterStatus.UPDATE_FAILED: + return ; + + case AppUpdaterStatus.UPDATE_UNAVAILABLE: + return ; + + case AppUpdaterStatus.UPDATE_IN_PROGRESS: + return undefined; + + case AppUpdaterStatus.IDLE: + return undefined; + + default: + return undefined; + } + }, [status]); + + const subText = + status === AppUpdaterStatus.UPDATE_AVAILABLE ? `${formatMessage('Bot Framework Composer')} v${version}` : ''; + + return showing ? ( + + ) : null; +}; diff --git a/Composer/packages/client/src/components/AppUpdater/styles.ts b/Composer/packages/client/src/components/AppUpdater/styles.ts new file mode 100644 index 0000000000..ad497dbd8b --- /dev/null +++ b/Composer/packages/client/src/components/AppUpdater/styles.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { css } from '@emotion/core'; +import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IDialogContentStyles, IDialogFooterStyles } from 'office-ui-fabric-react/lib/Dialog'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; + +export const optionIcon = checked => css` + vertical-align: text-bottom; + font-size: 18px; + margin-right: 10px; + color: ${checked ? SharedColors.cyanBlue10 : NeutralColors.black}; +`; + +export const optionRoot = css` + width: 100%; + height: 100%; +`; + +export const dialogCopy = css` + margin: 0px; + color: #000; +`; + +export const dialogContent: Partial = { + subText: { color: NeutralColors.black }, + header: { paddingBottom: '6px' }, +}; + +export const dialogFooter: Partial = { + actions: { + marginTop: '46px', + }, +}; + +export const updateAvailableDismissBtn: Partial = { + root: { + marginRight: '6px;', + }, +}; diff --git a/Composer/packages/client/src/components/Header/index.jsx b/Composer/packages/client/src/components/Header/index.jsx index e8722635f1..d7af2ce8cc 100644 --- a/Composer/packages/client/src/components/Header/index.jsx +++ b/Composer/packages/client/src/components/Header/index.jsx @@ -4,12 +4,28 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import formatMessage from 'format-message'; +import { IconButton } from 'office-ui-fabric-react/lib/Button'; +import { useContext, useCallback } from 'react'; import composerIcon from '../../images/composerIcon.svg'; +import { AppUpdaterStatus } from '../../constants'; +import { StoreContext } from '../../store'; -import { headerContainer, title, botName } from './styles'; +import { updateAvailableIcon, headerContainer, title, botName } from './styles'; export const Header = props => { + const { + actions: { setAppUpdateShowing }, + state: { appUpdate }, + } = useContext(StoreContext); + const { showing, status } = appUpdate; + + const onUpdateAvailableClick = useCallback(() => { + setAppUpdateShowing(true); + }, []); + + const showUpdateAvailableIcon = status === AppUpdaterStatus.UPDATE_AVAILABLE && !showing; + return (
{ src={composerIcon} /> {formatMessage('Bot Framework Composer')} - {props.botName} + {showUpdateAvailableIcon && ( + + )}
); }; diff --git a/Composer/packages/client/src/components/Header/styles.js b/Composer/packages/client/src/components/Header/styles.js index 65bfdc2346..7e788c1c15 100644 --- a/Composer/packages/client/src/components/Header/styles.js +++ b/Composer/packages/client/src/components/Header/styles.js @@ -40,3 +40,23 @@ export const botName = css` font-size: 16px; color: #fff; `; + +export const updateAvailableIcon = { + icon: { + color: '#FFF', + fontSize: '20px', + }, + root: { + position: 'absolute', + height: '20px', + width: '20px', + top: 'calc(50% - 10px)', + right: '20px', + }, + rootHovered: { + backgroundColor: 'transparent', + }, + rootPressed: { + backgroundColor: 'transparent', + }, +}; diff --git a/Composer/packages/client/src/constants/index.ts b/Composer/packages/client/src/constants/index.ts index 9162d5433b..f518460b75 100644 --- a/Composer/packages/client/src/constants/index.ts +++ b/Composer/packages/client/src/constants/index.ts @@ -88,8 +88,11 @@ export enum ActionTypes { SET_USER_SETTINGS = 'SET_USER_SETTINGS', ADD_SKILL_DIALOG_BEGIN = 'ADD_SKILL_DIALOG_BEGIN', ADD_SKILL_DIALOG_END = 'ADD_SKILL_DIALOG_END', - SET_MESSAGE = 'SET_MESSAGE', + SET_APP_UPDATE_ERROR = 'SET_APP_UPDATE_ERROR', + SET_APP_UPDATE_PROGRESS = 'SET_APP_UPDATE_PROGRESS', + SET_APP_UPDATE_SHOWING = 'SET_APP_UPDATE_SHOWING', + SET_APP_UPDATE_STATUS = 'SET_APP_UPDATE_STATUS', } export const Tips = { @@ -223,6 +226,15 @@ export const SupportedFileTypes = [ export const USER_TOKEN_STORAGE_KEY = 'composer.userToken'; +export enum AppUpdaterStatus { + IDLE, + UPDATE_AVAILABLE, + UPDATE_UNAVAILABLE, + UPDATE_IN_PROGRESS, + UPDATE_FAILED, + UPDATE_SUCCEEDED, +} + export const DefaultPublishConfig = { name: 'default', type: 'localpublish', diff --git a/Composer/packages/client/src/store/action/appUpdate.ts b/Composer/packages/client/src/store/action/appUpdate.ts new file mode 100644 index 0000000000..97631fcf94 --- /dev/null +++ b/Composer/packages/client/src/store/action/appUpdate.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { ActionCreator } from '../types'; + +import { ActionTypes } from './../../constants'; + +export const setAppUpdateError: ActionCreator = ({ dispatch }, error) => { + dispatch({ + type: ActionTypes.SET_APP_UPDATE_ERROR, + payload: error, + }); +}; + +export const setAppUpdateProgress: ActionCreator = ({ dispatch }, { progressPercent, downloadSizeInBytes }) => { + dispatch({ + type: ActionTypes.SET_APP_UPDATE_PROGRESS, + payload: { + downloadSizeInBytes, + progressPercent, + }, + }); +}; + +export const setAppUpdateShowing: ActionCreator = ({ dispatch }, showing) => { + dispatch({ + type: ActionTypes.SET_APP_UPDATE_SHOWING, + payload: showing, + }); +}; + +export const setAppUpdateStatus: ActionCreator = ({ dispatch }, { status, version }) => { + dispatch({ + type: ActionTypes.SET_APP_UPDATE_STATUS, + payload: { + status, + version, + }, + }); +}; diff --git a/Composer/packages/client/src/store/action/index.ts b/Composer/packages/client/src/store/action/index.ts index aef08c8854..9f1466427d 100644 --- a/Composer/packages/client/src/store/action/index.ts +++ b/Composer/packages/client/src/store/action/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export * from './appUpdate'; export * from './dialog'; export * from './editors'; export * from './error'; diff --git a/Composer/packages/client/src/store/index.tsx b/Composer/packages/client/src/store/index.tsx index 8964042312..4d5cce2eed 100644 --- a/Composer/packages/client/src/store/index.tsx +++ b/Composer/packages/client/src/store/index.tsx @@ -12,7 +12,7 @@ import storage from '../utils/storage'; import { reducer } from './reducer'; import bindActions from './action/bindActions'; import * as actions from './action'; -import { CreationFlowStatus, BotStatus } from './../constants'; +import { CreationFlowStatus, BotStatus, AppUpdaterStatus } from './../constants'; import { State, ActionHandlers, @@ -89,6 +89,11 @@ const initialState: State = { dialogNavWidth: 180, }), announcement: undefined, + appUpdate: { + progressPercent: 0, + showing: false, + status: AppUpdaterStatus.IDLE, + }, }; interface StoreContextValue { diff --git a/Composer/packages/client/src/store/reducer/index.ts b/Composer/packages/client/src/store/reducer/index.ts index bfcfe8b273..5f1e6a4f79 100644 --- a/Composer/packages/client/src/store/reducer/index.ts +++ b/Composer/packages/client/src/store/reducer/index.ts @@ -8,7 +8,7 @@ import { indexer, dialogIndexer, lgIndexer, luIndexer, autofixReferInDialog } fr import { SensitiveProperties, LuFile, DialogInfo, importResolverGenerator } from '@bfc/shared'; import formatMessage from 'format-message'; -import { ActionTypes, FileTypes, BotStatus, Text } from '../../constants'; +import { ActionTypes, FileTypes, BotStatus, Text, AppUpdaterStatus } from '../../constants'; import { DialogSetting, ReducerFunc } from '../types'; import { UserTokenPayload } from '../action/types'; import { getExtension, getBaseName } from '../../utils'; @@ -519,6 +519,43 @@ const setMessage: ReducerFunc = (state, message) => { return state; }; +const setAppUpdateError: ReducerFunc = (state, error) => { + state.appUpdate.error = error; + return state; +}; + +const setAppUpdateProgress: ReducerFunc<{ progressPercent: number; downloadSizeInBytes: number }> = ( + state, + { downloadSizeInBytes, progressPercent } +) => { + if (downloadSizeInBytes !== state.appUpdate.downloadSizeInBytes) { + state.appUpdate.downloadSizeInBytes = downloadSizeInBytes; + } + state.appUpdate.progressPercent = progressPercent; + return state; +}; + +const setAppUpdateShowing: ReducerFunc = (state, payload) => { + state.appUpdate.showing = payload; + return state; +}; + +const setAppUpdateStatus: ReducerFunc<{ status: AppUpdaterStatus; version?: string }> = (state, payload) => { + const { status, version } = payload; + if (state.appUpdate.status !== status) { + state.appUpdate.status; + } + state.appUpdate.status = status; + if (status === AppUpdaterStatus.UPDATE_AVAILABLE) { + state.appUpdate.version = version; + } + if (status === AppUpdaterStatus.IDLE) { + state.appUpdate.progressPercent = 0; + state.appUpdate.version = undefined; + } + return state; +}; + const noOp: ReducerFunc = state => { return state; }; @@ -575,4 +612,8 @@ export const reducer = createReducer({ [ActionTypes.UPDATE_BOTSTATUS]: setBotStatus, [ActionTypes.SET_USER_SETTINGS]: setCodeEditorSettings, [ActionTypes.SET_MESSAGE]: setMessage, + [ActionTypes.SET_APP_UPDATE_ERROR]: setAppUpdateError, + [ActionTypes.SET_APP_UPDATE_PROGRESS]: setAppUpdateProgress, + [ActionTypes.SET_APP_UPDATE_SHOWING]: setAppUpdateShowing, + [ActionTypes.SET_APP_UPDATE_STATUS]: setAppUpdateStatus, }); diff --git a/Composer/packages/client/src/store/types.ts b/Composer/packages/client/src/store/types.ts index 68ba3ebdfa..7f2c9b35c3 100644 --- a/Composer/packages/client/src/store/types.ts +++ b/Composer/packages/client/src/store/types.ts @@ -7,7 +7,7 @@ import React from 'react'; import { PromptTab, BotSchemas, ProjectTemplate, DialogInfo, LgFile, LuFile, Skill, UserSettings } from '@bfc/shared'; import { JSONSchema7 } from '@bfc/extension'; -import { CreationFlowStatus, BotStatus } from '../constants'; +import { AppUpdaterStatus, CreationFlowStatus, BotStatus } from '../constants'; import { ActionType } from './action/types'; @@ -121,6 +121,7 @@ export interface State { }; userSettings: UserSettings; announcement: string | undefined; + appUpdate: AppUpdateState; } export type ReducerFunc = (state: State, payload: T) => State; @@ -154,3 +155,12 @@ export interface DesignPageLocation { focused: string; promptTab?: PromptTab; } + +export interface AppUpdateState { + downloadSizeInBytes?: number; + error?: any; + progressPercent?: number; + showing: boolean; + status: AppUpdaterStatus; + version?: string; +} diff --git a/Composer/packages/electron-server/__tests__/appUpdater.test.ts b/Composer/packages/electron-server/__tests__/appUpdater.test.ts new file mode 100644 index 0000000000..dc7d0e888b --- /dev/null +++ b/Composer/packages/electron-server/__tests__/appUpdater.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const mockAutoUpdater = { + checkForUpdates: jest.fn(), + downloadUpdate: jest.fn(), + on: jest.fn(), + quitAndInstall: jest.fn(), + setFeedURL: jest.fn(), +}; +jest.mock('electron-updater', () => ({ + get autoUpdater() { + return mockAutoUpdater; + }, +})); + +import { AppUpdater } from '../src/appUpdater'; + +describe('App updater', () => { + let appUpdater: AppUpdater; + beforeEach(() => { + appUpdater = new AppUpdater(); + mockAutoUpdater.checkForUpdates.mockClear(); + mockAutoUpdater.checkForUpdates.mockClear(); + mockAutoUpdater.downloadUpdate.mockClear(); + mockAutoUpdater.on.mockClear(); + mockAutoUpdater.quitAndInstall.mockClear(); + mockAutoUpdater.setFeedURL.mockClear(); + }); + + it('should check for updates', () => { + appUpdater.checkForUpdates(); + + expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it('should not check for updates if it is already checking for an update', () => { + (appUpdater as any).checkingForUpdate = true; + appUpdater.checkForUpdates(); + + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('should not check for an update if it is already downloading an update', () => { + (appUpdater as any).downloadingUpdate = true; + appUpdater.checkForUpdates(); + + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('should download an update', () => { + appUpdater.downloadUpdate(); + + expect(mockAutoUpdater.downloadUpdate).toHaveBeenCalled(); + }); + + it('should not download an update if it is already downloading an update', () => { + (appUpdater as any).downloadingUpdate = true; + appUpdater.downloadUpdate(); + + expect(mockAutoUpdater.downloadUpdate).not.toHaveBeenCalled(); + }); + + it('should handle an error', () => { + const emitSpy = jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).onError('some error'); + + expect(emitSpy).toHaveBeenCalledWith('error', 'some error'); + }); + + it('should handle checking for update', () => { + jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).onCheckingForUpdate(); + + expect((appUpdater as any).checkingForUpdate).toBe(true); + }); + + it('should handle an available update', () => { + const emitSpy = jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).checkingForUpdate = true; + (appUpdater as any).onUpdateAvailable('update info'); + + expect((appUpdater as any).checkingForUpdate).toBe(false); + expect(emitSpy).toHaveBeenCalledWith('update-available', 'update info'); + }); + + it('should handle no available update', () => { + const emitSpy = jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).checkingForUpdate = true; + (appUpdater as any).onUpdateNotAvailable('update info'); + + expect((appUpdater as any).checkingForUpdate).toBe(false); + expect(emitSpy).toHaveBeenCalledWith('update-not-available', 'update info'); + }); + + it('should handle download progress', () => { + const emitSpy = jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).onDownloadProgress('25%'); + + expect(emitSpy).toHaveBeenCalledWith('progress', '25%'); + }); + + it('should handle a downloaded update', () => { + const emitSpy = jest.spyOn(appUpdater, 'emit'); + (appUpdater as any).checkingForUpdate = true; + (appUpdater as any).downloadingUpdate = true; + (appUpdater as any).onUpdateDownloaded('update info'); + + expect((appUpdater as any).checkingForUpdate).toBe(false); + expect((appUpdater as any).downloadingUpdate).toBe(false); + expect(emitSpy).toHaveBeenCalledWith('update-downloaded', 'update info'); + }); + + it('should reset to idle status', () => { + (appUpdater as any).checkingForUpdate = true; + (appUpdater as any).downloadingUpdate = true; + (appUpdater as any).resetToIdle(); + + expect((appUpdater as any).checkingForUpdate).toBe(false); + expect((appUpdater as any).downloadingUpdate).toBe(false); + }); +}); diff --git a/Composer/packages/electron-server/jest.config.js b/Composer/packages/electron-server/jest.config.js index f5fbe6a1e9..537ea3eede 100644 --- a/Composer/packages/electron-server/jest.config.js +++ b/Composer/packages/electron-server/jest.config.js @@ -1,5 +1,3 @@ -const path = require('path'); - const { createConfig } = require('@bfc/test-utils'); module.exports = createConfig('electron-server', 'node'); diff --git a/Composer/packages/electron-server/package.json b/Composer/packages/electron-server/package.json index 17be3176af..c68d570922 100644 --- a/Composer/packages/electron-server/package.json +++ b/Composer/packages/electron-server/package.json @@ -2,28 +2,27 @@ "name": "@bfc/electron-server", "license": "MIT", "author": "Microsoft Corporation", - "version": "1.0.0", + "version": "0.0.1", "description": "Electron wrapper around Composer that launches Composer as a desktop application.", "main": "./build/main.js", "engines": { "node": ">=12" }, "scripts": { - "test:watch": "jest --watch", - "test": "jest", - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && ncp src/preload.js build/preload.js", "clean": "rimraf build && rimraf dist", "copy-plugins": "node scripts/copy-plugins.js", "copy-templates": "node scripts/copy-templates.js", "dist": "node scripts/electronBuilderDist.js", "dist:full": "yarn clean && yarn build && yarn copy-templates && yarn run pack && yarn copy-plugins && yarn dist", - "pack": "node scripts/electronBuilderPack.js", - "start": "cross-env NODE_ENV=development electron .", - "typecheck": "tsc --noEmit", "lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src", "lint:fix": "yarn lint --fix", + "pack": "node scripts/electronBuilderPack.js", + "start": "cross-env NODE_ENV=development electron .", "start:electron": "./node_modules/.bin/electron --inspect=7777 --remote-debugging-port=7778 .", - "start:electron:dev": "cross-env NODE_ENV=development ELECTRON_TARGET_URL=http://localhost:3000/ npm run start:electron" + "start:electron:dev": "cross-env NODE_ENV=development ELECTRON_TARGET_URL=http://localhost:3000/ npm run start:electron", + "test": "jest", + "test:watch": "jest --watch" }, "devDependencies": { "@types/archiver": "^3.0.0", @@ -48,6 +47,7 @@ "fs-extra": "^9.0.0", "js-yaml": "^3.13.1", "mock-fs": "^4.10.1", + "ncp": "2.0.0", "nodemon": "^1.18.11", "path": "^0.12.7", "rimraf": "^2.6.3", @@ -57,8 +57,8 @@ "dependencies": { "@bfc/server": "*", "debug": "4.1.1", + "electron-updater": "4.2.5", "fix-path": "^3.0.0", - "jest": "^25.3.0", "lodash": "^4.17.15" } } diff --git a/Composer/packages/electron-server/scripts/common.js b/Composer/packages/electron-server/scripts/common.js index 6a99f6ca52..dceceb4e61 100644 --- a/Composer/packages/electron-server/scripts/common.js +++ b/Composer/packages/electron-server/scripts/common.js @@ -27,7 +27,8 @@ async function writeToDist(err, files, fileName) { sha512, }; const ymlStr = yaml.safeDump(ymlInfo); - fsp.writeFileSync(path.normalize(`../dist/${fileName}`), ymlStr); + const ymlPath = path.join(__dirname, `../dist/${fileName}`); + fsp.writeFileSync(ymlPath, ymlStr); } // https://github.com/electron-userland/electron-builder/issues/3913 as hashFilAsync has been removed from latest electron-builder diff --git a/Composer/packages/electron-server/scripts/windows.js b/Composer/packages/electron-server/scripts/windows.js index 3daeb1a20f..4d7e0c165a 100644 --- a/Composer/packages/electron-server/scripts/windows.js +++ b/Composer/packages/electron-server/scripts/windows.js @@ -8,5 +8,5 @@ const { writeToDist } = common; writeLatestYmlFile().catch(e => console.error(e)); /** Generates latest-mac.yml */ async function writeLatestYmlFile() { - glob('../**/*.exe', {}, (err, files) => writeToDist(err, files, 'latest-win.yml')); + glob('../**/*.exe', {}, (err, files) => writeToDist(err, files, 'latest.yml')); } diff --git a/Composer/packages/electron-server/src/appUpdater.ts b/Composer/packages/electron-server/src/appUpdater.ts new file mode 100644 index 0000000000..3eeefda7f6 --- /dev/null +++ b/Composer/packages/electron-server/src/appUpdater.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { EventEmitter } from 'events'; + +import { autoUpdater, UpdateInfo } from 'electron-updater'; + +import logger from './utility/logger'; +const log = logger.extend('app-updater'); + +export class AppUpdater extends EventEmitter { + private checkingForUpdate = false; + private downloadingUpdate = false; + + constructor() { + super(); + + const settings = { autoDownload: false, useNightly: false }; // TODO: implement and load these settings from disk / memory + autoUpdater.allowDowngrade = false; + autoUpdater.allowPrerelease = true; + autoUpdater.autoDownload = settings.autoDownload; + autoUpdater.setFeedURL({ + provider: 'github', + repo: 'BotFramework-Composer', + owner: 'microsoft', + }); + + autoUpdater.on('error', this.onError); + autoUpdater.on('checking-for-update', this.onCheckingForUpdate.bind(this)); + autoUpdater.on('update-available', this.onUpdateAvailable.bind(this)); + autoUpdater.on('update-not-available', this.onUpdateNotAvailable.bind(this)); + autoUpdater.on('download-progress', this.onDownloadProgress.bind(this)); + autoUpdater.on('update-downloaded', this.onUpdateDownloaded.bind(this)); + logger('Initialized'); + } + + public checkForUpdates() { + if (!(this.checkingForUpdate || this.downloadingUpdate)) { + autoUpdater.checkForUpdates(); + } + } + + public downloadUpdate() { + if (!this.downloadingUpdate) { + autoUpdater.downloadUpdate(); + } + } + + public quitAndInstall() { + logger('Quitting and installing...'); + autoUpdater.quitAndInstall(); + } + + private onError(err: Error) { + logger('Got error while checking for updates: ', err); + this.resetToIdle(); + try { + this.emit('error', err); // emitting 'error' will throw an error + } catch (e) {} // eslint-disable-line + } + + private onCheckingForUpdate() { + log('Checking for updates...'); + this.checkingForUpdate = true; + } + + private onUpdateAvailable(updateInfo: UpdateInfo) { + log('Update available: %O', updateInfo); + this.checkingForUpdate = false; + this.emit('update-available', updateInfo); + } + + private onUpdateNotAvailable(updateInfo: UpdateInfo) { + log('Update not available: %O', updateInfo); + this.checkingForUpdate = false; + this.emit('update-not-available', updateInfo); + } + + private onDownloadProgress(progress: any) { + log('Got update progress: %O', progress); + this.emit('progress', progress); + } + + private onUpdateDownloaded(updateInfo: UpdateInfo) { + log('Update downloaded: %O', updateInfo); + this.resetToIdle(); + this.emit('update-downloaded', updateInfo); + } + + private resetToIdle() { + log('Resetting to idle...'); + this.checkingForUpdate = false; + this.downloadingUpdate = false; + } +} diff --git a/Composer/packages/electron-server/src/constants.ts b/Composer/packages/electron-server/src/constants.ts new file mode 100644 index 0000000000..6876da47f1 --- /dev/null +++ b/Composer/packages/electron-server/src/constants.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const composerProtocol = 'bfcomposer://'; diff --git a/Composer/packages/electron-server/src/electronWindow.ts b/Composer/packages/electron-server/src/electronWindow.ts index 48c430e7e0..1a12fba466 100644 --- a/Composer/packages/electron-server/src/electronWindow.ts +++ b/Composer/packages/electron-server/src/electronWindow.ts @@ -4,7 +4,7 @@ import { join } from 'path'; -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow, screen } from 'electron'; import { isDevelopment } from './utility/env'; import { getUnpackedAsarPath } from './utility/getUnpackedAsarPath'; @@ -21,13 +21,16 @@ export default class ElectronWindow { private constructor() { // Create the browser window. + const { width, height } = screen.getPrimaryDisplay().workAreaSize; const browserWindowOptions: Electron.BrowserWindowConstructorOptions = { - width: 800, - height: 600, + width: width * 0.75, + height: height * 0.9, webPreferences: { nodeIntegration: false, + preload: join(__dirname, 'preload.js'), }, show: false, + title: `Bot Framework Composer (v${app.getVersion()})`, }; if (process.platform === 'linux' && !isDevelopment) { // workaround for broken .AppImage icons since electron-builder@21.0.1 removed .AppImage desktop integration @@ -35,6 +38,7 @@ export default class ElectronWindow { browserWindowOptions.icon = join(getUnpackedAsarPath(), 'resources/composerIcon_1024x1024.png'); } this._currentBrowserWindow = new BrowserWindow(browserWindowOptions); + this._currentBrowserWindow.on('page-title-updated', ev => ev.preventDefault()); // preserve explicit window title } public static destroy() { diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts index 8bd3442424..0c383e0abd 100644 --- a/Composer/packages/electron-server/src/main.ts +++ b/Composer/packages/electron-server/src/main.ts @@ -4,23 +4,27 @@ import { join, resolve } from 'path'; import { mkdirp } from 'fs-extra'; -import { app } from 'electron'; +import { app, ipcMain } from 'electron'; import fixPath from 'fix-path'; +import { UpdateInfo } from 'electron-updater'; import { isDevelopment } from './utility/env'; import { isWindows } from './utility/platform'; import { getUnpackedAsarPath } from './utility/getUnpackedAsarPath'; import ElectronWindow from './electronWindow'; import log from './utility/logger'; +import { AppUpdater } from './appUpdater'; import { parseDeepLinkUrl } from './utility/url'; +import { composerProtocol } from './constants'; const error = log.extend('error'); const baseUrl = isDevelopment ? 'http://localhost:3000/' : 'http://localhost:5000/'; let deeplinkingUrl = ''; function processArgsForWindows(args: string[]): string { - if (process.argv.length > 1) { - return parseDeepLinkUrl(args[args.length - 1]); + const deepLinkUrl = args.find(arg => arg.startsWith(composerProtocol)); + if (deepLinkUrl) { + return parseDeepLinkUrl(deepLinkUrl); } return ''; } @@ -34,8 +38,45 @@ async function createAppDataDir() { await mkdirp(composerAppDataPath); } +function initializeAppUpdater() { + log('Initializing app updater...'); + const mainWindow = ElectronWindow.getInstance().browserWindow; + if (mainWindow) { + const appUpdater = new AppUpdater(); + appUpdater.on('update-available', (updateInfo: UpdateInfo) => { + // TODO: if auto/silent download is enabled in settings, don't send this event. + // instead, just download silently + mainWindow.webContents.send('app-update', 'update-available', updateInfo); + }); + appUpdater.on('progress', progress => { + mainWindow.webContents.send('app-update', 'progress', progress); + }); + appUpdater.on('update-not-available', () => { + mainWindow.webContents.send('app-update', 'update-not-available'); + }); + appUpdater.on('update-downloaded', () => { + mainWindow.webContents.send('app-update', 'update-downloaded'); + }); + appUpdater.on('error', err => { + mainWindow.webContents.send('app-update', 'error', err); + }); + ipcMain.on('app-update', (_ev, name, payload) => { + if (name === 'start-download') { + appUpdater.downloadUpdate(); + } + if (name === 'install-update') { + appUpdater.quitAndInstall(); + } + }); + appUpdater.checkForUpdates(); + } else { + throw new Error('Main application window undefined during app updater initialization.'); + } + log('App updater initialized.'); +} + async function loadServer() { - let pluginsDir = ''; + let pluginsDir = ''; // let this be assigned by start() if in development if (!isDevelopment) { // only change paths if packaged electron app const unpackedDir = getUnpackedAsarPath(); @@ -66,7 +107,6 @@ async function main() { } await mainWindow.webContents.loadURL(baseUrl + deeplinkingUrl); - mainWindow.maximize(); mainWindow.show(); mainWindow.on('closed', function() { @@ -108,6 +148,7 @@ async function run() { await loadServer(); log('Server has been loaded'); await main(); + initializeAppUpdater(); }); // Quit when all windows are closed. diff --git a/Composer/packages/electron-server/src/preload.js b/Composer/packages/electron-server/src/preload.js new file mode 100644 index 0000000000..ff5c0bc7bb --- /dev/null +++ b/Composer/packages/electron-server/src/preload.js @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { ipcRenderer } = require('electron'); // eslint-disable-line + +// expose ipcRenderer to the browser +window.ipcRenderer = ipcRenderer; +// flag to distinguish electron client from web app client +window.__IS_ELECTRON__ = true; diff --git a/Composer/packages/electron-server/tsconfig.json b/Composer/packages/electron-server/tsconfig.json index cf21032835..7fdc0ce4f6 100644 --- a/Composer/packages/electron-server/tsconfig.json +++ b/Composer/packages/electron-server/tsconfig.json @@ -9,5 +9,5 @@ "@src/*": ["src/*"] } }, - "include": ["src/main.ts"], + "include": ["src/main.ts", "src/preload.js"], } diff --git a/Composer/yarn.lock b/Composer/yarn.lock index cbbb418a29..6dccbe96b9 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -4207,6 +4207,13 @@ "@types/glob" "*" "@types/node" "*" +"@types/semver@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.1.0.tgz#c8c630d4c18cd326beff77404887596f96408408" + integrity sha512-pOKLaubrAEMUItGNpgwl0HMFPrSAFic8oSVIvfu1UwcgGNmNyK9gyhBHKmBnUTwwVvpZfkzUC0GaMgnL6P86uA== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.2" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.2.tgz#f5ac4d7a6420a99a6a45af4719f4dcd8cd907a48" @@ -8826,6 +8833,20 @@ electron-to-chromium@^1.3.390: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.413.tgz#9c457a4165c7b42e59d66dff841063eb9bfe5614" integrity sha512-Jm1Rrd3siqYHO3jftZwDljL2LYQafj3Kki5r+udqE58d0i91SkjItVJ5RwlJn9yko8i7MOcoidVKjQlgSdd1hg== +electron-updater@4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.2.5.tgz#dbced8da6f8c6fc2dc662f2776131f5a49ce018d" + integrity sha512-ir8SI3capF5pN4LTQY79bP7oqiBKjgtdDW378xVId5VcGUZ+Toei2j+fgx1mq3y4Qg19z4HqLxEZ9FqMD0T0RA== + dependencies: + "@types/semver" "^7.1.0" + builder-util-runtime "8.6.2" + fs-extra "^8.1.0" + js-yaml "^3.13.1" + lazy-val "^1.0.4" + lodash.isequal "^4.5.0" + pako "^1.0.11" + semver "^7.1.3" + electron@8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/electron/-/electron-8.0.2.tgz#8dfd62fd42529fed94040b643660f2a82b4f8e95" @@ -12881,6 +12902,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" @@ -13701,6 +13727,11 @@ natural-orderby@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/natural-orderby/-/natural-orderby-2.0.3.tgz#8623bc518ba162f8ff1cdb8941d74deb0fdcc016" integrity sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q== + +ncp@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= needle@^2.2.1: version "2.2.4" @@ -14453,6 +14484,11 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" +pako@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + pako@~1.0.5: version "1.0.10" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732"