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

329 lines
9.5 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps, ReactElement } from 'react';
import React, { 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 { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep';
import { HTTPError } from '../../textsecure/Errors';
import { isRecord } from '../../util/isRecord';
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';
type PropsType = ComponentProps<typeof InstallScreen>;
type StateType =
| {
step: InstallScreenStep.Error;
error: InstallError;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string>;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
deviceName: string;
}
| {
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 getInstallError(err: unknown): InstallError {
if (err instanceof HTTPError) {
switch (err.code) {
case -1:
return InstallError.ConnectionFailed;
case 409:
return InstallError.TooOld;
case 411:
return InstallError.TooManyDevices;
default:
return InstallError.UnknownError;
}
}
// AccountManager.registerSecondDevice uses this specific "websocket closed" error
// message.
if (isRecord(err) && err.message === 'websocket closed') {
return InstallError.ConnectionFailed;
}
return InstallError.UnknownError;
}
export function SmartInstallScreen(): ReactElement {
const i18n = useSelector(getIntl);
const updates = useSelector(getUpdatesState);
const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());
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 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);
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<string> => {
if (hasCleanedUp) {
throw new Error('Cannot confirm number; the component was unmounted');
}
onQrCodeScanned();
if (window.SignalCI) {
chooseDeviceNamePromiseWrapperRef.current.resolve(
window.SignalCI.deviceName
);
}
const result = await chooseDeviceNamePromiseWrapperRef.current.promise;
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 result;
};
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 pTimeout(qrCodeResolution.promise, sleepMs, sleepError);
await 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 },
});
} else {
setState({
step: InstallScreenStep.Error,
error: getInstallError(error),
});
}
}
}
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: () => 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,
onSubmit: onSubmitDeviceName,
},
};
break;
case InstallScreenStep.LinkInProgress:
props = {
step: InstallScreenStep.LinkInProgress,
screenSpecificProps: { i18n },
};
break;
default:
throw missingCaseError(state);
}
return <InstallScreen {...props} />;
}