Skip to content

Commit

Permalink
feat: Implemented Auto-Update Electron UX (microsoft#2721)
Browse files Browse the repository at this point in the history
* 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 <christopher.whitten@microsoft.com>
  • Loading branch information
tonyanziano and cwhitten authored Apr 21, 2020
1 parent fa2e3b1 commit 06f5af6
Show file tree
Hide file tree
Showing 25 changed files with 901 additions and 28 deletions.
120 changes: 120 additions & 0 deletions Composer/packages/client/__tests__/components/appUpdater.test.jsx
Original file line number Diff line number Diff line change
@@ -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('<AppUpdater />', () => {
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
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(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('Update failed');
getByText(`Couldn't complete the update: 408 Request timed out.`);
});
});
3 changes: 3 additions & 0 deletions Composer/packages/client/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ expect.extend({
};
},
});

// for tests using Electron IPC to talk to main process
(window as any).ipcRenderer = { on: jest.fn() };
2 changes: 2 additions & 0 deletions Composer/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -209,6 +210,7 @@ export const App: React.FC = () => {
</ErrorBoundary>
</div>
<Suspense fallback={<div />}>{!state.onboarding.complete && <Onboarding />}</Suspense>
{(window as any).__IS_ELECTRON__ && <AppUpdater />}
</div>
</Fragment>
);
Expand Down
246 changes: 246 additions & 0 deletions Composer/packages/client/src/components/AppUpdater/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div key={key} css={optionRoot}>
<Icon iconName={checked ? 'RadioBtnOn' : 'RadioBtnOff'} css={optionIcon(checked)} />
<span>{text}</span>
</div>
);
}

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 (
<ChoiceGroup
defaultSelectedKey={downloadOptions.installAndUpdate}
options={[
{
key: downloadOptions.installAndUpdate,
text: formatMessage('Install the update and restart Composer.'),
onRenderField: SelectOption,
},
{
key: downloadOptions.downloadOnly,
text: formatMessage('Download the new version manually.'),
onRenderField: SelectOption,
},
]}
onChange={handleDownloadOptionChange}
required={true}
/>
);

case AppUpdaterStatus.UPDATE_FAILED:
return <p css={dialogCopy}>{`${formatMessage(`Couldn't complete the update:`)} ${error}`}</p>;

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 (
<ProgressIndicator
label={formatMessage('Downloading...')}
description={`${progressPercent}% ${formatMessage('of')} ${trimmedTotalInMB}`}
percentComplete={progressInHundredths}
/>
);
}

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 <p css={dialogCopy}>{text}</p>;
}

case AppUpdaterStatus.UPDATE_UNAVAILABLE:
return <p css={dialogCopy}>{formatMessage('Composer is up to date.')}</p>;

case AppUpdaterStatus.IDLE:
return undefined;

default:
return undefined;
}
}, [status, progressPercent]);

const footer = useMemo(() => {
switch (status) {
case AppUpdaterStatus.UPDATE_AVAILABLE:
return (
<div>
<DefaultButton onClick={handleDismiss} styles={updateAvailableDismissBtn} text={formatMessage('Cancel')} />
<PrimaryButton onClick={handlePreDownloadOkay} text={formatMessage('Okay')} />
</div>
);

case AppUpdaterStatus.UPDATE_SUCCEEDED:
return <PrimaryButton onClick={handlePostDownloadOkay} text={formatMessage('Okay')} />;

case AppUpdaterStatus.UPDATE_FAILED:
return <PrimaryButton onClick={handleDismiss} text={formatMessage('Okay')} />;

case AppUpdaterStatus.UPDATE_UNAVAILABLE:
return <PrimaryButton onClick={handleDismiss} text={formatMessage('Okay')} />;

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 ? (
<Dialog
hidden={false}
onDismiss={handleDismiss}
dialogContentProps={{
styles: dialogContent,
subText: subText,
type: DialogType.close,
title,
}}
minWidth={427}
maxWidth={427}
modalProps={{
isBlocking: false,
}}
>
{content}
<DialogFooter styles={dialogFooter}>{footer}</DialogFooter>
</Dialog>
) : null;
};
Loading

0 comments on commit 06f5af6

Please sign in to comment.