Files
pybricks-code/src/app/sagas.ts
T
David Lechner b2e098f2dd app: catch error when checking for updates
ServiceWorkerRegistration.update() can raise exceptions, so we need to
catch and handle them, otherwise it will crash redux sagas and the app
will stop responding.

Fixes: https://github.com/pybricks/pybricks-code/issues/1299
2022-11-11 13:00:40 -06:00

160 lines
5.2 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import { eventChannel } from 'redux-saga';
import { call, delay, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import { alertsShowAlert } from '../alerts/actions';
import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import * as serviceWorkerRegistration from '../serviceWorkerRegistration';
import { ensureError } from '../utils';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appCheckForUpdate,
appDidCheckForUpdate,
appDidFailToCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appReload,
appShowInstallPrompt,
didInstall,
} from './actions';
/**
* Handles appReload actions.
*
* Must be called (forked) with serviceWorkerRegistration context set.
*/
function* handleAppReload(registration: ServiceWorkerRegistration): Generator {
yield* call(() => registration.unregister());
location.reload();
}
/**
* Handles appCheckForUpdate actions.
*
* Must be called (forked) with serviceWorkerRegistration context set.
*/
function* handleAppCheckForUpdate(registration: ServiceWorkerRegistration): Generator {
try {
yield* call(() => registration.update());
const updateFound = registration.installing !== null;
yield* put(appDidCheckForUpdate(updateFound));
} catch (err) {
if (err instanceof TypeError) {
yield* put(alertsShowAlert('app', 'updateServerFailure'));
} else {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: ensureError(err),
}),
);
}
yield* put(appDidFailToCheckForUpdate());
}
}
/**
* Marshals CRA serviceWorkerRegistration to saga.
*/
function* monitorServiceWorkerRegistration(): Generator {
const chan = eventChannel<{
isUpdate: boolean;
registration: ServiceWorkerRegistration;
}>((emit) => {
serviceWorkerRegistration.register({
onSuccess: (r) => emit({ isUpdate: false, registration: r }),
onUpdate: (r) => emit({ isUpdate: true, registration: r }),
});
// istanbul ignore next: never unregistered
return () => serviceWorkerRegistration.unregister();
});
// HACK: is assumed that this will only be called at most two times, once
// with isUpdate === false and after that, once with isUpdate === true.
while (true) {
const { isUpdate, registration } = yield* take(chan);
if (isUpdate) {
yield* put(serviceWorkerDidUpdate());
} else {
yield* takeEvery(appReload, handleAppReload, registration);
yield* takeEvery(appCheckForUpdate, handleAppCheckForUpdate, registration);
yield* put(serviceWorkerDidSucceed());
}
}
}
function* monitorAppInstalled(): Generator {
const chan = eventChannel<Event>((emit) => {
const listener = (e: Event) => {
emit(e);
};
// chromium-only event
window.addEventListener('appinstalled', listener);
// istanbul ignore next: currently we don't ever stop monitoring
return () => window.removeEventListener('appinstalled', listener);
});
while (true) {
yield* take(chan);
yield* put(didInstall());
}
}
function* handleBeforeInstallPromptEvent(event: BeforeInstallPromptEvent): Generator {
// wait for user to request to install the app - may never happen
yield* take(appShowInstallPrompt);
yield* call(() => event.prompt());
const choice = yield* call(() => event.userChoice);
yield* put(appDidResolveInstallPrompt(choice));
}
function* monitorBeforeInstallPrompt(): Generator {
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
const listener = (e: BeforeInstallPromptEvent) => {
emit(e);
};
// @ts-expect-error: chromium-only event
window.addEventListener('beforeinstallprompt', listener);
// istanbul ignore next: currently we don't ever stop monitoring
// @ts-expect-error: chromium-only event
return () => window.removeEventListener('beforeinstallprompt', listener);
});
// in theory, the before install prompt event should only happen once, so
// we don't bother canceling the forked task when another event is received
while (true) {
const event = yield* take(chan);
yield* fork(handleBeforeInstallPromptEvent, event);
yield* put(appDidReceiveBeforeInstallPrompt());
}
}
// istabul ignore next: for dev environment only
function* fakeCheckForUpdate(): Generator {
console.warn('checking for updates is not supported in development mode');
// let the "busy" indication spin for a bit
yield* delay(3000);
// then indicate we are already up-to-date
yield* put(appDidCheckForUpdate(false));
}
export default function* app(): Generator {
yield* fork(monitorServiceWorkerRegistration);
yield* fork(monitorAppInstalled);
yield* fork(monitorBeforeInstallPrompt);
// istabul ignore if: for dev environment only
if (process.env.NODE_ENV === 'development') {
yield* takeEvery(appCheckForUpdate, fakeCheckForUpdate);
}
}