From e59bfd4474632078676fc6ac5e894f8ffee342cc Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 28 Jan 2021 15:37:40 -0600 Subject: [PATCH] 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. --- src/actions/app.ts | 28 ++++++++++++ src/components/SettingsDrawer.tsx | 58 +++++++++++++++++++++--- src/components/notification-i18n.en.json | 3 ++ src/components/notification-i18n.ts | 1 + src/components/settings-i18n.en.json | 9 ++++ src/components/settings-i18n.ts | 5 +- src/reducers/app.ts | 51 ++++++++++++++++++++- src/sagas/app.test.ts | 24 +++++++++- src/sagas/app.ts | 16 ++++++- src/sagas/notification.test.ts | 3 ++ src/sagas/notification.ts | 20 +++++++- 11 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/actions/app.ts b/src/actions/app.ts index 48e370d7..34e92b2a 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -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 & { + 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 & { + 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; @@ -94,6 +120,8 @@ export function closeLicenseDialog(): AppCloseLicenseDialogAction { /** common type for all app actions. */ export type AppAction = | AppReloadAction + | AppCheckForUpdatesAction + | AppDidCheckForUpdateAction | AppDidStartAction | AppOpenSettingsAction | AppCloseSettingsAction diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index da4b665d..8020fdad 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -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 { 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 ( {   + + + + + + {serviceWorker && !updateAvailable && ( + + )} + {serviceWorker && updateAvailable && ( + + )} - {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( diff --git a/src/components/notification-i18n.en.json b/src/components/notification-i18n.en.json index e5fb0bd5..c1ab03e6 100644 --- a/src/components/notification-i18n.en.json +++ b/src/components/notification-i18n.en.json @@ -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.", diff --git a/src/components/notification-i18n.ts b/src/components/notification-i18n.ts index 8e39bf64..132562ea 100644 --- a/src/components/notification-i18n.ts +++ b/src/components/notification-i18n.ts @@ -4,6 +4,7 @@ // Notification translation keys. export enum MessageId { + AppNoUpdateFound = 'app.noUpdateFound', CopyErrorMessage = 'copyErrorMessage', ReportBug = 'reportBug', BleUnexpectedError = 'ble.unexpectedError', diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index d3996591..6d5488cb 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -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" diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index bc16e13c..9f49fbb6 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -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', } diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 3bcce3ea..44436048 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -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 = (state = false, action) => { @@ -46,4 +50,49 @@ const showLicenseDialog: Reducer = (state = false, action) => { } }; -export default combineReducers({ showSettings, showAboutDialog, showLicenseDialog }); +const serviceWorker: Reducer = ( + state = null, + action, +) => { + switch (action.type) { + case ServiceWorkerActionType.DidSucceed: + return action.registration; + default: + return state; + } +}; + +const checkingForUpdate: Reducer = (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 = (state = false, action) => { + switch (action.type) { + case ServiceWorkerActionType.DidUpdate: + return true; + default: + return state; + } +}; + +export default combineReducers({ + showSettings, + showAboutDialog, + showLicenseDialog, + serviceWorker, + checkingForUpdate, + updateAvailable, +}); diff --git a/src/sagas/app.test.ts b/src/sagas/app.test.ts index 6443a394..86a50431 100644 --- a/src/sagas/app.test.ts +++ b/src/sagas/app.test.ts @@ -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 = { + 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(); +}); diff --git a/src/sagas/app.ts b/src/sagas/app.ts index 8e8ced7a..1f069fb7 100644 --- a/src/sagas/app.ts +++ b/src/sagas/app.ts @@ -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); } diff --git a/src/sagas/notification.test.ts b/src/sagas/notification.test.ts index 0f456bc9..0431e5f5 100644 --- a/src/sagas/notification.test.ts +++ b/src/sagas/notification.test.ts @@ -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(); diff --git a/src/sagas/notification.ts b/src/sagas/notification.ts index 30e637c2..96073b07 100644 --- a/src/sagas/notification.ts +++ b/src/sagas/notification.ts @@ -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('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); }