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); }