Fix error handling in QR code screen

This commit is contained in:
Fedor Indutny 2024-07-01 14:51:49 -07:00 committed by GitHub
parent 06789623d5
commit c046d36851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 253 additions and 65 deletions

View file

@ -42,7 +42,3 @@ export const _ConnectionFailed = (): JSX.Element => (
error={InstallError.ConnectionFailed}
/>
);
export const _UnknownError = (): JSX.Element => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.UnknownError} />
);

View file

@ -15,7 +15,6 @@ export enum InstallError {
TooManyDevices,
TooOld,
ConnectionFailed,
UnknownError,
QRCodeFailed,
}
@ -52,9 +51,6 @@ export function InstallScreenErrorStep({
case InstallError.ConnectionFailed:
errorMessage = i18n('icu:installConnectionFailed');
break;
case InstallError.UnknownError:
errorMessage = i18n('icu:installUnknownError');
break;
case InstallError.QRCodeFailed:
buttonText = i18n('icu:Install__learn-more');
errorMessage = i18n('icu:installUnknownError');

View file

@ -10,7 +10,10 @@ import enMessages from '../../../_locales/en/messages.json';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import type { PropsType } from './InstallScreenQrCodeNotScannedStep';
import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep';
import {
InstallScreenQrCodeNotScannedStep,
LoadError,
} from './InstallScreenQrCodeNotScannedStep';
const i18n = setupI18n('en', enMessages);
@ -34,8 +37,14 @@ export default {
argTypes: {},
} satisfies Meta<PropsType>;
function Simulation({ finalResult }: { finalResult: Loadable<string> }) {
const [provisioningUrl, setProvisioningUrl] = useState<Loadable<string>>({
function Simulation({
finalResult,
}: {
finalResult: Loadable<string, LoadError>;
}) {
const [provisioningUrl, setProvisioningUrl] = useState<
Loadable<string, LoadError>
>({
loadingState: LoadingState.Loading,
});
@ -83,7 +92,7 @@ export function QrCodeFailedToLoad(): JSX.Element {
i18n={i18n}
provisioningUrl={{
loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'),
error: LoadError.Unknown,
}}
updates={DEFAULT_UPDATES}
OS="macOS"
@ -112,12 +121,34 @@ export function SimulatedLoading(): JSX.Element {
return <Simulation finalResult={LOADED_URL} />;
}
export function SimulatedFailure(): JSX.Element {
export function SimulatedUnknownError(): JSX.Element {
return (
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'),
error: LoadError.Unknown,
}}
/>
);
}
export function SimulatedNetworkIssue(): JSX.Element {
return (
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: LoadError.NetworkIssue,
}}
/>
);
}
export function SimulatedTimeout(): JSX.Element {
return (
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: LoadError.Timeout,
}}
/>
);

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { LocalizerType } from '../../types/Util';
@ -20,12 +20,18 @@ import { getClassNamesFor } from '../../util/getClassNamesFor';
import type { UpdatesStateType } from '../../state/ducks/updates';
import { Environment, getEnvironment } from '../../environment';
export enum LoadError {
Timeout = 'Timeout',
Unknown = 'Unknown',
NetworkIssue = 'NetworkIssue',
}
// We can't always use destructuring assignment because of the complexity of this props
// type.
export type PropsType = Readonly<{
i18n: LocalizerType;
provisioningUrl: Loadable<string>;
provisioningUrl: Loadable<string, LoadError>;
hasExpired?: boolean;
updates: UpdatesStateType;
currentVersion: string;
@ -38,6 +44,9 @@ const getQrCodeClassName = getClassNamesFor(
'module-InstallScreenQrCodeNotScannedStep__qr-code'
);
const SUPPORT_PAGE =
'https://support.signal.org/hc/articles/360007320451#desktop_multiple_device';
export function InstallScreenQrCodeNotScannedStep({
currentVersion,
hasExpired,
@ -105,7 +114,7 @@ export function InstallScreenQrCodeNotScannedStep({
</li>
</ol>
{getEnvironment() !== Environment.Staging ? (
<a href="https://support.signal.org/hc/articles/360007320451#desktop_multiple_device">
<a target="_blank" rel="noreferrer" href={SUPPORT_PAGE}>
{i18n('icu:Install__support-link')}
</a>
) : (
@ -118,7 +127,10 @@ export function InstallScreenQrCodeNotScannedStep({
}
function InstallScreenQrCode(
props: Loadable<string> & { i18n: LocalizerType; retryGetQrCode: () => void }
props: Loadable<string, LoadError> & {
i18n: LocalizerType;
retryGetQrCode: () => void;
}
): ReactElement {
const { i18n } = props;
@ -128,33 +140,58 @@ function InstallScreenQrCode(
contents = <Spinner size="24px" svgSize="small" />;
break;
case LoadingState.LoadFailed:
contents = (
<span className={classNames(getQrCodeClassName('__error-message'))}>
<I18n
i18n={i18n}
id="icu:Install__qr-failed-load"
components={{
// eslint-disable-next-line react/no-unstable-nested-components
retry: (parts: Array<string | JSX.Element>) => (
<button
className={getQrCodeClassName('__link')}
onClick={props.retryGetQrCode}
onKeyDown={ev => {
if (ev.key === 'Enter') {
props.retryGetQrCode();
ev.preventDefault();
ev.stopPropagation();
}
}}
type="button"
>
{parts}
</button>
),
}}
/>
</span>
);
switch (props.error) {
case LoadError.Timeout:
contents = (
<>
<span
className={classNames(getQrCodeClassName('__error-message'))}
>
{i18n('icu:Install__qr-failed-load__error--timeout')}
</span>
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
</>
);
break;
case LoadError.Unknown:
contents = (
<>
<span
className={classNames(getQrCodeClassName('__error-message'))}
>
<I18n
i18n={i18n}
id="icu:Install__qr-failed-load__error--unknown"
components={{ paragraph: Paragraph }}
/>
</span>
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
</>
);
break;
case LoadError.NetworkIssue:
contents = (
<>
<span
className={classNames(getQrCodeClassName('__error-message'))}
>
{i18n('icu:Install__qr-failed-load__error--network')}
</span>
<a
className={classNames(getQrCodeClassName('__get-help'))}
target="_blank"
rel="noreferrer"
href={SUPPORT_PAGE}
>
{i18n('icu:Install__qr-failed-load__get-help')}
</a>
</>
);
break;
default:
throw missingCaseError(props.error);
}
break;
case LoadingState.Loaded:
contents = (
@ -183,3 +220,37 @@ function InstallScreenQrCode(
</div>
);
}
function RetryButton({
i18n,
onClick,
}: {
i18n: LocalizerType;
onClick: () => void;
}): JSX.Element {
const onKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLButtonElement>) => {
if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
onClick();
}
},
[onClick]
);
return (
<button
className={getQrCodeClassName('__link')}
onClick={onClick}
onKeyDown={onKeyDown}
type="button"
>
{i18n('icu:Install__qr-failed-load__retry')}
</button>
);
}
function Paragraph(children: React.ReactNode): JSX.Element {
return <p>{children}</p>;
}

View file

@ -21,6 +21,7 @@ import {
InstallScreenStep,
} from '../../components/InstallScreen';
import { InstallError } from '../../components/installScreen/InstallScreenErrorStep';
import { LoadError } from '../../components/installScreen/InstallScreenQrCodeNotScannedStep';
import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep';
import { WidthBreakpoint } from '../../components/_util';
import { HTTPError } from '../../textsecure/Errors';
@ -44,7 +45,7 @@ type StateType =
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string>;
provisioningUrl: Loadable<string, LoadError>;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
@ -67,25 +68,33 @@ const qrCodeBackOff = new BackOff([
60 * SECOND,
]);
function getInstallError(err: unknown): InstallError {
function classifyError(
err: unknown
): { installError: InstallError } | { loadError: LoadError } {
if (err instanceof HTTPError) {
switch (err.code) {
case -1:
return InstallError.ConnectionFailed;
if (
isRecord(err.cause) &&
err.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
) {
return { loadError: LoadError.NetworkIssue };
}
return { installError: InstallError.ConnectionFailed };
case 409:
return InstallError.TooOld;
return { installError: InstallError.TooOld };
case 411:
return InstallError.TooManyDevices;
return { installError: InstallError.TooManyDevices };
default:
return InstallError.UnknownError;
return { loadError: LoadError.Unknown };
}
}
// AccountManager.registerSecondDevice uses this specific "websocket closed" error
// message.
if (isRecord(err) && err.message === 'websocket closed') {
return InstallError.ConnectionFailed;
return { installError: InstallError.ConnectionFailed };
}
return InstallError.UnknownError;
return { loadError: LoadError.Unknown };
}
export const SmartInstallScreen = memo(function SmartInstallScreen() {
@ -255,8 +264,13 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
);
const sleepMs = qrCodeBackOff.getAndIncrement();
log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`);
await pTimeout(qrCodeResolution.promise, sleepMs, sleepError);
await qrCodePromise;
await Promise.all([
pTimeout(qrCodeResolution.promise, sleepMs, sleepError),
// Note that `registerSecondDevice` resolves once the registration
// is fully complete and thus should not be subjected to a timeout.
qrCodePromise,
]);
window.IPC.removeSetupMenuItems();
} catch (error) {
@ -280,12 +294,26 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
if (error === sleepError) {
setState({
step: InstallScreenStep.QrCodeNotScanned,
provisioningUrl: { loadingState: LoadingState.LoadFailed, error },
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: LoadError.Timeout,
},
});
return;
}
const classifiedError = classifyError(error);
if ('installError' in classifiedError) {
setState({
step: InstallScreenStep.Error,
error: classifiedError.installError,
});
} else {
setState({
step: InstallScreenStep.Error,
error: getInstallError(error),
step: InstallScreenStep.QrCodeNotScanned,
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: classifiedError.loadError,
},
});
}
}