signal-desktop/ts/state/smart/InstallScreen.tsx
2024-07-23 17:31:40 -07:00

394 lines
12 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import pTimeout, { TimeoutError } from 'p-timeout';
import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates';
import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
import * as log from '../../logging/log';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { assertDev } from '../../util/assert';
import { explodePromise } from '../../util/explodePromise';
import { missingCaseError } from '../../util/missingCaseError';
import * as Registration from '../../util/registration';
import {
InstallScreen,
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';
import { isRecord } from '../../util/isRecord';
import type { ConfirmNumberResultType } from '../../textsecure/AccountManager';
import * as Errors from '../../types/errors';
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import OS from '../../util/os/osMain';
import { SECOND } from '../../util/durations';
import { BackOff } from '../../util/BackOff';
import { drop } from '../../util/drop';
import { SmartToastManager } from './ToastManager';
import { fileToBytes } from '../../util/fileToBytes';
type PropsType = ComponentProps<typeof InstallScreen>;
type StateType =
| {
step: InstallScreenStep.Error;
error: InstallError;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string, LoadError>;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
deviceName: string;
backupFile?: File;
}
| {
step: InstallScreenStep.LinkInProgress;
};
const INITIAL_STATE: StateType = {
step: InstallScreenStep.QrCodeNotScanned,
provisioningUrl: { loadingState: LoadingState.Loading },
};
const qrCodeBackOff = new BackOff([
10 * SECOND,
20 * SECOND,
30 * SECOND,
60 * SECOND,
]);
function classifyError(
err: unknown
): { installError: InstallError } | { loadError: LoadError } {
if (err instanceof HTTPError) {
switch (err.code) {
case -1:
if (
isRecord(err.cause) &&
err.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
) {
return { loadError: LoadError.NetworkIssue };
}
return { installError: InstallError.ConnectionFailed };
case 409:
return { installError: InstallError.TooOld };
case 411:
return { installError: InstallError.TooManyDevices };
default:
return { loadError: LoadError.Unknown };
}
}
// AccountManager.registerSecondDevice uses this specific "websocket closed" error
// message.
if (isRecord(err) && err.message === 'websocket closed') {
return { installError: InstallError.ConnectionFailed };
}
return { loadError: LoadError.Unknown };
}
export const SmartInstallScreen = memo(function SmartInstallScreen() {
const i18n = useSelector(getIntl);
const updates = useSelector(getUpdatesState);
const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());
const chooseBackupFilePromiseWrapperRef =
useRef(explodePromise<File | undefined>());
const [state, setState] = useState<StateType>(INITIAL_STATE);
const [retryCounter, setRetryCounter] = useState(0);
const setProvisioningUrl = useCallback(
(value: string) => {
setState(currentState => {
if (currentState.step !== InstallScreenStep.QrCodeNotScanned) {
return currentState;
}
return {
...currentState,
provisioningUrl: {
loadingState: LoadingState.Loaded,
value,
},
};
});
},
[setState]
);
const onQrCodeScanned = useCallback(() => {
setState(currentState => {
if (currentState.step !== InstallScreenStep.QrCodeNotScanned) {
return currentState;
}
return {
step: InstallScreenStep.ChoosingDeviceName,
deviceName: normalizeDeviceName(
window.textsecure.storage.user.getDeviceName() ||
window.getHostName() ||
''
).slice(0, MAX_DEVICE_NAME_LENGTH),
};
});
}, [setState]);
const setDeviceName = useCallback(
(deviceName: string) => {
setState(currentState => {
if (currentState.step !== InstallScreenStep.ChoosingDeviceName) {
return currentState;
}
return {
...currentState,
deviceName,
};
});
},
[setState]
);
const setBackupFile = useCallback(
(backupFile: File) => {
setState(currentState => {
if (currentState.step !== InstallScreenStep.ChoosingDeviceName) {
return currentState;
}
return {
...currentState,
backupFile,
};
});
},
[setState]
);
const onSubmitDeviceName = useCallback(() => {
if (state.step !== InstallScreenStep.ChoosingDeviceName) {
return;
}
let deviceName: string = normalizeDeviceName(state.deviceName);
if (!deviceName.length) {
// This should be impossible, but we have it here just in case.
assertDev(
false,
'Unexpected empty device name. Falling back to placeholder value'
);
deviceName = i18n('icu:Install__choose-device-name__placeholder');
}
chooseDeviceNamePromiseWrapperRef.current.resolve(deviceName);
chooseBackupFilePromiseWrapperRef.current.resolve(state.backupFile);
setState({ step: InstallScreenStep.LinkInProgress });
}, [state, i18n]);
useEffect(() => {
let hasCleanedUp = false;
const qrCodeResolution = explodePromise<void>();
const accountManager = window.getAccountManager();
assertDev(accountManager, 'Expected an account manager');
const updateProvisioningUrl = (value: string): void => {
if (hasCleanedUp) {
return;
}
qrCodeResolution.resolve();
setProvisioningUrl(value);
};
const confirmNumber = async (): Promise<ConfirmNumberResultType> => {
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
onQrCodeScanned();
let deviceName: string;
let backupFileData: Uint8Array | undefined;
if (window.SignalCI) {
({ deviceName, backupData: backupFileData } = window.SignalCI);
} else {
deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise;
const backupFile =
await chooseBackupFilePromiseWrapperRef.current.promise;
backupFileData = backupFile ? await fileToBytes(backupFile) : undefined;
}
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
// Delete all data from the database unless we're in the middle of a re-link.
// Without this, the app restarts at certain times and can cause weird things to
// happen, like data from a previous light import showing up after a new install.
const shouldRetainData = Registration.everDone();
if (!shouldRetainData) {
try {
await window.textsecure.storage.protocol.removeAllData();
} catch (error) {
log.error(
'confirmNumber: error clearing database',
Errors.toLogFormat(error)
);
}
}
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
return { deviceName, backupFile: backupFileData };
};
async function getQRCode(): Promise<void> {
const sleepError = new TimeoutError();
try {
const qrCodePromise = accountManager.registerSecondDevice(
updateProvisioningUrl,
confirmNumber
);
const sleepMs = qrCodeBackOff.getAndIncrement();
log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`);
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) {
log.error(
'account.registerSecondDevice: got an error',
Errors.toLogFormat(error)
);
if (hasCleanedUp) {
return;
}
if (qrCodeBackOff.isFull()) {
log.error('InstallScreen/getQRCode: too many tries');
setState({
step: InstallScreenStep.Error,
error: InstallError.QRCodeFailed,
});
return;
}
if (error === sleepError) {
setState({
step: InstallScreenStep.QrCodeNotScanned,
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.QrCodeNotScanned,
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: classifiedError.loadError,
},
});
}
}
}
drop(getQRCode());
return () => {
hasCleanedUp = true;
};
}, [setProvisioningUrl, retryCounter, onQrCodeScanned]);
let props: PropsType;
switch (state.step) {
case InstallScreenStep.Error:
props = {
step: InstallScreenStep.Error,
screenSpecificProps: {
i18n,
error: state.error,
quit: () => window.IPC.shutdown(),
tryAgain: () => {
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
},
};
break;
case InstallScreenStep.QrCodeNotScanned:
props = {
step: InstallScreenStep.QrCodeNotScanned,
screenSpecificProps: {
i18n,
provisioningUrl: state.provisioningUrl,
hasExpired,
updates,
currentVersion: window.getVersion(),
startUpdate,
retryGetQrCode: () => {
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
OS: OS.getName(),
},
};
break;
case InstallScreenStep.ChoosingDeviceName:
props = {
step: InstallScreenStep.ChoosingDeviceName,
screenSpecificProps: {
i18n,
deviceName: state.deviceName,
setDeviceName,
setBackupFile,
onSubmit: onSubmitDeviceName,
},
};
break;
case InstallScreenStep.LinkInProgress:
props = {
step: InstallScreenStep.LinkInProgress,
screenSpecificProps: { i18n },
};
break;
default:
throw missingCaseError(state);
}
return (
<>
<InstallScreen {...props} />
<SmartToastManager
disableMegaphone
containerWidthBreakpoint={WidthBreakpoint.Narrow}
/>
</>
);
});