diff --git a/packages/app/client/src/commands/uiCommands.ts b/packages/app/client/src/commands/uiCommands.ts index 26949f1ab..fcaf7c12c 100644 --- a/packages/app/client/src/commands/uiCommands.ts +++ b/packages/app/client/src/commands/uiCommands.ts @@ -39,7 +39,10 @@ import { BotCreationDialog, DialogService, PostMigrationDialogContainer, - SecretPromptDialogContainer + SecretPromptDialogContainer, + UpdateAvailableDialogContainer, + UpdateUnavailableDialogContainer, + ProgressIndicatorContainer } from '../ui/dialogs'; import { store } from '../data/store'; import * as EditorActions from '../data/action/editorActions'; @@ -129,7 +132,27 @@ export function registerCommands(commandRegistry: CommandRegistry) { DialogService.showDialog(PostMigrationDialogContainer); }); + // --------------------------------------------------------------------------- + // Shows the progress indicator component + commandRegistry.registerCommand(UI.ShowProgressIndicator, async (props?: ProgressIndicatorPayload) => { + return await DialogService.showDialog(ProgressIndicatorContainer, props).catch(e => console.error(e)); + }); + + // --------------------------------------------------------------------------- + // Updates the progress of the progress indicator component commandRegistry.registerCommand(UI.UpdateProgressIndicator, (value: ProgressIndicatorPayload) => { store.dispatch(updateProgressIndicator(value)); }); + + // --------------------------------------------------------------------------- + // Shows the dialog telling the user that an update is available + commandRegistry.registerCommand(UI.ShowUpdateAvailableDialog, async (version: string = '') => { + return await DialogService.showDialog(UpdateAvailableDialogContainer, { version }).catch(e => console.error(e)); + }); + + // --------------------------------------------------------------------------- + // Shows the dialog telling the user that an update is unavailable + commandRegistry.registerCommand(UI.ShowUpdateUnavailableDialog, async () => { + return await DialogService.showDialog(UpdateUnavailableDialogContainer).catch(e => console.error(e)); + }); } diff --git a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss index 53cfea13a..08a0de986 100644 --- a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss +++ b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss @@ -11,12 +11,14 @@ } .multi-input-row { - width: 420px; justify-content: space-between; } -.small-input input { - width: 200px; +.input-container { + width: 100%; + & + div { + margin-left: 8px; + } } .bot-create-form { @@ -53,21 +55,20 @@ .key { width: 175px; margin: 16px 145px 0 32px; + > label[aria-disabled] { + color: initial; + } } .encrypt-key-check-box { margin-top: 30px; - - span { - width: auto; - } } .actions-list { list-style: none; position: absolute; right: 106px; // when re-enabling 'Generate new secret' link, original value: 0; - bottom: 3px; + bottom: 12.5px; padding: 0; margin: 0; diff --git a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss.d.ts b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss.d.ts index 6de991fe5..6aef6f626 100644 --- a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss.d.ts +++ b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.scss.d.ts @@ -1,7 +1,7 @@ // This is a generated file. Changes are likely to result in being overwritten export const main: string; export const multiInputRow: string; -export const smallInput: string; +export const inputContainer: string; export const botCreateForm: string; export const checkboxAnchorContainer: string; export const link: string; diff --git a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.tsx b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.tsx index a8a4c392c..035eb2f7c 100644 --- a/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.tsx +++ b/packages/app/client/src/ui/dialogs/botCreationDialog/botCreationDialog.tsx @@ -120,14 +120,14 @@ export class BotCreationDialog extends React.Component<{}, BotCreationDialogStat { endpointWarning && { endpointWarning } } ({ + DialogService: { + get hideDialog() { return mockHideDialog; } + } +})); + +jest.mock('../../dialogs', () => ({})); + +describe('UpdateAvailableDialog', () => { + let wrapper; + let node; + let instance; + + beforeEach(() => { + wrapper = mount( + + + + ); + + node = wrapper.find(UpdateAvailableDialog); + instance = node.instance(); + mockHideDialog = jest.fn(_ => null); + }); + + it('should render deeply', () => { + expect(wrapper.find(UpdateAvailableDialogContainer)).not.toBe(null); + expect(node.find(UpdateAvailableDialog)).not.toBe(null); + }); + + it('should change state when the install after download checkbox is toggled', () => { + instance.setState({ installAfterDownload: false }); + + instance.onChangeInstallAfterDownload(); + + const state = instance.state; + expect(state.installAfterDownload).toBe(true); + }); + + it('should close properly', () => { + instance.props.onCloseClick(); + + expect(mockHideDialog).toHaveBeenCalledWith(null); + }); + + it('should close and return the passed in value when "Download" is clicked', () => { + instance.props.onDownloadClick(true); + + expect(mockHideDialog).toHaveBeenCalledWith({ installAfterDownload: true }); + }); +}); diff --git a/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialog.tsx b/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialog.tsx new file mode 100644 index 000000000..1f97afbcb --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialog.tsx @@ -0,0 +1,76 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import * as React from 'react'; +import { Dialog, DialogFooter, PrimaryButton, DefaultButton, Checkbox } from '@bfemulator/ui-react'; + +export interface UpdateAvailableDialogProps { + onCloseClick?: () => any; + onDownloadClick?: (installAfterDownload: boolean) => any; + version?: string; +} + +export interface UpdateAvailableDialogState { + installAfterDownload: boolean; +} + +export class UpdateAvailableDialog extends React.Component { + constructor(props: UpdateAvailableDialogProps) { + super(props); + + this.state = { installAfterDownload: false }; + } + + public render(): JSX.Element { + const { onCloseClick, onDownloadClick, version } = this.props; + const { installAfterDownload } = this.state; + const { onChangeInstallAfterDownload } = this; + + return ( + +

Bot Framework Emulator { version } is available. Would you like to download the new version?

+ + + + onDownloadClick(this.state.installAfterDownload) }/> + +
+ ); + } + + private onChangeInstallAfterDownload = (): void => { + this.setState({ installAfterDownload: !this.state.installAfterDownload }); + } +} diff --git a/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialogContainer.ts b/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialogContainer.ts new file mode 100644 index 000000000..ea725072d --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateAvailableDialog/updateAvailableDialogContainer.ts @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { UpdateAvailableDialog, UpdateAvailableDialogProps } from './updateAvailableDialog'; +import { connect } from 'react-redux'; +import { DialogService } from '../service'; + +function mapDispatchToProps(_dispatch: any): UpdateAvailableDialogProps { + return { + onCloseClick: () => DialogService.hideDialog(null), + onDownloadClick: (installAfterDownload: boolean) => { + DialogService.hideDialog({ installAfterDownload }); + } + }; +} + +export const UpdateAvailableDialogContainer = connect(null, mapDispatchToProps)(UpdateAvailableDialog); diff --git a/packages/app/client/src/ui/dialogs/updateUnavailableDialog/index.ts b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/index.ts new file mode 100644 index 000000000..e4b3f92a2 --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/index.ts @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +export * from './updateUnavailableDialogContainer'; diff --git a/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.spec.tsx b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.spec.tsx new file mode 100644 index 000000000..a29c22817 --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.spec.tsx @@ -0,0 +1,78 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { UpdateUnavailableDialog } from './updateUnavailableDialog'; +import { UpdateUnavailableDialogContainer } from './updateUnavailableDialogContainer'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; +import { navBar } from '../../../data/reducer/navBar'; +import * as React from 'react'; +import { mount } from 'enzyme'; + +let mockHideDialog; +jest.mock('../service', () => ({ + DialogService: { + get hideDialog() { return mockHideDialog; } + } +})); + +jest.mock('../../dialogs', () => ({})); + +describe('UpdateUnavailableDialog', () => { + let wrapper; + let node; + let instance; + + beforeEach(() => { + wrapper = mount( + + + + ); + + node = wrapper.find(UpdateUnavailableDialog); + instance = node.instance(); + mockHideDialog = jest.fn(_ => null); + }); + + it('should render deeply', () => { + expect(wrapper.find(UpdateUnavailableDialogContainer)).not.toBe(null); + expect(node.find(UpdateUnavailableDialog)).not.toBe(null); + }); + + it('should close properly', () => { + instance.props.onCloseClick(); + + expect(mockHideDialog).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.tsx b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.tsx new file mode 100644 index 000000000..c2f61ca18 --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialog.tsx @@ -0,0 +1,58 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import * as React from 'react'; +import { Dialog, DialogFooter, PrimaryButton } from '@bfemulator/ui-react'; + +export interface UpdateUnavailableDialogProps { + onCloseClick?: () => any; +} + +export class UpdateUnavailableDialog extends React.Component { + constructor(props: UpdateUnavailableDialogProps) { + super(props); + } + + public render(): JSX.Element { + const { onCloseClick } = this.props; + + return ( + +

There are no updates available for download.

+ + + +
+ ); + } +} diff --git a/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialogContainer.ts b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialogContainer.ts new file mode 100644 index 000000000..1cb47fbe7 --- /dev/null +++ b/packages/app/client/src/ui/dialogs/updateUnavailableDialog/updateUnavailableDialogContainer.ts @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { connect } from 'react-redux'; +import { DialogService } from '../service'; +import { UpdateUnavailableDialog, UpdateUnavailableDialogProps } from './updateUnavailableDialog'; + +function mapDispatchToProps(_dispatch: any): UpdateUnavailableDialogProps { + return { + onCloseClick: () => DialogService.hideDialog(null) + }; +} + +export const UpdateUnavailableDialogContainer = connect(null, mapDispatchToProps)(UpdateUnavailableDialog); diff --git a/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx b/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx index 448a4c5fa..488765735 100644 --- a/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx +++ b/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx @@ -65,13 +65,15 @@ interface AppSettingsEditorState { } const defaultAppSettings: FrameworkSettings = { + autoUpdate: true, bypassNgrokLocalhost: true, locale: '', localhost: '', ngrokPath: '', stateSizeLimit: 64, use10Tokens: false, - useCodeValidation: false + useCodeValidation: false, + usePrereleases: false }; function shallowEqual(x: any, y: any) { @@ -186,6 +188,15 @@ export class AppSettingsEditor extends React.Component + Application Updates + +
@@ -196,6 +207,14 @@ export class AppSettingsEditor extends React.Component { + this.setUncommittedState({ autoUpdate: !this.state.uncommitted.autoUpdate }); + } + + private onChangeUsePrereleases = (): void => { + this.setUncommittedState({ usePrereleases: !this.state.uncommitted.usePrereleases }); + } + private setUncommittedState(patch: any) { this.setState(state => { const nextUncommitted = { @@ -241,7 +260,9 @@ export class AppSettingsEditor extends React.Component AppUpdater.quitAndInstall(), enabled: true, }; - } else if (AppUpdater.status === UpdateStatus.CheckingForUpdate) { + } else if (AppUpdater.status === UpdateStatus.UpdateDownloading) { return { id: 'auto-update', - label: 'Checking for update...', - enabled: false, - }; - } else if (AppUpdater.status === UpdateStatus.UpdateDownloading || - AppUpdater.status === UpdateStatus.UpdateAvailable) { - return { - id: 'auto-update', - label: `Update downloading: ${AppUpdater.downloadProgress}%`, + label: `Update downloading...`, enabled: false, }; } else { return { id: 'auto-update', label: 'Check for Update...', - click: () => AppUpdater.checkForUpdates(true, false), + click: () => AppUpdater.checkForUpdates(true), enabled: true, }; } diff --git a/packages/app/main/src/appUpdater.spec.ts b/packages/app/main/src/appUpdater.spec.ts new file mode 100644 index 000000000..352ce4657 --- /dev/null +++ b/packages/app/main/src/appUpdater.spec.ts @@ -0,0 +1,254 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +let mockAutoUpdater: any = { + quitAndInstall: null, + downloadUpdate: null, + checkForUpdates: null, + setFeedURL: null +}; + +const defaultSettings = { + autoUpdate: false, + usePrereleases: false +}; + +let mockSettings: any; + +jest.mock('electron-updater', () => ({ + get autoUpdater() { return mockAutoUpdater; }, + UpdateInfo: typeof {} +})); + +jest.mock('./settingsData/store', () => ({ + getSettings: () => ({ framework: mockSettings }) +})); + +import { AppUpdater } from './appUpdater'; + +describe('AppUpdater', () => { + beforeEach(() => { + mockAutoUpdater = {}; + mockSettings = { ...defaultSettings }; + }); + + it('should get userInitiated', () => { + const tmp = (AppUpdater as any)._userInitiated; + (AppUpdater as any)._userInitiated = true; + + expect(AppUpdater.userInitiated).toBe(true); + + (AppUpdater as any)._userInitiated = tmp; + }); + + it('should get status', () => { + const tmp = (AppUpdater as any)._status; + (AppUpdater as any)._status = true; + + expect(AppUpdater.status).toBe(true); + + (AppUpdater as any)._status = tmp; + }); + + it('should get downloadProgress', () => { + const tmp = (AppUpdater as any)._downloadProgress; + (AppUpdater as any)._downloadProgress = true; + + expect(AppUpdater.downloadProgress).toBe(true); + + (AppUpdater as any)._downloadProgress = tmp; + }); + + it('should get updateDownloaded', () => { + const tmp = (AppUpdater as any)._updateDownloaded; + (AppUpdater as any)._updateDownloaded = true; + + expect(AppUpdater.updateDownloaded).toBe(true); + + (AppUpdater as any)._updateDownloaded = tmp; + }); + + it('should return the correct repo depending on the prerelease setting', () => { + AppUpdater.allowPrerelease = true; + expect(AppUpdater.repo).toBe('BotFramework-Emulator-Nightlies'); + + AppUpdater.allowPrerelease = false; + expect(AppUpdater.repo).toBe('BotFramework-Emulator'); + }); + + it('should get and set autoDownload', () => { + mockAutoUpdater.autoDownload = false; + AppUpdater.autoDownload = true; + + expect(mockAutoUpdater.autoDownload).toBe(true); + expect(AppUpdater.autoDownload).toBe(true); + }); + + it('should get and set allowPrerelease', () => { + mockAutoUpdater.allowPrerelease = false; + AppUpdater.allowPrerelease = true; + + expect(mockAutoUpdater.allowPrerelease).toBe(true); + expect(AppUpdater.allowPrerelease).toBe(true); + }); + + it('should startup and check for updates', () => { + mockSettings.usePrereleases = false; + mockSettings.autoUpdate = true; + + const mockOn = jest.fn((_eventName: string, _handler: () => any) => null); + const mockSetFeedURL = jest.fn((_options: any) => null); + mockAutoUpdater.on = mockOn; + mockAutoUpdater.setFeedURL = mockSetFeedURL; + + const tmp = AppUpdater.checkForUpdates; + const mockCheckForUpdates = jest.fn((_) => Promise.resolve(true)); + AppUpdater.checkForUpdates = mockCheckForUpdates; + + AppUpdater.startup(); + + expect(AppUpdater.autoDownload).toBe(true); + expect(AppUpdater.allowPrerelease).toBe(false); + expect(mockAutoUpdater.autoInstallOnAppQuit).toBe(true); + expect(mockAutoUpdater.logger).toBe(null); + + // event handlers should have been set up + expect(mockOn).toHaveBeenCalledTimes(6); + + expect(mockCheckForUpdates).toHaveBeenCalledWith(false); + + AppUpdater.checkForUpdates = tmp; + }); + + it('should startup and not check for updates if autoUpdate is false ', () => { + mockSettings.usePrereleases = true; + mockSettings.autoUpdate = false; + + const mockOn = jest.fn((_eventName: string, _handler: () => any) => null); + const mockSetFeedURL = jest.fn((_options: any) => null); + mockAutoUpdater.on = mockOn; + mockAutoUpdater.setFeedURL = mockSetFeedURL; + + const tmp = AppUpdater.checkForUpdates; + const mockCheckForUpdates = jest.fn((_) => Promise.resolve(true)); + AppUpdater.checkForUpdates = mockCheckForUpdates; + + AppUpdater.startup(); + + expect(AppUpdater.autoDownload).toBe(false); + expect(AppUpdater.allowPrerelease).toBe(true); + + expect(mockCheckForUpdates).not.toHaveBeenCalled(); + + AppUpdater.checkForUpdates = tmp; + }); + + it('should check for updates from the stable release repo', () => { + const mockSetFeedURL = jest.fn((_options: any) => null); + const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.resolve()); + mockAutoUpdater.setFeedURL = mockSetFeedURL; + mockAutoUpdater.checkForUpdates = mockCheckForUpdates; + + AppUpdater.checkForUpdates(true); + + expect(AppUpdater.userInitiated).toBe(true); + + expect(mockSetFeedURL).toHaveBeenCalledWith({ + repo: 'BotFramework-Emulator', + owner: 'Microsoft', + provider: 'github' + }); + + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + }); + + it('should check for updates from the nightly release repo', () => { + mockSettings.usePrereleases = true; + const mockSetFeedURL = jest.fn((_options: any) => null); + const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.resolve()); + mockAutoUpdater.setFeedURL = mockSetFeedURL; + mockAutoUpdater.checkForUpdates = mockCheckForUpdates; + + AppUpdater.checkForUpdates(false); + + expect(mockSetFeedURL).toHaveBeenCalledWith({ + repo: 'BotFramework-Emulator-Nightlies', + owner: 'Microsoft', + provider: 'github' + }); + + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + }); + + it('should throw if there is an error while trying to check for updates', async () => { + const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.reject('ERROR')); + mockAutoUpdater.checkForUpdates = mockCheckForUpdates; + mockAutoUpdater.setFeedURL = () => null; + + await expect(AppUpdater.checkForUpdates(false)) + .rejects.toBe('There was an error while checking for the latest update: ERROR'); + }); + + it('should download updates', async () => { + const mockDownloadUpdate = jest.fn(() => Promise.resolve(true)); + mockAutoUpdater.downloadUpdate = mockDownloadUpdate; + + await AppUpdater.downloadUpdate(false); + + expect(mockDownloadUpdate).toHaveBeenCalledTimes(1); + }); + + it('should throw if there is an error while trying to download updates', async () => { + mockAutoUpdater.downloadUpdate = () => Promise.reject('ERROR'); + + await expect(AppUpdater.downloadUpdate(false)) + .rejects.toBe('There was an error while trying to download the latest update: ERROR'); + }); + + it('should quit and install', () => { + const mockQuitAndInstall = jest.fn((isSilent: boolean, forceRunAfter: boolean) => null); + mockAutoUpdater.quitAndInstall = mockQuitAndInstall; + + AppUpdater.quitAndInstall(); + + expect(mockQuitAndInstall).toHaveBeenCalledTimes(1); + expect(mockQuitAndInstall).toHaveBeenCalledWith(false, true); + }); + + it('should throw if there is an error while trying to quit and install', () => { + mockAutoUpdater.quitAndInstall = () => { throw 'ERROR'; }; + + expect(() => AppUpdater.quitAndInstall()) + .toThrow('There was an error while trying to quit and install the latest update: ERROR'); + }); +}); diff --git a/packages/app/main/src/appUpdater.ts b/packages/app/main/src/appUpdater.ts index c755d131b..f2c1346de 100644 --- a/packages/app/main/src/appUpdater.ts +++ b/packages/app/main/src/appUpdater.ts @@ -32,19 +32,18 @@ // import { autoUpdater as electronUpdater, UpdateInfo } from 'electron-updater'; -import * as process from 'process'; -import * as semver from 'semver'; import { EventEmitter } from 'events'; import { ProgressInfo } from 'builder-util-runtime'; +import { FrameworkSettings } from '@bfemulator/app-shared'; +import { getSettings } from './settingsData/store'; export enum UpdateStatus { Idle, - CheckingForUpdate, UpdateAvailable, UpdateDownloading, - UpdateReadyToInstall, + UpdateReadyToInstall } -let pjson; + export const AppUpdater = new class extends EventEmitter { private _userInitiated: boolean; private _autoDownload: boolean; @@ -52,8 +51,9 @@ export const AppUpdater = new class extends EventEmitter { private _allowPrerelease: boolean; private _updateDownloaded: boolean; private _downloadProgress: number; + private _installAfterDownload: boolean; - public get userInitiated() { + public get userInitiated(): boolean { return this._userInitiated; } @@ -69,41 +69,56 @@ export const AppUpdater = new class extends EventEmitter { return this._updateDownloaded; } - startup() { - this._allowPrerelease = (process.argv.indexOf('--prerelease') >= 0); + public get autoDownload(): boolean { + return this._autoDownload; + } - // Allow update to prerelease if this is a prerelease - try { - pjson = require('../../package.json'); - const rc = semver.prerelease(pjson.version); - if (rc && rc.length) { - this._allowPrerelease = true; - } - } catch (err) { - console.error(err); + public set autoDownload(value: boolean) { + electronUpdater.autoDownload = value; + this._autoDownload = value; + } + + public get allowPrerelease(): boolean { + return this._allowPrerelease; + } + + public set allowPrerelease(value: boolean) { + electronUpdater.allowPrerelease = value; + this._allowPrerelease = value; + } + + public get repo(): string { + if (this.allowPrerelease) { + return 'BotFramework-Emulator-Nightlies'; } + return 'BotFramework-Emulator'; + } - electronUpdater.logger = null; + public startup() { + const settings = getSettings().framework; + this.allowPrerelease = !!settings.usePrereleases; + this.autoDownload = !!settings.autoUpdate; - electronUpdater.setFeedURL({ - repo: 'BotFramework-Emulator', - owner: 'Microsoft', - provider: 'github' - }); + electronUpdater.allowDowngrade = true; // allow pre-release -> stable release + electronUpdater.autoInstallOnAppQuit = true; + electronUpdater.logger = null; electronUpdater.on('checking-for-update', () => { - this._status = UpdateStatus.CheckingForUpdate; this.emit('checking-for-update'); }); electronUpdater.on('update-available', (updateInfo: UpdateInfo) => { - if (!this._autoDownload) { + if (!this.autoDownload) { this._status = UpdateStatus.Idle; this.emit('update-available', updateInfo); } + // if this was initiated on startup, download in the background + if (!this.userInitiated) { + this.downloadUpdate(false); + } }); electronUpdater.on('update-not-available', (updateInfo: UpdateInfo) => { this._status = UpdateStatus.Idle; - this.emit('up-to-date', updateInfo); + this.emit('up-to-date'); }); electronUpdater.on('error', (err: Error, message: string) => { this._status = UpdateStatus.Idle; @@ -115,32 +130,47 @@ export const AppUpdater = new class extends EventEmitter { this.emit('download-progress', progress); }); electronUpdater.on('update-downloaded', (updateInfo: UpdateInfo) => { - this._status = UpdateStatus.UpdateReadyToInstall; - this._updateDownloaded = true; - this.emit('update-downloaded', updateInfo); + if (this._installAfterDownload) { + this.quitAndInstall(); + return; + } else { + this._status = UpdateStatus.UpdateReadyToInstall; + this._updateDownloaded = true; + this.emit('update-downloaded', updateInfo); + } }); + + if (this.autoDownload) { + this.checkForUpdates(false); + } } - public checkForUpdates(userInitiated: boolean = false, autoDownload: boolean = false) { + public async checkForUpdates(userInitiated: boolean): Promise { + const settings = getSettings().framework; + this.allowPrerelease = !!settings.usePrereleases; + this.autoDownload = !!settings.autoUpdate; + this._userInitiated = userInitiated; + + electronUpdater.setFeedURL({ + repo: this.repo, + owner: 'Microsoft', + provider: 'github' + }); + try { - if (electronUpdater) { - this._status = UpdateStatus.CheckingForUpdate; - this._userInitiated = userInitiated; - this._autoDownload = autoDownload; - electronUpdater.allowPrerelease = this._allowPrerelease; - electronUpdater.autoDownload = autoDownload; - electronUpdater.allowDowngrade = false; - electronUpdater.autoInstallOnAppQuit = true; - if (process.env.NODE_ENV === 'development') { - (electronUpdater as any).currentVersion = (pjson || {}).version; - } - electronUpdater.checkForUpdates() - .catch(err => { - console.error(err); - }); - } + await electronUpdater.checkForUpdates(); + } catch (e) { + throw `There was an error while checking for the latest update: ${e}`; + } + } + + public async downloadUpdate(installAfterDownload: boolean): Promise { + this._installAfterDownload = installAfterDownload; + + try { + await electronUpdater.downloadUpdate(); } catch (e) { - console.error(e); + throw `There was an error while trying to download the latest update: ${e}`; } } @@ -148,7 +178,7 @@ export const AppUpdater = new class extends EventEmitter { try { electronUpdater.quitAndInstall(false, true); } catch (e) { - console.error(e); + throw `There was an error while trying to quit and install the latest update: ${e}`; } } }; diff --git a/packages/app/main/src/main.ts b/packages/app/main/src/main.ts index 60bc98cd2..abac3ac53 100644 --- a/packages/app/main/src/main.ts +++ b/packages/app/main/src/main.ts @@ -80,26 +80,20 @@ if (app) { // ----------------------------------------------------------------------------- // App-Updater events -AppUpdater.on('checking-for-update', async (...args) => { - await AppMenuBuilder.refreshAppUpdateMenu(); -}); - AppUpdater.on('update-available', async (update: UpdateInfo) => { - await AppMenuBuilder.refreshAppUpdateMenu(); - - if (AppUpdater.userInitiated) { - // TODO - localization - mainWindow.commandService.call(SharedConstants.Commands.Electron.ShowMessageBox, true, { - title: app.getName(), - message: `A new update, ${update.version}, is available. Download it now?`, - buttons: ['Cancel', 'OK'], - defaultId: 1, - cancelId: 0 - }).then(result => { + try { + await AppMenuBuilder.refreshAppUpdateMenu(); + + if (AppUpdater.userInitiated) { + const { ShowUpdateAvailableDialog } = SharedConstants.Commands.UI; + const result = await mainWindow.commandService.remoteCall(ShowUpdateAvailableDialog, update.version); if (result) { - AppUpdater.checkForUpdates(true, true); + const { installAfterDownload = false } = result; + await AppUpdater.downloadUpdate(installAfterDownload); } - }); + } + } catch (e) { + console.error(`An error occurred in the updater's "update-available" event handler: ${e}`); } }); @@ -108,46 +102,66 @@ AppUpdater.on('update-downloaded', async (update: UpdateInfo) => { // TODO - localization if (AppUpdater.userInitiated) { - mainWindow.commandService.call(SharedConstants.Commands.Electron.ShowMessageBox, true, { - title: app.getName(), - message: `Finished downloading update ${update.version}. Restart and install now?`, - buttons: ['Cancel', 'OK'], - defaultId: 1, - cancelId: 0 - }).then(result => { - if (result) { + // send a notification when the update is finished downloading + const notification = newNotification( + `Emulator version ${update.version} has finished downloading. Restart and update now?` + ); + notification.addButton('Dismiss', () => { + const { Commands } = SharedConstants; + mainWindow.commandService.remoteCall(Commands.Notifications.Remove, notification.id); + }); + notification.addButton('Restart', async () => { + try { AppUpdater.quitAndInstall(); + } catch (e) { + sendNotificationToClient(newNotification(e), mainWindow.commandService); } }); + sendNotificationToClient(notification, mainWindow.commandService); } }); -AppUpdater.on('up-to-date', async (update: UpdateInfo) => { - // TODO - localization - await AppMenuBuilder.refreshAppUpdateMenu(); - // only show the alert if the user explicity checked for update, and no update was downloaded - const { userInitiated, updateDownloaded } = AppUpdater; - if (userInitiated && !updateDownloaded) { - mainWindow.commandService.call(SharedConstants.Commands.Electron.ShowMessageBox, true, { - title: app.getName(), - message: 'There are no updates currently available.' - }); +AppUpdater.on('up-to-date', async () => { + try { + // TODO - localization + await AppMenuBuilder.refreshAppUpdateMenu(); + + // only show the alert if the user explicity checked for update, and no update was downloaded + const { userInitiated, updateDownloaded } = AppUpdater; + if (userInitiated && !updateDownloaded) { + const { ShowUpdateUnavailableDialog } = SharedConstants.Commands.UI; + await mainWindow.commandService.remoteCall(ShowUpdateUnavailableDialog); + } + } catch (e) { + console.error(`An error occurred in the updater's "up-to-date" event handler: ${e}`); } }); -AppUpdater.on('download-progress', async (progress: ProgressInfo) => { - await AppMenuBuilder.refreshAppUpdateMenu(); +AppUpdater.on('download-progress', async (info: ProgressInfo) => { + try { + await AppMenuBuilder.refreshAppUpdateMenu(); + + // update the progress bar component + const { UpdateProgressIndicator } = SharedConstants.Commands.UI; + const progressPayload = { label: 'Downloading...', progress: info.percent }; + await mainWindow.commandService.remoteCall(UpdateProgressIndicator, progressPayload); + } catch (e) { + console.error(`An error occurred in the updater's "download-progress" event handler: ${e}`); + } }); AppUpdater.on('error', async (err: Error, message: string) => { // TODO - localization await AppMenuBuilder.refreshAppUpdateMenu(); // TODO - Send to debug.txt / error dump file - console.error(err, message); + if (message.includes('.yml')) { + AppUpdater.emit('up-to-date'); + return; + } if (AppUpdater.userInitiated) { - mainWindow.commandService.call(SharedConstants.Commands.Electron.ShowMessageBox, true, { + await mainWindow.commandService.call(SharedConstants.Commands.Electron.ShowMessageBox, true, { title: app.getName(), - message: 'Something went wrong while checking for updates.' + message: `An error occurred while using the updater: ${err}` }); } }); @@ -305,9 +319,6 @@ const createMainWindow = async () => { mainWindow.browserWindow.setTitle(app.getName()); windowManager = new WindowManager(); - // Start auto-updater - AppUpdater.startup(); - AppMenuBuilder.getMenuTemplate().then((template: Electron.MenuItemConstructorOptions[]) => { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); }); @@ -366,9 +377,10 @@ const createMainWindow = async () => { } mainWindow.webContents.setZoomLevel(zoomLevel); mainWindow.browserWindow.show(); - if (process.env.NODE_ENV !== 'development') { - AppUpdater.checkForUpdates(false, true); - } + + // Start auto-updater + AppUpdater.startup(); + // Renew arm token const { persistLogin, signedInUser } = settingsStore.getState().azure; if (persistLogin && signedInUser) { diff --git a/packages/app/main/src/utils/sendNotificationToClient.ts b/packages/app/main/src/utils/sendNotificationToClient.ts index ae7d74ae3..091fdbd57 100644 --- a/packages/app/main/src/utils/sendNotificationToClient.ts +++ b/packages/app/main/src/utils/sendNotificationToClient.ts @@ -34,14 +34,18 @@ import { setGlobal, deleteGlobal } from '../globals'; import { Notification, SharedConstants } from '@bfemulator/app-shared'; import { CommandService } from '@bfemulator/sdk-shared'; +import { mainWindow } from '../main'; /** Sends a notification to the client side using the Electron 'global' object * (need to use global object because functions can't be sent over IPC) */ export async function sendNotificationToClient( notification: Notification, - commandService: CommandService + commandService?: CommandService ): Promise { + if (!commandService) { + commandService = mainWindow.commandService; + } // attach the notification to the global object setGlobal(SharedConstants.NOTIFICATION_FROM_MAIN, notification); diff --git a/packages/app/shared/src/constants/sharedConstants.ts b/packages/app/shared/src/constants/sharedConstants.ts index 703584d08..2db913a44 100644 --- a/packages/app/shared/src/constants/sharedConstants.ts +++ b/packages/app/shared/src/constants/sharedConstants.ts @@ -88,7 +88,7 @@ export const SharedConstants = { ShowAboutDialog: 'shell:about', OpenFileLocation: 'shell:open-file-location', UnlinkFile: 'shell:unlink-file', - RenameFile: 'shell:rename-file', + RenameFile: 'shell:rename-file' }, Emulator: { @@ -161,6 +161,9 @@ export const SharedConstants = { ShowPostMigrationDialog: 'post-migration-dialog:show', UpdateProgressIndicator: 'shell:updateProgressIndicator', InvalidateAzureArmToken: 'shell:invalidateAzureArmToken', + ShowUpdateAvailableDialog: 'update-available-dialog:show', + ShowUpdateUnavailableDialog: 'update-unavailable-dialog:show', + ShowProgressIndicator: 'progress-indicator:show' } }, ContentTypes: { diff --git a/packages/app/shared/src/types/serverSettingsTypes.ts b/packages/app/shared/src/types/serverSettingsTypes.ts index fd44149e8..4c3e9e7b6 100644 --- a/packages/app/shared/src/types/serverSettingsTypes.ts +++ b/packages/app/shared/src/types/serverSettingsTypes.ts @@ -49,6 +49,10 @@ export interface FrameworkSettings { localhost?: string; // locale to use across all endpoints locale?: string; + // enables auto update on startup + autoUpdate?: boolean; + // enables pre-release updates + usePrereleases?: boolean; } export interface WindowStateSettings { @@ -116,7 +120,9 @@ export const frameworkDefault: FrameworkSettings = { use10Tokens: false, useCodeValidation: false, localhost: 'localhost', - locale: '' + locale: '', + usePrereleases: false, + autoUpdate: true }; export const windowStateDefault: WindowStateSettings = {