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

@ -1488,7 +1488,27 @@
}, },
"icu:Install__qr-failed-load": { "icu:Install__qr-failed-load": {
"messageformat": "The QR code couldn't load. Check your internet and try again. <retry>Retry</retry>", "messageformat": "The QR code couldn't load. Check your internet and try again. <retry>Retry</retry>",
"description": "Shown on the install screen if the QR code fails to load" "description": "(Deleted 2024/07/01) Shown on the install screen if the QR code fails to load"
},
"icu:Install__qr-failed-load__error--timeout": {
"messageformat": "The QR code couldn't load. Check your connection and try again.",
"description": "Shown on the install screen if the QR code fails to load due to connection timeout"
},
"icu:Install__qr-failed-load__error--unknown": {
"messageformat": "<paragraph>An unexpected error occurred.</paragraph><paragraph>Please try again.</paragraph>",
"description": "Shown on the install screen if the QR code fails to load due to an unknown error"
},
"icu:Install__qr-failed-load__error--network": {
"messageformat": "Signal cannot link this device using your current network.",
"description": "Shown on the install screen if the QR code fails to load due to a network error"
},
"icu:Install__qr-failed-load__retry": {
"messageformat": "Retry",
"description": "Text of the button shown on the install screen if the QR code fails to load"
},
"icu:Install__qr-failed-load__get-help": {
"messageformat": "Get help",
"description": "Text of the link to support page shown on the install screen if the QR code fails to load"
}, },
"icu:Install__support-link": { "icu:Install__support-link": {
"messageformat": "Need help?", "messageformat": "Need help?",

View file

@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.768 1.25c-.813 0-1.469 0-2 .043-.546.045-1.026.14-1.47.366a3.75 3.75 0 0 0-1.64 1.639c-.226.444-.32.924-.365 1.47-.043.531-.043 1.187-.043 2v2.464c0 .813 0 1.469.043 2 .045.546.14 1.026.366 1.47a3.75 3.75 0 0 0 1.639 1.64c.444.226.924.32 1.47.365.531.043 1.187.043 2 .043h2.464c.813 0 1.469 0 2-.043.546-.045 1.026-.14 1.47-.366a3.75 3.75 0 0 0 1.64-1.638c.226-.445.32-.925.365-1.471.043-.531.043-1.187.043-2V8.75a.75.75 0 0 0-1.5 0v.45c0 .853 0 1.447-.038 1.91-.037.453-.107.714-.207.912-.216.423-.56.767-.984.983-.197.1-.458.17-.912.207-.462.037-1.056.038-1.909.038H6.8c-.852 0-1.447 0-1.91-.038-.453-.037-.714-.107-.911-.207a2.25 2.25 0 0 1-.984-.984c-.1-.197-.17-.458-.207-.912-.037-.462-.038-1.056-.038-1.909V6.8c0-.852 0-1.447.038-1.91.037-.453.107-.714.207-.911a2.25 2.25 0 0 1 .984-.984c.197-.1.458-.17.912-.207.462-.037 1.057-.038 1.909-.038h.45a.75.75 0 0 0 0-1.5h-.482Z" fill="#000"/><path d="M13.25 6V3.599L12.03 5.03 7.78 9.28a.75.75 0 0 1-1.06-1.06l4.25-4.25 1.43-1.22H10a.75.75 0 0 1 0-1.5h4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.58.572a.73.73 0 0 0-1.1.627v.483a8.334 8.334 0 1 0 6.848 2.896.833.833 0 0 0-1.265 1.085 6.667 6.667 0 1 1-5.584-2.31v.448a.73.73 0 0 0 1.101.627l2.196-1.3a.73.73 0 0 0 0-1.255L10.58.572Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View file

@ -33,7 +33,7 @@
$size: 256px; $size: 256px;
align-items: center; align-items: center;
border: 2px solid transparent; border: 1.5px solid transparent;
box-sizing: content-box; box-sizing: content-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -51,13 +51,31 @@
&--load-failed { &--load-failed {
@include font-subtitle; @include font-subtitle;
border-color: $color-gray-05; border-color: $color-gray-05;
border-radius: 4px; border-radius: 10px;
color: $color-gray-60; color: $color-gray-60;
} }
&__link { &__link {
@include button-reset; @include button-reset;
@include font-body-2-bold;
display: flex;
gap: 4px;
align-items: center;
color: $color-ultramarine; color: $color-ultramarine;
margin-block-start: 16px;
&::before {
@include color-svg(
'../images/icons/v3/refresh/refresh-bold.svg',
$color-ultramarine
);
content: '';
display: block;
height: 16px;
width: 16px;
}
} }
&__code { &__code {
@ -69,6 +87,7 @@
&__error-message { &__error-message {
text-align: center; text-align: center;
@include font-body-2;
&::before { &::before {
@include color-svg( @include color-svg(
@ -78,14 +97,39 @@
content: ''; content: '';
display: block; display: block;
height: 22px; height: 22px;
margin-block: 8px 0; margin-block: 0 8px;
margin-inline: auto; margin-inline: auto;
width: 22px; width: 22px;
} }
a { margin-inline: 24px;
color: $color-ultramarine; }
text-decoration: none;
&__error-message p {
margin-block: 0;
}
&__get-help {
@include font-body-2-bold;
display: flex;
gap: 4px;
align-items: center;
margin-block-start: 16px;
color: $color-ultramarine;
text-decoration: none;
&::before {
@include color-svg(
'../images/icons/v3/open/open-compact-bold.svg',
$color-ultramarine
);
content: '';
display: block;
height: 16px;
width: 16px;
} }
} }
} }

View file

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

View file

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

View file

@ -10,7 +10,10 @@ import enMessages from '../../../_locales/en/messages.json';
import type { Loadable } from '../../util/loadable'; import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable'; import { LoadingState } from '../../util/loadable';
import type { PropsType } from './InstallScreenQrCodeNotScannedStep'; import type { PropsType } from './InstallScreenQrCodeNotScannedStep';
import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep'; import {
InstallScreenQrCodeNotScannedStep,
LoadError,
} from './InstallScreenQrCodeNotScannedStep';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -34,8 +37,14 @@ export default {
argTypes: {}, argTypes: {},
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;
function Simulation({ finalResult }: { finalResult: Loadable<string> }) { function Simulation({
const [provisioningUrl, setProvisioningUrl] = useState<Loadable<string>>({ finalResult,
}: {
finalResult: Loadable<string, LoadError>;
}) {
const [provisioningUrl, setProvisioningUrl] = useState<
Loadable<string, LoadError>
>({
loadingState: LoadingState.Loading, loadingState: LoadingState.Loading,
}); });
@ -83,7 +92,7 @@ export function QrCodeFailedToLoad(): JSX.Element {
i18n={i18n} i18n={i18n}
provisioningUrl={{ provisioningUrl={{
loadingState: LoadingState.LoadFailed, loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'), error: LoadError.Unknown,
}} }}
updates={DEFAULT_UPDATES} updates={DEFAULT_UPDATES}
OS="macOS" OS="macOS"
@ -112,12 +121,34 @@ export function SimulatedLoading(): JSX.Element {
return <Simulation finalResult={LOADED_URL} />; return <Simulation finalResult={LOADED_URL} />;
} }
export function SimulatedFailure(): JSX.Element { export function SimulatedUnknownError(): JSX.Element {
return ( return (
<Simulation <Simulation
finalResult={{ finalResult={{
loadingState: LoadingState.LoadFailed, 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 // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import React from 'react'; import React, { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
@ -20,12 +20,18 @@ import { getClassNamesFor } from '../../util/getClassNamesFor';
import type { UpdatesStateType } from '../../state/ducks/updates'; import type { UpdatesStateType } from '../../state/ducks/updates';
import { Environment, getEnvironment } from '../../environment'; 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 // We can't always use destructuring assignment because of the complexity of this props
// type. // type.
export type PropsType = Readonly<{ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
provisioningUrl: Loadable<string>; provisioningUrl: Loadable<string, LoadError>;
hasExpired?: boolean; hasExpired?: boolean;
updates: UpdatesStateType; updates: UpdatesStateType;
currentVersion: string; currentVersion: string;
@ -38,6 +44,9 @@ const getQrCodeClassName = getClassNamesFor(
'module-InstallScreenQrCodeNotScannedStep__qr-code' 'module-InstallScreenQrCodeNotScannedStep__qr-code'
); );
const SUPPORT_PAGE =
'https://support.signal.org/hc/articles/360007320451#desktop_multiple_device';
export function InstallScreenQrCodeNotScannedStep({ export function InstallScreenQrCodeNotScannedStep({
currentVersion, currentVersion,
hasExpired, hasExpired,
@ -105,7 +114,7 @@ export function InstallScreenQrCodeNotScannedStep({
</li> </li>
</ol> </ol>
{getEnvironment() !== Environment.Staging ? ( {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')} {i18n('icu:Install__support-link')}
</a> </a>
) : ( ) : (
@ -118,7 +127,10 @@ export function InstallScreenQrCodeNotScannedStep({
} }
function InstallScreenQrCode( function InstallScreenQrCode(
props: Loadable<string> & { i18n: LocalizerType; retryGetQrCode: () => void } props: Loadable<string, LoadError> & {
i18n: LocalizerType;
retryGetQrCode: () => void;
}
): ReactElement { ): ReactElement {
const { i18n } = props; const { i18n } = props;
@ -128,33 +140,58 @@ function InstallScreenQrCode(
contents = <Spinner size="24px" svgSize="small" />; contents = <Spinner size="24px" svgSize="small" />;
break; break;
case LoadingState.LoadFailed: case LoadingState.LoadFailed:
contents = ( switch (props.error) {
<span className={classNames(getQrCodeClassName('__error-message'))}> case LoadError.Timeout:
<I18n contents = (
i18n={i18n} <>
id="icu:Install__qr-failed-load" <span
components={{ className={classNames(getQrCodeClassName('__error-message'))}
// eslint-disable-next-line react/no-unstable-nested-components >
retry: (parts: Array<string | JSX.Element>) => ( {i18n('icu:Install__qr-failed-load__error--timeout')}
<button </span>
className={getQrCodeClassName('__link')} <RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
onClick={props.retryGetQrCode} </>
onKeyDown={ev => { );
if (ev.key === 'Enter') { break;
props.retryGetQrCode(); case LoadError.Unknown:
ev.preventDefault(); contents = (
ev.stopPropagation(); <>
} <span
}} className={classNames(getQrCodeClassName('__error-message'))}
type="button" >
> <I18n
{parts} i18n={i18n}
</button> id="icu:Install__qr-failed-load__error--unknown"
), components={{ paragraph: Paragraph }}
}} />
/> </span>
</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; break;
case LoadingState.Loaded: case LoadingState.Loaded:
contents = ( contents = (
@ -183,3 +220,37 @@ function InstallScreenQrCode(
</div> </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, InstallScreenStep,
} from '../../components/InstallScreen'; } from '../../components/InstallScreen';
import { InstallError } from '../../components/installScreen/InstallScreenErrorStep'; import { InstallError } from '../../components/installScreen/InstallScreenErrorStep';
import { LoadError } from '../../components/installScreen/InstallScreenQrCodeNotScannedStep';
import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep'; import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep';
import { WidthBreakpoint } from '../../components/_util'; import { WidthBreakpoint } from '../../components/_util';
import { HTTPError } from '../../textsecure/Errors'; import { HTTPError } from '../../textsecure/Errors';
@ -44,7 +45,7 @@ type StateType =
} }
| { | {
step: InstallScreenStep.QrCodeNotScanned; step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string>; provisioningUrl: Loadable<string, LoadError>;
} }
| { | {
step: InstallScreenStep.ChoosingDeviceName; step: InstallScreenStep.ChoosingDeviceName;
@ -67,25 +68,33 @@ const qrCodeBackOff = new BackOff([
60 * SECOND, 60 * SECOND,
]); ]);
function getInstallError(err: unknown): InstallError { function classifyError(
err: unknown
): { installError: InstallError } | { loadError: LoadError } {
if (err instanceof HTTPError) { if (err instanceof HTTPError) {
switch (err.code) { switch (err.code) {
case -1: 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: case 409:
return InstallError.TooOld; return { installError: InstallError.TooOld };
case 411: case 411:
return InstallError.TooManyDevices; return { installError: InstallError.TooManyDevices };
default: default:
return InstallError.UnknownError; return { loadError: LoadError.Unknown };
} }
} }
// AccountManager.registerSecondDevice uses this specific "websocket closed" error // AccountManager.registerSecondDevice uses this specific "websocket closed" error
// message. // message.
if (isRecord(err) && err.message === 'websocket closed') { 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() { export const SmartInstallScreen = memo(function SmartInstallScreen() {
@ -255,8 +264,13 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
); );
const sleepMs = qrCodeBackOff.getAndIncrement(); const sleepMs = qrCodeBackOff.getAndIncrement();
log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`); log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`);
await pTimeout(qrCodeResolution.promise, sleepMs, sleepError); await Promise.all([
await qrCodePromise; 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(); window.IPC.removeSetupMenuItems();
} catch (error) { } catch (error) {
@ -280,12 +294,26 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
if (error === sleepError) { if (error === sleepError) {
setState({ setState({
step: InstallScreenStep.QrCodeNotScanned, 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 { } else {
setState({ setState({
step: InstallScreenStep.Error, step: InstallScreenStep.QrCodeNotScanned,
error: getInstallError(error), provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: classifiedError.loadError,
},
}); });
} }
} }