286 lines
8.1 KiB
TypeScript
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} />;
|
|
}
|