mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
<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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Notification translation keys.
|
||||
|
||||
export enum MessageId {
|
||||
AppNoUpdateFound = 'app.noUpdateFound',
|
||||
CopyErrorMessage = 'copyErrorMessage',
|
||||
ReportBug = 'reportBug',
|
||||
BleUnexpectedError = 'ble.unexpectedError',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user