add UI to manually check for update

This adds a button to call the ServiceWorkerRegistration.update()
function. This button will be hidden if there is already an update
pending.

This also adds a button to restart the app to apply the update. This
is nice in case users accidentally close the notification that asks
to restart.

The about button is moved to the new app group in the setting menu.
This commit is contained in:
David Lechner
2021-01-28 15:37:40 -06:00
parent 447e7e901b
commit e59bfd4474
11 changed files with 205 additions and 13 deletions
+28
View File
@@ -9,6 +9,10 @@ import { Action } from 'redux';
export enum AppActionType {
/** Reload the app. */
Reload = 'app.action.reload',
/** Checks for an available update. */
CheckForUpdate = 'app.action.checkForUpdate',
/** Indicates that checking for update finished. */
DidCheckForUpdate = 'app.action.didCheckForUpdate',
/** The app has just ben started. */
DidStart = 'app.action.didStart',
/** Open settings dialog. */
@@ -35,6 +39,28 @@ export function reload(registration: ServiceWorkerRegistration): AppReloadAction
return { type: AppActionType.Reload, registration };
}
/** Action that requests to check for updates. */
export type AppCheckForUpdatesAction = Action<AppActionType.CheckForUpdate> & {
registration: ServiceWorkerRegistration;
};
/** Action that requests to check for updates. */
export function checkForUpdate(
registration: ServiceWorkerRegistration,
): AppCheckForUpdatesAction {
return { type: AppActionType.CheckForUpdate, registration };
}
/** Action that indicates that checking for an update has completed. */
export type AppDidCheckForUpdateAction = Action<AppActionType.DidCheckForUpdate> & {
updateFound: boolean;
};
/** Action that indicates that checking for an update has completed. */
export function didCheckForUpdate(updateFound: boolean): AppDidCheckForUpdateAction {
return { type: AppActionType.DidCheckForUpdate, updateFound };
}
/** Action that indicates the app has just started. */
export type AppDidStartAction = Action<AppActionType.DidStart>;
@@ -94,6 +120,8 @@ export function closeLicenseDialog(): AppCloseLicenseDialogAction {
/** common type for all app actions. */
export type AppAction =
| AppReloadAction
| AppCheckForUpdatesAction
| AppDidCheckForUpdateAction
| AppDidStartAction
| AppOpenSettingsAction
| AppCloseSettingsAction
+51 -7
View File
@@ -19,7 +19,7 @@ import { WithI18nProps, withI18n } from '@shopify/react-i18n';
import React from 'react';
import { connect } from 'react-redux';
import { Action, Dispatch } from '../actions';
import { closeSettings, openAboutDialog } from '../actions/app';
import { checkForUpdate, closeSettings, openAboutDialog, reload } from '../actions/app';
import { setBoolean, toggleBoolean } from '../actions/settings';
import { RootState } from '../reducers';
import { pseudolocalize } from '../settings/i18n';
@@ -42,6 +42,9 @@ type StateProps = {
showDocs: boolean;
darkMode: boolean;
flashCurrentProgram: boolean;
serviceWorker: ServiceWorkerRegistration | null;
checkingForUpdate: boolean;
updateAvailable: boolean;
};
type DispatchProps = {
@@ -51,6 +54,8 @@ type DispatchProps = {
onFlashCurrentProgramChanged: (checked: boolean) => void;
onAbout: () => void;
onToggleDocs: () => void;
onCheckForUpdate: (registration: ServiceWorkerRegistration) => void;
onReload: (registration: ServiceWorkerRegistration) => void;
};
type SettingsProps = StateProps & DispatchProps & WithI18nProps;
@@ -59,16 +64,21 @@ type SettingsProps = StateProps & DispatchProps & WithI18nProps;
class SettingsDrawer extends React.PureComponent<SettingsProps> {
render(): JSX.Element {
const {
i18n,
open,
onClose,
showDocs,
onShowDocsChanged,
darkMode,
onDarkModeChanged,
serviceWorker,
flashCurrentProgram,
checkingForUpdate,
updateAvailable,
onClose,
onShowDocsChanged,
onDarkModeChanged,
onFlashCurrentProgramChanged,
onAbout,
onCheckForUpdate,
onReload,
i18n,
} = this.props;
return (
<Drawer
@@ -200,6 +210,36 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
&nbsp;
<ExternalLinkIcon />
</AnchorButton>
<AboutDialog />
</ButtonGroup>
</FormGroup>
<FormGroup label={i18n.translate(SettingsStringId.AppTitle)}>
<ButtonGroup
minimal={true}
vertical={true}
alignText="left"
>
{serviceWorker && !updateAvailable && (
<Button
icon="refresh"
onClick={() => onCheckForUpdate(serviceWorker)}
loading={checkingForUpdate}
>
{i18n.translate(
SettingsStringId.AppCheckForUpdateLabel,
)}
</Button>
)}
{serviceWorker && updateAvailable && (
<Button
icon="refresh"
onClick={() => onReload(serviceWorker)}
>
{i18n.translate(
SettingsStringId.AppRestartLabel,
)}
</Button>
)}
<Button
icon="info-sign"
onClick={() => {
@@ -207,9 +247,8 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
return true;
}}
>
{i18n.translate(SettingsStringId.HelpAboutLabel)}
{i18n.translate(SettingsStringId.AppAboutLabel)}
</Button>
<AboutDialog />
</ButtonGroup>
</FormGroup>
{process.env.NODE_ENV === 'development' && (
@@ -251,6 +290,9 @@ const mapStateToProps = (state: RootState): StateProps => ({
showDocs: state.settings.showDocs,
darkMode: state.settings.darkMode,
flashCurrentProgram: state.settings.flashCurrentProgram,
serviceWorker: state.app.serviceWorker,
checkingForUpdate: state.app.checkingForUpdate,
updateAvailable: state.app.updateAvailable,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
@@ -263,6 +305,8 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)),
onAbout: (): Action => dispatch(openAboutDialog()),
onToggleDocs: (): Action => dispatch(toggleBoolean(SettingId.ShowDocs)),
onCheckForUpdate: (registration) => dispatch(checkForUpdate(registration)),
onReload: (registration) => dispatch(reload(registration)),
});
export default connect(
+3
View File
@@ -1,6 +1,9 @@
{
"copyErrorMessage": "Copy Error Message",
"reportBug": "Report Bug",
"app": {
"noUpdateFound": "{appName} is already up to date."
},
"ble": {
"gattPermission": "The web browser did not give permission to use Bluetooth Low Energy",
"gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Try removing the \"{hubName}\" device in your OS Bluetooth settings, then try again.",
+1
View File
@@ -4,6 +4,7 @@
// Notification translation keys.
export enum MessageId {
AppNoUpdateFound = 'app.noUpdateFound',
CopyErrorMessage = 'copyErrorMessage',
ReportBug = 'reportBug',
BleUnexpectedError = 'ble.unexpectedError',
+9
View File
@@ -35,6 +35,15 @@
},
"bugs": {
"label": "Bug Reports"
}
},
"app": {
"title": "App",
"checkForUpdate": {
"label": "Check for Update"
},
"restart": {
"label": "Restart to Install Update"
},
"about": {
"label": "About"
+4 -1
View File
@@ -19,5 +19,8 @@ export enum SettingsStringId {
HelpSupportLabel = 'settings.help.support.label',
HelpChatLabel = 'settings.help.chat.label',
HelpBugsLabel = 'settings.help.bugs.label',
HelpAboutLabel = 'settings.help.about.label',
AppTitle = 'settings.app.title',
AppCheckForUpdateLabel = 'settings.app.checkForUpdate.label',
AppRestartLabel = 'settings.app.restart.label',
AppAboutLabel = 'settings.app.about.label',
}
+50 -1
View File
@@ -6,11 +6,15 @@
import { Reducer, combineReducers } from 'redux';
import { Action } from '../actions';
import { AppActionType } from '../actions/app';
import { ServiceWorkerActionType } from '../actions/service-worker';
export interface AppState {
readonly showSettings: boolean;
readonly showAboutDialog: boolean;
readonly showLicenseDialog: boolean;
readonly serviceWorker: ServiceWorkerRegistration | null;
readonly checkingForUpdate: boolean;
readonly updateAvailable: boolean;
}
const showSettings: Reducer<boolean, Action> = (state = false, action) => {
@@ -46,4 +50,49 @@ const showLicenseDialog: Reducer<boolean, Action> = (state = false, action) => {
}
};
export default combineReducers({ showSettings, showAboutDialog, showLicenseDialog });
const serviceWorker: Reducer<ServiceWorkerRegistration | null, Action> = (
state = null,
action,
) => {
switch (action.type) {
case ServiceWorkerActionType.DidSucceed:
return action.registration;
default:
return state;
}
};
const checkingForUpdate: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case AppActionType.CheckForUpdate:
return true;
case AppActionType.DidCheckForUpdate:
if (!action.updateFound) {
return false;
}
// otherwise we wait for service worker to download everything
return state;
case ServiceWorkerActionType.DidUpdate:
return false;
default:
return state;
}
};
const updateAvailable: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case ServiceWorkerActionType.DidUpdate:
return true;
default:
return state;
}
};
export default combineReducers({
showSettings,
showAboutDialog,
showLicenseDialog,
serviceWorker,
checkingForUpdate,
updateAvailable,
});
+23 -1
View File
@@ -2,7 +2,7 @@
// Copyright (c) 2021 The Pybricks Authors
import { AsyncSaga, delay } from '../../test';
import { reload } from '../actions/app';
import { checkForUpdate, didCheckForUpdate, reload } from '../actions/app';
import app from './app';
test('reload', async () => {
@@ -30,3 +30,25 @@ test('reload', async () => {
await saga.end();
});
test('checkForUpdates', async () => {
const saga = new AsyncSaga(app);
// mock registration as if service worker was register on app startup
const registration: Partial<ServiceWorkerRegistration> = {
update: jest.fn(),
installing: null,
};
saga.put(checkForUpdate(registration as ServiceWorkerRegistration));
// yield to allow generators to complete
await delay(0);
expect(registration.update).toHaveBeenCalled();
const action = await saga.take();
expect(action).toStrictEqual(didCheckForUpdate(false));
await saga.end();
});
+14 -2
View File
@@ -1,14 +1,26 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { call, takeEvery } from 'typed-redux-saga/macro';
import { AppActionType, AppReloadAction } from '../actions/app';
import { call, put, takeEvery } from 'typed-redux-saga/macro';
import {
AppActionType,
AppCheckForUpdatesAction,
AppReloadAction,
didCheckForUpdate,
} from '../actions/app';
function* reload(action: AppReloadAction): Generator {
yield* call(() => action.registration.unregister());
location.reload();
}
function* checkForUpdate(action: AppCheckForUpdatesAction): Generator {
yield* call(() => action.registration.update());
const updateFound = action.registration.installing !== null;
yield* put(didCheckForUpdate(updateFound));
}
export default function* app(): Generator {
yield* takeEvery(AppActionType.Reload, reload);
yield* takeEvery(AppActionType.CheckForUpdate, checkForUpdate);
}
+3
View File
@@ -5,6 +5,7 @@ import { IToaster } from '@blueprintjs/core';
import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware';
import { AsyncSaga } from '../../test';
import { Action } from '../actions';
import { didCheckForUpdate } from '../actions/app';
import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDidFailToConnect,
@@ -71,6 +72,7 @@ test.each([
didFailToFinish(FailToFinishReasonType.FailedToCompile),
didFailToFinish(FailToFinishReasonType.FirmwareSize),
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
didCheckForUpdate(false),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -100,6 +102,7 @@ test.each([
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
didFailToFinish(FailToFinishReasonType.FailedToConnect),
didSucceed({} as ServiceWorkerRegistration),
didCheckForUpdate(true),
])('actions that should not show a notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+19 -1
View File
@@ -14,7 +14,7 @@ import { Replacements } from '@shopify/react-i18n';
import React from 'react';
import { channel } from 'redux-saga';
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { reload } from '../actions/app';
import { AppActionType, AppDidCheckForUpdateAction, reload } from '../actions/app';
import {
BleDeviceActionType,
BleDeviceDidFailToConnectAction,
@@ -351,6 +351,23 @@ function* showServiceWorkerUpdate(
yield* put(reload(updateAction.registration));
}
function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator {
if (action.updateFound) {
// this will be handled by ServiceWorkerActionType.DidUpdate action
return;
}
const { toaster } = yield* getContext<NotificationContext>('notification');
toaster.show({
intent: mapIntent(Level.Info),
icon: mapIcon(Level.Info),
message: React.createElement(Notification, {
messageId: MessageId.AppNoUpdateFound,
replacements: { appName },
}),
});
}
export default function* (): Generator {
yield* takeEvery(
BleDeviceActionType.DidFailToConnect,
@@ -366,4 +383,5 @@ export default function* (): Generator {
yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
yield* takeEvery(NotificationActionType.Add, addNotification);
yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate);
yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo);
}