signal-desktop/ts/state/smart/InstallScreen.tsx

395 lines
12 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2021 Signal Messenger, LLC
2021-12-16 15:02:22 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
2021-12-16 15:02:22 +00:00
import { useSelector } from 'react-redux';
import pTimeout, { TimeoutError } from 'p-timeout';
2021-12-16 15:02:22 +00:00
import { getIntl } from '../selectors/user';
2023-03-20 20:42:00 +00:00
import { getUpdatesState } from '../selectors/updates';
import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
2021-12-16 15:02:22 +00:00
import * as log from '../../logging/log';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { assertDev } from '../../util/assert';
2021-12-16 15:02:22 +00:00
import { explodePromise } from '../../util/explodePromise';
import { missingCaseError } from '../../util/missingCaseError';
2023-04-11 03:54:43 +00:00
import * as Registration from '../../util/registration';
2021-12-16 15:02:22 +00:00
import {
InstallScreen,
InstallScreenStep,
} from '../../components/InstallScreen';
import { InstallError } from '../../components/installScreen/InstallScreenErrorStep';
2024-07-01 21:51:49 +00:00
import { LoadError } from '../../components/installScreen/InstallScreenQrCodeNotScannedStep';
2021-12-16 15:02:22 +00:00
import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep';
import { WidthBreakpoint } from '../../components/_util';
2021-12-16 15:02:22 +00:00
import { HTTPError } from '../../textsecure/Errors';
import { isRecord } from '../../util/isRecord';
2024-03-15 14:20:33 +00:00
import type { ConfirmNumberResultType } from '../../textsecure/AccountManager';
2022-03-01 23:01:21 +00:00
import * as Errors from '../../types/errors';
2021-12-16 15:02:22 +00:00
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';
2024-03-15 14:20:33 +00:00
import { fileToBytes } from '../../util/fileToBytes';
2021-12-16 15:02:22 +00:00
type PropsType = ComponentProps<typeof InstallScreen>;
type StateType =
| {
step: InstallScreenStep.Error;
error: InstallError;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
2024-07-01 21:51:49 +00:00
provisioningUrl: Loadable<string, LoadError>;
2021-12-16 15:02:22 +00:00
}
| {
step: InstallScreenStep.ChoosingDeviceName;
deviceName: string;
2024-03-15 14:20:33 +00:00
backupFile?: File;
2021-12-16 15:02:22 +00:00
}
| {
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,
]);
2024-07-01 21:51:49 +00:00
function classifyError(
err: unknown
): { installError: InstallError } | { loadError: LoadError } {
2021-12-16 15:02:22 +00:00
if (err instanceof HTTPError) {
switch (err.code) {
case -1:
2024-07-01 21:51:49 +00:00
if (
isRecord(err.cause) &&
err.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
) {
return { loadError: LoadError.NetworkIssue };
}
return { installError: InstallError.ConnectionFailed };
2021-12-16 15:02:22 +00:00
case 409:
2024-07-01 21:51:49 +00:00
return { installError: InstallError.TooOld };
2021-12-16 15:02:22 +00:00
case 411:
2024-07-01 21:51:49 +00:00
return { installError: InstallError.TooManyDevices };
2021-12-16 15:02:22 +00:00
default:
2024-07-01 21:51:49 +00:00
return { loadError: LoadError.Unknown };
2021-12-16 15:02:22 +00:00
}
}
// AccountManager.registerSecondDevice uses this specific "websocket closed" error
// message.
if (isRecord(err) && err.message === 'websocket closed') {
2024-07-01 21:51:49 +00:00
return { installError: InstallError.ConnectionFailed };
2021-12-16 15:02:22 +00:00
}
2024-07-01 21:51:49 +00:00
return { loadError: LoadError.Unknown };
2021-12-16 15:02:22 +00:00
}
export const SmartInstallScreen = memo(function SmartInstallScreen() {
2021-12-16 15:02:22 +00:00
const i18n = useSelector(getIntl);
2023-03-20 20:42:00 +00:00
const updates = useSelector(getUpdatesState);
const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
2021-12-16 15:02:22 +00:00
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());
2024-07-24 00:31:40 +00:00
const chooseBackupFilePromiseWrapperRef =
useRef(explodePromise<File | undefined>());
2021-12-16 15:02:22 +00:00
const [state, setState] = useState<StateType>(INITIAL_STATE);
const [retryCounter, setRetryCounter] = useState(0);
2021-12-16 15:02:22 +00:00
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]
);
2024-03-15 14:20:33 +00:00
const setBackupFile = useCallback(
(backupFile: File) => {
setState(currentState => {
if (currentState.step !== InstallScreenStep.ChoosingDeviceName) {
return currentState;
}
return {
...currentState,
backupFile,
};
});
},
[setState]
);
2021-12-16 15:02:22 +00:00
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(
2021-12-16 15:02:22 +00:00
false,
'Unexpected empty device name. Falling back to placeholder value'
);
2023-03-30 00:03:25 +00:00
deviceName = i18n('icu:Install__choose-device-name__placeholder');
2021-12-16 15:02:22 +00:00
}
chooseDeviceNamePromiseWrapperRef.current.resolve(deviceName);
2024-03-15 14:20:33 +00:00
chooseBackupFilePromiseWrapperRef.current.resolve(state.backupFile);
2021-12-16 15:02:22 +00:00
setState({ step: InstallScreenStep.LinkInProgress });
}, [state, i18n]);
useEffect(() => {
let hasCleanedUp = false;
const qrCodeResolution = explodePromise<void>();
2021-12-16 15:02:22 +00:00
const accountManager = window.getAccountManager();
assertDev(accountManager, 'Expected an account manager');
2021-12-16 15:02:22 +00:00
const updateProvisioningUrl = (value: string): void => {
if (hasCleanedUp) {
return;
}
qrCodeResolution.resolve();
2021-12-16 15:02:22 +00:00
setProvisioningUrl(value);
};
2024-03-15 14:20:33 +00:00
const confirmNumber = async (): Promise<ConfirmNumberResultType> => {
2021-12-16 15:02:22 +00:00
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
onQrCodeScanned();
2024-03-15 14:20:33 +00:00
let deviceName: string;
let backupFileData: Uint8Array | undefined;
if (window.SignalCI) {
2024-03-15 14:20:33 +00:00
({ deviceName, backupData: backupFileData } = window.SignalCI);
} else {
deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise;
2024-07-24 00:31:40 +00:00
const backupFile =
await chooseBackupFilePromiseWrapperRef.current.promise;
2021-12-16 15:02:22 +00:00
2024-03-15 14:20:33 +00:00
backupFileData = backupFile ? await fileToBytes(backupFile) : undefined;
}
2021-12-16 15:02:22 +00:00
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.
2023-04-11 03:54:43 +00:00
const shouldRetainData = Registration.everDone();
2021-12-16 15:02:22 +00:00
if (!shouldRetainData) {
try {
await window.textsecure.storage.protocol.removeAllData();
} catch (error) {
log.error(
'confirmNumber: error clearing database',
2022-03-01 23:01:21 +00:00
Errors.toLogFormat(error)
2021-12-16 15:02:22 +00:00
);
}
}
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
2024-03-15 14:20:33 +00:00
return { deviceName, backupFile: backupFileData };
2021-12-16 15:02:22 +00:00
};
async function getQRCode(): Promise<void> {
const sleepError = new TimeoutError();
2021-12-16 15:02:22 +00:00
try {
const qrCodePromise = accountManager.registerSecondDevice(
2021-12-16 15:02:22 +00:00
updateProvisioningUrl,
confirmNumber
);
const sleepMs = qrCodeBackOff.getAndIncrement();
log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`);
2024-07-01 21:51:49 +00:00
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,
]);
2023-01-13 00:24:59 +00:00
window.IPC.removeSetupMenuItems();
2022-03-01 23:01:21 +00:00
} catch (error) {
log.error(
'account.registerSecondDevice: got an error',
Errors.toLogFormat(error)
);
2021-12-16 15:02:22 +00:00
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,
2024-07-01 21:51:49 +00:00
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: LoadError.Timeout,
},
});
2024-07-01 21:51:49 +00:00
return;
}
const classifiedError = classifyError(error);
if ('installError' in classifiedError) {
setState({
step: InstallScreenStep.Error,
2024-07-01 21:51:49 +00:00
error: classifiedError.installError,
});
} else {
setState({
step: InstallScreenStep.QrCodeNotScanned,
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: classifiedError.loadError,
},
});
}
2021-12-16 15:02:22 +00:00
}
}
drop(getQRCode());
2021-12-16 15:02:22 +00:00
return () => {
hasCleanedUp = true;
};
}, [setProvisioningUrl, retryCounter, onQrCodeScanned]);
2021-12-16 15:02:22 +00:00
let props: PropsType;
switch (state.step) {
case InstallScreenStep.Error:
props = {
step: InstallScreenStep.Error,
screenSpecificProps: {
i18n,
error: state.error,
2023-01-13 00:24:59 +00:00
quit: () => window.IPC.shutdown(),
tryAgain: () => {
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
2021-12-16 15:02:22 +00:00
},
};
break;
case InstallScreenStep.QrCodeNotScanned:
props = {
step: InstallScreenStep.QrCodeNotScanned,
screenSpecificProps: {
i18n,
provisioningUrl: state.provisioningUrl,
2023-03-20 20:42:00 +00:00
hasExpired,
updates,
currentVersion: window.getVersion(),
startUpdate,
retryGetQrCode: () => {
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
OS: OS.getName(),
2021-12-16 15:02:22 +00:00
},
};
break;
case InstallScreenStep.ChoosingDeviceName:
props = {
step: InstallScreenStep.ChoosingDeviceName,
screenSpecificProps: {
i18n,
deviceName: state.deviceName,
setDeviceName,
2024-03-15 14:20:33 +00:00
setBackupFile,
2021-12-16 15:02:22 +00:00
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}
/>
</>
);
});