signal-desktop/ts/state/smart/InstallScreen.tsx
2023-03-29 17:03:25 -07:00

286 lines
8.1 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 { 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 {
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 { getName as getOSName } from '../../OS';
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 },
};
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 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 accountManager = window.getAccountManager();
assertDev(accountManager, 'Expected an account manager');
const updateProvisioningUrl = (value: string): void => {
if (hasCleanedUp) {
return;
}
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 = window.Signal.Util.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;
};
void (async () => {
try {
await accountManager.registerSecondDevice(
updateProvisioningUrl,
confirmNumber
);
window.IPC.removeSetupMenuItems();
} catch (error) {
log.error(
'account.registerSecondDevice: got an error',
Errors.toLogFormat(error)
);
if (hasCleanedUp) {
return;
}
setState({
step: InstallScreenStep.Error,
error: getInstallError(error),
});
}
})();
return () => {
hasCleanedUp = true;
};
}, [setProvisioningUrl, 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,
OS: getOSName(),
},
};
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} />;
}